@@ -212,7 +213,8 @@ Then tone it with Color Grading for selenium, sepia or split-tone prints.
A library that stays out of your way: **All Photos**, **Recently Added**, **Picks**, **By Date**, **Albums** nested in
**Folders**, and **Recently Deleted**. Rate with 0–5, flag with P / X /
U, colour-label with 6–9. Search understands fields:
-`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains`. Every view sorts by capture date,
+`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains person:"Jane Doe"`; plain words also match
+the people named on faces, and quotes keep several words together. Every view sorts by capture date,
import date, edit date, name, rating, size or at random (a stable shuffle; View → Sort → Reshuffle for a new one). The justified **Photo Grid** and **Square Grid** views are virtualized,
so they stay smooth whether you have forty photos or forty thousand.
@@ -451,7 +453,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme).
-**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Polski, Čeština, Italiano, ქართული) or **Settings → General →
+**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Polski, Čeština, Italiano, ქართული, Svenska, Suomi, Bahasa Indonesia, Tiếng Việt) or **Settings → General →
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
diff --git a/README.zh-hans.md b/README.zh-hans.md
index 58e1d627f..824913f14 100644
--- a/README.zh-hans.md
+++ b/README.zh-hans.md
@@ -14,7 +14,8 @@
+
+
+
+## Edit like you mean it
+
+LightCraft 是裝在單一原生應用程式裡的完整暗房。每一項調整都是**非破壞性**的,永遠不會動到原始檔案。
+每個滑桿都經過一條**場景參考、寬色域、32 位元浮點的管線**算繪:亮部像底片一樣柔和過渡,陰影提亮卻不產生光暈,
+色彩從拍攝到匯出始終保持乾淨。
+
+
+
+
+
+## Masking that goes where you point
+
+用**筆刷**塗抹(大小、羽化、流量、濃度、擦除),放置附可拖曳控制點的**線性漸層**與**放射狀漸層**,
+或依**明度範圍**、**色彩範圍**、**天空**、**主體**與**背景**來選取。用**新增/減去/交集**組合各個元件,
+反轉其中任何一個,每個遮色片可設定 15 項局部調整(色溫、色調、曝光、對比、亮部、陰影、白色色階、黑色色階、紋理、
+清晰度、去朦朧、色相、飽和度、銳利化、雜訊抑制),再加上一個整體的強度。
+
+
+
+## Star history
+
+[](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
+
+照片庫、工具與預設集面板可以重新分組、拆分、浮動於應用程式視窗內、關閉與重新開啟。工具面板會保留目前的編輯/裁切/遮色片模式;照片檢視區與底片顯示窗格則維持在一起。拖曳面板標題到某個目標邊緣或另一個標題上即可調整。右鍵選單提供「浮動面板」、「移至群組」與「關閉面板」。既有的面板切換按鈕會在上次儲存的位置重新開啟面板;「重設面板配置」則會還原應用程式的版面配置。浮動面板會保持在目前的應用程式視窗之內。
diff --git a/ROADMAP.md b/ROADMAP.md
index f142cd226..ec392e88d 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327; German catalog 91% → 97%: interface strings and What's New lines translated) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work
from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in
@@ -49,7 +49,7 @@ Hours are calibrated on this repo's PR history; see
| UI/UX fidelity | ~70% | ~85% | 32–54 h | [ui-parity](docs/ui-parity.md) |
| File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) |
| Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) |
-| Localization | ~55% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) |
+| Localization | ~64% of the 12 key languages | 8 of 12 present | 130–210 h (+135–239 h new scripts) | [localization-parity](docs/localization-parity.md) |
| Performance | ~70% | ~80% | 30–50 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
| Stability | ~60% | — | 30–60 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
| Platforms | ~60% | macOS, Windows, Linux, FreeBSD, web | 25–45 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
@@ -78,7 +78,7 @@ Weights and evidence: [target-app-parity.md](docs/target-app-parity.md#features)
## Languages
-Measured from `crates/ui-egui/locales` (share of 2,304 catalog messages translated); none is `full`
+Measured from `crates/ui-egui/locales` (share of 2,304 catalog messages translated; German counts 3,644 of 3,754, because its What's New lines add keys); none is `full`
while lower-layer errors stay English. Detail: [localization-parity.md](docs/localization-parity.md).
| Language | Code | Translated | Status |
@@ -90,13 +90,13 @@ while lower-layer errors stay English. Detail: [localization-parity.md](docs/loc
| Arabic | ar | 0% | none (no RTL / shaping) |
| French | fr | 88% | partial |
| Portuguese (Brazil) | pt-br | 85% | partial |
-| Indonesian | id | 0% | none |
+| Indonesian | id | 100% (What's New in English) | partial |
| Japanese | ja | 87% | partial |
-| German | de | 91% | partial |
+| German | de | 97% (3,644/3,754) | partial |
| Korean | ko | 0% | none (no Hangul face) |
| Vietnamese | vi | 0% | none |
-Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%), Italian (92%), Georgian (100%).
+Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%), Italian (92%), Georgian (100%), Finnish (79%).
## Upcoming
@@ -117,6 +117,9 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
- 2026-10-14: Camera-coverage sweep verifies 30 more models on 40 CC0 files, with file and sensor
hashes pinned (`docs/raw/coverage.py`: 515 → 545 verified). Eleven candidates excluded after rechecking
identity, provenance and camera-image agreement. Phone and drone DNG tone differences remain tracked in #1277.
+- 2026-10-18: Minolta / Konica Minolta MRW decodes from sensor data (12-bit packed and 16-bit words,
+ RGGB and GBRG, as-shot WB from `WBG`); DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D verified on
+ CC0 files. 551 models verified, 201 preview-only; headline numbers unchanged.
- 2026-10-14: 97 Canon CR2, Pentax and Ricoh models verified, one CC0 raw.pixls.us file each pinned and
checked against the camera JPEG (`crates/raw/tests/canon_pentax_corpus.rs`). 334 models verified, ~50% of
photographers on a verified body; headline numbers unchanged.
@@ -183,17 +186,21 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
| Date | Change | Summary |
|---|---|---|
+| 2026-10-19 | minor | Indonesian interface (`id`): every plain and formats message, plus 69 command labels, locale, menu command and tests; What's New left in English; Localization ~55% → ~64% (mean of the 12 key languages), 8 of 12 present |
| 2026-10-12 | minor | Italian interface (`it`, #1192): 2,201 of 2,398 messages, locale, menu command and tests; headline numbers unchanged |
| 2026-10-13 | minor | Sony ILCA-99M2 camera colour profile in the log; numbers unchanged |
| 2026-10-14 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled |
+| 2026-10-16 | minor | German 91% → 97% (217 interface strings and 862 What's New lines added); headline numbers unchanged |
| 2026-10-14 | minor | 21 Sony and Leica models verified (258 models, 18%); headline numbers unchanged |
| 2026-10-12 | minor | UI/UX remaining 34–58 h → 32–54 h (canvas tools by keyboard); headline numbers unchanged |
| 2026-10-14 | minor | 17 uncompressed Olympus and Samsung models verified on one CC0 file each (254 models verified, 18%); headline numbers unchanged |
| 2026-10-12 | minor | Mamiya MEF decoded and imported: ZD verified (241); headline numbers unchanged |
| 2026-10-12 | minor | Epson ERF decoded: 3 more verified models (240); headline numbers unchanged |
| 2026-10-12 | minor | Georgian (`ka`) interface language added (every catalog message) |
+| 2026-10-17 | minor | Finnish (`fi`) interface language added: 2,610 of 3,308 messages (79%), locale, menu command and tests; headline numbers unchanged |
| 2026-10-14 | minor | Full-resolution zoom (gap 4) closed: soft proofing gets zoom windows; headline numbers unchanged |
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged |
+| 2026-10-18 | minor | Minolta MRW decodes from sensor data; 6 Konica Minolta bodies verified; headline numbers unchanged |
| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); headline numbers unchanged |
| 2026-10-12 | minor | 22 more CR3 bodies verified, EOS R6 Mark III white balance: RAW decoding 77% → 83%, RAW ~56% → ~60%; mainstream 49% → 51%, essentials 64% → 65%; full unchanged at ~61% |
| 2026-10-12 | minor | 117 Nikon and Fujifilm bodies verified (354 models, 24%); headline numbers unchanged |
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 000000000..6d7340dd5
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,54 @@
+# Security Policy
+
+LightCraft reads camera raw files, image formats, metadata and sidecars, imports other photo catalogs, downloads and runs machine-learning models, and exposes CLI, JSON control, and MCP automation. Treat files, paths, metadata, catalogs, model files, automation requests, and command parameters as potentially malicious.
+
+## Reporting a vulnerability
+
+Please give the repository maintainers a reasonable opportunity to investigate and coordinate a fix before publishing exploitable details, proof-of-concept code, or weaponized files.
+
+This repository does not currently document a dedicated private security email or private reporting channel. Contact the repository maintainers through the repository owner/maintainer channels already listed in the project, provide only a minimal non-exploitable summary in public, and ask for an appropriate private transfer method before sharing sensitive details. Do not open a public issue containing a working exploit, secret, sensitive crash dump, or malicious attachment.
+
+If no private channel can be established, withhold weaponized material and report the smallest safe description that allows maintainers to make contact.
+
+## What to include
+
+A useful report includes:
+
+- affected LightCraft version, commit, or release artifact;
+- operating system, architecture, build profile (desktop or web), and relevant feature flags;
+- the affected component or path;
+- security impact and required attacker access;
+- exact reproduction steps;
+- expected behavior and actual behavior;
+- crash logs, backtraces, sanitizer output, or resource measurements when applicable;
+- whether the problem reproduces from a clean checkout;
+- any known workaround or containment.
+
+Remove credentials, personal paths, private photos, and unrelated system information from logs.
+
+## Malicious test files
+
+Do not attach a potentially harmful or non-redistributable file to a public issue. Initially provide its format, size, cryptographic hash, observed effect, provenance category (for example, synthetic or fuzz-generated), and the command needed to reproduce the problem. Coordinate a private transfer method with maintainers before sending the sample.
+
+Test files accepted into the repository must be minimized, free of personal or proprietary data, legally redistributable, and documented with the expected failure behavior. A fixed parser defect should receive a permanent regression test whenever safe and practical.
+
+## Scope
+
+Security reports may cover:
+
+- camera raw, DNG, TIFF, HEIF, JPEG and other image decoding, and EXIF/XMP metadata and sidecar parsing;
+- decompression bombs, oversized dimensions, integer overflow, memory exhaustion, recursion, or hangs;
+- the LightCraft catalog, and importing other catalogs and libraries (such as Lightroom catalogs);
+- import/export and filesystem path handling, including traversal, symlinks, and unsafe overwrite or move behavior;
+- downloading, verifying, unpacking and running machine-learning models (denoise, faces, masks);
+- engine command validation and serialization/deserialization;
+- the desktop control channel, the MCP server, and the MCP-to-app connection;
+- the WebAssembly build;
+- GPU resource validation or driver-facing input handling;
+- dependencies, CI, packaging, signing, checksums, and release artifact integrity.
+
+General bugs without a security impact should use the project's normal issue and contribution process.
+
+## Current limitations
+
+The control channel (`lightcraft --control PORT`) listens on loopback only and has no authentication, so any local process can drive the app while it is enabled; only enable it when you need it ([control-protocol.md](docs/control-protocol.md)).
diff --git a/apps/lightcraft/Cargo.toml b/apps/lightcraft/Cargo.toml
index 26122c3e6..95e080974 100644
--- a/apps/lightcraft/Cargo.toml
+++ b/apps/lightcraft/Cargo.toml
@@ -26,6 +26,7 @@ lightcraft-raster = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11"] }
egui = { workspace = true }
serde_json = { workspace = true }
+chrono = { workspace = true }
# probing the window's renderer before it opens (`window_gpu`)
pollster = { workspace = true }
log = { workspace = true }
diff --git a/apps/lightcraft/src/control_server.rs b/apps/lightcraft/src/control_server.rs
index 537c75bd1..777831942 100644
--- a/apps/lightcraft/src/control_server.rs
+++ b/apps/lightcraft/src/control_server.rs
@@ -39,22 +39,33 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver {
fn accept_loop(listener: TcpListener, tx: Sender, ctx: egui::Context) {
let open = Arc::new(AtomicUsize::new(0));
- for mut stream in listener.incoming().flatten() {
- if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
- open.fetch_sub(1, Ordering::SeqCst);
- let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
- continue;
- }
- let tx = tx.clone();
- let ctx = ctx.clone();
- let open = Arc::clone(&open);
- std::thread::spawn(move || {
- serve(stream, &tx, &ctx);
- open.fetch_sub(1, Ordering::SeqCst);
- });
+ for stream in listener.incoming().flatten() {
+ let _ = spawn_connection(stream, tx.clone(), ctx.clone(), Arc::clone(&open), IDLE_TIMEOUT);
}
}
+/// Admit and serve one connection, releasing its slot when serving ends.
+fn spawn_connection(
+ mut stream: TcpStream,
+ tx: Sender,
+ ctx: egui::Context,
+ open: Arc,
+ idle_timeout: Duration,
+) -> Option> {
+ if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
+ open.fetch_sub(1, Ordering::SeqCst);
+ let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
+ return None;
+ }
+ Some(std::thread::spawn(move || {
+ serve(stream, &tx, &ctx, idle_timeout);
+ open.fetch_sub(1, Ordering::SeqCst);
+ }))
+}
+
+/// Five minutes allows pauses between automation commands without retaining abandoned slots forever.
+const IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
+
/// How long a request waits for its reply.
const REPLY_TIMEOUT: Duration = Duration::from_secs(60);
/// A request the app has not picked up while it drew no frame for this long is given up: the
@@ -66,9 +77,17 @@ const NO_FRAMES_AFTER: Duration = Duration::from_secs(5);
pub const HIDDEN_WINDOW_ERROR: &str = "the app drew no frame for 5 s, so the request was not run: its window is probably not visible \
(on Wayland a window on a hidden workspace or minimized is not redrawn, which pauses the app). Bring the window on screen and send it again";
-fn serve(stream: TcpStream, tx: &Sender, ctx: &egui::Context) {
- let Ok(read) = stream.try_clone() else { return };
- serve_lines(read, stream, |method, params| {
+/// Reads and writes go through the one `stream` (`&TcpStream` is `Read` and `Write`): no
+/// `try_clone`. On Windows a cloned socket handle is inheritable, so every program the app starts
+/// while a client is connected (a link in the browser, Show in Explorer, an external editor) kept a
+/// copy of the connection: closing it then reached the client only when that program exited, as a
+/// reset instead of a clean close.
+fn serve(stream: TcpStream, tx: &Sender, ctx: &egui::Context, idle_timeout: Duration) {
+ if let Err(e) = stream.set_read_timeout(Some(idle_timeout)) {
+ log::warn!("control connection closed: could not set idle read timeout: {e}");
+ return;
+ }
+ serve_lines(&stream, &stream, |method, params| {
let (req, rrx) = ControlRequest::new(method, params);
let state = Arc::clone(&req.state);
tx.send(req).ok()?;
@@ -157,7 +176,10 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec, max: usize) -
buf.clear();
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
- let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
+ let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| match e.kind() {
+ std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => "control connection idle read timed out; closing the connection".to_string(),
+ _ => e.to_string(),
+ })?;
if n == 0 {
return Ok(false);
}
@@ -209,6 +231,95 @@ fn serve_lines(read: R, mut out: W, mut handle: impl FnMut(St
mod tests {
use super::*;
+ /// Own the client and worker together: even a failing timeout assertion closes the socket
+ /// before joining, so the regression cannot leave a worker stuck in a read.
+ struct Connection {
+ client: BufReader,
+ requests: Receiver,
+ worker: Option>,
+ }
+
+ impl Connection {
+ fn new(open: &Arc, idle: Duration) -> Self {
+ let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
+ let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
+ client.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
+ let (stream, _) = listener.accept().unwrap();
+ let (tx, requests) = channel();
+ let worker = spawn_connection(stream, tx, egui::Context::default(), Arc::clone(open), idle).unwrap();
+ Self { client: BufReader::new(client), requests, worker: Some(worker) }
+ }
+
+ fn join(&mut self) {
+ if let Some(worker) = self.worker.take() {
+ worker.join().unwrap();
+ }
+ }
+ }
+
+ impl Drop for Connection {
+ fn drop(&mut self) {
+ let _ = self.client.get_ref().shutdown(std::net::Shutdown::Both);
+ while let Ok(req) = self.requests.try_recv() {
+ let _ = req.reply.send(json!({"ok": false, "error": "test closing"}));
+ }
+ self.join();
+ }
+ }
+
+ #[test]
+ fn idle_connections_release_their_slots_without_dispatching() {
+ for input in ["", "{\"method\":\"engine.execute\""] {
+ let open = Arc::new(AtomicUsize::new(0));
+ let mut connection = Connection::new(&open, Duration::from_millis(100));
+ assert_eq!(open.load(Ordering::SeqCst), 1);
+ connection.client.get_mut().write_all(input.as_bytes()).unwrap();
+ let mut reply = String::new();
+ let closed = connection.client.read_to_string(&mut reply);
+ assert!(closed.is_ok(), "idle connection stayed open: {closed:?}");
+ connection.join();
+ assert_eq!(open.load(Ordering::SeqCst), 0, "the production worker releases its slot");
+ assert!(connection.requests.try_recv().is_err(), "incomplete input never runs");
+ let reply: Value = serde_json::from_str(&reply).unwrap();
+ assert_eq!(reply["ok"], false);
+ assert!(reply["error"].as_str().unwrap().contains("idle read timed out"));
+
+ // A subsequent connection is admitted through the same accounting path.
+ let mut next = Connection::new(&open, Duration::from_secs(1));
+ next.client.get_mut().shutdown(std::net::Shutdown::Write).unwrap();
+ let mut eof = String::new();
+ next.client.read_to_string(&mut eof).unwrap();
+ next.join();
+ assert!(eof.is_empty());
+ assert_eq!(open.load(Ordering::SeqCst), 0);
+ }
+ }
+
+ #[test]
+ fn persistent_requests_and_slow_replies_outlive_the_idle_read_timeout() {
+ let open = Arc::new(AtomicUsize::new(0));
+ let mut connection = Connection::new(&open, Duration::from_millis(100));
+ for id in [1, 2] {
+ writeln!(connection.client.get_mut(), "{}", json!({"id": id, "method": "engine.commands"})).unwrap();
+ let req = connection.requests.recv_timeout(Duration::from_secs(3)).unwrap();
+ assert_eq!(req.method, "engine.commands");
+ assert!(req.take());
+ // A dispatched command waits for its reply, not for socket input.
+ std::thread::sleep(Duration::from_millis(250));
+ req.reply.send(json!({"ok": true, "result": id})).unwrap();
+ let mut reply = String::new();
+ connection.client.read_line(&mut reply).unwrap();
+ let reply: Value = serde_json::from_str(&reply).unwrap();
+ assert_eq!(reply, json!({"id": id, "ok": true, "result": id}));
+ }
+ connection.client.get_mut().shutdown(std::net::Shutdown::Write).unwrap();
+ let mut eof = String::new();
+ connection.client.read_to_string(&mut eof).unwrap();
+ connection.join();
+ assert!(eof.is_empty());
+ assert_eq!(open.load(Ordering::SeqCst), 0);
+ }
+
/// Issue #718: an app that draws no frames (a hidden Wayland window) gets the request given
/// up with a reason, quickly, and the app then skips it instead of running it late.
#[test]
@@ -337,6 +448,37 @@ mod tests {
assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err());
}
+ /// A program the app starts while a client is connected doesn't keep the connection open:
+ /// when the server closes it, the client sees that at once, not when the program exits.
+ #[cfg(windows)]
+ #[test]
+ fn a_program_started_meanwhile_does_not_hold_the_connection_open() {
+ let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
+ let addr = listener.local_addr().unwrap();
+ let (tx, _rx) = channel::();
+ let server = std::thread::spawn(move || {
+ let (s, _) = listener.accept().unwrap();
+ serve(s, &tx, &egui::Context::default(), IDLE_TIMEOUT);
+ });
+ let mut c = TcpStream::connect(addr).unwrap();
+ c.set_read_timeout(Some(Duration::from_secs(20))).unwrap();
+ // the connection is being served: start a program that lives about 4 s
+ std::thread::sleep(Duration::from_millis(200));
+ let mut child = std::process::Command::new("cmd").args(["/c", "ping -n 5 127.0.0.1 >nul"]).spawn().unwrap();
+ std::thread::sleep(Duration::from_millis(200));
+ // junk makes the server answer once and close
+ c.write_all(b"hello\n").unwrap();
+ let t0 = std::time::Instant::now();
+ let mut text = String::new();
+ let read = c.read_to_string(&mut text);
+ let waited = t0.elapsed();
+ server.join().unwrap();
+ let _ = child.wait();
+ assert!(read.is_ok(), "the close must be clean, not a reset: {read:?}");
+ assert!(text.contains("closing the connection"), "{text}");
+ assert!(waited < Duration::from_secs(2), "the client saw the close only after {waited:?}, when the program exited");
+ }
+
#[test]
fn tcp_connection_is_closed_after_junk() {
// End to end over a real loopback socket (the transport the app uses).
diff --git a/apps/lightcraft/src/logging.rs b/apps/lightcraft/src/logging.rs
index 718392f35..6239aded6 100644
--- a/apps/lightcraft/src/logging.rs
+++ b/apps/lightcraft/src/logging.rs
@@ -113,28 +113,36 @@ fn matches(name: &str, target: &str) -> bool {
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
+/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
+/// logging never panics and never silently resets to the epoch for a convertible instant.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
- let secs = since.as_secs();
- let (days, rem) = (secs / 86_400, secs % 86_400);
- let (y, m, d) = civil_from_days(days);
- format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
+ // Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
+ // past `i64::MAX` seconds saturates rather than falling back to the epoch.
+ let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
+ let (y, t) = utc_parts(secs, since.subsec_nanos());
+ // `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
+ format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
}
-/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
-/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
-/// value can overflow it.
-fn civil_from_days(days: u64) -> (u64, u64, u64) {
- let z = days.saturating_add(719_468);
- let era = z / 146_097;
- let doe = z % 146_097;
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let d = doy - (153 * mp + 2) / 5 + 1;
- let m = if mp < 10 { mp + 3 } else { mp - 9 };
- let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
- (y, m, d)
+/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
+/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
+/// so month, day and time of day come from chrono and only the year is carried, which keeps every
+/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
+fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
+ use chrono::Datelike;
+ let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
+ let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
+ // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
+ let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
+ let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
+ match (date, time) {
+ (Some(d), Some(t)) => {
+ let y = i128::from(d.year()) + i128::from(cycles) * 400;
+ (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
+ }
+ _ => (1970, chrono::NaiveDateTime::default()), // unreachable
+ }
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -483,6 +491,20 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
+ // Past chrono's year range still converts via the 400-year cycle (the old code did too,
+ // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
+ // SystemTime can't hold this instant, so it is only checked where it exists.
+ // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
+ if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
+ assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
+ }
+ // Years past 9999 keep the plain zero-padded year (no `+`).
+ assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
+ // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
+ // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
+ if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
+ assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
+ }
}
#[test]
diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md
index 7f8a8a719..30eea5654 100644
--- a/assets/ATTRIBUTION.md
+++ b/assets/ATTRIBUTION.md
@@ -33,7 +33,11 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders |
| `crates/ui-egui/locales/cs.json`, `crates/ui-egui/locales/cs-formats.json` | Czech interface catalogs | @Losminek with AI assistance (Codex) | original work: translations of English source phrases, reviewed against the app UI conventions; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-11 | catalog coverage, Czech dates and count-neutral wording; independent scope/terminology review; native-speaker proofreading pending |
| `crates/ui-egui/locales/it.json`, `crates/ui-egui/locales/it-formats.json` | Italian interface catalogs | @GabrielePecchioli with Claude Code; PhotoCraft, VectorCraft, PdfCraft and WordCraft translation contributors for reused labels | original translations from English source; 410 initial English-key matches reviewed from [PhotoCraft da3da09](https://github.com/storytold/photocraft/blob/da3da0942c14c1159f40c652afcf33584332c733/crates/ui-egui/src/i18n/it.tsv), [VectorCraft 5432821](https://github.com/storytold/vectorcraft/blob/543282148007bcb5633c493a5374cd26b0339c8d/crates/ui-egui/src/i18n/it.tsv), [PdfCraft e8e275c](https://github.com/storytold/pdfcraft/blob/e8e275cbd40ccd0dc6b56f82b16a575571043702/crates/ui-egui/src/i18n/it.tsv) and the Italian WordCraft catalog ([storytold/wordcraft#598](https://github.com/storytold/wordcraft/pull/598)); 46 of them retranslated where the sibling's meaning didn't fit a photo app | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-12 | adapted to LightCraft photo-development context, count-neutral labels for Italian plural agreement, reviewed placeholders |
+| `crates/ui-egui/locales/id.json`, `crates/ui-egui/locales/id-formats.json` | Indonesian interface catalogs | @RevDonz with Claude Code; PhotoCraft, VectorCraft, FilmCraft and FigCraft translation contributors for reused labels | original translations from English source; 356 labels match the sibling apps' Indonesian wording, reviewed from [PhotoCraft c39d752](https://github.com/storytold/photocraft/blob/c39d752758155492bbaba71d802cfaa03206fb4a/crates/ui-egui/src/i18n/id.tsv), [VectorCraft 9fe0f03](https://github.com/storytold/vectorcraft/blob/9fe0f03de2bd29e8c86a1179f977c3e651069424/crates/ui-egui/src/i18n/id.tsv), [FilmCraft fae7f8a](https://github.com/storytold/filmcraft/blob/fae7f8a9be09bd74f95fbe08c3d98adc17fe1030/crates/ui-egui/src/i18n/id.tsv) and [FigCraft 8482c3e](https://github.com/storytold/figcraft/blob/8482c3e85bc5f2a430e2f3be2826674872f6d084/crates/ui-egui/locales/id.toml) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-19 | adapted to LightCraft's photo context, count-neutral nouns, reviewed placeholders |
| `crates/ui-egui/locales/ka.json`, `crates/ui-egui/locales/ka-formats.json` | Georgian interface catalogs | Davit Matchakhelidze with Claude; WordCraft, GridCraft, PhotoCraft, FilmCraft and VectorCraft Georgian catalogs for reused labels | original translations from the English source (meaning-based, no vendor localization used); 450 English-key matches reused from the sibling Craft apps' Georgian catalogs | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-12 | adapted to LightCraft photo-library and raw-development context; count-neutral numerals; placeholders checked |
+| `crates/ui-egui/locales/sv.json`, `crates/ui-egui/locales/sv-formats.json` | Swedish interface catalogs | LightCraft contributors | original work: clean-room Swedish translations of English source phrases | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-15 | none |
+| `crates/ui-egui/locales/fi.json`, `crates/ui-egui/locales/fi-formats.json` | Finnish interface catalogs | @roopeg with Claude Code (AI-assisted; native-speaker review by @roopeg) | original work: translations of the English source phrases, using common Finnish photo-editing vocabulary; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-17 | count-neutral labels for Finnish partitive agreement, Finnish dates and times (`20.9.2026`, `16:04`), reviewed placeholders |
+| `crates/ui-egui/locales/vi.json`, `crates/ui-egui/locales/vi-formats.json` | Vietnamese interface catalogs | @duydev (DUY Tran Nhat) with AI assistance (Cursor) | original work: translations of English source phrases; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-13 | plural-neutral counts, Vietnamese dates and weekdays, reviewed placeholders |
Notes
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
diff --git a/contributors/people.toml b/contributors/people.toml
index 6f0bd6085..4eb002cef 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -61,3 +61,6 @@ sync_github_name = true
[people.mahmatias]
display_name = "Márcio Matias de Andrade"
sync_github_name = true
+
+[people.ronibalthazar]
+sync_github_name = true
diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml
index 97d8868de..27273d5c4 100644
--- a/crates/catalog/Cargo.toml
+++ b/crates/catalog/Cargo.toml
@@ -17,6 +17,7 @@ serde_json = { workspace = true }
thiserror = { workspace = true }
crc32fast = { workspace = true }
log = { workspace = true }
+chrono = { workspace = true }
web-time = { workspace = true }
[dev-dependencies]
diff --git a/crates/catalog/src/dates.rs b/crates/catalog/src/dates.rs
index ca463dc55..2b71049ee 100644
--- a/crates/catalog/src/dates.rs
+++ b/crates/catalog/src/dates.rs
@@ -2,6 +2,7 @@
//! weekday and month names, shifting a time by an offset, and grouping a photo list into runs of
//! the same day / month / year (the date headers of the photo grid).
+use chrono::Datelike;
use serde::{Deserialize, Serialize};
pub use crate::stacks::iso_seconds;
@@ -11,32 +12,32 @@ pub const WEEKDAYS: [&str; 7] = ["Monday", "Tuesday", "Wednesday", "Thursday", "
pub const MONTHS: [&str; 12] =
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
-/// Unix seconds → `YYYY-MM-DDTHH:MM:SS` (proleptic Gregorian; H. Hinnant's algorithm).
+/// Unix seconds → `YYYY-MM-DDTHH:MM:SS` (UTC, proleptic Gregorian). A time outside chrono's year
+/// range still converts: the calendar repeats every 400 years, so the instant is moved into range
+/// by whole cycles and only the year is carried (matching the old `civil_from_days`), rather than
+/// resetting to the epoch. The callers here stay well inside years 0000–9999.
pub fn civil(secs: i64) -> String {
- let days = secs.div_euclid(86_400);
- let rem = secs.rem_euclid(86_400);
- let (y, m, d) = civil_from_days(days);
- format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}", rem / 3600, rem % 3600 / 60, rem % 60)
-}
-
-/// Days since 1970-01-01 → (year, month 1..=12, day 1..=31).
-pub fn civil_from_days(days: i64) -> (i64, i64, i64) {
- let z = days + 719_468;
- let era = z.div_euclid(146_097);
- let doe = z - era * 146_097;
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let d = doy - (153 * mp + 2) / 5 + 1;
- let m = if mp < 10 { mp + 3 } else { mp - 9 };
- (yoe + era * 400 + i64::from(m <= 2), m, d)
+ let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
+ let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
+ // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
+ let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
+ let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
+ match (date, time) {
+ (Some(d), Some(t)) => {
+ let y = i128::from(d.year()) + i128::from(cycles) * 400;
+ let y = i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX });
+ // `{:04}` rather than `%Y`, which writes a `+` before years past 9999 (the old `{y:04}` did not).
+ format!("{y:04}-{}", d.and_time(t).format("%m-%dT%H:%M:%S"))
+ }
+ _ => "1970-01-01T00:00:00".to_string(), // unreachable
+ }
}
/// Weekday name of an ISO date (`None` if it doesn't parse).
pub fn weekday(iso: &str) -> Option<&'static str> {
let days = iso_seconds(iso.get(..10)?)?.div_euclid(86_400);
- // 1970-01-01 was a Thursday
- Some(WEEKDAYS[(days + 3).rem_euclid(7) as usize])
+ let date = chrono::NaiveDate::from_num_days_from_ce_opt(i32::try_from(days.checked_add(719_163)?).ok()?)?;
+ Some(WEEKDAYS[date.weekday().num_days_from_monday() as usize])
}
/// Shift an ISO time by `secs`, keeping anything after the seconds (fractions, zone) as it was.
@@ -156,38 +157,56 @@ impl Catalog {
/// never split by a header. Empty for sort keys without a date and for [`GroupBy::None`]
/// ([`GroupBy::Auto`] means days here).
pub fn date_runs(&self, ids: &[PhotoId], key: SortKey, by: GroupBy) -> Vec {
- if by == GroupBy::None || ids.is_empty() {
+ if !groups_by_date(key, by) || ids.is_empty() {
return Vec::new();
}
- let n = by.prefix();
- let date_of = |id: &PhotoId| -> String {
- let Some(p) = self.photo(*id) else { return String::new() };
- let d = match key {
- SortKey::CaptureDate => p.captured.as_deref(),
- SortKey::ImportDate => Some(p.imported.as_str()),
- SortKey::EditDate => p.edited.as_deref(),
- _ => None,
- };
- d.filter(|d| iso_seconds(d.get(..10).unwrap_or("")).is_some()).and_then(|d| d.get(..n)).unwrap_or("").to_string()
- };
- if !matches!(key, SortKey::CaptureDate | SortKey::ImportDate | SortKey::EditDate) {
- return Vec::new();
- }
- let stacks = self.stack_index();
- let mut runs: Vec = Vec::new();
- let mut stack_key: std::collections::HashMap = Default::default();
- for (i, id) in ids.iter().enumerate() {
- let k = match stacks.get(id) {
- Some((sid, _)) => stack_key.entry(*sid).or_insert_with(|| date_of(id)).clone(),
- None => date_of(id),
- };
- match runs.last_mut() {
- Some(r) if r.key == k => r.count += 1,
- _ => runs.push(DateRun { label: group_label(&k), key: k, start: i, count: 1 }),
- }
- }
- runs
+ date_runs_of(ids, &self.stack_index(), |_, id| self.date_group(id, key, by))
}
+
+ /// The date group of photo `id` on its own for [`Catalog::date_runs`] (`2026-09-30` /
+ /// `2026-09` / `2026`; empty without a usable date): a stack's members take the group of its
+ /// first photo in the list there.
+ pub fn date_group(&self, id: PhotoId, key: SortKey, by: GroupBy) -> String {
+ let Some(p) = self.photo(id) else { return String::new() };
+ let d = match key {
+ SortKey::CaptureDate => p.captured.as_deref(),
+ SortKey::ImportDate => Some(p.imported.as_str()),
+ SortKey::EditDate => p.edited.as_deref(),
+ _ => None,
+ };
+ d.filter(|d| iso_seconds(d.get(..10).unwrap_or("")).is_some()).and_then(|d| d.get(..by.prefix())).unwrap_or("").to_string()
+ }
+}
+
+/// Whether [`Catalog::date_runs`] has runs for sort key `key` grouped `by`.
+pub fn groups_by_date(key: SortKey, by: GroupBy) -> bool {
+ by != GroupBy::None && matches!(key, SortKey::CaptureDate | SortKey::ImportDate | SortKey::EditDate)
+}
+
+/// [`Catalog::date_runs`] from each photo's own group (`group(position, id)`, see
+/// [`Catalog::date_group`]) and the stack index ([`Catalog::stack_index`]).
+pub fn date_runs_of>(
+ ids: &[PhotoId],
+ stacks: &std::collections::HashMap,
+ mut group: impl FnMut(usize, PhotoId) -> K,
+) -> Vec {
+ let mut runs: Vec = Vec::new();
+ let mut stack_key: std::collections::HashMap = Default::default();
+ for (i, id) in ids.iter().enumerate() {
+ let own;
+ let k: &str = match stacks.get(id) {
+ Some((sid, _)) => stack_key.entry(*sid).or_insert_with(|| group(i, *id).as_ref().to_string()),
+ None => {
+ own = group(i, *id);
+ own.as_ref()
+ }
+ };
+ match runs.last_mut() {
+ Some(r) if r.key == k => r.count += 1,
+ _ => runs.push(DateRun { label: group_label(k), key: k.to_string(), start: i, count: 1 }),
+ }
+ }
+ runs
}
#[cfg(test)]
@@ -199,6 +218,14 @@ mod tests {
fn calendar() {
assert_eq!(civil(0), "1970-01-01T00:00:00");
assert_eq!(civil(951_782_400), "2000-02-29T00:00:00");
+ // A year past 9999 keeps the plain zero-padded year (no `+`), as the old `{y:04}` did.
+ assert_eq!(civil(253_402_300_800), "10000-01-01T00:00:00");
+ // Beyond chrono's year range the old `civil_from_days` still converted; pin that output
+ // (never the epoch) in both directions and at the i64 extremes.
+ assert_eq!(civil(10_000_000_000_000), "318857-05-20T17:46:40");
+ assert_eq!(civil(-10_000_000_000_000), "-314918-08-13T06:13:20");
+ assert_eq!(civil(i64::MAX), "292277026596-12-04T15:30:07");
+ assert_eq!(civil(i64::MIN), "-292277022657-01-27T08:29:52");
assert_eq!(weekday("2026-09-30"), Some("Wednesday"));
assert_eq!(weekday("2026-10-01T09:00:00"), Some("Thursday"));
assert_eq!(weekday("2000-02-29"), Some("Tuesday"));
diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs
index 6ac3544ee..b405e1db9 100644
--- a/crates/catalog/src/model.rs
+++ b/crates/catalog/src/model.rs
@@ -329,7 +329,7 @@ pub struct Analysis {
/// file's white balance is edited relative to its as-shot look or in Kelvin depends on the camera:
/// [`relative_wb_camera`].
pub fn relative_wb_format(format: &str) -> bool {
- ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF", "ERF"].iter().any(|f| format.eq_ignore_ascii_case(f))
+ ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF", "ERF", "MRW"].iter().any(|f| format.eq_ignore_ascii_case(f))
}
/// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed
@@ -610,6 +610,7 @@ mod edited_tests {
("a.pef", "pef", true),
("a.orf", "ORF", true),
("a.erf", "ERF", true),
+ ("a.mrw", "MRW", true),
("a.dng", "DNG", false),
] {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00");
@@ -653,6 +654,7 @@ mod edited_tests {
("SONY ILCE-1", "ARW", true),
("", "ARW", true),
("SONY ILCE-7M3", "DNG", false),
+ ("NIKON CORPORATION NIKON Z5_2", "NEF", false),
] {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.raw", format, 10, 10, "2026-10-01T00:00:00");
p.kind = MediaKind::Raw;
diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs
index 7625c9ab6..a94421369 100644
--- a/crates/catalog/src/query.rs
+++ b/crates/catalog/src/query.rs
@@ -141,9 +141,40 @@ pub(crate) struct Prepared {
only: Option>,
}
+/// The tokens of a search text: split at whitespace, except inside double quotes, so
+/// `person:"Jane Doe"` is one token (issue #1449). An unclosed quote runs to the end of the text; a
+/// backslash is an ordinary character. One pass over the text, nothing allocated.
+fn search_tokens(text: &str) -> impl Iterator {
+ let mut rest = text;
+ std::iter::from_fn(move || {
+ rest = rest.trim_start();
+ if rest.is_empty() {
+ return None;
+ }
+ let mut quoted = false;
+ let end = rest
+ .char_indices()
+ .find(|&(_, c)| {
+ quoted ^= c == '"';
+ !quoted && c.is_whitespace()
+ })
+ .map_or(rest.len(), |(i, _)| i);
+ let (tok, tail) = rest.split_at(end);
+ rest = tail;
+ Some(tok)
+ })
+}
+
+/// `s` without the double quotes around it (a missing closing quote is allowed).
+fn unquote(s: &str) -> &str {
+ s.strip_prefix('"').map_or(s, |inner| inner.strip_suffix('"').unwrap_or(inner))
+}
+
fn token_matches(p: &Photo, tok: &str) -> bool {
let t = tok.to_lowercase();
- if let Some((field, val)) = t.split_once(':') {
+ // a token that starts with a quote is a phrase, even with a colon in it
+ if let Some((field, quoted)) = t.split_once(':').filter(|_| !t.starts_with('"')) {
+ let val = unquote(quoted);
let num = |s: &str| -> Option<(char, f64)> {
let (op, rest) = match s.chars().next()? {
c @ ('>' | '<' | '=') => (c, &s[1..]),
@@ -180,18 +211,22 @@ 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),
- // Not a known field: the colon is part of a plain word ("10:30", "Re:").
+ // Not a known field: the colon is part of a plain word ("10:30", "Re:"), its value unquoted
+ // (`re:"a b"` is the text `re:a b`).
+ _ if val.len() != quoted.len() => text_matches(p, &format!("{field}:{val}")),
_ => text_matches(p, &t),
};
}
- text_matches(p, &t)
+ text_matches(p, unquote(&t))
}
/// Whether the lower-cased word `t` is in the file name, title, caption, camera, lens, location,
-/// format or a keyword of `p`.
+/// format, a keyword or the name of a person (a named face) 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))
+ || p.people().iter().any(|n| n.to_lowercase().contains(t))
}
/// Whether `p` has a named face region called `name` (case-insensitive).
@@ -409,7 +444,7 @@ impl Filter {
{
return false;
}
- self.text.split_whitespace().all(|tok| token_matches(p, tok))
+ search_tokens(&self.text).all(|tok| token_matches(p, tok))
}
}
diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs
index a87c9effc..667509d6f 100644
--- a/crates/catalog/src/rules.rs
+++ b/crates/catalog/src/rules.rs
@@ -794,12 +794,90 @@ fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool {
}
}
+/// One byte as the `filePath` rules compare it: `/` and `\` both separate, case doesn't matter.
+fn fold_path_byte(b: u8) -> u8 {
+ if b == b'\\' { b'/' } else { b.to_ascii_lowercase() }
+}
+
+/// Whether `a` and `b` are equal under [`fold_path_byte`].
+fn path_bytes_eq(a: &[u8], b: &[u8]) -> bool {
+ a.len() == b.len() && a.iter().zip(b).all(|(x, y)| fold_path_byte(*x) == fold_path_byte(*y))
+}
+
+/// `needle` inside `hay`, under [`fold_path_byte`]; allocates nothing.
+fn path_bytes_contains(hay: &[u8], needle: &[u8]) -> bool {
+ let Some((&first, rest)) = needle.split_first() else { return true };
+ // the spellings of the needle's first byte that fold to it: a plain two-way compare per byte
+ let (a, b) = match fold_path_byte(first) {
+ b'/' => (b'/', b'\\'),
+ c if c.is_ascii_lowercase() => (c, c.to_ascii_uppercase()),
+ c => (c, c),
+ };
+ let Some(starts) = hay.len().checked_sub(needle.len()).and_then(|last| hay.get(..=last)) else { return false };
+ let at = |i: usize, c: u8| (c == a || c == b) && hay.get(i + 1..i + needle.len()).is_some_and(|tail| path_bytes_eq(tail, rest));
+ // eight bytes at a time: does any of them spell the first byte (a zero byte after xor)?
+ const ONES: u64 = 0x0101_0101_0101_0101;
+ let has_zero = |v: u64| v.wrapping_sub(ONES) & !v & (ONES << 7) != 0;
+ let (chunks, tail) = starts.as_chunks::<8>();
+ let tail_from = starts.len() - tail.len();
+ for (k, chunk) in chunks.iter().enumerate() {
+ let w = u64::from_le_bytes(*chunk);
+ if (has_zero(w ^ (ONES * u64::from(a))) || has_zero(w ^ (ONES * u64::from(b)))) && chunk.iter().enumerate().any(|(j, &c)| at(k * 8 + j, c)) {
+ return true;
+ }
+ }
+ tail.iter().enumerate().any(|(j, &c)| at(tail_from + j, c))
+}
+
+/// A `filePath` rule. The common case (an ASCII path and an ASCII text) is compared in place, folding
+/// case and `\` as it goes, with nothing allocated per photo; any other text takes
+/// [`file_path_matches_slow`], so Unicode lower-casing (ß, İ, final sigma…) behaves as it always did.
+fn file_path_matches(op: &str, source: &Source, value: &Value) -> bool {
+ let path = match source {
+ Source::File { path } => path.as_str(),
+ Source::Demo { .. } => "",
+ };
+ let Value::String(want) = value else { return file_path_matches_slow(op, path, value) };
+ if !path.is_ascii() || !want.is_ascii() {
+ return file_path_matches_slow(op, path, value);
+ }
+ let (path, want) = (path.trim().as_bytes(), want.trim().as_bytes());
+ match op {
+ // the whole string, spaces included (not word by word like the other text fields)
+ "contains" => !want.is_empty() && path_bytes_contains(path, want),
+ "notContains" => want.is_empty() || !path_bytes_contains(path, want),
+ "is" => path_bytes_eq(path, want),
+ "isNot" => !path_bytes_eq(path, want),
+ "startsWith" => path.get(..want.len()).is_some_and(|head| path_bytes_eq(head, want)),
+ "endsWith" => path.len().checked_sub(want.len()).and_then(|from| path.get(from..)).is_some_and(|tail| path_bytes_eq(tail, want)),
+ "isEmpty" => path.is_empty(),
+ "isNotEmpty" => !path.is_empty(),
+ _ => false,
+ }
+}
+
+/// [`file_path_matches`] for any text: the path and the text are normalised into new strings.
+fn file_path_matches_slow(op: &str, path: &str, value: &Value) -> bool {
+ // `/` and `\` both separate, whatever platform the catalog came from; demo photos have no path
+ let path = path.replace('\\', "/");
+ let want = lower(value).replace('\\', "/");
+ match op {
+ // the whole string, spaces included (not word by word like the other text fields)
+ "contains" => !want.trim().is_empty() && path.to_lowercase().contains(want.trim()),
+ "notContains" => want.trim().is_empty() || !path.to_lowercase().contains(want.trim()),
+ _ => text_op(op, &path, &want),
+ }
+}
+
impl Rule {
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
let (field, op, value) = match self {
Rule::Group { group } => return group.matches(p, cat),
Rule::Field { field, op, value } => (field.as_str(), op.as_str(), value),
};
+ if field == "filePath" {
+ return file_path_matches(op, &p.source, value);
+ }
let m = &p.meta;
let want = lower(value);
let text = |have: &str| text_op(op, have, &want);
@@ -854,20 +932,6 @@ impl Rule {
"fileName" => text(&p.file_name),
"extension" => text_op(op, p.extension(), want.trim_start_matches('.')),
"duration" => num_op(op, p.duration.filter(|d| d.is_finite()), value),
- "filePath" => {
- // `/` and `\` both separate, whatever platform the catalog came from; demo photos have no path
- let path = match &p.source {
- Source::File { path } => path.replace('\\', "/"),
- Source::Demo { .. } => String::new(),
- };
- let want = want.replace('\\', "/");
- match op {
- // the whole string, spaces included (not word by word like the other text fields)
- "contains" => !want.trim().is_empty() && path.to_lowercase().contains(want.trim()),
- "notContains" => want.trim().is_empty() || !path.to_lowercase().contains(want.trim()),
- _ => text_op(op, &path, &want),
- }
- }
"format" => text(&p.format),
"title" => text(&m.title),
"caption" => text(&m.caption),
@@ -1693,4 +1757,120 @@ mod tests {
assert_eq!(field_label("filePath"), Some("File Path"));
assert_eq!(field_label("nope"), None);
}
+
+ /// The `filePath` rule exactly as it was before the in-place ASCII path: new strings per photo.
+ fn file_path_old(op: &str, source: &Source, value: &serde_json::Value) -> bool {
+ let want = lower(value);
+ let path = match source {
+ Source::File { path } => path.replace('\\', "/"),
+ Source::Demo { .. } => String::new(),
+ };
+ let want = want.replace('\\', "/");
+ match op {
+ "contains" => !want.trim().is_empty() && path.to_lowercase().contains(want.trim()),
+ "notContains" => want.trim().is_empty() || !path.to_lowercase().contains(want.trim()),
+ _ => text_op(op, &path, &want),
+ }
+ }
+
+ #[test]
+ fn file_path_rules_match_the_old_strings_for_any_path() {
+ let paths = [
+ "",
+ " ",
+ " /a/b.jpg \t",
+ "/Volumes/share/photos/Trip/IMG_0001.jpg",
+ "/Volumes/share/PHOTOS/trip/img_0001.JPG",
+ r"D:\Photos\Lake Como\IMG_0001.JPG",
+ r"d:/photos/lake como/img_0001.jpg",
+ r"\\nas\share\x.jpg",
+ "/photos/Straße/a.jpg",
+ "/photos/STRASSE/a.jpg",
+ "/photos/İstanbul/a.jpg",
+ "/photos/i̇stanbul/a.jpg",
+ "/photos/ΟΔΟΣ/a.jpg",
+ "/photos/οδος/a.jpg",
+ "/photos/ΟΔΌΣ/a.jpg",
+ "/photos/日本/東京.jpg",
+ "/p/\u{0B}x\u{0B}",
+ "/p/\u{85}x\u{85}",
+ "a",
+ ];
+ let wants = [
+ "",
+ " ",
+ "a",
+ "A",
+ "/",
+ r"\",
+ r"\photos\",
+ "/photos/",
+ "photos",
+ "PHOTOS",
+ "Lake Como",
+ " lake como ",
+ "lake como",
+ r"d:\photos\lake",
+ "IMG_0001.JPG",
+ ".jpg",
+ "straße",
+ "STRASSE",
+ "strasse",
+ "İstanbul",
+ "i̇stanbul",
+ "istanbul",
+ "ΟΔΟΣ",
+ "οδος",
+ "οδοσ",
+ "ΟΔΌΣ",
+ "東京",
+ "日本",
+ "x",
+ "\u{0B}x",
+ "/a/b.jpg",
+ "/a/b.jpg \t",
+ "\u{85}x",
+ ];
+ let ops = ["contains", "notContains", "is", "isNot", "startsWith", "endsWith", "isEmpty", "isNotEmpty", "bogus"];
+ let mut values: Vec = wants.iter().map(|w| json!(w)).collect();
+ values.extend([json!(null), json!(5), json!(true), json!(["a", "b"])]);
+ let mut sources: Vec = paths.iter().map(|p| Source::File { path: (*p).into() }).collect();
+ sources.push(Source::Demo { scene: 0 });
+ for source in &sources {
+ for op in ops {
+ for value in &values {
+ assert_eq!(file_path_matches(op, source, value), file_path_old(op, source, value), "{source:?} {op} {value}");
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn file_path_rule_through_a_rule_set_ignores_case_and_slash_direction() {
+ let mut p = photo(1);
+ p.source = Source::File { path: r"D:\Photos\Lake Como\IMG_0001.JPG".into() };
+ let r = rs(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/LAKE como/"}]}));
+ assert!(r.matches(&p, &Catalog::default()));
+ }
+
+ /// Every piece of a long path, in both cases and both slash directions, wherever it starts
+ /// (the search skips eight bytes at a time, so starts and ends at every offset matter).
+ #[test]
+ fn file_path_contains_finds_every_piece_of_a_long_path() {
+ let path = r"D:\Photos\Lake Como\Wedding-Day_2026\IMG_0001 (copy).JPG";
+ let source = Source::File { path: path.into() };
+ let shout = |s: &str| s.to_uppercase();
+ let swap = |s: &str| s.replace('\\', "/");
+ for from in 0..path.len() {
+ for to in from + 1..=path.len() {
+ let piece = &path[from..to];
+ for want in [piece.to_string(), shout(piece), swap(piece), piece.replace('/', r"\"), format!("{piece}x"), format!("x{piece}")] {
+ let value = json!(want);
+ for op in ["contains", "notContains"] {
+ assert_eq!(file_path_matches(op, &source, &value), file_path_old(op, &source, &value), "{op} {want:?}");
+ }
+ }
+ }
+ }
+ }
}
diff --git a/crates/catalog/src/stacks.rs b/crates/catalog/src/stacks.rs
index 6542bfe18..84e89d124 100644
--- a/crates/catalog/src/stacks.rs
+++ b/crates/catalog/src/stacks.rs
@@ -299,14 +299,12 @@ pub fn iso_seconds(s: &str) -> Option {
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);
- let yoe = y2 - era * 400;
- let mp = (m + 9) % 12;
- let doy = (153 * mp + 2) / 5 + d - 1;
- let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
- let days = era * 146_097 + doe - 719_468;
+ // Days since 1970-01-01 (proleptic Gregorian). A day past the month's length rolls over the
+ // way the old Hinnant arithmetic did (e.g. 2026-02-30 → 2026-03-02), which `normalize_iso`
+ // relies on to reject impossible dates.
+ let first = chrono::NaiveDate::from_ymd_opt(i32::try_from(y).ok()?, u32::try_from(m).ok()?, 1)?;
+ let date = first.checked_add_days(chrono::Days::new(u64::try_from(d - 1).ok()?))?;
+ let days = (date - chrono::NaiveDate::from_ymd_opt(1970, 1, 1)?).num_days();
Some(days * 86_400 + hh * 3600 + mm * 60 + ss)
}
diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs
index a32b5acf5..f8b72dad8 100644
--- a/crates/catalog/src/tests.rs
+++ b/crates/catalog/src/tests.rs
@@ -836,6 +836,71 @@ fn cmp_name_ignores_case_without_allocating() {
assert_eq!(cmp_name("a", "ab"), Less);
}
+/// Search Photos finds named people in free text, and a quoted value keeps a name of several words
+/// together: `person:"Jane Doe"` (issue #1449). Malformed quotes narrow the result or change nothing.
+#[test]
+fn search_text_finds_people_and_quoted_values() {
+ use lightcraft_meta::{Rect, Region, RegionKind};
+ let region = |name: &str, kind: RegionKind| Region {
+ rect: Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 },
+ kind,
+ name: Some(name.to_string()),
+ description: None,
+ };
+ let mut c = Catalog::new();
+ let mut add = |name: &str, regions: Vec, keywords: &[&str]| {
+ let id = c.alloc_photo_id();
+ let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00");
+ p.meta.regions = regions;
+ p.meta.keywords = keywords.iter().map(|k| k.to_string()).collect();
+ c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
+ id
+ };
+ let jane = add("a.jpg", vec![region("Jane Doe", RegionKind::Face), region("Rex", RegionKind::Pet)], &[]);
+ let both = add("b.jpg", vec![region("Jane Roe", RegionKind::Face), region("John Doe", RegionKind::Face)], &[]);
+ let zoe = add("c.jpg", vec![region("Zoë Ångström", RegionKind::Face)], &["New York"]);
+ let none = add("lunch 10:30 am.jpg", vec![], &["york minster"]);
+ let q = |text: &str| {
+ let mut ids = c.query(&Filter { text: text.into(), ..Default::default() }, &Sort::default());
+ ids.sort();
+ ids
+ };
+
+ // free text: a person's name, any case, any part of it; pets are not people
+ assert_eq!(q("jane"), vec![jane, both]);
+ assert_eq!(q("DOE"), vec![jane, both]);
+ assert_eq!(q("ångström"), vec![zoe]);
+ assert!(q("rex").is_empty(), "a pet is not a person");
+ // words are still ANDed: b.jpg has a Jane and a Doe, a quoted phrase asks for them together
+ assert_eq!(q("jane doe"), vec![jane, both]);
+ assert_eq!(q("\"jane doe\""), vec![jane]);
+
+ // fielded: the exact name, of several words when quoted
+ assert_eq!(q("person:\"Jane Doe\""), vec![jane]);
+ assert_eq!(q("who:\"jane roe\""), vec![both]);
+ assert_eq!(q("person:\"Zoë Ångström\""), vec![zoe]);
+ assert!(q("person:jane").is_empty(), "person: is the whole name, as before");
+ assert!(q("person:Jane Doe").is_empty(), "unquoted, the second word is its own token, as before");
+ assert_eq!(q("keyword:\"new york\""), vec![zoe], "other fields take quoted values too");
+ assert_eq!(q("person:\"Jane Doe\" a.jpg"), vec![jane], "tokens after a quoted one still count");
+
+ // malformed input never widens the match and never panics
+ assert_eq!(q("person:\"Jane Doe"), vec![jane], "an unclosed quote runs to the end");
+ assert!(q("person:\"Jane Doe a.jpg").is_empty(), "…and takes the rest of the text with it");
+ assert!(q("person:\"\"").is_empty());
+ assert!(q("person:").is_empty());
+ assert_eq!(q("\""), vec![jane, both, zoe, none], "a lone quote is an empty phrase, like an empty search");
+ assert_eq!(q("\"\" jane"), vec![jane, both]);
+ assert!(q("\"jane:doe\"").is_empty(), "a quoted phrase is free text even with a colon");
+ // an unknown field is plain text, its quoted value unquoted (#2099): `10:"30 am"` is the text `10:30 am`
+ assert_eq!(q("10:\"30 am\""), vec![none]);
+ assert!(q("10:\"30 pm\"").is_empty());
+ assert!(q("jane\\\"").is_empty(), "a backslash is an ordinary character");
+ let long = format!("person:\"{}", "é ".repeat(50_000));
+ assert!(q(&long).is_empty());
+ assert_eq!(q(" \t jane \u{3000} "), vec![jane, both], "any whitespace separates tokens");
+}
+
/// What the catalog remembers of its latest ops: which photos each changed and what of them, as long as that can be
/// said photo by photo.
#[test]
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index 091efe066..9caa8cdb0 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -46,6 +46,8 @@ lightcraft-segment = { workspace = true, optional = true }
# model downloads run natively only (the web build fetches no models)
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
lightcraft-fetch = { workspace = true }
+# capture-time parsing for face recognition (native only)
+chrono = { workspace = true }
[features]
# AI masks with SAM 3 (Object / Describe). Off for the web and CLI builds.
diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs
index e56e80305..d074567a7 100644
--- a/crates/engine/src/camera_preview.rs
+++ b/crates/engine/src/camera_preview.rs
@@ -35,7 +35,11 @@ use lightcraft_raw::{
/// 8: the bundled NIKON Z 6_2 profile.
/// 9: the bundled NIKON Z 8 and NIKON D5300 profiles.
/// 10: DNG tone fit for profiles with a look table and for 1024 px (Lightroom) previews.
-pub const LOOK_VERSION: u32 = 10;
+/// 12: tone curves of low-contrast scenes (range limit 1.2, with the bright-area check for curves under 1.5, #699);
+/// a tone-only fallback for near-black scenes with too few colour pairs (#1587); DNG tone fitting uses a
+/// lens-corrected proxy when the file carries lens opcodes; validated A7CR distortion, including the lens data
+/// stored with each smart preview.
+pub const LOOK_VERSION: u32 = 12;
#[derive(Clone, Debug)]
pub(crate) struct CameraLook {
@@ -68,6 +72,7 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool {
| RawFormat::Pef
| RawFormat::Srw
| RawFormat::Orf
+ | RawFormat::Mrw
| RawFormat::Erf
)
}
@@ -89,7 +94,18 @@ pub(crate) fn starting_colour(
lens: Option<&LensGeometry>,
embedded: Option<&lightcraft_develop::EmbeddedLens>,
) -> (Xy, CameraTransform, Option) {
- let (xy, t, look) = starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t, lens), |raw, t| monochrome_look(raw, bytes, t, lens));
+ let (xy, t, look) = starting_colour_with(
+ raw,
+ |raw, t| fit_preview(raw, bytes, t, lens),
+ |raw, t| monochrome_look(raw, bytes, t, lens),
+ |raw, t| {
+ if !file_local_look(raw.format) {
+ return None;
+ }
+ let (sensor, reference, clipped) = proxies(raw, bytes, t, PROXY, 0.0, lens)?;
+ fit_dark_tone(&sensor, &reference, &clipped)
+ },
+ );
if look.is_none() && dng_tone_eligible(raw, &t) {
let look = fit_dng_tone(raw, bytes, &t, embedded);
return (xy, t, look);
@@ -118,11 +134,12 @@ pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool {
}
/// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white
-/// JPEG's tone fit passed in (tests pin the order with them).
+/// JPEG's tone fit and the near-black tone fallback passed in (tests pin the order with them).
fn starting_colour_with(
raw: &mut RawImage,
fit: impl FnOnce(&RawImage, &CameraTransform) -> (Option, bool),
monochrome: impl FnOnce(&RawImage, &CameraTransform) -> Option,
+ dark: impl FnOnce(&RawImage, &CameraTransform) -> Option,
) -> (Xy, CameraTransform, Option) {
let start = |raw: &RawImage| {
let xy = lightcraft_raw::color::as_shot_white_xy(raw);
@@ -146,9 +163,9 @@ fn starting_colour_with(
}
None => (xy, t),
};
- // only a black-and-white JPEG: a colour one that gave no look pays nothing more
+ // A black-and-white JPEG first tries its existing tone fit on the chosen colour.
let look = if black_and_white { monochrome(raw, &t) } else { None };
- (xy, t, look)
+ (xy, t, look.or_else(|| dark(raw, &t)))
}
/// The colour a raw starts from ([`starting_colour`]) as DNG colour tags, for a DNG of the raw's camera RGB (Photo
@@ -270,20 +287,21 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
// try its colour first and fit tone/chroma per photo (picture styles vary).
let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get);
let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone())));
- let look = match fit_look(&sensor, &reference, &clipped, colour.clone()) {
+ // Sony's Dynamic Range Optimizer (on by default) brightens the camera JPEG's darker regions,
+ // not the raw: keep the colour fitted to the JPEG, take the tone curve without DRO.
+ let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true);
+ let look = match fit_look_with(&sensor, &reference, &clipped, colour.clone(), dro) {
Some(look) => look,
// A black-and-white JPEG has no colour to fit: the profile's colour and the JPEG's tone curve
// (`monochrome_look` does it without a profile).
None if colour.is_some() && black_and_white => {
let unclipped = without_clipped(&sensor, &clipped);
- let Some(look) = fit_tone_only(unclipped.as_ref().unwrap_or(&sensor), &reference, colour) else { return (None, true) };
+ let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro, grey: true };
+ let Some(look) = fit_tone_only_with(unclipped.as_ref().unwrap_or(&sensor), &reference, colour, &bright) else { return (None, true) };
look
}
None => return (None, black_and_white),
};
- // Sony's Dynamic Range Optimizer (on by default) brightens the camera JPEG's darker regions,
- // not the raw: keep the colour fitted to the JPEG, take the tone curve without DRO.
- let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true);
let look = if dro { with_dro_off_tone(look) } else { look };
if lightcraft_pipeline::profiling() {
eprintln!(
@@ -302,6 +320,12 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
/// the render's highlight reconstruction.
const SENSOR_CLIP: f32 = 0.99;
+/// [`fit_look_with`] for a photo without Sony's DRO.
+#[cfg(test)]
+fn fit_look(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option)>) -> Option {
+ fit_look_with(sensor, reference, clipped, colour, false)
+}
+
/// The photo's look, refitted without the proxy pixels whose sensor values are clipped.
///
/// A clipped raw pixel no longer records the scene's colour: with one channel held at the clip
@@ -320,17 +344,19 @@ const SENSOR_CLIP: f32 = 0.99;
/// replace a look fitted away from edges). A photo that had no look gets one when the search
/// without the clipped pixels finds it, and otherwise the [`fit_partial`] look when that is clearly
/// closer to the camera JPEG than the neutral fallback.
-fn fit_look(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option)>) -> Option {
+fn fit_look_with(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option)>, dro: bool) -> Option {
let unclipped = without_clipped(sensor, clipped);
- let Some((look, attempt)) = search_ordered(sensor, reference, colour.clone(), TONE_FITS) else {
+ // from the proxies as they are: the retries' filters (clipped pixels, edges) don't remove them
+ let bright = HighlightCheck { pairs: highlights(sensor, reference), dro, grey: false };
+ let Some((look, attempt)) = search_ordered(sensor, reference, colour.clone(), TONE_FITS, &bright) else {
return unclipped
.as_ref()
- .and_then(|u| search_ordered(u, reference, colour, TONE_FITS))
+ .and_then(|u| search_ordered(u, reference, colour, TONE_FITS, &bright))
.map(|(look, _)| look)
- .or_else(|| fit_partial(unclipped.as_ref().unwrap_or(sensor), reference));
+ .or_else(|| fit_partial(unclipped.as_ref().unwrap_or(sensor), reference, &bright));
};
let Some(unclipped) = unclipped else { return Some(look) };
- let refit = fit_attempt(&unclipped, reference, &colour, attempt);
+ let refit = fit_attempt(&unclipped, reference, &colour, attempt, &bright);
if refit.is_none() && lightcraft_pipeline::profiling() {
eprintln!("[profile] camera look: no fit without the clipped pixels on the accepted attempt, keeping the fit on all pixels");
}
@@ -389,8 +415,14 @@ fn sony_dro_off_tone() -> Option {
/// curve is brighter than the photo's own (the 1″ compacts map raw values darker), the photo keeps
/// its own. Its colour (matrix, hue/saturation table and chroma curve) stays as fitted.
fn with_dro_off_tone(look: CameraLook) -> CameraLook {
- let Some(dro_off) = sony_dro_off_tone() else { return look };
- let own = look.tone.knots();
+ CameraLook { tone: dro_off(&look.tone), ..look }
+}
+
+/// The curve of [`with_dro_off_tone`]: `own` lowered to Sony's without DRO wherever that is darker,
+/// or `own` unchanged when it can't be.
+fn dro_off(own_tone: &CameraTone) -> CameraTone {
+ let Some(dro_off) = sony_dro_off_tone() else { return *own_tone };
+ let own = own_tone.knots();
let mut knots = own.map(|[x, y]| [x, y.min(dro_off.apply(x))]);
// Past its last knot a curve goes on as a shoulder whose rate comes from its last two knots
// (`CameraTone::apply`): lowering the second-last knot more than the last steepens it, and the
@@ -400,11 +432,11 @@ fn with_dro_off_tone(look: CameraLook) -> CameraLook {
let rate = ((b[1] - a[1]) / (b[0] - a[0])).clamp(0.1, 16.0) / (1.0 - b[1]).max(0.01);
let floor = last[1] - rate * (1.0 - last[1]).max(0.01) * (b[0] - a[0]);
knots[30][1] = knots[30][1].max(floor).min(last[1]);
- let Some(tone) = CameraTone::new(knots).and_then(|tone| tone.with_chroma(*look.tone.chroma())) else { return look };
- if !at_most(&tone, &look.tone) {
- return look;
+ let Some(tone) = CameraTone::new(knots).and_then(|tone| tone.with_chroma(*own_tone.chroma())) else { return *own_tone };
+ if !at_most(&tone, own_tone) {
+ return *own_tone;
}
- CameraLook { tone, ..look }
+ tone
}
/// Whether curve `a` is nowhere brighter than `b` over the scene luminances the finish stage's tone
@@ -452,10 +484,11 @@ fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens:
let unclipped = without_clipped(&sensor, &clipped);
let fit_on = unclipped.as_ref().unwrap_or(&sensor);
let colour = Some((Mat3::IDENTITY, None));
+ let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false };
let look = TONE_FITS.iter().find_map(|&tone| {
- [false, true]
- .into_iter()
- .find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true }))
+ [false, true].into_iter().find_map(|away_from_edges| {
+ fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true }, &bright)
+ })
});
let Some(look) = look else { return refuse("fit") };
let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) {
@@ -928,7 +961,10 @@ fn into_lens_geometry(img: &Rgb32f, raw: &RawImage, k: usize, lens: &LensGeometr
fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option {
Some(match raw.develop_binned(k, SENSOR_CLIP).ok()? {
Some(sensor) => sensor,
- None if raw.cpp == 3 && raw.cfa.is_none() => fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box),
+ // Lens opcodes cannot be applied to a binned mosaic: they are defined at full resolution.
+ None if (raw.cpp == 3 && raw.cfa.is_none()) || (raw.format == RawFormat::Dng && !raw.opcodes.list3.is_empty()) => {
+ fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box)
+ }
None => return None,
})
}
@@ -985,7 +1021,8 @@ fn local_contrast(image: &Rgb32f, i: usize) -> f32 {
/// `edge_limit`, pixels whose 3×3 neighbourhood in either proxy spans a larger luminance ratio
/// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated
/// colours (see [`EDGE_CONTRAST`]).
-type Pairs = Vec<([f64; 3], [f64; 3])>;
+type Pair = ([f64; 3], [f64; 3]);
+type Pairs = Vec;
fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option, dark: bool) -> Option<(Pairs, Pairs)> {
if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() {
return None;
@@ -1147,7 +1184,8 @@ fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt
/// The look from [`search_ordered`] without its attempt.
#[cfg(test)]
fn fit_pairs_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>, order: &[ToneFit]) -> Option {
- search_ordered(sensor, reference, colour, order).map(|(look, _)| look)
+ search_ordered(sensor, reference, colour, order, &HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: false })
+ .map(|(look, _)| look)
}
/// The tone fits the search tries, in order: the first whose look passes the acceptance gates is
@@ -1177,21 +1215,33 @@ struct Attempt {
/// valid when the camera JPEG's geometry differs slightly from the raw's (issue #232). The whole
/// search runs with one tone fit before the next is tried, so the conditional-median fallback
/// accepts exactly what the previous releases accepted.
-fn search_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>, order: &[ToneFit]) -> Option<(CameraLook, Attempt)> {
+fn search_ordered(
+ sensor: &Rgb32f,
+ reference: &Rgb32f,
+ colour: Option<(Mat3, Option)>,
+ order: &[ToneFit],
+ bright: &HighlightCheck,
+) -> Option<(CameraLook, Attempt)> {
let profiles: &[bool] = if colour.is_some() { &[true, false] } else { &[false] };
order.iter().find_map(|&tone| {
profiles.iter().find_map(|&profile| {
[false, true].into_iter().find_map(|away_from_edges| {
let attempt = Attempt { tone, profile, away_from_edges, dark: false };
- fit_attempt(sensor, reference, &colour, attempt).map(|look| (look, attempt))
+ fit_attempt(sensor, reference, &colour, attempt, bright).map(|look| (look, attempt))
})
})
})
}
-fn fit_attempt(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option)>, attempt: Attempt) -> Option {
+fn fit_attempt(
+ sensor: &Rgb32f,
+ reference: &Rgb32f,
+ colour: &Option<(Mat3, Option)>,
+ attempt: Attempt,
+ bright: &HighlightCheck,
+) -> Option {
let colour = if attempt.profile { colour.clone() } else { None };
- fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone, attempt.dark)
+ fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone, attempt.dark, bright)
}
fn fit_pairs_on(
@@ -1201,6 +1251,7 @@ fn fit_pairs_on(
edge_limit: Option,
tone_fit: ToneFit,
dark: bool,
+ highlights: &HighlightCheck,
) -> Option {
// A known colour model needs enough signal for tone fitting, not a scene rich enough to
// learn a new colour matrix. Still reject monochrome references.
@@ -1228,6 +1279,9 @@ fn fit_pairs_on(
look.tone = tone;
}
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
+ if !highlights_hold(&look.tone, highlights, colour) {
+ continue;
+ }
let (mut linear, mut perceptual, mut samples) = (0.0, 0.0, 0);
for (x, target) in pairs.iter().step_by(3) {
let corrected = displayed(colour(*x), &tone);
@@ -1342,11 +1396,11 @@ fn rank_correlation_ties(pairs: &[([f64; 3], [f64; 3])]) -> f64 {
/// and in gamma-encoded display values, and when the JPEG shows the same picture
/// ([`PARTIAL_MIN_RANK_CORRELATION`]). There is no limit on its own error: it only has to be clearly
/// closer to the camera JPEG than what the photo would get otherwise.
-fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f) -> Option {
- [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_partial_on(sensor, reference, edge_limit))
+fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f, highlights: &HighlightCheck) -> Option {
+ [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_partial_on(sensor, reference, edge_limit, highlights))
}
-fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option) -> Option {
+fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option, highlights: &HighlightCheck) -> Option {
// as for a known colour model: enough signal for the tone, but no monochrome reference
let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit, false)?;
let related = rank_correlation(&pairs);
@@ -1370,6 +1424,9 @@ fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option)
look.tone = tone;
}
let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
+ if !highlights_hold(&look.tone, highlights, |x| matrix.apply(x)) {
+ return None;
+ }
let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2);
let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0);
for (x, target) in pairs.iter().step_by(3) {
@@ -1420,7 +1477,7 @@ fn monochrome(reference: &Rgb32f) -> bool {
/// would start from the spectral matrices or the neutral fallback with the default tone curve, about 1.3 EV darker
/// and flatter than the same body's photos shot in colour. But the JPEG still shows the camera's tone curve.
/// `transform` is the colour the photo gets otherwise (the spectral matrices or the fallback's); the look keeps it and
-/// adds the tone curve alone ([`fit_tone_only`]), as [`fit_look`] tries the photo's own colour after a profile's. The
+/// adds the tone curve alone ([`fit_tone_only_with`]), as [`fit_look_with`] tries the photo's own colour after a profile's. The
/// chroma curve stays neutral: the JPEG has no colourfulness to learn.
fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> Option {
if !file_local_look(raw.format) {
@@ -1430,10 +1487,11 @@ fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, le
if !monochrome(&reference) {
return None;
}
- let unclipped = without_clipped(&sensor, &clipped);
- let look = fit_tone_only(unclipped.as_ref().unwrap_or(&sensor), &reference, None)?;
// Sony's Dynamic Range Optimizer brightens a B/W JPEG as much as a colour one
let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true);
+ let unclipped = without_clipped(&sensor, &clipped);
+ let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro, grey: true };
+ let look = fit_tone_only_with(unclipped.as_ref().unwrap_or(&sensor), &reference, None, &bright)?;
let look = if dro { with_dro_off_tone(look) } else { look };
if lightcraft_pipeline::profiling() {
eprintln!(
@@ -1450,18 +1508,103 @@ fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, le
/// space) or, without one, of the sensor proxy's own colour. Fitted like [`fit_partial`]'s, by quantile matching of
/// the luminances, on all pixels and then away from edges, and used only when the JPEG shows the same picture
/// ([`PARTIAL_MIN_RANK_CORRELATION`]) and the look cuts the held-out squared luminance error to below
-/// [`MIN_IMPROVEMENT`] of the default curve's, in linear and in gamma-encoded display values.
-fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option {
- [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_tone_only_on(sensor, reference, &colour, edge_limit))
+/// [`MIN_IMPROVEMENT`] of the default curve's, in linear and in gamma-encoded display values. A curve narrower than
+/// [`NARROW_TONE_RANGE`] must also keep the JPEG's bright areas ([`highlights_hold`], by luminance).
+fn fit_tone_only_with(
+ sensor: &Rgb32f,
+ reference: &Rgb32f,
+ colour: Option<(Mat3, Option)>,
+ highlights: &HighlightCheck,
+) -> Option {
+ [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_tone_only_on(sensor, reference, &colour, edge_limit, highlights))
}
-fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option)>, edge_limit: Option) -> Option {
+/// [`fit_tone_only_with`] for a photo without Sony's DRO, checked against its own bright areas.
+#[cfg(test)]
+fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option {
+ fit_tone_only_with(sensor, reference, colour, &HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: true })
+}
+
+fn fit_tone_only_on(
+ sensor: &Rgb32f,
+ reference: &Rgb32f,
+ colour: &Option<(Mat3, Option)>,
+ edge_limit: Option,
+ highlights: &HighlightCheck,
+) -> Option {
// every pair counts: the JPEG has no colour to require
let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit, false)?;
- let related = rank_correlation_ties(&pairs);
+ fit_tone_only_pairs(sensor, reference, colour, edge_limit, &pairs, false, highlights)
+}
+
+/// A near-black frame may have enough luminance signal for tone but fewer than 256 colour pairs.
+/// Try only after every existing look fails. Keep the colour of the source transform,
+/// exclude clipped pixels from training, and keep their luminance at least as close to the JPEG
+/// as the default curve does. Bright JPEGs need the existing fits to constrain their highlights.
+fn fit_dark_tone(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool]) -> Option {
+ if (sensor.width, sensor.height) != (reference.width, reference.height)
+ || sensor.data.len() != reference.data.len()
+ || clipped.len() != sensor.data.len()
+ || sensor.data.is_empty()
+ || sensor.data.iter().any(|p| !p.iter().all(|v| v.is_finite()))
+ || reference.data.iter().any(|p| !p.iter().all(|v| v.is_finite() && *v >= 0.0) || luminance_2020(*p) >= 0.85)
+ {
+ return None;
+ }
+ let dark = reference.data.iter().filter(|p| luminance_2020(**p) < 0.015).count();
+ if (dark as f64) < reference.data.len() as f64 * 0.9 {
+ return None;
+ }
+ // no JPEG pixel reaches 0.85 here, so the bright-area check has nothing to hold
+ let bright = HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: true };
+ [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| {
+ let pairs: Pairs = sensor
+ .data
+ .iter()
+ .zip(&reference.data)
+ .enumerate()
+ .filter(|(i, (input, output))| {
+ // The same signal floors as the camera toe, without colour ratios or counts.
+ !clipped.get(*i).copied().unwrap_or(true)
+ && input.iter().all(|v| *v > 0.0001 && *v < 1.5)
+ && (0.0003..0.85).contains(&luminance_2020(**output))
+ && edge_limit.is_none_or(|limit| local_contrast(sensor, *i) <= limit && local_contrast(reference, *i) <= limit)
+ })
+ .map(|(_, (input, output))| (input.map(f64::from), output.map(f64::from)))
+ .collect();
+ if pairs.len() < 256 {
+ return None;
+ }
+ let look = fit_tone_only_pairs(sensor, reference, &None, edge_limit, &pairs, true, &bright)?;
+ let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
+ for ((input, target), clipped) in sensor.data.iter().zip(&reference.data).zip(clipped) {
+ if *clipped {
+ let target = luma(target.map(f64::from));
+ let before = luma(displayed(input.map(f64::from), &neutral));
+ let after = luma(displayed(input.map(f64::from), &tone));
+ if !after.is_finite() || (after - target).abs() > (before - target).abs() {
+ return None;
+ }
+ }
+ }
+ Some(look)
+ })
+}
+
+fn fit_tone_only_pairs(
+ sensor: &Rgb32f,
+ reference: &Rgb32f,
+ colour: &Option<(Mat3, Option)>,
+ edge_limit: Option,
+ pairs: &Pairs,
+ dark: bool,
+ highlights: &HighlightCheck,
+) -> Option {
+ let kind = if dark { "near-black scene" } else { "black-and-white JPEG" };
+ let related = rank_correlation_ties(pairs);
if related < PARTIAL_MIN_RANK_CORRELATION {
if lightcraft_pipeline::profiling() {
- eprintln!("[profile] camera look from a black-and-white JPEG: luminance rank correlation {related:.3}, not the same picture");
+ eprintln!("[profile] camera look from a {kind}: luminance rank correlation {related:.3}, not the same picture");
}
return None;
}
@@ -1471,8 +1614,19 @@ fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3,
let p = matrix.apply(x);
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
};
- let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, coloured)?;
+ let tone = if dark {
+ // Split this wider set once. The camera toe's separate colour/extra split belongs to
+ // the original colour pairs and would put some of these held-out pixels into training.
+ fit_tone(
+ pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(coloured(*x).map(|v| v.max(0.0))), luma(*y))).collect(),
+ )?
+ } else {
+ fit_tone_with_shadows(ToneFit::Quantile, pairs, sensor, reference, edge_limit, coloured)?
+ };
let look = CameraLook { matrix, tone, hue_sat: hue_sat.clone() };
+ if !highlights_hold(&look.tone, highlights, coloured) {
+ return None;
+ }
let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2);
let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0);
@@ -1486,7 +1640,7 @@ fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3,
}
if lightcraft_pipeline::profiling() {
eprintln!(
- "[profile] camera look from a black-and-white JPEG: holdout luminance RMS {:.5} -> {:.5} ({samples} pixels{})",
+ "[profile] camera look from a {kind}: holdout luminance RMS {:.5} -> {:.5} ({samples} pixels{})",
(before / samples.max(1) as f64).sqrt(),
(after / samples.max(1) as f64).sqrt(),
if edge_limit.is_some() { ", away from edges" } else { "" }
@@ -1780,6 +1934,89 @@ impl ToneFit {
}
}
+/// The bright areas a narrow tone curve is checked against ([`highlights_hold`]): every proxy pixel
+/// whose camera JPEG luminance is 0.85 or more, with its sensor value as developed (for a clipped
+/// pixel a lower bound of the scene). Taken from the proxies before any training filter (clipped
+/// pixels, edges, the sensor value range of [`collect_pairs`]), so no retry of the search can
+/// leave them out.
+fn highlights(sensor: &Rgb32f, reference: &Rgb32f) -> Pairs {
+ if sensor.data.len() != reference.data.len() {
+ return Vec::new();
+ }
+ sensor
+ .data
+ .iter()
+ .zip(&reference.data)
+ .filter(|(x, y)| x.iter().chain(y.iter()).all(|v| v.is_finite() && *v >= 0.0) && luminance_2020(**y) >= 0.85)
+ .map(|(x, y)| (x.map(f64::from), y.map(f64::from)))
+ .collect()
+}
+
+/// Smallest ratio between the scene luminance of the brightest and of the darkest 1/32 of the
+/// pairs a tone curve is fitted to (its last and first knot). Below it the training pairs are
+/// nearly uniform (under 0.26 EV from their darker to their brighter parts): the curve would be
+/// fitted to a sliver of the tone range and the rest of it extrapolated. Above it the acceptance
+/// gates judge the curve. This was 1.5, which rejected low-contrast scenes the curve fits very
+/// well: in a hazy ILCE-7RM4 burst the frames at 1.46–1.49 opened with the neutral fallback (no full or partial
+/// look, 18 L* darker than the camera JPEG) while the frame at 1.51 fitted at a held-out RMS of
+/// 0.013, as the others do once allowed (issue #631). Across 135 public Sony files the lowest is 4.0.
+/// A curve under [`NARROW_TONE_RANGE`] must also pass [`highlights_hold`].
+const MIN_TONE_RANGE: f32 = 1.2;
+
+/// [`MIN_TONE_RANGE`] before issue #631. A curve fitted to a narrower range than this must also
+/// render the photo's bright areas no further from the camera JPEG than the neutral fallback does
+/// ([`highlights_hold`]). Neither the fit nor the held-out gates see those areas (camera JPEG
+/// luminance 0.85 and up, left out of the training pairs by [`collect_pairs`]), and a curve fitted
+/// to a sliver of the tone range extrapolates them: in review, a synthetic frame whose curve passed
+/// the gates at a held-out RMS of 0.0004 rendered its bright sky at 0.29 where the camera JPEG has
+/// 0.93.
+const NARROW_TONE_RANGE: f32 = 1.5;
+
+/// What a narrow tone curve is checked against ([`highlights_hold`]).
+struct HighlightCheck {
+ /// The frame's [`highlights`].
+ pairs: Pairs,
+ /// The photo's curve will be lowered to Sony's without DRO ([`with_dro_off_tone`]) after the
+ /// fit, so the check looks at it lowered.
+ dro: bool,
+ /// The camera JPEG is black and white ([`fit_tone_only_with`]): the check compares luminance, as that
+ /// fit's gates do, since the JPEG has no colour to match.
+ grey: bool,
+}
+
+/// Whether a look with this `tone` and `colour` (sensor value to the finish stage's input) keeps
+/// the frame's [`highlights`] (camera JPEG luminance 0.85 and up, left out of the fit) no further
+/// from the camera JPEG than the neutral fallback does, with the tone curve as the photo will get
+/// it (lowered for DRO), in luminance alone for a black-and-white JPEG. Only asked of curves narrower than
+/// [`NARROW_TONE_RANGE`]: true for wider ones, and for frames without such pixels.
+fn highlights_hold(tone: &CameraTone, check: &HighlightCheck, colour: impl Fn([f64; 3]) -> [f64; 3]) -> bool {
+ let knots = tone.knots();
+ if knots[31][0] >= knots[0][0] * NARROW_TONE_RANGE {
+ return true;
+ }
+ // the curve as the photo will get it
+ let used = if check.dro { dro_off(tone) } else { *tone };
+ let (map, neutral) = (ToneMap::camera(&used, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
+ let (mut look, mut fallback) = (0.0, 0.0);
+ for (x, target) in &check.pairs {
+ let (rendered, base) = (displayed(colour(*x), &map), displayed(*x, &neutral));
+ if check.grey {
+ look += (luma(rendered) - luma(*target)).powi(2);
+ fallback += (luma(base) - luma(*target)).powi(2);
+ continue;
+ }
+ for c in 0..3 {
+ look += (rendered[c] - target[c]).powi(2);
+ fallback += (base[c] - target[c]).powi(2);
+ }
+ }
+ let hold = look.is_finite() && look <= fallback;
+ if !hold && lightcraft_pipeline::profiling() {
+ eprintln!("[profile] camera look: narrow tone curve renders the bright areas worse than the fallback ({look:.3} > {fallback:.3})");
+ }
+ hold
+}
+
/// The camera's tone curve from scene/JPEG luminance pairs, by quantile matching: knot `i` pairs
/// the median of the `i`-th 1/32 of the sorted scene luminances with the median of the `i`-th
/// 1/32 of the sorted JPEG luminances. A tone curve is monotone, so it maps each quantile of the
@@ -1807,7 +2044,7 @@ fn fit_tone(pairs: Vec<(f64, f64)>) -> Option {
let mid = a + (b - a) / 2;
*knot = [*xs.get(mid)? as f32, *ys.get(mid)? as f32];
}
- if knots[31][0] < knots[0][0] * 1.5 {
+ if knots[31][0] < knots[0][0] * MIN_TONE_RANGE {
return None;
}
CameraTone::new(knots)
@@ -1835,7 +2072,7 @@ fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option {
let mut ys: Vec<_> = bin.iter().map(|p| p.1).collect();
*knot = [median(&mut xs)? as f32, median(&mut ys)? as f32];
}
- if knots[31][0] < knots[0][0] * 1.5 {
+ if knots[31][0] < knots[0][0] * MIN_TONE_RANGE {
return None;
}
// Pool adjacent violating bins (isotonic regression): no reversals or arbitrary polynomial.
@@ -2558,6 +2795,7 @@ mod tests {
&mut raw,
|_, _| panic!("no JPEG fit for a raw with its own matrices"),
|_, _| panic!("no black-and-white JPEG fit for a raw with its own matrices"),
+ |_, _| panic!("no dark tone fit for a raw with its own matrices"),
);
assert_eq!(raw.color, color);
assert!(!t.matrix_is_fallback && look.is_none());
@@ -2579,6 +2817,7 @@ mod tests {
(Some(fitted.clone()), false)
},
|_, _| panic!("no black-and-white JPEG fit after an accepted fit"),
+ |_, _| panic!("no dark tone fit after an accepted fit"),
);
assert_eq!(look.map(|l| l.matrix), Some(fitted.matrix), "{model}");
assert_eq!(raw.color, lightcraft_raw::ColorData::default(), "{model}: spectral matrices left out");
@@ -2601,7 +2840,8 @@ mod tests {
let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap();
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default());
- let (xy, t, look) = starting_colour_with(&mut raw, |_, _| (None, false), |_, _| panic!("a colour JPEG never takes the black-and-white step"));
+ let (xy, t, look) =
+ starting_colour_with(&mut raw, |_, _| (None, false), |_, _| panic!("a colour JPEG never takes the black-and-white step"), |_, _| None);
assert!(look.is_none());
assert_eq!(raw.color.illuminant, [17, 21]);
assert_eq!(raw.color.forward_matrix, camera.forward_matrix.map(Some));
@@ -2631,6 +2871,7 @@ mod tests {
assert!(!t.matrix_is_fallback && raw.color.forward_matrix == camera.forward_matrix.map(Some), "the spectral matrices are in");
Some(toned.clone())
},
+ |_, _| panic!("an accepted monochrome look keeps its tone"),
);
assert!(look.as_ref().is_some_and(|l| l.matrix == Mat3::IDENTITY && l.hue_sat.is_none()), "the tone curve on top");
assert!(!t.matrix_is_fallback);
@@ -2644,6 +2885,7 @@ mod tests {
assert!(t.matrix_is_fallback, "outside the table: the neutral fallback's colour");
Some(toned.clone())
},
+ |_, _| panic!("an accepted monochrome look keeps its tone"),
);
assert!(look.is_some() && t.matrix_is_fallback);
assert_eq!(raw.color, lightcraft_raw::ColorData::default());
@@ -2734,6 +2976,7 @@ mod tests {
RawFormat::Pef,
RawFormat::Srw,
RawFormat::Orf,
+ RawFormat::Mrw,
RawFormat::Erf,
];
assert!(local.into_iter().all(file_local_look));
@@ -3504,7 +3747,8 @@ mod tests {
let none = fit_look(&sensor, &reference, &vec![false; sensor.data.len()], None).unwrap();
assert_eq!((none.matrix.0, none.tone), (all.matrix.0, all.tone));
// without clipped pixels the refit keeps the accepted attempt (here: all pixels, own colour, quantile tone)
- let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS).unwrap();
+ let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false };
+ let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS, &check).unwrap();
assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false, dark: false });
assert!(without_clipped(&sensor, &[true; 3]).is_none(), "a mask of another size is ignored");
}
@@ -3591,6 +3835,110 @@ mod tests {
assert!(tone.apply(0.05) < tone.apply(0.2) && tone.apply(0.2) < tone.apply(0.8));
}
+ fn near_black_scene() -> (Rgb32f, Rgb32f) {
+ let mut sensor = Rgb32f::new(96, 64);
+ let mut reference = sensor.clone();
+ for (i, (input, output)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
+ let y = if i % 60 == 0 { 0.3 + (i % 7) as f32 * 0.01 } else { 0.006 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.075 };
+ let rgb = [1.15, 1.0, 0.75];
+ *input = rgb.map(|v| v * y / luminance_2020(rgb));
+ *output = input.map(|v| v * 0.08);
+ }
+ (sensor, reference)
+ }
+
+ #[test]
+ fn a_near_black_scene_gets_tone_without_learning_colour() {
+ let (sensor, reference) = near_black_scene();
+ assert!(collect_pairs(&sensor, &reference, 0.005, None, false).is_none(), "too few midtones even for the partial look");
+ let mut raw = raw_of(RawFormat::Nef, "NIKON CORPORATION", "NIKON D610", Default::default());
+ let (_, _, look) = starting_colour_with(
+ &mut raw,
+ |_, _| (fit_look(&sensor, &reference, &vec![false; sensor.data.len()], None), false),
+ |_, _| None,
+ |_, _| fit_dark_tone(&sensor, &reference, &vec![false; sensor.data.len()]),
+ );
+ let look = look.expect("tone from the dark pixels");
+ assert_eq!(look.matrix, Mat3::IDENTITY);
+ assert!(look.hue_sat.is_none());
+ let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
+ let error = |tone: &ToneMap| {
+ sensor
+ .data
+ .iter()
+ .zip(&reference.data)
+ .map(|(x, y)| (luma(displayed(x.map(f64::from), tone)) - luma(y.map(f64::from))).powi(2))
+ .sum::()
+ };
+ assert!(error(&tone) < error(&ToneMap::new(0.0, 0.0, 0.0)) * 0.001);
+ assert_eq!(look.tone.apply(0.0), 0.0);
+ let mut previous = 0.0;
+ for i in 0..10_000 {
+ let y = look.tone.apply(i as f32 / 10_000.0);
+ assert!(y.is_finite() && y >= previous && y <= 1.0);
+ previous = y;
+ }
+ // Models with spectral matrices keep them before the dark tone is fitted.
+ let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default());
+ let (_, t, look) = starting_colour_with(
+ &mut raw,
+ |_, _| (None, false),
+ |_, _| None,
+ |raw, t| {
+ assert!(!t.matrix_is_fallback && lightcraft_raw::color::has_matrix(&raw.color));
+ fit_dark_tone(&sensor, &reference, &vec![false; sensor.data.len()])
+ },
+ );
+ assert!(!t.matrix_is_fallback);
+ assert_eq!(look.expect("tone on top of spectral colour").matrix, Mat3::IDENTITY);
+ }
+
+ #[test]
+ fn dark_tone_holdout_never_enters_training() {
+ let (sensor, mut reference) = near_black_scene();
+ let none = vec![false; sensor.data.len()];
+ let original = fit_dark_tone(&sensor, &reference, &none).expect("dark tone");
+ // Every pixel clears the wider signal floors. Changing only its held-out third must
+ // change the measured error, but cannot change the fitted curve.
+ for p in reference.data.iter_mut().step_by(3) {
+ *p = p.map(|v| v * 1.03);
+ }
+ let changed = fit_dark_tone(&sensor, &reference, &none).expect("still clearly better than the fallback");
+ assert_eq!(changed.tone, original.tone);
+ }
+
+ #[test]
+ fn dark_tone_requires_signal_correlation_and_unclipped_highlights() {
+ let (sensor, reference) = near_black_scene();
+ let none = vec![false; sensor.data.len()];
+ let mut unrelated = reference.clone();
+ for (i, p) in unrelated.data.iter_mut().enumerate() {
+ *p = reference.data[(i * 3571) % reference.data.len()];
+ }
+ assert!(fit_dark_tone(&sensor, &unrelated, &none).is_none(), "unrelated dark JPEG");
+ let mut flat = reference.clone();
+ flat.data.fill([0.002; 3]);
+ assert!(fit_dark_tone(&sensor, &flat, &none).is_none(), "a quantized flat JPEG cannot teach tone");
+ let mut sparse = reference.clone();
+ sparse.data.iter_mut().skip(80).for_each(|p| *p = [0.0; 3]);
+ assert!(fit_dark_tone(&sensor, &sparse, &none).is_none(), "fewer than 256 usable pairs");
+ let mut bright = reference.clone();
+ bright.data[0] = [0.95; 3];
+ assert!(fit_dark_tone(&sensor, &bright, &none).is_none(), "dark pairs cannot constrain a bright highlight");
+ let mut clipped = none;
+ clipped[0] = true;
+ let original = fit_dark_tone(&sensor, &reference, &clipped).expect("tone without the clipped pixel");
+ let mut changed = sensor.clone();
+ changed.data[0] = [0.18; 3];
+ let look = fit_dark_tone(&changed, &reference, &clipped).expect("the clipped pixel is still closer to its JPEG");
+ assert_eq!(look.tone, original.tone, "clipped samples cannot change the fitted curve");
+ let mut highlight = reference.clone();
+ highlight.data[0] = [0.8; 3];
+ assert!(fit_dark_tone(&changed, &highlight, &clipped).is_none(), "the curve cannot darken a clipped highlight away from the JPEG");
+ assert!(fit_dark_tone(&sensor, &Rgb32f::new(64, 96), &clipped).is_none(), "mismatched dimensions");
+ assert!(fit_dark_tone(&sensor, &reference, &[]).is_none(), "mismatched clip mask");
+ }
+
/// An overcast, nearly colourless scene (under 5% of the pixels coloured, so no colour matrix is
/// learnt and the full look is never tried) whose camera renders it two stops brighter than the
/// neutral fallback: the partial look is the tone curve (and chroma curve) alone.
@@ -3636,7 +3984,8 @@ mod tests {
let neutral = ToneMap::new(0.0, 0.0, 0.0);
let mut reference = sensor.clone();
reference.map_in_place(|p| displayed(p.map(f64::from), &neutral).map(|v| v as f32));
- assert!(fit_partial(&sensor, &reference).is_none(), "no clear gain over the fallback");
+ let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false };
+ assert!(fit_partial(&sensor, &reference, &check).is_none(), "no clear gain over the fallback");
// an unrelated picture
for (i, p) in reference.data.iter_mut().enumerate() {
*p = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01];
@@ -3740,6 +4089,7 @@ mod tests {
assert!(!t.matrix_is_fallback, "on the spectral matrices");
own.clone()
},
+ |_, _| None,
);
assert!(look.is_some() && !t.matrix_is_fallback);
}
@@ -3895,8 +4245,10 @@ mod tests {
}
}
let colour = Some((Mat3::IDENTITY, None));
- let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true })
- .expect("tone fitted");
+ let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false };
+ let look =
+ fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }, &check)
+ .expect("tone fitted");
assert_eq!(look.matrix, Mat3::IDENTITY);
assert!(look.hue_sat.is_none());
let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
@@ -3959,6 +4311,27 @@ mod tests {
assert!(plain.data.iter().all(|p| *p == [0.2; 3]));
}
+ /// Lens opcodes prevent mosaic binning. The DNG fit still gets a proxy, with the file's
+ /// full-resolution corrections applied once rather than silently losing the tone fit.
+ #[test]
+ fn dng_sensor_proxy_applies_lens_opcodes_before_downsampling() {
+ use lightcraft_raw::Opcode;
+ let bytes = crate::tests_xmp::synthetic_dng_with(None, Default::default());
+ let mut raw = lightcraft_raw::decode(&bytes).unwrap();
+ raw.data = lightcraft_raw::RawData::U16(vec![4000; raw.width * raw.height]);
+ raw.opcodes.list3 = vec![Opcode::FixVignetteRadial { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }];
+ assert!(raw.develop_binned(2, SENSOR_CLIP).unwrap().is_none());
+ let proxy = sensor_proxy(&raw, 2, 32).expect("a corrected CFA proxy even when binning is unavailable");
+ assert_eq!((proxy.width, proxy.height), (32, 24));
+ let center = proxy.data[12 * 32 + 16][1];
+ let corner = proxy.data[0][1];
+ assert!((1.8..2.1).contains(&(corner / center)), "one vignetting gain, not zero or two: {corner} / {center}");
+ let full = raw.develop(lightcraft_raw::Method::Bilinear).unwrap();
+ assert_eq!(proxy.data, full.data, "the same opcode interpretation as raw development");
+ let small = sensor_proxy(&raw, 2, 16).unwrap();
+ assert_eq!(small.data, fit(&full, 16, 16, Filter::Box).data, "correct first, then reduce");
+ }
+
/// Which DNGs get a tone fitted to their camera JPEG: a colour matrix, a tone curve that is absent or the
/// identity, with or without a look table; and no other raw format.
#[test]
@@ -3993,4 +4366,181 @@ mod tests {
assert!(256.0 < floor(7008.0));
assert!(1024.0 >= floor(9504.0), "a 61 MP raw too");
}
+
+ /// Pairs whose scene luminance spans `ratio` from the darkest to the brightest pixel, mapped
+ /// to the JPEG through a steep camera curve (a hazy scene the camera stretches).
+ fn low_contrast_pairs(ratio: f64) -> Vec<(f64, f64)> {
+ (0..4096).map(|i| 0.08 * ratio.powf(i as f64 / 4095.0)).map(|x| (x, (0.2 + (x - 0.08) * 3.0).min(0.95))).collect()
+ }
+
+ /// The ratio the tone fits test: scene luminance at the middle of the brightest 1/32 over that
+ /// of the darkest 1/32 (their last and first knots).
+ fn knot_range(mut scene: Vec) -> f64 {
+ scene.sort_by(f64::total_cmp);
+ let n = scene.len();
+ scene[31 * n / 32 + n / 64] / scene[n / 64]
+ }
+
+ /// Issue #631: a low-contrast scene (its darker and brighter parts under 1.5× apart) still gets
+ /// a tone curve; only a nearly uniform one doesn't. Both tone fits use the same limit.
+ #[test]
+ fn tone_fits_accept_low_contrast_scenes() {
+ for fit in [fit_tone, fit_tone_conditional] {
+ let pairs = low_contrast_pairs(1.47);
+ let range = knot_range(pairs.iter().map(|p| p.0).collect());
+ assert!((1.4..1.5).contains(&range), "the scene sits just below the old limit of 1.5: {range}");
+ let curve = fit(pairs).expect("a low-contrast scene, as in the burst of issue #631");
+ assert!((curve.apply(0.1) - 0.26).abs() < 0.01, "follows the camera curve: {}", curve.apply(0.1));
+ assert!(fit(low_contrast_pairs(1.1)).is_none(), "a nearly uniform frame");
+ }
+ }
+
+ /// The same scene end to end: colour varies, luminance spans under 1.5×, and the camera
+ /// stretches it; the look is accepted and lands on the JPEG instead of the neutral fallback.
+ #[test]
+ fn a_low_contrast_scene_gets_a_camera_look() {
+ let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
+ let mut sensor = Rgb32f::new(96, 64);
+ let mut reference = sensor.clone();
+ for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
+ let ev = 0.06 + (i % 31) as f32 * 0.0008;
+ *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
+ let p = known.apply_f32(*src);
+ let y = luminance_2020(p);
+ *dst = p.map(|v| v * (0.2 + (y - 0.07) * 3.0) / y);
+ }
+ let look = fit_pairs(&sensor, &reference).expect("a camera look, not the neutral fallback");
+ let range = knot_range(sensor.data.iter().map(|x| luma(look.matrix.apply(x.map(f64::from)))).collect());
+ assert!(range < 1.5, "the scene sits below the old limit of 1.5: {range}");
+ let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
+ let error: f64 = sensor
+ .data
+ .iter()
+ .zip(&reference.data)
+ .map(|(x, y)| {
+ let p = displayed(look.matrix.apply(x.map(f64::from)), &tone);
+ (0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::() / 3.0
+ })
+ .sum::()
+ / sensor.data.len() as f64;
+ assert!(error.sqrt() < 0.02, "{}", error.sqrt());
+ }
+
+ /// Codex review of #631: a low-contrast scene's curve is fitted to its midtones only, and the
+ /// camera JPEG's bright areas (luminance 0.85 and up) are in neither the fit nor the held-out
+ /// gates. With 1.2 alone, this frame's curve passed the gates and rendered its bright sky
+ /// (camera JPEG 0.93) at about 0.28. The second review found the same through the retries'
+ /// filters: a clipped sky (left out by the refit without clipped pixels), scattered bright
+ /// pixels (left out away from edges) and sensor values over 1.5 (out of the pairs' range).
+ #[test]
+ fn a_narrow_curve_that_darkens_the_bright_areas_is_refused() {
+ let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
+ for (case, sky_value, is_sky) in [
+ ("bright sky", 0.9, &(|i: usize| i < 5600) as &dyn Fn(usize) -> bool),
+ ("clipped sky", 1.0, &|i: usize| i < 5600),
+ ("scattered bright pixels", 0.9, &|i: usize| i.is_multiple_of(8)),
+ ("sky over the pairs' range", 1.6, &|i: usize| i < 5600),
+ ] {
+ let mut sensor = Rgb32f::new(96, 64);
+ let mut reference = sensor.clone();
+ let mut clipped = vec![false; sensor.data.len()];
+ for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
+ if is_sky(i) {
+ // bright, nearly white in the camera JPEG
+ *src = [sky_value; 3];
+ *dst = [0.93; 3];
+ clipped[i] = case == "clipped sky";
+ continue;
+ }
+ let ev = 0.06 + (i % 31) as f32 * 0.0008;
+ *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
+ let p = known.apply_f32(*src);
+ let y = luminance_2020(p);
+ // nearly flat midtones: the fitted curve's extrapolation can't reach the sky
+ *dst = p.map(|v| v * (0.2 + (y - 0.07) * 0.05) / y);
+ }
+ let Some(look) = fit_look(&sensor, &reference, &clipped, None) else { continue };
+ let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
+ let correction = look.hue_sat.as_ref().and_then(HueSat::new);
+ let p = look.matrix.apply([f64::from(sky_value); 3]);
+ let p = correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from));
+ let sky = luma(displayed(p, &tone));
+ assert!(sky > 0.8, "{case}: an accepted look must not darken what the camera renders at 0.93: {sky}");
+ }
+ }
+
+ /// Third Codex review of #631: for a Sony photo shot with DRO, the fitted curve is lowered to
+ /// Sony's without DRO after the fit, so it must be checked as lowered. On this frame the
+ /// fitted curve keeps the sky (0.81 against the fallback's 0.79), and lowered it rendered the
+ /// sky at 0.61 where the camera JPEG has 0.93.
+ #[test]
+ fn a_narrow_curve_is_checked_as_lowered_for_dro() {
+ let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
+ let mut sensor = Rgb32f::new(96, 64);
+ let mut reference = sensor.clone();
+ for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
+ if i < 5600 {
+ *src = [0.9; 3];
+ *dst = [0.93; 3];
+ continue;
+ }
+ let ev = 0.06 + (i % 31) as f32 * 0.0008;
+ *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
+ let p = known.apply_f32(*src);
+ let y = luminance_2020(p);
+ *dst = p.map(|v| v * (0.6 + (y - 0.07) * 0.25) / y);
+ }
+ let neutral = luma(displayed([0.9; 3], &ToneMap::new(0.0, 0.0, 0.0)));
+ if let Some(look) = fit_look_with(&sensor, &reference, &vec![false; sensor.data.len()], None, true) {
+ let look = with_dro_off_tone(look);
+ let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
+ let correction = look.hue_sat.as_ref().and_then(HueSat::new);
+ let p = look.matrix.apply([0.9; 3]);
+ let p = correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from));
+ let sky = luma(displayed(p, &tone));
+ assert!(sky > neutral - 0.02, "the DRO-lowered curve must not darken the sky past the fallback ({neutral}): {sky}");
+ }
+ }
+
+ /// A black-and-white JPEG of a low-contrast scene (issues #577 and #631): grey pixels, scene luminance spanning
+ /// under 1.5×, which the old range limit refused before any gate. Its tone curve is fitted.
+ #[test]
+ fn a_low_contrast_black_and_white_jpeg_gives_its_tone_curve() {
+ let mut sensor = Rgb32f::new(96, 64);
+ let mut reference = sensor.clone();
+ for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
+ let ev = 0.06 + (i % 31) as f32 * 0.0008;
+ *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
+ *dst = [0.2 + (luminance_2020(*src) - 0.06) * 3.0; 3];
+ }
+ let range = knot_range(sensor.data.iter().map(|x| luma(x.map(f64::from))).collect());
+ assert!(range < 1.5, "the scene sits below the old limit of 1.5: {range}");
+ let look = fit_tone_only(&sensor, &reference, None).expect("the JPEG's tone curve");
+ let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
+ let error = sensor.data.iter().zip(&reference.data).map(|(x, y)| (luma(displayed(x.map(f64::from), &tone)) - f64::from(y[0])).powi(2));
+ let rms = (error.sum::() / sensor.data.len() as f64).sqrt();
+ assert!(rms < 0.02, "{rms}");
+ }
+
+ /// [`a_narrow_curve_that_darkens_the_bright_areas_is_refused`] for a black-and-white JPEG: its tone curve is
+ /// fitted to nearly flat midtones under 1.5×, and the bright sky (camera JPEG 0.93) is left out of the fit.
+ #[test]
+ fn a_narrow_black_and_white_curve_that_darkens_the_bright_areas_is_refused() {
+ let mut sensor = Rgb32f::new(96, 64);
+ let mut reference = sensor.clone();
+ for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
+ if i < 5600 {
+ *src = [0.9; 3];
+ *dst = [0.93; 3];
+ continue;
+ }
+ let ev = 0.06 + (i % 31) as f32 * 0.0008;
+ *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
+ *dst = [0.2 + (luminance_2020(*src) - 0.06) * 0.05; 3];
+ }
+ if let Some(look) = fit_tone_only(&sensor, &reference, None) {
+ let sky = luma(displayed(look.matrix.apply([0.9; 3]), &ToneMap::camera(&look.tone, 0.0, 0.0, 0.0)));
+ assert!(sky > 0.8, "an accepted curve must not darken what the camera renders at 0.93: {sky}");
+ }
+ }
}
diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs
index d13023b6e..c7b6c437b 100644
--- a/crates/engine/src/camera_profiles.rs
+++ b/crates/engine/src/camera_profiles.rs
@@ -159,29 +159,31 @@ pub fn get(model: &str) -> Option> {
/// keys, so thumbnails rendered before a profile existed are not reused after.
pub fn cache_key() -> u64 {
static KEY: OnceLock = OnceLock::new();
- *KEY.get_or_init(|| {
- let entries = dir().and_then(|d| std::fs::read_dir(d).ok());
- let mut files: Vec<(String, u64, u64)> = entries
- .into_iter()
- .flatten()
- .flatten()
- .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
- .filter_map(|e| {
- let meta = e.metadata().ok()?;
- let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
- Some((e.file_name().to_string_lossy().into_owned(), meta.len(), modified))
- })
- .collect();
- files.sort();
- let mut h = lightcraft_preview::Hasher128::new();
- for (name, len, modified) in &files {
- h.str(name).u64(*len).u64(*modified);
- }
- for (model, json) in BUNDLED {
- h.str(model).str(json);
- }
- h.finish().0 as u64
- })
+ *KEY.get_or_init(|| cache_key_for(dir().as_deref()))
+}
+
+fn cache_key_for(directory: Option<&Path>) -> u64 {
+ let entries = directory.and_then(|d| std::fs::read_dir(d).ok());
+ let mut files: Vec<(String, u64, std::time::Duration)> = entries
+ .into_iter()
+ .flatten()
+ .flatten()
+ .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
+ .filter_map(|e| {
+ let meta = e.metadata().ok()?;
+ let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
+ Some((e.file_name().to_string_lossy().into_owned(), meta.len(), modified))
+ })
+ .collect();
+ files.sort();
+ let mut h = lightcraft_preview::Hasher128::new();
+ for (name, len, modified) in &files {
+ h.str(name).u64(*len).u64(modified.as_secs()).u64(u64::from(modified.subsec_nanos()));
+ }
+ for (model, json) in BUNDLED {
+ h.str(model).str(json);
+ }
+ h.finish().0 as u64
}
/// Per camera model: the photos read and their pooled colour pairs.
@@ -294,6 +296,38 @@ mod tests {
assert!(bundled("No Such Camera").is_none());
}
+ #[test]
+ fn same_second_profile_replacement_changes_render_cache_key() {
+ let dir = std::env::temp_dir().join(format!("lc-camera-profile-cache-{}", std::process::id()));
+ let mut first = profile();
+ first.model = "ILCE-7CR".into();
+ let path = save(&first, &dir).unwrap();
+ let set_modified = |millis| {
+ let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000) + std::time::Duration::from_millis(millis);
+ let file = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
+ file.set_times(std::fs::FileTimes::new().set_modified(time)).unwrap();
+ file.metadata().unwrap()
+ };
+ let before = set_modified(100);
+ assert_eq!(load(&path).unwrap(), first);
+ let before_key = cache_key_for(Some(&dir));
+
+ let mut second = first;
+ second.matrix[0][0] = 1.7;
+ save(&second, &dir).unwrap();
+ let after = set_modified(350);
+ assert_eq!(load(&path).unwrap(), second);
+ assert_eq!(before.len(), after.len());
+ let before_time = before.modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap();
+ let after_time = after.modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap();
+ assert_eq!(before_time.as_secs(), after_time.as_secs());
+ assert_ne!(before_time.subsec_nanos(), after_time.subsec_nanos());
+ let after_key = cache_key_for(Some(&dir));
+ let _ = std::fs::remove_dir_all(dir);
+
+ assert_ne!(before_key, after_key, "a changed profile must not reuse the previous render cache");
+ }
+
#[test]
fn file_names_are_safe() {
assert_eq!(file_name("ILCE-7M4"), "ILCE-7M4.json");
diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs
index 655965688..01c67c267 100644
--- a/crates/engine/src/cmd/browse.rs
+++ b/crates/engine/src/cmd/browse.rs
@@ -114,6 +114,16 @@ fn forget_gone(s: &mut Session, dir: &str, subfolders: bool, files: &[String]) -
forgotten
}
+impl Session {
+ /// Record that the folder `root` was browsed now, with the folders of `files` (the photos it showed): what
+ /// `library.browse` does after reading a folder, for a UI that reads it on a worker thread instead. Untouched
+ /// Local photos are forgotten only from folders not browsed for the set number of days, so a browse that
+ /// isn't recorded lets a folder in daily use be forgotten.
+ pub fn note_browsed(&mut self, root: &str, files: &[String]) {
+ stamp_browsed(self, root, files);
+ }
+}
+
/// Folders whose browse time is refreshed at most this often (keeps repeated browsing from
/// growing the log; far finer than the days the eviction counts in).
const RESTAMP_SECS: i64 = 3600;
@@ -148,7 +158,11 @@ fn stamp_browsed(s: &mut Session, root: &str, files: &[String]) {
/// referred to by an undo / redo step.
fn in_use(s: &mut Session) -> std::collections::HashSet {
let mut keep = held(s);
- keep.extend(s.visible().iter().copied());
+ // Missing Photos lists library photos only, never Local records, so it keeps nothing here; and
+ // working it out checks every original (minutes on a sleeping NAS when the library opens on it)
+ if s.source != crate::LibrarySource::Missing {
+ keep.extend(s.visible().iter().copied());
+ }
keep
}
diff --git a/crates/engine/src/cmd/cull.rs b/crates/engine/src/cmd/cull.rs
index 030cb6e93..142accd5a 100644
--- a/crates/engine/src/cmd/cull.rs
+++ b/crates/engine/src/cmd/cull.rs
@@ -21,6 +21,9 @@ fn secs(iso: &str) -> Option {
fn analyze(s: &mut Session, p: &Value) -> Result {
let ids: Vec = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p)? } else { s.visible_cloned() };
+ // a photo listed twice is one photo, not a burst of two look-alikes (first occurrence kept)
+ let mut seen = std::collections::HashSet::new();
+ let ids: Vec = ids.into_iter().filter(|id| seen.insert(*id)).collect();
let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32);
let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false);
// measure (thumbnail-level sources: fast, and enough for focus at 512 px)
@@ -87,7 +90,7 @@ fn analyze(s: &mut Session, p: &Value) -> Result {
/// content), most similar first; the view is filtered to them.
fn find_similar(s: &mut Session, p: &Value) -> Result {
const C: &str = "library.findSimilar";
- let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| super::bad(C, "no photo"))?;
+ let id = super::photo_id(p, C)?.or(s.active()).ok_or_else(|| super::bad(C, "no photo"))?;
let min = p.get("similarity").and_then(Value::as_f64).unwrap_or(0.8) as f32;
let all: Vec = s.catalog.photos().filter(|q| q.in_library()).map(|q| q.id).collect();
let sig = |s: &mut Session, id: PhotoId| -> Option<[f32; 64]> {
diff --git a/crates/engine/src/cmd/denoise.rs b/crates/engine/src/cmd/denoise.rs
index f93a2434a..644d3b89e 100644
--- a/crates/engine/src/cmd/denoise.rs
+++ b/crates/engine/src/cmd/denoise.rs
@@ -513,10 +513,6 @@ fn settings(s: &mut Session, p: &Value) -> Result {
Ok(json!({"auto": st.auto(), "cacheGb": st.cache_bytes() >> 30, "threads": st.threads, "runOn": st.run_on().name()}))
}
-fn ids_param(p: &Value) -> Option> {
- p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect())
-}
-
fn status_json(s: &Session, photo: Option) -> Value {
let (files, bytes) = s.denoise_cache_usage();
let st = &s.denoise.settings;
@@ -594,7 +590,7 @@ fn queue(s: &mut Session, p: &Value) -> Result {
if s.denoise_products_dir().is_none() {
return Err(bad(C, "denoise keeps its pictures in the library's folder: open a library on disk first"));
}
- let ids = match ids_param(p) {
+ let ids = match super::photo_ids(p, C)? {
Some(ids) => ids,
None => match str_param(p, "scope").unwrap_or("selected") {
"selected" => s.selection.active.iter().chain(s.selection.ids.iter()).copied().collect(),
@@ -612,14 +608,14 @@ fn queue(s: &mut Session, p: &Value) -> Result {
}
fn cancel(s: &mut Session, p: &Value) -> Result {
- let photo = p.get("id").and_then(Value::as_u64).map(PhotoId);
+ let photo = super::photo_id(p, "denoise.cancel")?;
let n = s.denoise_cancel(photo, bool_or(p, "all", photo.is_none()));
Ok(json!({"cancelled": n}))
}
fn status(s: &mut Session, p: &Value) -> Result {
s.denoise_refresh_active(false);
- Ok(status_json(s, p.get("id").and_then(Value::as_u64).map(PhotoId)))
+ Ok(status_json(s, super::photo_id(p, "denoise.status")?))
}
/// `denoise.pump {pace?}`: cheap to call every frame. Takes in what the background job has finished and keeps one going.
diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs
index ce7bf4a65..fbc3d4041 100644
--- a/crates/engine/src/cmd/develop.rs
+++ b/crates/engine/src/cmd/develop.rs
@@ -2,7 +2,7 @@
use lightcraft_catalog::{Op, PhotoId, Version};
use lightcraft_develop::{DevelopSettings, Preset, ProcessVersion, Section, SettingsGroup, Treatment, Upright, WbMode, controls};
-use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop};
+use lightcraft_geom::{CropGeometry, CropHandle, MIN_CROP_FRACTION, Point, Rect, crop_fit_angle, drag_crop};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, index_req, ok, str_param};
@@ -208,8 +208,7 @@ pub fn specs() -> Vec {
}
refit_crop_angle(angle, before, d, dims);
};
- if let Some(ids) = p.get("ids").and_then(Value::as_array) {
- let ids: Vec = ids.iter().filter_map(Value::as_u64).map(PhotoId).collect();
+ if let Some(ids) = super::photo_ids(p, "develop.set")? {
let ops = ids
.iter()
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
@@ -533,13 +532,24 @@ pub fn specs() -> Vec {
cmd!("crop.set", "Set Crop", [], None, "{rect?: [x0,y0,x1,y1] normalized in the straightened frame, angle?: degrees}", has_active, |s, p| {
let id = active(s, "crop.set")?;
let (w, h) = crop_dims(s, id);
+ // the smallest crop a drag of the crop tool can make: below it an export comes out 1×1 (#1952)
+ let min_side = MIN_CROP_FRACTION * w.min(h) * (1.0 - 1e-9);
+ let too_small = |r: Rect| {
+ (r.width() * w < min_side || r.height() * h < min_side)
+ .then(|| bad("crop.set", format!("rect is smaller than the smallest crop ({} % of the shorter side)", MIN_CROP_FRACTION * 100.0)))
+ };
edit(s, "crop.set", "Crop", |d| {
- if let Some(a) = p.get("rect").and_then(Value::as_array) {
+ let explicit = p.get("rect").and_then(Value::as_array);
+ if let Some(a) = explicit {
let v: Vec = a.iter().filter_map(Value::as_f64).collect();
if v.len() != 4 {
return Err(bad("crop.set", "rect needs 4 numbers"));
}
- d.crop.geometry.rect = Rect::new(v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3]));
+ let r = Rect::new(v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3]));
+ if let Some(e) = too_small(r) {
+ return Err(e);
+ }
+ d.crop.geometry.rect = r;
}
let angle = p.get("angle").and_then(Value::as_f64).map(|a| a.clamp(-45.0, 45.0));
d.crop.geometry = match (p.get("rect"), angle) {
@@ -547,6 +557,12 @@ pub fn specs() -> Vec {
(None, Some(a)) => d.crop.geometry.with_angle(a, w, h),
(_, a) => CropGeometry { angle: a.unwrap_or(d.crop.geometry.angle), ..d.crop.geometry }.constrained(w, h),
};
+ // a rect far larger than the photo is shrunk to fit it, and can come out a sliver
+ if explicit.is_some()
+ && let Some(e) = too_small(d.crop.geometry.rect)
+ {
+ return Err(e);
+ }
Ok(())
})
}),
diff --git a/crates/engine/src/cmd/face_recognize.rs b/crates/engine/src/cmd/face_recognize.rs
index 228a1ce3d..e66bb56f3 100644
--- a/crates/engine/src/cmd/face_recognize.rs
+++ b/crates/engine/src/cmd/face_recognize.rs
@@ -82,7 +82,7 @@ fn name_faces(s: &mut Session, p: &Value) -> Result {
/// makes the face yours: a later "Detect Faces" run no longer replaces it.
fn set_name(s: &mut Session, p: &Value) -> Result {
const C: &str = "faces.setName";
- let id = p.get("id").and_then(Value::as_u64).map(lightcraft_catalog::PhotoId).or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?;
+ let id = super::photo_id(p, C)?.or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?;
let index = p.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(C, "missing or invalid `index`"))?;
let name = name_param(p, C)?;
let mut meta = s.catalog.photo(id).ok_or_else(|| bad(C, "no such photo"))?.meta.clone();
@@ -470,8 +470,7 @@ mod imp {
let embedder = current(s, C)?;
collect(s);
let budget = Duration::from_millis(p.get("budgetMs").and_then(Value::as_u64).unwrap_or(200).min(600_000));
- let first: Vec =
- p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect()).unwrap_or_default();
+ let first: Vec = super::super::photo_ids(p, C)?.unwrap_or_default();
let todo = pending(s, &first, false);
let started = Instant::now();
let (mut embedded, mut failed, mut done) = (0, 0, 0);
@@ -509,13 +508,12 @@ mod imp {
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
- // days from civil (proleptic Gregorian)
- let y = if m <= 2 { y - 1 } else { y };
- let era = y.div_euclid(400);
- let yoe = y - era * 400;
- let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
- let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
- Some((era * 146_097 + doe - 719_468) * 86_400 + hh * 3600 + mm * 60 + ss)
+ // Days since 1970-01-01 (proleptic Gregorian); a day past the month's length rolls over as
+ // the old Hinnant arithmetic did.
+ let first = chrono::NaiveDate::from_ymd_opt(i32::try_from(y).ok()?, u32::try_from(m).ok()?, 1)?;
+ let date = first.checked_add_days(chrono::Days::new(u64::try_from(d - 1).ok()?))?;
+ let days = (date - chrono::NaiveDate::from_ymd_opt(1970, 1, 1)?).num_days();
+ Some(days * 86_400 + hh * 3600 + mm * 60 + ss)
}
/// `faces.evaluate`: leave-one-out over the library's named faces, to see how well recognition works on *your*
diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs
index 5567e0abf..e437dccc2 100644
--- a/crates/engine/src/cmd/library.rs
+++ b/crates/engine/src/cmd/library.rs
@@ -145,10 +145,6 @@ fn strs(p: &Value, key: &str) -> Vec {
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default()
}
-fn ids_param(p: &Value) -> Option> {
- p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect())
-}
-
/// The targets an album can hold and how many were browsed (Local) photos, which album views
/// never show (issue #684): those are skipped, and reported, instead of becoming invisible members.
fn album_targets(s: &Session, targets: Vec) -> (Vec, usize) {
@@ -623,7 +619,7 @@ pub fn specs() -> Vec {
"{ids: [id], active?: id, mode?: replace|add|toggle|range} — every id must be a photo of the library",
always,
|s, p| {
- let ids = ids_param(p).unwrap_or_default();
+ let ids = super::photo_ids(p, "library.select")?.unwrap_or_default();
let active = p.get("active").and_then(Value::as_u64).map(PhotoId);
let mode = str_param(p, "mode").unwrap_or("replace");
// An id that is not in the library is an error, and the selection stays as it was:
@@ -808,9 +804,13 @@ pub fn specs() -> Vec {
"{label: red|yellow|green|blue|purple|none, ids?}",
has_selection,
|s, p| {
- let l = match str_param(p, "label") {
- None | Some("none") => None,
- Some(x) => Some(ColorLabel::parse(x).ok_or_else(|| bad("photo.label", "unknown label"))?),
+ // `none` (or null) clears; a missing label or one that isn't text is an error, not a clear
+ let l = match p.get("label") {
+ Some(Value::Null) => None,
+ Some(Value::String(x)) if x == "none" => None,
+ Some(Value::String(x)) => Some(ColorLabel::parse(x).ok_or_else(|| bad("photo.label", "unknown label"))?),
+ None => return Err(bad("photo.label", "missing `label` (red|yellow|green|blue|purple|none)")),
+ Some(_) => return Err(bad("photo.label", "label must be red|yellow|green|blue|purple|none")),
};
for_targets(s, p, "Set Color Label", |id| Some(Op::SetLabel { id, label: l }))
}
@@ -829,8 +829,7 @@ pub fn specs() -> Vec {
"{id?, index} — remove one face / pet region (by its position in the photo's regions) from the photo in the catalog; undoable. The XMP sidecar is never rewritten for this, even with auto-write on, so reading the metadata from the file brings the region back",
always,
|s, p| {
- let id =
- p.get("id").and_then(Value::as_u64).map(PhotoId).or_else(|| s.active()).ok_or_else(|| bad("photo.removeRegion", "no photo"))?;
+ let id = super::photo_id(p, "photo.removeRegion")?.or_else(|| s.active()).ok_or_else(|| bad("photo.removeRegion", "no photo"))?;
let index = p
.get("index")
.and_then(Value::as_u64)
@@ -856,7 +855,7 @@ pub fn specs() -> Vec {
|s, p| {
const C: &str = "photo.setRegion";
// a drag is one undo step however many frames it took: the caller commits once, on release
- let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?;
+ let id = super::photo_id(p, C)?.or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?;
let index = p
.get("index")
.and_then(Value::as_u64)
@@ -1267,7 +1266,7 @@ pub fn specs() -> Vec {
has_library_selection,
|s, p| {
let id = album_param(p, "id", "album.addPhotos")?;
- let targets = ids_param(p).unwrap_or_else(|| s.selected());
+ let targets = super::photo_ids(p, "album.addPhotos")?.unwrap_or_else(|| s.selected());
let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?;
if al.is_smart() || al.folder {
return Err(bad("album.addPhotos", "smart albums and folders can't hold photos"));
@@ -1296,7 +1295,8 @@ pub fn specs() -> Vec {
"{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped",
has_library_selection,
|s, p| {
- let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.selected()))?;
+ let targets =
+ album_targets_or_err(s, "album.toggleTarget", super::photo_ids(p, "album.toggleTarget")?.unwrap_or_else(|| s.selected()))?;
let (targets, skipped) = targets;
let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder));
let id = match target.or_else(|| s.catalog.quick_collection()) {
@@ -1458,7 +1458,7 @@ pub fn specs() -> Vec {
_ => return Err(bad("album.removePhotos", "no album: pass `id` or show an album")),
},
};
- let targets = ids_param(p).unwrap_or_else(|| s.selected());
+ let targets = super::photo_ids(p, "album.removePhotos")?.unwrap_or_else(|| s.selected());
let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?;
if al.is_smart() {
return Err(bad("album.removePhotos", "smart albums update automatically: change their rules"));
@@ -1709,12 +1709,21 @@ fn has_library(s: &Session) -> std::result::Result<(), String> {
/// Coordinates typed by a person: "51.5072, -0.1276", "51.5072 N 0.1276 W",
/// "51°30'26\"N 0°7'39\"W" (degrees, minutes, seconds; N/S/E/W or signs).
pub(crate) fn parse_gps(t: &str) -> Option<(f64, f64)> {
+ // typographic minus signs and dashes mean a negative value, like ASCII '-'
+ let t = t.replace(['\u{2212}', '\u{2012}', '\u{2013}', '\u{FE63}', '\u{FF0D}'], "-");
let t = t.trim();
// split into the two coordinates: at a comma, else after the first N/S hemisphere letter
let (a, b) = match t.split_once(',') {
Some((a, b)) => (a.trim().to_string(), b.trim().to_string()),
None => {
- let i = t.find(['N', 'S', 'n', 's']).map(|i| i + 1).or_else(|| t.find(char::is_whitespace))?;
+ let lead = t.starts_with(['N', 'S', 'n', 's']);
+ let i = if lead {
+ // hemisphere letter first: "N 51.5 W 0.1" splits at the E/W letter
+ t.char_indices().skip(1).find(|(_, c)| matches!(c, 'E' | 'W' | 'e' | 'w')).map(|(i, _)| i)
+ } else {
+ t.find(['N', 'S', 'n', 's']).map(|i| i + 1)
+ }
+ .or_else(|| t.find(char::is_whitespace))?;
(t[..i].trim().to_string(), t[i..].trim().to_string())
}
};
@@ -1746,6 +1755,10 @@ mod gps_tests {
assert!((la - 51.50722).abs() < 1e-4 && (lo + 0.1275).abs() < 1e-4, "{la} {lo}");
let (la, lo) = p("33.8688 S, 151.2093 E").unwrap();
assert!((la + 33.8688).abs() < 1e-9 && (lo - 151.2093).abs() < 1e-9);
+ assert_eq!(p("51.5 N 0.1 W"), Some((51.5, -0.1)));
+ assert_eq!(p("N 51.5072 W 0.1276"), Some((51.5072, -0.1276)));
+ assert_eq!(p("S 33.8688 E 151.2093"), Some((-33.8688, 151.2093)));
+ assert_eq!(p("\u{2212}33.8688, 151.2093"), Some((-33.8688, 151.2093)));
assert_eq!(p("95, 10"), None);
assert_eq!(p("hello"), None);
}
diff --git a/crates/engine/src/cmd/lut_profiles.rs b/crates/engine/src/cmd/lut_profiles.rs
index f03809b62..b7a0d4992 100644
--- a/crates/engine/src/cmd/lut_profiles.rs
+++ b/crates/engine/src/cmd/lut_profiles.rs
@@ -17,7 +17,7 @@ pub struct LutProfile {
pub id: String,
pub name: String,
pub group: String,
- /// The `.cube` file (the library's copy when there is a library on disk).
+ /// The library's copy of the `.cube` file (empty without a library on disk).
pub file: String,
}
@@ -123,12 +123,9 @@ fn import(s: &mut Session, p: &Value) -> Result {
std::fs::write(&f, &text).map_err(|e| bad(C, format!("{}: {e}", f.display())))?;
f.to_string_lossy().to_string()
}
- None => {
- // no library on disk: remember the original (the in-memory session ends with the app)
- let f = std::env::temp_dir().join(format!("lightcraft-{}.cube", id.trim_start_matches("lut:")));
- std::fs::write(&f, &text).map_err(|e| bad(C, e.to_string()))?;
- f.to_string_lossy().to_string()
- }
+ // no library on disk: the registered LUT is all this session needs (a copy would only
+ // be read back when a library opens, and that takes its own list from its prefs)
+ None => String::new(),
};
lightcraft_pipeline::lut::register(&id, lut);
s.lut_profiles.push(LutProfile { id: id.clone(), name: display.clone(), group: group.clone(), file });
diff --git a/crates/engine/src/cmd/missing.rs b/crates/engine/src/cmd/missing.rs
index 6f194c652..1bff30d2b 100644
--- a/crates/engine/src/cmd/missing.rs
+++ b/crates/engine/src/cmd/missing.rs
@@ -58,7 +58,7 @@ pub(crate) fn relink_op(id: PhotoId, path: &str) -> Op {
fn relink(s: &mut Session, p: &Value) -> Result {
const C: &str = "photo.relink";
- let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad(C, "no photo (give `id`)"))?;
+ let id = super::photo_id(p, C)?.or(s.active()).ok_or_else(|| bad(C, "no photo (give `id`)"))?;
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
let abs = std::path::absolute(path).map_err(|e| bad(C, e.to_string()))?;
if !abs.is_file() {
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index 22af48f44..9b8dc3dbd 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -193,6 +193,32 @@ pub(crate) fn names_photos(p: &Value) -> bool {
["ids", "id"].iter().any(|k| p.get(k).is_some_and(|v| !v.is_null()))
}
+/// The photos `ids` names: `None` when it is missing or null (the command then falls back to the
+/// selection, or its own default), else every entry as a photo id. An `ids` that isn't a list of
+/// whole numbers is an error, never `None`: falling back would act on the selection instead of
+/// the photos the call meant (`{"ids": 3}`, `{"ids": ["3"]}`).
+pub(crate) fn photo_ids(p: &Value, cmd: &str) -> Result