diff --git a/Cargo.lock b/Cargo.lock
index 30ce9e1f7..5d7f5111a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3140,6 +3140,7 @@ version = "0.9.1"
dependencies = [
"avif-serialize",
"bytemuck",
+ "dhat",
"image",
"image-webp",
"jixel",
diff --git a/README.md b/README.md
index 53ec908de..19eb7c0f2 100644
--- a/README.md
+++ b/README.md
@@ -129,7 +129,7 @@ Split-tone shadows, midtones and highlights independently with drag-anywhere col
## Before & after
-Hold \\ to peek at the original, press Y for side by side, or Shift+Y for a
+Press \\ to toggle the Before view, Y for side by side, or Shift+Y for a
split view. Every image below is a real screenshot of LightCraft, captured automatically by an agent through the
[control channel](#built-for-agents).
@@ -150,9 +150,13 @@ split view. Every image below is a real screenshot of LightCraft, captured autom
Paint with a **Brush** (size, feather, flow, density, erase), drop **Linear** and **Radial Gradients** with draggable
pins, or select by **Luminance Range**, **Color Range**, **Sky**, **Subject** and **Background**. Combine components
-with **Add / Subtract / Intersect**, invert any of them, and dial in 15 local adjustments per mask (Temp, Tint,
-Exposure, Contrast, Highlights, Shadows, Whites, Blacks, Texture, Clarity, Dehaze, Hue, Saturation, Sharpness, Noise)
-plus an overall Amount.
+with **Add / Subtract / Intersect**, invert any of them, and dial in 17 local adjustments per mask (Temp, Tint,
+Exposure, Contrast, Highlights, Shadows, Whites, Blacks, Texture, Clarity, Dehaze, Hue, Saturation, Sharpness, Noise,
+Moiré, Defringe), plus an overall Amount.
+
+Desktop **Object** and **Describe** masks use SAM 3 for click-based and text-prompt selection, with a separately
+installed model; see [model setup and terms](docs/ai-masks.md). Subject, Sky and Background selections still use
+classical heuristics. Existing model-generated masks render and export without the model installed.
@@ -164,7 +168,7 @@ plus an overall Amount.
## Presets, profiles & the Color Mixer
-Eighteen hand-built presets ship in the box (*Golden Hour, Teal & Orange, Faded Matte, Selenium Tone, Crisp
+Forty-one hand-built presets ship in the box (*Golden Hour, Teal & Orange, Faded Matte, Selenium Tone, Crisp
Landscape* and more), each with an **Amount** slider from 0 to 200 %. Save your own from any group of settings, mark
favourites, and copy, paste or sync edits across a whole selection with exactly the groups you choose.
@@ -209,6 +213,10 @@ A library that stays out of your way: **All Photos**, **Recently Added**, **Pick
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.
+The **People** view groups named faces, including names read from XMP; face boxes can be edited in the loupe.
+On desktop, automatic detection and recognition use optional models installed through **Settings → Faces**; no weights are
+bundled. See [face models and setup](docs/faces.md).
+
Photo Grid with albums nested in a folder, ratings and flags. Demo library.
@@ -281,7 +289,13 @@ lightcraft --control 7980 ~/Pictures/trip
print.cell.add width=5 height=7 print.cell.add width=2.5 height=3.5 print.layout.autoLayout print.exportPdf path=Package.pdf
```
- Printer colour management and page overlays (identity plate, watermark, page numbers) are not there yet.
+ The Print Job panel supports printer/paper ICC profiles, rendering intent, black point compensation,
+ print adjustment, sharpening and 8/16-bit PDF output. Direct printing through an OS print dialog and page
+ overlays (identity plate, watermark, page numbers) are not there yet.
+- **Slideshows:** **View → Slideshow** plays the photos in view full screen, with fades, repeat and random order.
+ **Settings → Slideshow** configures layouts, templates, backgrounds, overlays and title screens. Native exports
+ support PDF, JPEG sequences and AV1 video in MP4 or WebM; music and a dedicated Slideshow module editor remain
+ missing. See [docs/slideshow.md](docs/slideshow.md).
- **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents operate the real UI, not a
side door.
- The screenshots in this README were produced end to end by the [`docs/showcase/`](docs/showcase/) scripts.
@@ -293,7 +307,7 @@ lightcraft --control 7980 ~/Pictures/trip
- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2/CR3, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans,
Panasonic RW2 / Leica RWL, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP,
- PSD composites and JPEG XL open today; HEIC/HEIF photos too with `--features heif` (HEVC is a build-time choice,
+ PSD composites, JPEG XL, GIF (first frame) and BMP open today; HEIC/HEIF photos too with `--features heif` (HEVC is a build-time choice,
as in PhotoCraft).
- **Scene-referred & wide-gamut.** Linear Rec.2020 float internally, Bradford-adapted white balance, gamut mapping
instead of clipping, a filmic shoulder for raw and pixel-exact pass-through for JPEGs you haven't touched.
@@ -319,7 +333,7 @@ lightcraft --control 7980 ~/Pictures/trip
LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (details in the [roadmap](ROADMAP.md) and
[docs/target-app-parity.md](docs/target-app-parity.md)):
-- **By feature count we're at ~81%** of Lightroom (core features 98%), tracked row by row in
+- **By feature count we're at ~82%** of Lightroom (core features 98%), tracked row by row in
[docs/parity-checklist.md](docs/parity-checklist.md).
- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~51% for a typical working photographer and ~65% for
casual use.** It's great for JPEG/DNG and most Sony / Panasonic /
@@ -327,9 +341,13 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
- **The biggest gaps:**
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
- - **AI masks and denoise:** subject and sky selection are classical heuristics;
+ - **AI quality and model setup:** Subject and Sky selection are classical heuristics; Object/Describe use an
+ optional SAM 3 model. AI Denoise supports Bayer RAW with separately installed weights, while X-Trans and
+ linear-RGB denoise and Super Resolution remain missing; see [AI masks](docs/ai-masks.md) and [AI Denoise](docs/denoise.md);
- **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
- - **on-screen HDR display, video and the Classic Print / Book / Map modules.**
+ - **on-screen HDR display, video import/editing, and the Book / Map module views.** Print layouts and
+ colour-managed PDF output work; direct printer output and page overlays remain missing. Slideshow playback
+ and export work, with a dedicated module editor and music still missing.
- **What's next:** see [the ranked gaps](docs/gaps.md) and [the roadmap](docs/roadmap.md).
| Area | Status |
@@ -337,16 +355,16 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
| Library: albums, folders, smart albums, stacks (incl. auto-stack), virtual copies, ratings, flags, labels, filter bar, search, sort, grids, filmstrip | ✅ |
| Culling: Compare (synced zoom) and Survey views, auto-advance, instant previews | ✅ |
| Light, Color, Effects (vignette styles), Tone Curve (+ refine saturation, targeted adjustment), Color Mixer (+ targeted), Point Color, Color Grading, Calibration, B&W | ✅ |
-| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) |
+| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect; Object and Describe selections | ✅ · Object/Describe require optional SAM 3; Subject/Sky remain heuristic ([details](docs/ai-masks.md)) |
| Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ |
| Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ |
-| Camera colour: DNG files use their own matrices | ✅ DNG · 🚧 own Sony/Fujifilm profiles; measured calibration database missing |
+| Camera colour: DNG matrices, guarded camera-JPEG look fits and built-in profiles for selected cameras | 🚧 measured calibration and per-model coverage remain incomplete ([raw parity](docs/raw-parity.md)) |
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed, incl. lossy after split), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed, 12.8 bpp), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🚧 · other CR3 variants, compressed ORF / SRW and Nikon HE NEF decode ⬜ ([raw parity](docs/raw-parity.md)) |
-| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise 🚧 (Bayer, user-installed weights), Super Resolution ⬜ |
-| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 |
-| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ |
-| HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; export as ISO 21496-1 gain map JPEG, PQ AVIF or 32-bit float TIFF | ✅ · HDR display ⬜ |
+| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise 🚧 (Bayer, user-installed weights; [setup](docs/denoise.md)), Super Resolution ⬜ |
+| Remove / Heal / Clone spots (auto source), non-generative Fill, editable spot pins, Visualize Spots, Red Eye and Pet Eye | ✅ · Fill uses PatchMatch; no generative fill |
+| Export: JPEG / PNG / TIFF / flat PSD / WebP / AVIF / JPEG XL / raw or rendered DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ |
+| HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; gain map JPEG, PQ/HLG AVIF, PQ PNG/JPEG XL and float TIFF export | ✅ · HDR display ⬜ |
| Library persistence (crash-safe op log + snapshots, background compaction, failed saves reported), disk thumbnail cache | ✅ |
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ |
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
@@ -354,7 +372,11 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files, Panasonic / Leica RW2 / RWL distortion data and Nikon Z 6II / Z 8 / Z f distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
| Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ |
| GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 |
-| AI: segmentation masks, AI denoise, super resolution, faces; video | 🚧 (see [gaps](docs/gaps.md)) |
+| People view, XMP face names and editable face boxes; automatic face detection and recognition | ✅ · detection/recognition require optional models ([setup](docs/faces.md)) |
+| GPX tracklog auto-tagging and offline reverse geocoding | ✅ · place names require the GeoNames dataset; Map view ⬜ |
+| Print layouts, picture packages, templates and colour-managed PDF export | ✅ layouts/PDF · 🚧 direct printer output and page overlays |
+| Slideshow playback, layouts, overlays, templates and PDF/JPEG/AV1 export | ✅ playback · 🚧 dedicated module editor and music ([details](docs/slideshow.md)) |
+| Video import/editing and Book module | ⬜ |
| Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 |
✅ works today · 🚧 in progress · ⬜ not started
@@ -373,7 +395,7 @@ cargo run --release -p lightcraft -- --memory # a throwaway in-memory
cargo run --release -p lightcraft -- --control 7980 # with the automation channel
cargo xtask web --serve # the same app in the browser: http://127.0.0.1:8080/
cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5
-cargo xtask ci # fmt, clippy, tests, layering, wasm checks
+cargo xtask ci # fmt, clippy, HEIF, tests, parity, layers, assets, WASM
```
CI defaults to line-table debug information and one build job per 1.5 GB of RAM
@@ -426,7 +448,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, Українська, Čeština, ქართული) or **Settings → General →
+**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Polski, Čeština, Italiano, ქართული) 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
@@ -536,7 +558,7 @@ option to start it. For unattended installs: `msiexec /i lightcraft--window
| Format | x86_64 | aarch64 (ARM64) | Notes |
|---|---|---|---|
-| AppImage | `lightcraft--linux-x86_64.AppImage` | `lightcraft--linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
+| AppImage | `lightcraft--linux-x86_64.AppImage` | `lightcraft--linux-aarch64.AppImage` | Portable; updates with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
| Flatpak | `lightcraft--linux-x86_64.flatpak` | `lightcraft--linux-aarch64.flatpak` | Sandboxed; `flatpak install --user ` |
| Debian/Ubuntu | `lightcraft--linux-x86_64.deb` | `lightcraft--linux-aarch64.deb` | |
| Fedora/RHEL/openSUSE | `lightcraft--linux-x86_64.rpm` | `lightcraft--linux-aarch64.rpm` | |
diff --git a/contributors/people.toml b/contributors/people.toml
index bec67597c..08d002e8b 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -45,3 +45,15 @@ sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
+
+[people.nledford]
+real_name = "Nathaniel Ledford"
+
+[people.marcoarnulfo]
+real_name = "Marco Arnulfo"
+display_name = "Marco Arnulfo"
+sync_github_name = false
+
+[people.egegl]
+real_name = "Ege Gursel"
+sync_github_name = true
diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs
index 289ee4fb8..edcfae22a 100644
--- a/crates/catalog/src/journal.rs
+++ b/crates/catalog/src/journal.rs
@@ -271,7 +271,7 @@ fn resync(line: &[u8], last: u64) -> Option<(usize, (u64, Op))> {
const START: &[u8] = b"{\"seq\":";
(1..line.len().saturating_sub(START.len() - 1)).filter(|&i| line[i..].starts_with(START)).find_map(|i| {
let rec = std::str::from_utf8(&line[i..]).ok().map(|l| l.trim_end_matches('\r')).and_then(decode_record)?;
- (rec.0 <= last + 1).then_some((i, rec))
+ (rec.0 <= last.saturating_add(1)).then_some((i, rec))
})
}
@@ -298,6 +298,9 @@ fn write_snapshot(store: &mut dyn Store, seq: u64, catalog: &Catalog) -> std::io
Ok(SnapshotTiming { serialize_ms, write_sync_ms: (ms_since(t0) - serialize_ms).max(0.0), bytes, ..Default::default() })
}
+/// The highest `seq` a snapshot may hold; beyond it the counter is treated as corrupt, far from overflow.
+const MAX_SEQ: u64 = u64::MAX / 2;
+
/// Just the identification of `catalog.snap` (read before the catalog itself).
#[derive(serde::Deserialize)]
struct SnapHeader {
@@ -331,6 +334,10 @@ impl Journal {
return Err(newer(format!("{SNAPSHOT} is catalog format v{}", h.version)));
}
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
+ // a seq this high can't come from real use; the next record's seq would wrap
+ if s.seq > MAX_SEQ {
+ return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: seq {} is out of range", s.seq)));
+ }
let mut catalog = s.catalog;
report.repaired = catalog.repair();
for r in &report.repaired {
@@ -392,7 +399,7 @@ impl Journal {
let _ = op;
report.stale += 1;
}
- Some((seq, op)) if seq == j.seq + 1 => {
+ Some((seq, op)) if j.seq.checked_add(1) == Some(seq) => {
if catalog.apply(op).is_err() {
report.failed += 1;
} else {
@@ -476,7 +483,7 @@ impl Journal {
let mut buf = String::new();
let mut seq = self.seq;
for op in ops {
- seq += 1;
+ seq = seq.checked_add(1).ok_or_else(|| CatalogError::Corrupt(format!("{LOG}: op counter overflow")))?;
buf.push_str(&encode_record(seq, op));
buf.push('\n');
}
@@ -527,7 +534,7 @@ impl Journal {
log::warn!("catalog: background snapshot failed ({e}); writing one now");
}
let t0 = web_time::Instant::now();
- let seq = self.seq + unlogged;
+ let seq = self.seq.checked_add(unlogged).ok_or_else(|| CatalogError::Corrupt(format!("{SNAPSHOT}: op counter overflow")))?;
let mut timing = match write_snapshot(self.store.as_mut(), seq, catalog) {
Ok(t) => t,
Err(e) => {
diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs
index 0c7ca5112..6ac3544ee 100644
--- a/crates/catalog/src/model.rs
+++ b/crates/catalog/src/model.rs
@@ -643,6 +643,7 @@ mod edited_tests {
("NIKON CORPORATION NIKON D850", "NEF", false),
("Canon Canon EOS 5D Mark III", "CR2", false),
("Canon Canon EOS R6m2", "CR3", false),
+ ("Canon Canon EOS R6 Mark II", "CR3", false),
("Canon Canon EOS R7", "CR3", true),
("FUJIFILM X-T4", "RAF", false),
("ILCE-7M3", "ARW", false),
diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs
index 311f6dcde..7625c9ab6 100644
--- a/crates/catalog/src/query.rs
+++ b/crates/catalog/src/query.rs
@@ -157,13 +157,20 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
Some((_, v)) => (x - v).abs() < 1e-9,
None => false,
};
+ // Fields stored as `f32` compare at f32 precision: 2.8f32 widened to f64 is not 2.8.
+ let cmp32 = |x: f32, s: &str| match num(s) {
+ Some(('>', v)) => x > v as f32,
+ Some(('<', v)) => x < v as f32,
+ Some((_, v)) => x == v as f32,
+ None => false,
+ };
return match field {
"rating" | "stars" => cmp(p.rating as f64, val),
"flag" => Flag::parse(val) == Some(p.flag),
"label" | "color" => p.label.is_some_and(|l| format!("{l:?}").eq_ignore_ascii_case(val)),
"iso" => p.meta.iso.is_some_and(|i| cmp(i as f64, val)),
- "f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp(a as f64, val)),
- "focal" => p.meta.focal_mm.is_some_and(|a| cmp(a as f64, val)),
+ "f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp32(a, val)),
+ "focal" => p.meta.focal_mm.is_some_and(|a| cmp32(a, val)),
"camera" => p.meta.camera.to_lowercase().contains(val),
"lens" => p.meta.lens.to_lowercase().contains(val),
"keyword" | "kw" => p.meta.keywords.iter().any(|k| crate::keywords::is_under(k, val)),
diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs
index 255417098..a32b5acf5 100644
--- a/crates/catalog/src/tests.rs
+++ b/crates/catalog/src/tests.rs
@@ -144,6 +144,27 @@ fn free_text_with_a_colon_searches_the_text_fields() {
assert_eq!(q("11:45"), Vec::::new(), "an unknown field matching no text finds nothing");
}
+/// `f:`/`aperture:` and `focal:` are stored as `f32`; a typed value that isn't exact in binary
+/// (2.8, 5.6) must still match, and `<`/`>` must not count the photo's own value.
+#[test]
+fn search_tokens_match_f32_aperture_and_focal() {
+ let mut c = Catalog::new();
+ let a = photo(&mut c, "a.jpg", "2026-05-01T10:00:00");
+ let mut m = c.photo(a).unwrap().meta.clone();
+ m.aperture = Some(2.8);
+ m.focal_mm = Some(5.6);
+ c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap();
+ let q = |t: &str| c.query(&Filter { text: t.into(), ..Default::default() }, &Sort::default());
+ assert_eq!(q("f:2.8"), vec![a], "f:2.8");
+ assert_eq!(q("aperture:=2.8"), vec![a], "aperture:=2.8");
+ assert_eq!(q("focal:5.6"), vec![a], "focal:5.6");
+ assert!(q("f:2.9").is_empty(), "f:2.9");
+ assert!(q("f:<2.8").is_empty(), "f:<2.8");
+ assert!(q("f:>2.8").is_empty(), "f:>2.8");
+ assert!(q("focal:<5.6").is_empty(), "focal:<5.6");
+ assert_eq!(q("f:>2.7"), vec![a], "f:>2.7");
+}
+
/// A `captured` string that is not ASCII (the Lightroom importer copies `captureTime` into an
/// `AddPhoto` op verbatim, and a JSON snapshot is valid UTF-8 whatever it holds) must not be
/// sliced on a byte index in `date_groups`: grouping counts the well-formed dates and ignores
diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs
index 8570bd077..26b4e902a 100644
--- a/crates/catalog/src/tests_journal.rs
+++ b/crates/catalog/src/tests_journal.rs
@@ -376,3 +376,18 @@ fn a_damaged_process_value_loads_as_v1_with_the_edits() {
let d = &loaded.photo(id).unwrap().develop;
assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.8));
}
+
+/// A snapshot whose `seq` is near `u64::MAX` is refused as corrupt: the next record's seq would
+/// wrap (and every later edit would be skipped as stale on reopen) or overflow-panic in debug.
+#[test]
+fn snapshot_with_seq_near_u64_max_is_corrupt() {
+ for seq in [u64::MAX, u64::MAX - 1, u64::MAX / 2 + 1] {
+ let m = MemStore::new();
+ m.set(
+ SNAPSHOT,
+ format!("{{\"format\":\"lightcraft-catalog\",\"version\":1,\"seq\":{seq},\"catalog\":{}}}\n", Catalog::new().to_snapshot()).into_bytes(),
+ );
+ let e = Journal::open(Box::new(m)).err();
+ assert!(matches!(e, Some(CatalogError::Corrupt(_))), "seq {seq}: {e:?}");
+ }
+}
diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml
index 1ae016bc8..589d285fa 100644
--- a/crates/codecs/Cargo.toml
+++ b/crates/codecs/Cargo.toml
@@ -55,3 +55,6 @@ jxl-oxide = { workspace = true }
proptest = { workspace = true }
# Lossless synthetic HEIC files for the `heif` feature's tests.
lightcraft-heif = { workspace = true, features = ["testdata"] }
+
+[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
+dhat = { workspace = true }
diff --git a/crates/codecs/src/jxl.rs b/crates/codecs/src/jxl.rs
index 26e144aed..adf59c0aa 100644
--- a/crates/codecs/src/jxl.rs
+++ b/crates/codecs/src/jxl.rs
@@ -8,7 +8,7 @@ use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
use crate::space::{NamedSpace, SourceSpace, SpaceOrigin, Trc};
use crate::{DecodeOptions, Decoded, Error, Format, Result};
use jxl_oxide::color::{ColourSpace, EnumColourEncoding, Primaries, RenderingIntent, TransferFunction, WhitePoint};
-use jxl_oxide::{AuxBoxData, JxlImage};
+use jxl_oxide::{AllocTracker, AuxBoxData, InitializeResult, JxlImage};
const F: Format = Format::Jxl;
@@ -16,10 +16,60 @@ fn err(e: impl std::fmt::Display) -> Error {
Error::Malformed(F, e.to_string())
}
+/// Tracked allocation budget per allowed pixel for frames, extra channels and modular trees.
+/// This bounds allocations covered by `AllocTracker`, not every allocation made by the decoder (#1517).
+const TRACKED_BYTES_PER_PIXEL: u64 = 4 * 4 * 8;
+
+/// The allocation budget never drops below this, so small images keep the decoder's fixed
+/// overhead (group buffers, entropy tables) whatever `max_pixels` is.
+const MIN_TRACKED_BYTES: u64 = 64 << 20;
+
+/// Bytes handed to the decoder at a time (the size `JxlImageBuilder::read` reads in).
+const INPUT_CHUNK: usize = 4096;
+
+/// Initializes from bounded input, checks dimensions before rendering, then loads remaining frames
+/// within a tracked allocation budget.
+fn open(bytes: &[u8], opts: &DecodeOptions) -> Result {
+ let budget = opts.max_pixels.saturating_mul(TRACKED_BYTES_PER_PIXEL).max(MIN_TRACKED_BYTES);
+ let tracker = AllocTracker::with_limit(usize::try_from(budget).unwrap_or(usize::MAX));
+ let mut uninit = JxlImage::builder().alloc_tracker(tracker).build_uninit();
+ // Initialization can load already-buffered frame data, so small feeds limit work before
+ // dimension checking.
+ let mut rest = bytes;
+ let mut window = INPUT_CHUNK;
+ let mut img = loop {
+ let consumed = uninit.feed_bytes(rest.get(..window).unwrap_or(rest)).map_err(err)?;
+ rest = rest.get(consumed..).unwrap_or_default();
+ match uninit.try_init().map_err(err)? {
+ InitializeResult::Initialized(img) => break img,
+ InitializeResult::NeedMoreData(more) => uninit = more,
+ }
+ if consumed == 0 {
+ // the parser wants more than the window holds at once
+ if window >= rest.len() {
+ return Err(err("truncated JPEG XL header"));
+ }
+ window = window.saturating_mul(2);
+ }
+ };
+ check_size(F, img.width() as u64, img.height() as u64, opts)?;
+ // Match `JxlImageBuilder::read`: bounded feeds until the final frame, not merely the first
+ // keyframe. `feed_bytes` copies codestream data into an untracked Vec, including any tail
+ // after the last frame; feeding the remaining file at once bypasses the allocation budget.
+ while !img.is_loading_done() && !rest.is_empty() {
+ let consumed = img.feed_bytes(rest.get(..INPUT_CHUNK).unwrap_or(rest)).map_err(err)?;
+ if consumed == 0 {
+ break;
+ }
+ rest = rest.get(consumed..).unwrap_or_default();
+ }
+ img.finalize().map_err(err)?;
+ Ok(img)
+}
+
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
- let mut img = JxlImage::builder().read(std::io::Cursor::new(bytes)).map_err(err)?;
+ let mut img = open(bytes, opts)?;
let (w, h) = (img.width(), img.height());
- check_size(F, w as u64, h as u64, opts)?;
let header = img.image_header();
let bits = header.metadata.bit_depth.bits_per_sample() as u8;
let float = matches!(header.metadata.bit_depth, jxl_oxide::image::BitDepth::FloatSample { .. });
diff --git a/crates/codecs/src/tiff_codec.rs b/crates/codecs/src/tiff_codec.rs
index b077c214a..1bbfa8e6c 100644
--- a/crates/codecs/src/tiff_codec.rs
+++ b/crates/codecs/src/tiff_codec.rs
@@ -95,6 +95,11 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
fn limits(opts: &DecodeOptions) -> Limits {
let mut limits = Limits::default();
limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize;
+ // A strip or tile whose declared byte count exceeds this is refused: the default 128 MiB refuses a
+ // single-strip uncompressed 16-bit export of ~22 MP (#1983). Raised to the decoded image's cap
+ // (32 bytes per allowed pixel), never lowered: a strip or tile can outgrow the pixels it shows
+ // (JPEG headers, padding to the tile size).
+ limits.intermediate_buffer_size = limits.intermediate_buffer_size.max(limits.decoding_buffer_size);
limits.ifd_value_size = 32 << 20;
limits
}
diff --git a/crates/codecs/tests/jxl_streaming.rs b/crates/codecs/tests/jxl_streaming.rs
new file mode 100644
index 000000000..298034656
--- /dev/null
+++ b/crates/codecs/tests/jxl_streaming.rs
@@ -0,0 +1,98 @@
+#![cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
+
+use lightcraft_codecs::exif::minimal_exif;
+use lightcraft_codecs::*;
+use lightcraft_raster::Rgba8;
+
+fn codestream(bytes: &[u8]) -> Vec {
+ if bytes.starts_with(b"\xff\x0a") {
+ return bytes.to_vec();
+ }
+ let mut raw = Vec::new();
+ let mut at = 12;
+ while at + 8 <= bytes.len() {
+ let len = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
+ let end = if len == 0 { bytes.len() } else { at + len };
+ match &bytes[at + 4..at + 8] {
+ b"jxlc" => raw.extend_from_slice(&bytes[at + 8..end]),
+ b"jxlp" => raw.extend_from_slice(&bytes[at + 12..end]),
+ _ => {}
+ }
+ at = end;
+ }
+ assert!(raw.starts_with(b"\xff\x0a"));
+ raw
+}
+
+fn box_bytes(out: &mut Vec, kind: &[u8; 4], data: &[u8]) {
+ out.extend_from_slice(&u32::try_from(data.len() + 8).unwrap().to_be_bytes());
+ out.extend_from_slice(kind);
+ out.extend_from_slice(data);
+}
+
+#[test]
+fn metadata_in_final_feed_matches_builder() {
+ let pixels = Rgba8::from_fn(8, 8, |x, y| [(x * 20) as u8, (y * 20) as u8, 80, 255]);
+ let encoded = encode_jxl(&EncodeImage::rgba8(&pixels), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
+ let raw = codestream(&encoded);
+ let exif = minimal_exif(1);
+ let mut exif_box = vec![0; 4]; // TIFF header offset, relative to the end of this field
+ exif_box.extend_from_slice(&exif);
+ // Split the codestream across two feeds, with Exif and XMP following its last frame.
+ let mut bytes = b"\0\0\0\x0cJXL \x0d\x0a\x87\x0a".to_vec();
+ box_bytes(&mut bytes, b"ftyp", b"jxl \0\0\0\0jxl ");
+ let split = raw.len() / 2;
+ let mut part = 0u32.to_be_bytes().to_vec();
+ part.extend_from_slice(&raw[..split]);
+ box_bytes(&mut bytes, b"jxlp", &part);
+ let padding = vec![0; 4096 - bytes.len() - 8];
+ box_bytes(&mut bytes, b"free", &padding);
+ let mut part = 0x8000_0001u32.to_be_bytes().to_vec();
+ part.extend_from_slice(&raw[split..]);
+ box_bytes(&mut bytes, b"jxlp", &part);
+ box_bytes(&mut bytes, b"Exif", &exif_box);
+ box_bytes(&mut bytes, b"xml ", b"");
+ assert!(bytes.len() < 8192, "metadata must fit in the completing input chunk");
+ let old = jxl_oxide::JxlImage::builder().read(bytes.as_slice()).unwrap();
+ let old_exif = match old.aux_boxes().first_exif().unwrap() {
+ jxl_oxide::AuxBoxData::Data(ex) => ex.payload().to_vec(),
+ _ => panic!("builder must preserve Exif in its final feed"),
+ };
+ let old_xml = match old.aux_boxes().first_xml() {
+ jxl_oxide::AuxBoxData::Data(xml) => xml.to_vec(),
+ _ => panic!("builder must preserve XMP in its final feed"),
+ };
+ let decoded = decode(&bytes, DecodeOptions::default()).unwrap();
+ assert_eq!(decoded.exif.as_deref(), Some(old_exif.as_slice()));
+ assert_eq!(decoded.xmp.as_deref(), Some(std::str::from_utf8(&old_xml).unwrap()));
+ assert_eq!(decoded.to_srgb8(), pixels);
+}
+
+#[test]
+fn progressive_truncation_matches_builder() {
+ let pixels = Rgba8::from_fn(96, 64, |x, y| {
+ let mut seed = (y * 96 + x + 1) as u32;
+ let mut rgb = [0; 4];
+ for channel in &mut rgb[..3] {
+ seed ^= seed << 13;
+ seed ^= seed >> 17;
+ seed ^= seed << 5;
+ *channel = seed as u8;
+ }
+ rgb[3] = 255;
+ rgb
+ });
+ for lossless in [false, true] {
+ let encoded =
+ encode_jxl(&EncodeImage::rgba8(&pixels), &JxlOptions { lossless, progressive: true, ..Default::default() }, &EncodeMeta::default())
+ .unwrap();
+ let raw = codestream(&encoded);
+ assert!(raw.len() > 4096, "fixture must exercise post-header feeds");
+ for cut in [0, 1, 2, 12, 4095, 4096, 4097, raw.len() / 2, raw.len() - 1, raw.len()] {
+ let bytes = &raw[..cut];
+ // `finalize` permits partial images: rendering determines whether frame 0 is usable.
+ let old_ok = jxl_oxide::JxlImage::builder().read(bytes).is_ok_and(|image| image.render_frame(0).is_ok());
+ assert_eq!(decode(bytes, DecodeOptions::default()).is_ok(), old_ok, "lossless={lossless}, cut={cut}/{}", raw.len());
+ }
+ }
+}
diff --git a/crates/codecs/tests/jxl_tail_heap.rs b/crates/codecs/tests/jxl_tail_heap.rs
new file mode 100644
index 000000000..f2200e862
--- /dev/null
+++ b/crates/codecs/tests/jxl_tail_heap.rs
@@ -0,0 +1,43 @@
+#![cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
+
+use lightcraft_codecs::*;
+use lightcraft_raster::Rgba8;
+
+#[global_allocator]
+static ALLOC: dhat::Alloc = dhat::Alloc;
+
+fn codestream(bytes: &[u8]) -> Vec {
+ if bytes.starts_with(b"\xff\x0a") {
+ return bytes.to_vec();
+ }
+ let mut raw = Vec::new();
+ let mut at = 12;
+ while at + 8 <= bytes.len() {
+ let len = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
+ let end = if len == 0 { bytes.len() } else { at + len };
+ match &bytes[at + 4..at + 8] {
+ b"jxlc" => raw.extend_from_slice(&bytes[at + 8..end]),
+ b"jxlp" => raw.extend_from_slice(&bytes[at + 12..end]),
+ _ => {}
+ }
+ at = end;
+ }
+ assert!(raw.starts_with(b"\xff\x0a"));
+ raw
+}
+
+#[test]
+fn tiny_jxl_tail_does_not_allocate_a_tail_sized_buffer() {
+ let image = Rgba8::from_fn(8, 8, |x, y| [(x * 20) as u8, (y * 20) as u8, 80, 255]);
+ let encoded = encode_jxl(&EncodeImage::rgba8(&image), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
+ let mut raw = codestream(&encoded);
+ let opts = DecodeOptions { max_pixels: 64, ..Default::default() };
+ decode(&raw, opts).unwrap();
+ raw.resize(raw.len() + (1 << 20), 0);
+ let _profiler = dhat::Profiler::builder().testing().build();
+ let decoded = decode(&raw, opts).unwrap();
+ assert_eq!((decoded.width, decoded.height), (8, 8));
+ let peak = dhat::HeapStats::get().max_bytes;
+ eprintln!("peak heap bytes: {peak}");
+ assert!(peak < (128 << 10), "tiny image used {peak} heap bytes");
+}
diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs
index af689ae5d..d802366c3 100644
--- a/crates/codecs/tests/roundtrip.rs
+++ b/crates/codecs/tests/roundtrip.rs
@@ -263,6 +263,90 @@ fn tiff_cmyk_and_orientation() {
assert!(red[0] > 0.99 && red[1] < 0.01 && red[2] < 0.01, "{red:?}");
}
+/// An uncompressed 16-bit RGB export in a single strip, the shape of the file in #1983 (3923 × 5877,
+/// 138 MB). Its one strip is larger than the `tiff` crate's default 128 MiB intermediate buffer, so
+/// the thumbnail (and the full decode) failed with "decoder limits exceeded".
+#[test]
+fn tiff_single_strip_larger_than_128_mib() {
+ use tiff::encoder::{TiffEncoder, colortype};
+ let (w, h) = (3923u32, 5877u32);
+ let mut cur = std::io::Cursor::new(Vec::new());
+ {
+ let mut enc = TiffEncoder::new(&mut cur).unwrap();
+ let mut im = enc.new_image::(w, h).unwrap();
+ im.rows_per_strip(h).unwrap();
+ im.write_data(&vec![0x8000u16; (w * h * 3) as usize]).unwrap();
+ }
+ let bytes = cur.into_inner();
+ assert!(bytes.len() > 128 << 20);
+ let t = decode_thumbnail(&bytes, 256).unwrap();
+ assert_eq!((t.source_width, t.source_height), (w, h));
+ let px = t.image.get(10, 10);
+ assert!(px[0].abs_diff(128) <= 2 && px[0] == px[1] && px[1] == px[2], "{px:?}");
+}
+
+/// A little-endian TIFF with one IFD of single-value `(tag, type, value)` entries, followed by `data`.
+fn ifd_tiff(entries: &[(u16, u16, u32)], data: &[u8]) -> Vec {
+ let mut b = b"II*\0".to_vec();
+ b.extend(8u32.to_le_bytes());
+ b.extend((entries.len() as u16).to_le_bytes());
+ for &(tag, ty, value) in entries {
+ b.extend(tag.to_le_bytes());
+ b.extend(ty.to_le_bytes());
+ b.extend(1u32.to_le_bytes());
+ b.extend(value.to_le_bytes());
+ }
+ b.extend(0u32.to_le_bytes());
+ b.extend_from_slice(data);
+ b
+}
+
+/// A strip or tile can be larger than the pixels it shows (a JPEG header, padding to the tile size), so
+/// a small `max_pixels` must not shrink the intermediate buffer below the `tiff` crate's 128 MiB
+/// default. Both files below were refused with "decoder limits exceeded" when the buffer was capped at
+/// 32 bytes per allowed pixel. Fixtures from AlexChambers' review of #1989.
+#[test]
+fn tiff_small_pixel_budget_reads_headers_and_padded_tiles() {
+ let opts = |max_pixels| DecodeOptions { max_size: None, max_pixels };
+
+ // 1 × 1 gray, one JPEG-compressed strip (~400 bytes for one pixel), with a one-pixel budget.
+ let mut jpeg = Vec::new();
+ jpeg_encoder::Encoder::new(&mut jpeg, 90).encode(&[128], 1, 1, jpeg_encoder::ColorType::Luma).unwrap();
+ let at = 8 + 2 + 10 * 12 + 4;
+ let entries = [
+ (256, 4, 1),
+ (257, 4, 1),
+ (258, 3, 8),
+ (259, 3, 7),
+ (262, 3, 1),
+ (273, 4, at),
+ (277, 3, 1),
+ (278, 4, 1),
+ (279, 4, jpeg.len() as u32),
+ (284, 3, 1),
+ ];
+ let d = decode(&ifd_tiff(&entries, &jpeg), opts(1)).unwrap();
+ assert_eq!((d.image.width, d.image.height), (1, 1));
+
+ // 16 × 16 gray stored in one 256 × 256 tile (64 KiB), with a budget of exactly 256 pixels.
+ let at = 8 + 2 + 11 * 12 + 4;
+ let entries = [
+ (256, 4, 16),
+ (257, 4, 16),
+ (258, 3, 8),
+ (259, 3, 1),
+ (262, 3, 1),
+ (277, 3, 1),
+ (284, 3, 1),
+ (322, 4, 256),
+ (323, 4, 256),
+ (324, 4, at),
+ (325, 4, 65536),
+ ];
+ let d = decode(&ifd_tiff(&entries, &[128; 65536]), opts(256)).unwrap();
+ assert_eq!((d.image.width, d.image.height), (16, 16));
+}
+
/// A one-strip, uncompressed, single-sample TIFF (little-endian) whose samples are `bits` wide and
/// already packed into `strip`, one padded row after another.
fn packed_tiff(width: u16, height: u16, bits: u16, photometric: u16, strip: &[u8]) -> Vec {
@@ -734,6 +818,21 @@ fn jxl_lossless_exact_with_metadata() {
assert!(max_diff(&d.image, &want.image) < 1e-5, "16-bit lossless differs by {}", max_diff(&d.image, &want.image));
}
+/// #1517: the pixel limit rejects an oversized image; selected truncations never panic.
+#[test]
+#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
+fn jxl_size_limit_and_truncation() {
+ let b = encode_jxl(&EncodeImage::rgba8(&gradient(97, 61)), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
+ let r = decode(&b, DecodeOptions { max_pixels: 97 * 61 - 1, ..Default::default() });
+ assert!(matches!(r, Err(Error::TooLarge(97, 61))), "{r:?}");
+ let d = decode(&b, DecodeOptions { max_pixels: 97 * 61, ..Default::default() }).unwrap();
+ assert_eq!((d.width, d.height), (97, 61));
+ for cut in [0, 1, 2, 5, 12, 40, b.len() / 2, b.len() - 1] {
+ let _ = decode(&b[..cut], DecodeOptions::default());
+ }
+ assert!(decode(&b[..12.min(b.len())], DecodeOptions::default()).is_err());
+}
+
#[test]
#[cfg(feature = "jxl")]
fn jxl_lossy_quality_and_effort() {
diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs
index 855c3d049..89fe93c9f 100644
--- a/crates/color/src/lib.rs
+++ b/crates/color/src/lib.rs
@@ -180,8 +180,8 @@ pub struct Rgb8(pub u8, pub u8, pub u8);
impl Rgb8 {
pub fn from_hex(s: &str) -> Option {
- let s = s.trim_start_matches('#');
- if s.len() != 6 {
+ let s = s.strip_prefix('#').unwrap_or(s);
+ if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let v = u32::from_str_radix(s, 16).ok()?;
@@ -196,6 +196,15 @@ impl Rgb8 {
mod tests {
use super::*;
+ #[test]
+ fn rgb8_from_hex_rejects_signs_and_extra_hashes() {
+ assert_eq!(Rgb8::from_hex("#0f0f0f"), Some(Rgb8(15, 15, 15)));
+ assert_eq!(Rgb8::from_hex("ff8000"), Some(Rgb8(255, 128, 0)));
+ for bad in ["+12345", "#+12345", "#+f+f+f", "-12345", "##123456", "#12345", ""] {
+ assert_eq!(Rgb8::from_hex(bad), None, "{bad}");
+ }
+ }
+
#[test]
fn srgb_matrix_matches_standard() {
let m = SRGB.to_xyz();
diff --git a/crates/denoise-core/src/run.rs b/crates/denoise-core/src/run.rs
index 987a859b9..4314ed8c3 100644
--- a/crates/denoise-core/src/run.rs
+++ b/crates/denoise-core/src/run.rs
@@ -14,7 +14,7 @@ use crate::manifest::Gain;
use crate::tiles::{starts, weight};
/// Most pixels one picture may have (guards the allocations that follow from its size).
-pub const MAX_PIXELS: usize = 100_000_000;
+pub const MAX_PIXELS: usize = 110_000_000;
/// A tile whose mean signal is below this (white = 1) is too dark to measure its scale from.
const MIN_SIGNAL: f32 = 0.003;
@@ -642,4 +642,19 @@ mod tests {
assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![0.0; 5]), &p, &Control::default()), Err(Error::Model(_))));
assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![f32::NAN; 3 * 64 * 64]), &p, &Control::default()), Err(Error::Model(_))));
}
+
+ #[test]
+ fn accepts_102_mp_inputs_and_rejects_inputs_above_the_limit() {
+ let layout = Layout { dx: 0, dy: 0 };
+ let model = Mock { tile: 32, scale: 1.0 };
+ let p = params(32, 8, Gain::None);
+ assert!(matches!(
+ denoise_bayer(&[], 11_648, 8_736, layout, &model, &p, &Control::default()),
+ Err(Error::Input(message)) if message == "the mosaic does not have width × height samples"
+ ));
+ assert!(matches!(
+ denoise_bayer(&[], MAX_PIXELS + 1, 1, layout, &model, &p, &Control::default()),
+ Err(Error::Input(message)) if message == "the picture has no size, or is too large"
+ ));
+ }
}
diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs
index f376b3c67..4631e5e49 100644
--- a/crates/develop/src/lib.rs
+++ b/crates/develop/src/lib.rs
@@ -41,7 +41,7 @@ impl DevelopSettings {
/// Merge a partial JSON object into these settings (fields not mentioned are kept). A white balance that
/// names no [`WbScale`] keeps the meaning it always had (`Legacy`), whatever scale these settings were on: a
/// partial from before the scale existed, or one that never said, must not become Kelvin by landing on
- /// Kelvin settings.
+ /// Kelvin settings. Out-of-range values are brought back into range ([`DevelopSettings::keep_in_range`]).
pub fn merged(&self, partial: &Value) -> Result {
let mut v = self.to_json();
presets::deep_merge(&mut v, partial);
@@ -52,9 +52,34 @@ impl DevelopSettings {
{
out.wb.scale = WbScale::Legacy;
}
+ out.keep_in_range(self);
Ok(out)
}
+ /// What crs XMP import does to the values it reads, for settings that arrive as JSON (presets, `develop.merge`,
+ /// `render --settings`; issue #1946): every slider clamped through its control spec, point curves in 0..1, and a
+ /// crop with no area left as it was in `before`. Unrotated, the crop is clipped to the photo first; a turned crop
+ /// may reach past 0..1 and is left to the crop tools, which know the photo's size.
+ fn keep_in_range(&mut self, before: &DevelopSettings) {
+ for c in controls::CONTROLS {
+ if let Some(v) = controls::get(self, c.id) {
+ controls::set(self, c.id, v);
+ }
+ }
+ let c = &mut self.curve;
+ for p in [&mut c.master, &mut c.red, &mut c.green, &mut c.blue].into_iter().flatten() {
+ *p = lightcraft_geom::Point::new(p.x.clamp(0.0, 1.0), p.y.clamp(0.0, 1.0));
+ }
+ let g = &mut self.crop.geometry;
+ if g.angle == 0.0 {
+ g.rect = g.rect.intersect(&lightcraft_geom::Rect::UNIT);
+ }
+ if g.rect.is_empty() {
+ let old = before.crop.geometry.rect;
+ g.rect = if old.is_empty() { lightcraft_geom::Rect::UNIT } else { old };
+ }
+ }
+
/// Stable 64-bit hash of the settings (FNV-1a over canonical JSON), for preview cache keys.
pub fn hash64(&self) -> u64 {
let s = serde_json::to_string(self).unwrap_or_default();
@@ -255,6 +280,30 @@ mod tests {
assert_eq!(m.light.exposure, 0.0);
}
+ #[test]
+ fn merged_settings_are_kept_in_range() {
+ // issue #1946: `develop.merge`, `render --settings` and presets stored whatever the JSON said
+ let mut base = DevelopSettings::default();
+ base.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.2, 0.8, 0.9);
+ let m = base
+ .merged(&json!({"light": {"exposure": 1e308}, "vignette": {"amount": -1e308}, "geometry": {"scale": 0}, "optics": {"distortion": 1e308}}))
+ .unwrap();
+ assert_eq!((m.light.exposure, m.vignette.amount, m.geometry.scale, m.optics.distortion), (5.0, -100.0, 50.0, 100.0));
+ // point curves stay in 0..1, as an imported XMP curve does
+ let m = base.merged(&json!({"curve": {"master": [{"x": -3, "y": 0}, {"x": 1, "y": 1e308}]}})).unwrap();
+ assert_eq!(m.curve.master, vec![lightcraft_geom::Point::new(0.0, 0.0), lightcraft_geom::Point::new(1.0, 1.0)]);
+ // a crop with no area is left out, as XMP import leaves out one whose edges are inverted
+ for rect in
+ [json!({"x0": 0.9, "x1": 0.1, "y0": 0, "y1": 1}), json!({"x0": 1e308, "x1": -1e308, "y0": 0, "y1": 1}), json!({"x0": 0.5, "x1": 0.5})]
+ {
+ let m = base.merged(&json!({"crop": {"geometry": {"rect": rect}}})).unwrap();
+ assert_eq!(m.crop.geometry.rect, base.crop.geometry.rect, "{rect}");
+ }
+ // unrotated, the crop stays inside the photo
+ let m = base.merged(&json!({"crop": {"geometry": {"rect": {"x0": -1e308, "y0": 0.25, "x1": 1e308, "y1": 0.75}}}})).unwrap();
+ assert_eq!(m.crop.geometry.rect, lightcraft_geom::Rect::new(0.0, 0.25, 1.0, 0.75));
+ }
+
#[test]
fn unedited_and_reset() {
let mut s = DevelopSettings::default();
diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs
index 85f27d321..b3edc801c 100644
--- a/crates/develop/src/presets.rs
+++ b/crates/develop/src/presets.rs
@@ -184,17 +184,11 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value {
}
/// Apply a partial settings object with an amount (1.0 = 100 %). Out-of-range values are clamped
-/// through the control specs.
+/// through the control specs, as every merge does ([`DevelopSettings::merged`]).
pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings {
let patch = if (amount - 1.0).abs() < 1e-9 { Cow::Borrowed(partial) } else { Cow::Owned(scale_patch(&s.to_json(), partial, amount)) };
// (a white balance without a scale keeps its old meaning: `DevelopSettings::merged`)
- let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone());
- for c in crate::controls::CONTROLS {
- if let Some(v) = crate::controls::get(&out, c.id) {
- crate::controls::set(&mut out, c.id, v);
- }
- }
- out
+ s.merged(&patch).unwrap_or_else(|_| s.clone())
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
diff --git a/crates/engine/src/cmd/manage.rs b/crates/engine/src/cmd/manage.rs
index 66ddfe9fb..339b20b90 100644
--- a/crates/engine/src/cmd/manage.rs
+++ b/crates/engine/src/cmd/manage.rs
@@ -9,8 +9,13 @@ use crate::Result;
fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec, String, usize)> {
let template = str_param(p, "template").ok_or_else(|| bad(c, "missing `template`"))?.to_string();
- let start = p.get("start").and_then(Value::as_u64).unwrap_or(1) as usize;
- Ok((s.targets(p)?, template, start))
+ let start = p.get("start").and_then(Value::as_u64).unwrap_or(1);
+ let ids = s.targets(p)?;
+ // every number of the sequence `start..start + n` must fit
+ let last = start.checked_add((ids.len() as u64).saturating_sub(1));
+ let start = last.and_then(|l| usize::try_from(l).ok()).and_then(|_| usize::try_from(start).ok());
+ let start = start.ok_or_else(|| bad(c, "start out of range"))?;
+ Ok((ids, template, start))
}
/// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name).
diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs
index 05a851893..26f5d9bc4 100644
--- a/crates/engine/src/cmd/print.rs
+++ b/crates/engine/src/cmd/print.rs
@@ -324,7 +324,7 @@ pub fn specs() -> Vec {
"{ids?} → {settings: {layout, image, page, guides, job}, papers, pageWidth, pageHeight, printable, pageCount, cellsPerPage, gridCells?, overlaps}; lengths are inches",
always, get),
cmd!(query "print.set", "Set Print Settings", [], None,
- "{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b]}, guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get",
+ "{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b], pageNumbers, pageInfo, cropMarks} (Page Options: single image and contact sheet only), guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get",
always, set),
cmd!(query "print.reset", "Reset Print Settings", [], None, "{} → print.get", always, reset),
cmd!(query "print.cell.add", "Add Print Cell", [], None,
diff --git a/crates/engine/src/crs_masks.rs b/crates/engine/src/crs_masks.rs
index f5384e23d..848251c65 100644
--- a/crates/engine/src/crs_masks.rs
+++ b/crates/engine/src/crs_masks.rs
@@ -99,6 +99,21 @@ fn adjust(c: &XmpValue) -> Value {
Value::Object(a)
}
+/// The correction's local sliders we have no counterpart for (e.g. `LocalGrain`), when set:
+/// reported, never dropped silently.
+fn unmapped_local(c: &XmpValue) -> Vec {
+ let XmpValue::Struct(m) = c else { return Vec::new() };
+ m.keys()
+ .filter_map(|k| {
+ let field = k.strip_prefix("crs:").filter(|f| f.starts_with("Local"))?;
+ // 100 = "curve saturation unchanged", written with every correction
+ let neutral = if field == "LocalCurveRefineSaturation" { 100.0 } else { 0.0 };
+ let set = num(c, k).is_some_and(|v| v != neutral);
+ (set && !LOCAL.iter().any(|(crs, ..)| *crs == field)).then(|| field.to_string())
+ })
+ .collect()
+}
+
/// Perceptual lightness 0..1 (how range masks store luminance) → our range scale: log
/// luminance on −8..+4 EV mapped to 0..1.
fn lightness_to_range(l: f64) -> f64 {
@@ -214,8 +229,12 @@ fn component(m: &XmpValue, container: &str, aspect: f64) -> Result {
let q: Vec = text(r, "crs:LumRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
let [a, b, c, d] = q[..] else { return Err("luminance range".into()) };
+ // full strength at white (1, the top of their scale) takes in everything brighter: ours
+ // goes on above scene white, so that end stays open instead of cutting off at 0 EV
+ let open_top = c >= 1.0;
let (a, b, c, d) = (lightness_to_range(a), lightness_to_range(b), lightness_to_range(c), lightness_to_range(d));
- json!({"kind": "luminanceRange", "lo": b, "hi": c, "lo_feather": (b - a).max(0.0), "hi_feather": (d - c).max(0.0)})
+ let (hi, hi_feather) = if open_top { (1.0, 0.0) } else { (c, (d - c).max(0.0)) };
+ json!({"kind": "luminanceRange", "lo": b, "hi": hi, "lo_feather": (b - a).max(0.0), "hi_feather": hi_feather})
}
Some("3") => {
let q: Vec = text(r, "crs:DepthRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
@@ -252,6 +271,7 @@ pub fn masks(values: &Values, aspect: f64) -> (Vec, Vec) {
if parts.is_empty() {
continue;
}
+ skipped.extend(unmapped_local(c));
// brush dabs of one correction are one brush component
let mut merged: Vec = Vec::new();
for p in parts {
@@ -506,6 +526,28 @@ mod tests {
assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture");
}
+ #[test]
+ fn range_to_white_stays_open_and_unmapped_local_sliders_are_reported() {
+ // written for this test: a highlights range (full from 0.85 up to white) carrying a local slider we lack
+ let x = r#"
+
+
+
+
+
+
+
+
+"#;
+ let d = lightcraft_meta::parse_xmp(x).unwrap();
+ let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
+ let s = DevelopSettings::default().merged(&partial).expect("valid settings");
+ let MaskShape::LuminanceRange { hi, hi_feather, .. } = &s.masks[0].components[0].shape else { panic!() };
+ assert_eq!((*hi, *hi_feather), (1.0, 0.0), "brighter than scene white is still in the range");
+ assert_eq!(s.masks[0].adjust.clarity, -30.0);
+ assert_eq!(unmapped, ["Mask: LocalGrain"], "a refine saturation of 100 is neutral, not reported");
+ }
+
#[test]
fn lrtemplate_corrections() {
let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = {
diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs
index d294ed4d9..e9d1cbc58 100644
--- a/crates/engine/src/export.rs
+++ b/crates/engine/src/export.rs
@@ -1310,7 +1310,15 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
let exif = meta.map(lightcraft_meta::write_exif);
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) };
- let e = EncodeImage::rgba8(&img);
+ // Opaque photo exports need no alpha channel (#1670). Keep real transparency for
+ // callers of this encoder, and leave unrelated formats on their existing path.
+ let rgb: Option> = (matches!(o.format, ExportFormat::Png | ExportFormat::Tiff | ExportFormat::Webp)
+ && img.data.iter().all(|p| p.last() == Some(&255)))
+ .then(|| img.data.iter().flat_map(|p| p.iter().take(3).copied()).collect());
+ let e = match &rgb {
+ Some(data) => EncodeImage::new(img.width as u32, img.height as u32, 3, Samples::U8(data)),
+ None => EncodeImage::rgba8(&img),
+ };
let r = match o.format {
ExportFormat::Jpeg => {
// 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality).
@@ -2281,6 +2289,43 @@ mod tests {
img
}
+ /// Issue #1670: removing a redundant alpha channel preserves samples and metadata.
+ #[test]
+ fn opaque_eight_bit_exports_are_rgb() {
+ let img = test_image();
+ let metadata = Metadata { copyright: Some("Synthetic export test".into()), ..Default::default() };
+ for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] {
+ for compression in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] {
+ let options = ExportOptions { format, tiff_compression: compression, ..Default::default() };
+ let bytes = encode_with_metadata(&img, &options, Some(&metadata)).unwrap();
+ let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap();
+ assert!(!decoded.has_alpha, "{format:?}/{compression:?} must be RGB");
+ assert_eq!((decoded.width, decoded.height, decoded.bit_depth), (96, 64, 8));
+ assert_eq!(decoded.to_srgb8().data, img.data, "{format:?}/{compression:?} pixels");
+ assert_eq!(decoded.icc.as_deref(), Some(icc::write_named(NamedSpace::Srgb).as_slice()));
+ assert!(decoded.xmp.as_deref().unwrap_or_default().contains("Synthetic export test"));
+ if format == ExportFormat::Png {
+ assert_eq!(bytes[25], 2, "PNG IHDR declares RGB");
+ }
+ if format != ExportFormat::Tiff {
+ break;
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn eight_bit_exports_preserve_real_alpha() {
+ let mut img = test_image();
+ img.data[0][3] = 128;
+ for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] {
+ let bytes = encode_image(&img, &ExportOptions { format, ..Default::default() }).unwrap();
+ let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap();
+ assert!(decoded.has_alpha, "{format:?} must retain real alpha");
+ assert_eq!(decoded.to_srgb8().data, img.data, "{format:?} samples");
+ }
+ }
+
#[test]
fn formats_have_magic() {
let img = test_image();
diff --git a/crates/engine/src/faces_index.rs b/crates/engine/src/faces_index.rs
index bf10e3502..17bffbc19 100644
--- a/crates/engine/src/faces_index.rs
+++ b/crates/engine/src/faces_index.rs
@@ -167,6 +167,9 @@ impl Index {
self.entries.insert((photo, key), (emb.into(), view));
}
}
+ // a half-written last record (a crash while appending): written fresh at the next save, so later records are
+ // not appended after it and shifted
+ self.rewrite = !rest.is_empty();
}
fn header(&self) -> Vec {
@@ -201,12 +204,11 @@ impl Index {
return Ok(());
}
if self.file_matches && !self.rewrite {
- let mut f = std::fs::OpenOptions::new().append(true).open(&path)?;
let mut buf = Vec::new();
for (k, e) in &self.unsaved {
buf.extend(self.record(k, e));
}
- f.write_all(&buf)?;
+ append(&path, &buf)?;
} else {
let mut buf = self.header();
for (k, e) in &self.entries {
@@ -229,6 +231,22 @@ impl Drop for Index {
}
}
+/// Append `buf` to the file at `path`. When the write fails (a full disk), the file is cut back to its old length so
+/// the part that got in does not shift what is appended later.
+fn append(path: &Path, buf: &[u8]) -> std::io::Result<()> {
+ let mut f = std::fs::OpenOptions::new().append(true).open(path)?;
+ let old_len = f.metadata()?.len();
+ let written = f.write_all(buf);
+ if written.is_err() {
+ drop(f);
+ // an append-only handle may not truncate (Windows): cut through a writable one
+ if let Ok(w) = std::fs::OpenOptions::new().write(true).open(path) {
+ let _ = w.set_len(old_len);
+ }
+ }
+ written
+}
+
const SCAN_MAGIC: &[u8; 4] = b"LCFS";
const SCAN_VERSION: u8 = 1;
/// The detector a search was made with: a record made by another one is ignored (a better detector may find more).
@@ -325,8 +343,11 @@ impl Scanned {
let head = self.header();
let Some(rest) = bytes.strip_prefix(head.as_slice()) else { return };
self.file_matches = true;
- // a half-written last id (a crash while appending) is ignored
- self.set.extend(rest.as_chunks::<8>().0.iter().map(|c| u64::from_le_bytes(*c)));
+ // a half-written last id (a crash while appending) is ignored, and the record is written fresh at the next save
+ // so later ids are not appended after it and shifted
+ let (ids, torn) = rest.as_chunks::<8>();
+ self.set.extend(ids.iter().map(|c| u64::from_le_bytes(*c)));
+ self.rewrite = !torn.is_empty();
}
/// Write what was added since the last save: appended when the file is this detector's, else the whole record
@@ -341,9 +362,8 @@ impl Scanned {
return Ok(());
}
if self.file_matches && !self.rewrite {
- let mut f = std::fs::OpenOptions::new().append(true).open(&path)?;
let buf: Vec = self.unsaved.iter().flat_map(|id| id.to_le_bytes()).collect();
- f.write_all(&buf)?;
+ append(&path, &buf)?;
} else {
let mut buf = self.header();
buf.extend(self.set.iter().flat_map(|id| id.to_le_bytes()));
@@ -487,6 +507,51 @@ mod tests {
assert!(!t.contains(1) && t.contains(2) && t.contains(3));
let _ = std::fs::remove_dir_all(&dir);
}
+
+ /// A torn last record (a crash or a full disk while appending) does not shift the records appended after it
+ /// (issue #2129).
+ #[test]
+ fn records_appended_after_a_torn_tail_stay_aligned() {
+ let dir = std::env::temp_dir().join(format!("lc-faces-torn-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let (emb, scan) = (dir.join("face-embeddings.bin"), dir.join("face-scanned.bin"));
+ let junk = |p: &Path| {
+ let mut f = std::fs::OpenOptions::new().append(true).open(p).unwrap();
+ f.write_all(&[0x3f; 5]).unwrap();
+ };
+ let mut a = Index::default();
+ a.reset("m@abc", 3, Some(emb.clone()));
+ a.insert(7, &rect(0.1), vec![1.0, 0.0, 0.5]);
+ a.save().unwrap();
+ drop(a);
+ junk(&emb);
+ let mut b = Index::default();
+ b.reset("m@abc", 3, Some(emb.clone()));
+ b.insert(9, &rect(0.5), vec![0.5, 0.25, 0.125]);
+ b.save().unwrap();
+ drop(b);
+ let mut c = Index::default();
+ c.reset("m@abc", 3, Some(emb));
+ assert_eq!(c.len(), 2);
+ assert_eq!(c.get(7, &rect(0.1)).map(|e| e.to_vec()), Some(vec![1.0, 0.0, 0.5]));
+ assert_eq!(c.get(9, &rect(0.5)).map(|e| e.to_vec()), Some(vec![0.5, 0.25, 0.125]));
+ let mut s = Scanned::default();
+ s.reset(Some(scan.clone()));
+ s.insert(42);
+ s.save().unwrap();
+ drop(s);
+ junk(&scan);
+ let mut t = Scanned::default();
+ t.reset(Some(scan.clone()));
+ t.insert(43);
+ t.save().unwrap();
+ drop(t);
+ let mut u = Scanned::default();
+ u.reset(Some(scan));
+ assert!(u.contains(42) && u.contains(43) && u.len() == 2);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
}
/// What the recognition commands keep between calls: the loaded model and the embeddings made with it.
diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs
index dd246cf84..31bb930fb 100644
--- a/crates/engine/src/files.rs
+++ b/crates/engine/src/files.rs
@@ -707,8 +707,9 @@ pub(crate) fn decode_raw_preview(
covering: Option,
) -> Option {
let jpeg = stand_in_image(bytes, covering)?;
- // the colour space is a property of the JPEG; a TIFF stand-in is read by the TIFF reader
- let is_jpeg = matches!(jpeg, std::borrow::Cow::Owned(_));
+ // the colour space is a property of the JPEG; a TIFF stand-in (the file itself, or a Hasselblad RGB preview
+ // wrapped as a TIFF) and a JPEG XL preview are read by their own decoders
+ let is_jpeg = jpeg.starts_with(&[0xff, 0xd8]);
match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) {
Some(space) => {
let space = match space {
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index d6c721792..25e6ef52f 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -300,6 +300,10 @@ pub struct Session {
pub xmp: sidecar::XmpPrefs,
/// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json.
pub last_export: Option,
+ /// The options of the last Photo Merge started, per command (`merge.hdr`, `merge.panorama`,
+ /// `merge.hdrPanorama`): the merge dialog opens with them and "… with Last Settings" uses
+ /// them. Persisted in prefs.json.
+ pub last_merge: std::collections::BTreeMap,
/// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json.
pub export_presets: Vec,
/// The Print module's current layout, image settings, page, guides and job (`print.*`). Lives
@@ -461,6 +465,7 @@ impl Session {
library_identity: Arc::new(()),
xmp: sidecar::XmpPrefs::default(),
last_export: None,
+ last_merge: Default::default(),
export_presets: Vec::new(),
print: Default::default(),
print_templates: Vec::new(),
diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs
index 51655aea5..e91f0bfff 100644
--- a/crates/engine/src/library.rs
+++ b/crates/engine/src/library.rs
@@ -207,6 +207,8 @@ struct PrefsFile {
/// Parameters of the last export (for Export with Previous).
#[serde(alias = "last_export")]
last_export: Option,
+ /// Options of the last Photo Merge, per command.
+ last_merge: std::collections::BTreeMap,
/// The user's export presets.
export_presets: Vec,
/// Print templates.
@@ -492,6 +494,7 @@ impl Session {
let prefs = settings.read::(files.as_mut(), "prefs.json").unwrap_or_default();
self.xmp = prefs.xmp;
self.last_export = prefs.last_export;
+ self.last_merge = prefs.last_merge;
self.export_presets = prefs.export_presets;
self.print_templates = prefs.print_templates;
self.metadata_presets = prefs.metadata_presets;
@@ -746,6 +749,7 @@ impl Session {
let v = serde_json::to_vec_pretty(&PrefsFile {
xmp: self.xmp,
last_export: self.last_export.clone(),
+ last_merge: self.last_merge.clone(),
export_presets: self.export_presets.clone(),
print_templates: self.print_templates.clone(),
metadata_presets: self.metadata_presets.clone(),
diff --git a/crates/engine/src/print/compose.rs b/crates/engine/src/print/compose.rs
index 91e5db69e..f4e50703b 100644
--- a/crates/engine/src/print/compose.rs
+++ b/crates/engine/src/print/compose.rs
@@ -1,5 +1,6 @@
//! Composes print pages: places each cell's photo (fit or Zoom to Fill, optionally Rotate to Fit),
-//! draws Photo Border and Inner Stroke, fills the page background and returns [`PageRaster`]s.
+//! draws Photo Border and Inner Stroke, fills the page background, adds the Page Options (crop
+//! marks, page number, page info) and returns [`PageRaster`]s.
//!
//! Work is split like an export: [`prepare`] reads the session (which photos go in which cell, one
//! render job per cell) and [`Prepared::run`] does the heavy part without it, so it can run on a
@@ -28,6 +29,13 @@ pub const MAX_BORDER_IN: f64 = 2.0;
pub const MAX_STROKE_PT: f64 = 36.0;
/// Renders kept for cells that print the same photo at the same size.
const RENDER_CACHE: usize = 4;
+/// A crop mark's length and its gap from the photo's corner, inches.
+pub const CROP_MARK_IN: f64 = 0.25;
+pub const CROP_MARK_GAP_IN: f64 = 0.0625;
+/// A crop mark's weight, points.
+pub const CROP_MARK_PT: f64 = 0.5;
+/// Size of the page number and page info text, points.
+pub const PAGE_TEXT_PT: f64 = 9.0;
/// Lightroom's Image Settings panel.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -85,11 +93,18 @@ impl ImageSettings {
pub struct PageStyle {
/// Page background colour, sRGB.
pub background: [u8; 3],
+ /// Page Options (Single Image and Contact Sheet, as in Lightroom; the packages ignore them):
+ /// the page number at the bottom right of each page.
+ pub page_numbers: bool,
+ /// The print settings (sharpening, profile, resolution) at the bottom left of each page.
+ pub page_info: bool,
+ /// Marks at the corners of each photo to cut along.
+ pub crop_marks: bool,
}
impl Default for PageStyle {
fn default() -> Self {
- Self { background: [255; 3] }
+ Self { background: [255; 3], page_numbers: false, page_info: false, crop_marks: false }
}
}
@@ -100,6 +115,41 @@ pub fn rec2020_from_srgb8(c: [u8; 3]) -> [u16; 3] {
lin.map(|v| (trc.encode(v) * 65535.0 + 0.5) as u16)
}
+/// Whether a layout style prints the Page Options (page numbers, page info, crop marks):
+/// Lightroom offers them for Single Image and Contact Sheet; its packages have Cut Guides instead.
+pub fn has_page_options(style: Style) -> bool {
+ !style.is_package()
+}
+
+/// The colour of crop marks and page text on `background`: black, or white on a dark page.
+pub fn mark_ink(background: [u8; 3]) -> [u8; 3] {
+ let [r, g, b] = background.map(f64::from);
+ if 0.2126 * r + 0.7152 * g + 0.0722 * b < 128.0 { [255; 3] } else { [0; 3] }
+}
+
+/// The Page Info line: print sharpening, profile and resolution (Lightroom names the printer
+/// where the resolution is; this print goes to a PDF).
+pub fn page_info_text(settings: &PrintSettings) -> String {
+ use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen};
+ let c = &settings.job.color;
+ let media = match c.media {
+ PrintMedia::Matte => "Matte",
+ PrintMedia::Glossy => "Glossy",
+ };
+ let sharpen = match c.sharpen {
+ PrintSharpen::Off => "Off".to_string(),
+ PrintSharpen::Low => format!("Low ({media})"),
+ PrintSharpen::Standard => format!("Standard ({media})"),
+ PrintSharpen::High => format!("High ({media})"),
+ };
+ let profile = match &c.profile {
+ PrintProfile::Managed { space: ManagedSpace::Srgb } => "Managed by Printer (sRGB)".to_string(),
+ PrintProfile::Managed { space: ManagedSpace::AdobeRgb } => "Managed by Printer (Adobe RGB)".to_string(),
+ PrintProfile::Icc { path } => path.file_stem().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()),
+ };
+ format!("Print Sharpening: {sharpen} · Profile: {profile} · {:.0} ppi", settings.job.effective_ppi())
+}
+
// ---- placement ------------------------------------------------------------------------------
/// How a photo of `iw` x `ih` goes into an `aw` x `ah` area.
@@ -355,8 +405,8 @@ impl CellStyle {
}
/// Draws `photo` into `cell` of `page`: border frame, the photo fitted or filled (and turned when
-/// asked), then the inner stroke on top of it.
-pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<(), String> {
+/// asked), then the inner stroke on top of it. Returns where the photo landed.
+pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result {
// The border may not eat the cell: at least one pixel of photo area stays.
let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2);
let (ax, ay) = (cell.x + border, cell.y + border);
@@ -382,7 +432,105 @@ pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &Cell
page.fill_rect(dx, dy + s, s, h - 2 * s, style.stroke);
page.fill_rect(dx + w - s, dy + s, s, h - 2 * s, style.stroke);
}
- Ok(())
+ Ok(PxRect { x: dx, y: dy, w, h })
+}
+
+/// What the Page Options draw on every page, in page pixels.
+struct Marks {
+ /// Crop mark length, gap from the corner and weight.
+ crop: Option<(usize, usize, usize)>,
+ numbers: bool,
+ info: Option,
+ ink: [u16; 3],
+ text_px: f32,
+ /// The printable area: the text sits in the margin below it, aligned with its sides.
+ area: Option,
+}
+
+impl Marks {
+ fn of(settings: &PrintSettings, ppi: f64, pw: usize, ph: usize) -> Self {
+ let on = has_page_options(settings.layout.style);
+ let page = &settings.page;
+ let px = |inches: f64| (inches * ppi).round() as usize;
+ Marks {
+ crop: (on && page.crop_marks).then(|| (px(CROP_MARK_IN), px(CROP_MARK_GAP_IN), px(CROP_MARK_PT / 72.0).max(1))),
+ numbers: on && page.page_numbers,
+ info: (on && page.page_info).then(|| page_info_text(settings)),
+ ink: rec2020_from_srgb8(mark_ink(page.background)),
+ text_px: (PAGE_TEXT_PT / 72.0 * ppi) as f32,
+ area: settings.layout.printable().ok().and_then(|a| px_rect(&a, ppi, pw, ph)),
+ }
+ }
+
+ /// Draws the marks of page `number` (one-based) around `photos`, the photos on it.
+ fn draw(&self, page: &mut Image16, photos: &[PxRect], number: usize) {
+ if let Some((len, gap, weight)) = self.crop {
+ for r in photos {
+ crop_marks(page, r, len, gap, weight, photos, self.ink);
+ }
+ }
+ let area = self.area.unwrap_or(PxRect { x: 0, y: 0, w: page.w, h: page.h });
+ let (area_bottom, page_h) = ((area.y + area.h) as f32, page.h as f32);
+ // centred in the bottom margin, and on the paper when the margin is thinner than the text
+ let top = |h: f32| (area_bottom + (page_h - area_bottom - h) / 2.0).clamp(0.0, (page_h - h).max(0.0));
+ if self.numbers
+ && let Some(t) = crate::text::TextLayout::new(&number.to_string(), self.text_px, false, crate::fonts::CRAFT_FONTS)
+ {
+ let x = ((area.x + area.w) as f32 - t.width).max(0.0);
+ draw_text(page, &t, x, top(t.height), self.ink);
+ }
+ if let Some(info) = &self.info
+ && let Some(t) = crate::text::TextLayout::new(info, self.text_px, false, crate::fonts::CRAFT_FONTS)
+ {
+ draw_text(page, &t, area.x as f32, top(t.height), self.ink);
+ }
+ }
+}
+
+/// Crop marks for the photo at `r`: at each corner, a line along each of its edges that starts
+/// `gap` past the corner and runs `len` outward, `weight` thick. Marks never cover a photo.
+fn crop_marks(page: &mut Image16, r: &PxRect, len: usize, gap: usize, weight: usize, photos: &[PxRect], ink: [u16; 3]) {
+ let (x0, y0, x1, y1) = (r.x as i64, r.y as i64, (r.x + r.w) as i64, (r.y + r.h) as i64);
+ let (l, g, t) = (len as i64, gap as i64, weight.min(r.w).min(r.h) as i64);
+ let segments = [
+ (x0 - g - l, y0, l, t),
+ (x1 + g, y0, l, t),
+ (x0 - g - l, y1 - t, l, t),
+ (x1 + g, y1 - t, l, t),
+ (x0, y0 - g - l, t, l),
+ (x0, y1 + g, t, l),
+ (x1 - t, y0 - g - l, t, l),
+ (x1 - t, y1 + g, t, l),
+ ];
+ let on_photo = |x: usize, y: usize| photos.iter().any(|p| x >= p.x && x < p.x + p.w && y >= p.y && y < p.y + p.h);
+ for (sx, sy, sw, sh) in segments {
+ for y in sy.max(0)..(sy + sh).min(page.h as i64) {
+ for x in sx.max(0)..(sx + sw).min(page.w as i64) {
+ let (x, y) = (x as usize, y as usize);
+ if !on_photo(x, y) {
+ page.fill_rect(x, y, 1, 1, ink);
+ }
+ }
+ }
+ }
+}
+
+/// Blends text in `ink` into `page` with its top-left corner at (`x`, `y`).
+fn draw_text(page: &mut Image16, text: &crate::text::TextLayout, x: f32, y: f32, ink: [u16; 3]) {
+ let (w, h) = (page.w, page.h);
+ text.draw_in(x, y, w as f32, h as f32, |gx, gy, cov| {
+ let (Ok(gx), Ok(gy)) = (usize::try_from(gx), usize::try_from(gy)) else { return };
+ if gx >= w || gy >= h {
+ return;
+ }
+ let i = (gy * w + gx) * 3;
+ if let Some(px) = page.rgb.get_mut(i..i + 3) {
+ let k = cov.clamp(0.0, 1.0);
+ for (p, c) in px.iter_mut().zip(ink) {
+ *p = (f32::from(*p) + (f32::from(c) - f32::from(*p)) * k).round().clamp(0.0, 65535.0) as u16;
+ }
+ }
+ });
}
// ---- the job ---------------------------------------------------------------------------------
@@ -402,6 +550,7 @@ pub struct Prepared {
ppi: f32,
background: [u16; 3],
style: CellStyle,
+ marks: Marks,
/// Print sharpening, applied to each photo once it's rendered at its print size.
color: crate::print_color::PrintColorSettings,
}
@@ -430,6 +579,7 @@ impl Prepared {
let mut out = Vec::with_capacity(self.pages.len());
for (index, &(w, h)) in self.pages.iter().enumerate() {
let mut page = Image16::filled(w, h, self.background)?;
+ let mut placed = Vec::new();
while let Some(item) = items.next_if(|i| i.page == index) {
if !progress(done, &item.name) {
return Err("Print cancelled".into());
@@ -448,8 +598,9 @@ impl Prepared {
}
};
let photo = &cache.get(at).ok_or("render cache lost an entry")?.1;
- draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?;
+ placed.push(draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?);
}
+ self.marks.draw(&mut page, &placed, index + 1);
out.push(PageRaster { width: w as u32, height: h as u32, ppi: self.ppi, rgb: page.rgb });
}
if !progress(done, "") {
@@ -508,6 +659,7 @@ pub fn prepare(session: &mut Session, settings: &PrintSettings, photos: &[PhotoI
ppi: ppi as f32,
background: rec2020_from_srgb8(settings.page.background),
style,
+ marks: Marks::of(&settings, ppi, pw, ph),
color: settings.job.color.clone(),
})
}
@@ -825,6 +977,94 @@ mod tests {
assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels");
}
+ /// Pages for two photos, one per 4x6 page at 100 ppi, each filling its 0.5 in margins (so the
+ /// margins are bare background unless something draws there).
+ fn page_options_pages(s: &mut Session, style: Style, page: PageStyle) -> Vec {
+ let ids = first_ids(s, 2);
+ let mut set = settings(style);
+ if style.is_package() {
+ set.layout.add_cell(3.0, 5.0).unwrap();
+ set.layout.new_page().unwrap();
+ let id = set.layout.add_cell(3.0, 5.0).unwrap();
+ set.layout.set_cell_page(id, 1).unwrap();
+ }
+ set.image.zoom_to_fill = true;
+ set.page = page;
+ prepare(s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap()
+ }
+
+ fn rgb(p: &PageRaster, x: usize, y: usize) -> [u16; 3] {
+ let i = (y * p.width as usize + x) * 3;
+ [p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]]
+ }
+
+ /// The pixels of `x0..x1` x `y0..y1` that are not the white background.
+ fn inked(p: &PageRaster, x0: usize, x1: usize, y0: usize, y1: usize) -> usize {
+ (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| rgb(p, x, y) != WHITE).count()
+ }
+
+ #[test]
+ fn crop_marks_sit_outside_each_photo_corner() {
+ let mut s = Session::with_demo();
+ let plain = page_options_pages(&mut s, Style::SingleImage, PageStyle::default());
+ assert_eq!(inked(&plain[0], 0, 400, 0, 50), 0, "no marks unless asked");
+ let pages = page_options_pages(&mut s, Style::SingleImage, PageStyle { crop_marks: true, ..Default::default() });
+ // The photo fills 50..350 x 50..550; a mark is a line 0.25 in long, 1/16 in from the corner,
+ // along each edge of the photo.
+ for p in &pages {
+ for (x, y) in [(30, 50), (50, 30), (370, 50), (349, 30), (30, 549), (50, 570), (370, 549), (349, 570)] {
+ assert_eq!(rgb(p, x, y), [0; 3], "a mark at ({x}, {y})");
+ }
+ for (x, y) in [(47, 50), (50, 47), (30, 60), (60, 30), (10, 50), (50, 10)] {
+ assert_eq!(rgb(p, x, y), WHITE, "the gap, or past the mark, at ({x}, {y})");
+ }
+ }
+ // a dark page gets white marks
+ let dark = page_options_pages(&mut s, Style::SingleImage, PageStyle { background: [20; 3], crop_marks: true, ..Default::default() });
+ assert_eq!(rgb(&dark[0], 30, 50), WHITE);
+ }
+
+ #[test]
+ fn page_numbers_and_info_print_in_the_bottom_margin() {
+ let mut s = Session::with_demo();
+ let numbered = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_numbers: true, ..Default::default() });
+ // bottom right: the number, different on each page; nothing at the bottom left
+ let (right, left) = ((200, 400), (0, 200));
+ for p in &numbered {
+ assert!(inked(p, right.0, right.1, 550, 600) > 10, "a page number");
+ assert_eq!(inked(p, left.0, left.1, 550, 600), 0);
+ assert_eq!(inked(p, 0, 400, 0, 50), 0, "nothing at the top");
+ }
+ let region = |p: &PageRaster| (550..600).flat_map(|y| (right.0..right.1).map(move |x| (x, y))).map(|(x, y)| rgb(p, x, y)).collect::>();
+ assert_ne!(region(&numbered[0]), region(&numbered[1]), "page 1 and page 2 are numbered differently");
+ let info = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_info: true, ..Default::default() });
+ for p in &info {
+ assert!(inked(p, left.0, left.1, 550, 600) > 10, "the print settings line");
+ }
+ // the packages have no Page Options in Lightroom: nothing is drawn
+ let all = PageStyle { page_numbers: true, page_info: true, crop_marks: true, ..Default::default() };
+ let package = page_options_pages(&mut s, Style::PicturePackage, all);
+ assert_eq!(package.len(), 2);
+ for p in &package {
+ assert_eq!(inked(p, 0, 400, 550, 600) + inked(p, 0, 400, 0, 50), 0);
+ }
+ }
+
+ #[test]
+ fn page_info_names_the_sharpening_profile_and_resolution() {
+ use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen};
+ let mut set = PrintSettings::default();
+ let line = page_info_text(&set);
+ assert_eq!(line, "Print Sharpening: Standard (Glossy) · Profile: Managed by Printer (sRGB) · 240 ppi");
+ set.job.color.sharpen = PrintSharpen::Off;
+ set.job.color.media = PrintMedia::Matte;
+ set.job.color.profile = PrintProfile::Icc { path: "/profiles/Lustre Paper.icc".into() };
+ set.job.ppi = 300.0;
+ assert_eq!(page_info_text(&set), "Print Sharpening: Off · Profile: Lustre Paper · 300 ppi");
+ set.job.color.profile = PrintProfile::Managed { space: ManagedSpace::AdobeRgb };
+ assert!(page_info_text(&set).contains("Managed by Printer (Adobe RGB)"));
+ }
+
#[test]
fn caps_and_hostile_settings_are_refused() {
let mut s = Session::with_demo();
diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs
index 16003cd61..f0d725793 100644
--- a/crates/engine/src/rename.rs
+++ b/crates/engine/src/rename.rs
@@ -916,7 +916,8 @@ fn plan_rename_core(
) -> Vec {
let mut taken: HashSet = HashSet::new(); // lower-case target paths/names claimed in this batch
let mut out = Vec::new();
- for (seq, p) in (start..).zip(photos) {
+ for (i, p) in photos.iter().enumerate() {
+ let seq = start.saturating_add(i);
let want = expand(template, p, seq);
let (stem, ext) = split_ext(&want);
let (stem, ext) = (stem.to_string(), ext.to_string());
diff --git a/crates/engine/src/slideshow.rs b/crates/engine/src/slideshow.rs
index 8f615bf0c..dbee505af 100644
--- a/crates/engine/src/slideshow.rs
+++ b/crates/engine/src/slideshow.rs
@@ -115,7 +115,7 @@ fn color(v: &Value) -> std::result::Result