diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 000000000..2408084cf
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,6 @@
+# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
+reviews:
+ auto_review:
+ # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
+ ignore_usernames:
+ - "dependabot[bot]"
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 000000000..2c4b8cb1a
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,30 @@
+version: 2
+updates:
+ # Maintain Rust / Cargo dependencies
+ - package-ecosystem: "cargo"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ time: "06:00"
+ timezone: "America/New_York"
+ # Consolidate updates into a single PR rather than one PR per crate
+ groups:
+ cargo-dependencies:
+ patterns:
+ - "*"
+ commit-message:
+ prefix: "chore(deps)"
+
+ # Maintain GitHub Actions workflows
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ time: "06:00"
+ timezone: "America/New_York"
+ groups:
+ actions:
+ patterns:
+ - "*"
+ commit-message:
+ prefix: "ci(deps)"
diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index 5fc2092a9..d85332b23 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -33,4 +33,4 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
require-craft-fonts: true
- craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
+ craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
index d992f7433..01cb9cd66 100644
--- a/.github/workflows/nix.yml
+++ b/.github/workflows/nix.yml
@@ -18,11 +18,6 @@ concurrency:
cancel-in-progress: true
jobs:
- eval:
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- - name: nix flake check --no-build
- run: nix flake check --no-build --all-systems
+ nix:
+ # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
+ uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e24229356..b8fb01326 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -26,7 +26,8 @@ jobs:
with:
app-name: lightcraft
app-title: LightCraft
- craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
+ craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
+ web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
new file mode 100644
index 000000000..ecee45a6d
--- /dev/null
+++ b/.github/workflows/sonarcloud.yml
@@ -0,0 +1,20 @@
+name: SonarCloud
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: sonarcloud-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ sonarcloud:
+ # Tracked at @main so org-wide CI improvements propagate without PR churn.
+ uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
+ secrets: inherit
diff --git a/AGENTS.md b/AGENTS.md
index c9f54b71d..959d2808f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -62,6 +62,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
- **Layering** (`plan/architecture.md` §3, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd.
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id. Every slider is a `develop` control spec.
- **Resolution independence:** settings use normalized image coordinates and relative radii; previews and exports must match.
+- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
- **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment.
diff --git a/CLAUDE.md b/CLAUDE.md
index 00a2314aa..d2d538d65 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -67,6 +67,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
label, cancel)` for its whole run. It adopts the cancel flag the job already checks (`Cancel::Flag`), or uses
`Cancel::No` when the job can't stop. No progress window, panel or progress toast of its own; its tests assert the row.
- **Resolution independence:** settings use normalized image coordinates and relative radii; previews and exports must match.
+- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
- **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment.
diff --git a/Cargo.lock b/Cargo.lock
index 4b6d54289..216fdeca4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -936,6 +936,15 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "cipher"
version = "0.4.4"
@@ -1072,6 +1081,11 @@ dependencies = [
"libc",
]
+[[package]]
+name = "craft-docpath"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
+
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -3073,8 +3087,9 @@ dependencies = [
[[package]]
name = "lightcraft"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
+ "chrono",
"eframe",
"egui",
"lightcraft-codecs",
@@ -3098,8 +3113,9 @@ dependencies = [
[[package]]
name = "lightcraft-catalog"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
+ "chrono",
"crc32fast",
"lightcraft-develop",
"lightcraft-geom",
@@ -3115,7 +3131,7 @@ dependencies = [
[[package]]
name = "lightcraft-cli"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"dhat",
"lightcraft-codecs",
@@ -3131,10 +3147,11 @@ dependencies = [
[[package]]
name = "lightcraft-codecs"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"avif-serialize",
"bytemuck",
+ "dhat",
"image",
"image-webp",
"jixel",
@@ -3161,7 +3178,7 @@ dependencies = [
[[package]]
name = "lightcraft-color"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"lightcraft-geom",
"proptest",
@@ -3170,7 +3187,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"faer-core",
"lightcraft-denoise-core",
@@ -3187,7 +3204,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise-core"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"lightcraft-raster",
"miniz_oxide 0.8.9",
@@ -3202,7 +3219,7 @@ dependencies = [
[[package]]
name = "lightcraft-develop"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"lightcraft-color",
"lightcraft-geom",
@@ -3214,9 +3231,11 @@ dependencies = [
[[package]]
name = "lightcraft-engine"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"ab_glyph",
+ "chrono",
+ "craft-docpath",
"harfrust",
"jxl-oxide",
"libc",
@@ -3256,7 +3275,7 @@ dependencies = [
[[package]]
name = "lightcraft-faces"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"lightcraft-testkit",
"matrixmultiply",
@@ -3271,7 +3290,7 @@ dependencies = [
[[package]]
name = "lightcraft-fetch"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"log",
"rustls",
@@ -3282,7 +3301,7 @@ dependencies = [
[[package]]
name = "lightcraft-geocode"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"miniz_oxide 0.8.9",
"serde",
@@ -3290,7 +3309,7 @@ dependencies = [
[[package]]
name = "lightcraft-geom"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"proptest",
"serde",
@@ -3298,7 +3317,7 @@ dependencies = [
[[package]]
name = "lightcraft-gpu"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"bytemuck",
"lightcraft-color",
@@ -3318,7 +3337,7 @@ dependencies = [
[[package]]
name = "lightcraft-heif"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"heic-rs",
"rayon",
@@ -3326,14 +3345,14 @@ dependencies = [
[[package]]
name = "lightcraft-inpaint"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"rayon",
]
[[package]]
name = "lightcraft-lensdb"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"lightcraft-develop",
"lightcraft-geom",
@@ -3342,7 +3361,7 @@ dependencies = [
[[package]]
name = "lightcraft-mcp"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-engine",
@@ -3353,7 +3372,7 @@ dependencies = [
[[package]]
name = "lightcraft-merge"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-color",
@@ -3369,8 +3388,9 @@ dependencies = [
[[package]]
name = "lightcraft-meta"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
+ "chrono",
"lightcraft-geom",
"lightcraft-tiff",
"proptest",
@@ -3381,7 +3401,7 @@ dependencies = [
[[package]]
name = "lightcraft-pipeline"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"image",
"lightcraft-color",
@@ -3399,7 +3419,7 @@ dependencies = [
[[package]]
name = "lightcraft-preview"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"jpeg-encoder",
"lightcraft-raster",
@@ -3411,7 +3431,7 @@ dependencies = [
[[package]]
name = "lightcraft-raster"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"libc",
"lightcraft-color",
@@ -3423,7 +3443,7 @@ dependencies = [
[[package]]
name = "lightcraft-raw"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"dhat",
"jpeg-encoder",
@@ -3447,7 +3467,7 @@ dependencies = [
[[package]]
name = "lightcraft-scenes"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"image",
"lightcraft-color",
@@ -3458,7 +3478,7 @@ dependencies = [
[[package]]
name = "lightcraft-segment"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"candle-core",
"candle-nn",
@@ -3472,7 +3492,7 @@ dependencies = [
[[package]]
name = "lightcraft-slideshow"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"lightcraft-raster",
"serde",
@@ -3481,15 +3501,15 @@ dependencies = [
[[package]]
name = "lightcraft-sysmem"
-version = "0.8.0"
+version = "0.11.0"
[[package]]
name = "lightcraft-testkit"
-version = "0.8.0"
+version = "0.11.0"
[[package]]
name = "lightcraft-tiff"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"proptest",
"serde",
@@ -3498,7 +3518,7 @@ dependencies = [
[[package]]
name = "lightcraft-ui-egui"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"craft-ui",
"egui",
@@ -3527,7 +3547,7 @@ dependencies = [
[[package]]
name = "lightcraft-web"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"crc32fast",
"eframe",
@@ -6997,7 +7017,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.8.0"
+version = "0.11.0"
dependencies = [
"brotli",
"flate2",
diff --git a/Cargo.toml b/Cargo.toml
index ac8cbeca1..0a0d78e5f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
-version = "0.8.0"
+version = "0.11.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -11,6 +11,7 @@ repository = "https://github.com/storytold/lightcraft"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
+craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# internal
lightcraft-denoise-core = { path = "crates/denoise-core" }
lightcraft-denoise = { path = "crates/denoise", default-features = false }
@@ -48,6 +49,8 @@ sha2 = "0.10"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
+# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
+chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
proptest = "1"
bytemuck = { version = "1", features = ["derive"] }
diff --git a/README.md b/README.md
index 53ec908de..15f8a7d83 100644
--- a/README.md
+++ b/README.md
@@ -14,11 +14,12 @@
English ·
- 简体中文
+ 简体中文 ·
+ 繁體中文
- Photo library and raw development; an open-source, clean-room reimplementation of Adobe Lightroom, rebuilt in pure Rust.
+ Photo library and raw development; an open-source, non-destructive photo library and raw developer, built in pure Rust.
Native on macOS, Windows and Linux. In the browser via WebAssembly. Drivable end to end by AI agents over MCP.
@@ -28,6 +29,9 @@
+
+
+
@@ -129,7 +133,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 +154,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 +172,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.
@@ -205,10 +213,15 @@ 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.
+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 +294,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 +312,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,17 +338,21 @@ 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%** (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
+- **As a day-to-day tool 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 /
Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera.
- **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 +360,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 +377,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 +400,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 +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, Українська, Čeština, ქართული) 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
@@ -491,9 +518,9 @@ egui frontend is one swappable crate; nothing below it knows a UI exists. `mcp`
Humans and agents follow the same rules, so read [AGENTS.md](AGENTS.md) first. The short version:
-- **Clean-room.** Never read Adobe binaries or GPL raw/photo code (darktable, RawTherapee, LibRaw, rawspeed, dcraw…);
+- **Clean-room.** Never read proprietary binaries or GPL raw/photo code (darktable, RawTherapee, LibRaw, rawspeed, dcraw…);
work from public specs and black-box observation.
-- **No Adobe assets, ever:** no icons, screenshots, presets, profiles, LUTs or fonts from Adobe products. Every
+- **No proprietary assets, ever:** no icons, screenshots, presets, profiles, LUTs or fonts from commercial products. Every
image, icon and font in the repo is original, public domain, Creative Commons, OFL or permissively licensed, and has
an entry in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) added in the same commit. New fonts go to
[storytold/craft-fonts](https://github.com/storytold/craft-fonts), not here.
@@ -536,7 +563,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` | |
@@ -623,8 +650,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and LightCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
-Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. LightCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.
-
Made by the ArtCraft team and community.
diff --git a/README.zh-hans.md b/README.zh-hans.md
index f2d155214..824913f14 100644
--- a/README.zh-hans.md
+++ b/README.zh-hans.md
@@ -14,11 +14,12 @@
English ·
- 简体中文
+ 简体中文 ·
+ 繁體中文
- 照片图库与 raw 显影;Adobe Lightroom 的开源净室实现,用纯 Rust 重建。
+ 照片图库与 raw 显影;开源、非破坏性的照片图库与 raw 显影工具,用纯 Rust 构建。
在 macOS、Windows 和 Linux 上原生运行,也可通过 WebAssembly 在浏览器中使用,并能由 AI 智能体经 MCP 端到端驱动。
@@ -299,9 +300,9 @@ lightcraft --control 7980 ~/Pictures/trip
LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(细节见[路线图](ROADMAP.md#where-we-stand)):
-- **按功能数量算,我们达到了 Lightroom 的约 79%**(核心功能 98%),逐行记录在
+- **按功能数量算,我们达到了约 79%**(核心功能 98%),逐行记录在
[docs/parity.md](docs/parity.md)。
-- **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
+- **作为日常使用的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
较老佳能的 raw 都很出色。
- **最大的缺口:**
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
@@ -453,9 +454,9 @@ egui 前端是一个可替换的 crate;它下面没有任何东西知道 UI
人和智能体遵循同样的规则,所以请先读 [AGENTS.md](AGENTS.md)。简短版本:
-- **净室。** 绝不阅读 Adobe 的二进制文件或 GPL 的 raw/照片代码(darktable、RawTherapee、LibRaw、rawspeed、dcraw……);
+- **净室。** 绝不阅读专有软件的二进制文件或 GPL 的 raw/照片代码(darktable、RawTherapee、LibRaw、rawspeed、dcraw……);
只依据公开规范和黑盒观察来工作。
-- **绝不使用 Adobe 资源:** 不使用 Adobe 产品的图标、截图、预设、配置文件、LUT 或字体。仓库中的每一个
+- **绝不使用专有资源:** 不使用商业产品的图标、截图、预设、配置文件、LUT 或字体。仓库中的每一个
图像、图标和字体都是原创、公有领域、知识共享、OFL 或宽松许可的,并在同一提交中
于 [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 添加条目。新字体提交到
[storytold/craft-fonts](https://github.com/storytold/craft-fonts),而不是这里。
@@ -579,8 +580,6 @@ ArtCraft 团队的商标,不在本许可范围内。它们只能在未修改
本仓库和 LightCraft 的一部分,按 [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) 使用。
分支和修改版本必须移除它们。
-Adobe、Photoshop、Illustrator、Premiere Pro、Lightroom、Acrobat、After Effects 和 InDesign 是 Adobe Inc. 在美国和/或其他国家的商标或注册商标。LightCraft 是一个独立的开源项目,与 Adobe Inc. 无隶属关系,也未获其赞助或认可;这些名称仅用于说明它所兼容的工作流。
-
由 ArtCraft 团队和社区制作。
diff --git a/README.zh-hant.md b/README.zh-hant.md
new file mode 100644
index 000000000..a17505f3f
--- /dev/null
+++ b/README.zh-hant.md
@@ -0,0 +1,629 @@
+
+
+
+
+
+
+
+
+
+
+LightCraft
+
+Your photos. Your pixels. Your machine.
+
+
+ English ·
+ 简体中文 ·
+ 繁體中文
+
+
+
+ 照片庫與 raw 顯影;以淨室(clean-room)方式、用純 Rust 重新實作的開源 Adobe Lightroom。
+ 在 macOS、Windows 與 Linux 上原生執行,也能透過 WebAssembly 在瀏覽器中使用,AI 代理也能透過 MCP 全程操作。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ getartcraft.com 上的 LightCraft ·
+ ArtCraft ·
+ 所有 Crafting Apps
+
+
+
+
+
+
+
+ 安瑟·亞當斯,《提頓山脈與蛇河》(1942)。公眾領域,美國國家檔案館。於 LightCraft 中顯影。
+
+
+> [!NOTE]
+> **ArtCraft 是一個由各行各業的創作者組成的社群。** 數位藝術、生成藝術、音樂、
+> 遊戲 — 只要你在創作,你就是我們的一員。**[來 Discord 打聲招呼吧](https://discord.gg/artcraft)。**
+
+
+ 編輯 ·
+ 色彩分級 ·
+ 編輯前/後對比 ·
+ 遮色片 ·
+ 預設集 ·
+ 照片庫 ·
+ 代理與 MCP ·
+ 效能 ·
+ 功能狀態 ·
+ 快速開始 ·
+ 路線圖 ·
+ 下載 ·
+ Crafting Apps
+
+
+
+
+## Edit like you mean it
+
+LightCraft 是裝在單一原生應用程式裡的完整暗房。每一項調整都是**非破壞性**的,永遠不會動到原始檔案。
+每個滑桿都經過一條**場景參考、寬色域、32 位元浮點的管線**算繪:亮部像底片一樣柔和過渡,陰影提亮卻不產生光暈,
+色彩從拍攝到匯出始終保持乾淨。
+
+
+
+
+
+### ☀️ 光線
+**曝光、對比、亮部、陰影、白色色階、黑色色階**,搭配邊緣感知的局部色調映射(對對數亮度做引導式濾波)。
+把亮部拉到 −100,就能救回一片過曝的天空,而且不會出現用簡單曲線時常見的灰色光暈。
+
+### 🎨 色彩
+依**色溫**與**色調**調整**白平衡**(raw 用 K 值,JPEG 用相對值),附帶預設、自動,以及點一下就能中和的**滴管**。
+保護膚色的**自然飽和度**、**飽和度**、8 段式**混色器**(色相/飽和度/明度),以及附帶混合與平衡的
+三區**色彩分級**色輪。所有運算都在 OkLCh 這個現代感知色彩空間中完成。
+
+
+
+
+### ✨ 效果
+讓細節更細緻的**紋理**、讓中間調更有力的**清晰度**、**去朦朧**(暗通道先驗加引導式細化;
+推到負值可增添氛圍感)、裁切後附帶亮部優先、圓度與羽化的**暗角**,以及解析度無關、可調大小與粗糙度的
+底片**顆粒**。
+
+### 📈 色調曲線
+可移動分界點的參數曲線(區域曲線),**再加上** RGB、紅、綠、藍的點曲線。曲線在結構上就是單調的,
+所以不會意外出現色調反轉。
+
+
+
+
+
+
+
+
+ RGB 點曲線上一條平緩的 S 形曲線,就在「光線」滑桿正下方。安瑟·亞當斯,1942(公眾領域)。
+
+
+
+
+## Color grading, the cinematic way
+
+用可隨處拖曳的色輪,分別為陰影、中間調與亮部做分離色調。下圖中,桃樂絲·蘭格的
+*《移民母親》* 只拖曳兩次,就調出溫暖、如沖印照片般的色調(亮部 42°,陰影 28°)。
+
+
+
+
+ 「色彩」面板中的陰影、中間調與亮部色輪。桃樂絲·蘭格,《移民母親》(1936)。公眾領域,美國國會圖書館。
+
+
+
+
+## Before & after
+
+按住 \\ 偷看原圖,按 Y 並排比較,或按 Shift +Y 分割檢視。
+下面每一張都是 LightCraft 的真實截圖,由代理透過[控制通道](#built-for-agents)自動擷取。
+
+
+
+《提頓山脈與蛇河》。 亮部 −45、陰影 +38、清晰度 +28、去朦朧 +18。安瑟·亞當斯,1942(公眾領域)。
+《移民母親》。 陰影 +42、紋理 +18、分離色調分級、暗角。桃樂絲·蘭格,1936(公眾領域)。
+
+
+《地出》。 去朦朧 +22、亮部 −30、更暖的白平衡、自然飽和度 +22。NASA/比爾·安德斯,阿波羅 8 號,1968(公眾領域)。
+《藍色大理石》。 亮部 −38、黑色色階 −20、去朦朧 +15、自然飽和度 +30。NASA,阿波羅 17 號,1972(公眾領域)。
+
+
+
+
+
+## Masking that goes where you point
+
+用**筆刷**塗抹(大小、羽化、流量、濃度、擦除),放置附可拖曳控制點的**線性漸層**與**放射狀漸層**,
+或依**明度範圍**、**色彩範圍**、**天空**、**主體**與**背景**來選取。用**新增/減去/交集**組合各個元件,
+反轉其中任何一個,每個遮色片可設定 15 項局部調整(色溫、色調、曝光、對比、亮部、陰影、白色色階、黑色色階、紋理、
+清晰度、去朦朧、色相、飽和度、銳利化、雜訊抑制),再加上一個整體的強度。
+
+
+
+
+ 疊加在線性「天空」遮色片之上的放射狀「陽光光暈」遮色片(色溫 +40、曝光 +0.50)。照片來自 LightCraft 程式產生的示範照片庫。
+
+
+
+
+## Presets, profiles & the Color Mixer
+
+內建十八個手工打造的預設集(*黃金時刻、青綠與橙色、褪色霧面、硒調色、清晰風景*等),每個都有 0 到 200 % 的**強度**滑桿。你可以從任意一組設定儲存自己的預設集、加入喜好項目,
+並在整個選取範圍中,只複製、貼上或同步你選定的那些群組。
+
+
+
+
+ 預設集欄與 8 段式混色器並排。照片來自示範照片庫。
+
+
+
+
+
+
+### ✂️ 裁切、拉直與幾何
+自由或鎖定的長寬比(1:1、4:5、5:7、2:3、4:3、16:9、16:10、原始比例),拖曳旋轉拉直時會始終把最大的裁切框
+保持在畫面內,三分法/格線/黃金比例裁切疊加,翻轉與 90° 旋轉,再加上手動的垂直、水平、旋轉、長寬比、
+縮放與偏移變換。
+
+
+
+
+### ⚫️ 黑白
+一鍵(V )轉為單色,搭配 8 段式**黑白混色**,可壓暗天空或讓樹葉發亮。
+之後再用色彩分級調出硒調色、深褐色調或分離色調的沖印效果。
+
+
+
+
+一個 16:9 的裁切框拉直了 +2.5°,附三分法裁切疊加。安瑟·亞當斯,1942(公眾領域)。
+分割檢視:左邊是彩色原圖,右邊是附清晰度、暗角與顆粒的黑白。示範照片庫。
+
+
+
+
+
+## Organize everything
+
+一個不擋路的照片庫:**所有照片**、**最近加入**、**精選照片**、**依日期**、以巢狀收在**資料夾**裡的**相簿**,
+以及**最近刪除**。用 0 –5 評分,用 P / X /
+U 加旗標,用 6 –9 加色彩標籤。搜尋支援欄位語法:
+`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains`。每個檢視都能依拍攝日期、
+匯入日期、編輯日期、名稱、評分、大小或隨機排序(一種穩定的亂序;「檢視 → 排序 → 重新隨機排序」可換一個順序)。
+左右對齊的**照片格狀檢視**與**方格檢視**都經過虛擬化處理,
+所以無論是四十張還是四萬張照片都一樣流暢。
+
+
+
+照片格狀檢視,相簿以巢狀收在資料夾裡,附評分與旗標。示範照片庫。
+四張公眾領域展示照片的方格檢視。
+
+
+資訊面板:檔案、尺寸、評分、標題、圖說、著作權與相機中繼資料。NASA/比爾·安德斯,《地出》,阿波羅 8 號,1968(公眾領域)。
+
+
+
+
+
+## Built for agents
+
+LightCraft 裡的每一個選單項目、滑桿、筆刷筆觸、裁切控點與按鍵,都是一個擁有穩定 ID 與
+JSON 參數的**指令**。介面、鍵盤、CLI、JSON-lines 控制通道與一個 **MCP 伺服器**全都透過同一個入口分派。
+代理可以挑片、顯影、為天空加上遮色片並匯出,而且能*看到*結果。
+
+```sh
+lightcraft --control 7980 ~/Pictures/trip
+```
+```jsonc
+{"method": "engine.execute", "params": {"command": "photo.flag", "params": {"flag": "pick"}}}
+{"method": "engine.execute", "params": {"command": "develop.set", "params": {"values": {"light.highlights": -45, "light.shadows": 38}}}}
+{"method": "engine.execute", "params": {"command": "mask.add", "params": {"kind": "radial", "center": [0.62, 0.4], "rx": 0.2, "ry": 0.14}}}
+{"method": "ui.clickWidget", "params": {"id": "slider:effects.clarity"}} // 依名稱操作任意控制項
+{"method": "ui.pointer", "params": {"events": [{"kind":"down","x":0.2,"y":0.3}, {"kind":"up","x":0.4,"y":0.3}]}}
+{"method": "ui.screenshot", "params": {"path": "after.png"}}
+```
+
+- **指令註冊表。** `engine.commands` 列出可用的指令;`develop.controls` 列出每個滑桿的範圍、
+ 預設值與目前值。
+- **MCP 伺服器。** `lightcraft-cli mcp` 把指令註冊表開放給 Claude(或任何 MCP 用戶端),
+ 同時提供匯入、查詢、顯影、遮色片、算繪(以影像回傳)與匯出的輔助工具。它可以無介面執行,也可以附掛
+ 到正在執行的應用程式上,支援截圖、點擊與手勢。詳見 [docs/mcp.md](docs/mcp.md)。
+
+ ```sh
+ cargo build --release -p lightcraft-cli
+ claude mcp add lightcraft -- "$PWD/target/release/lightcraft-cli" mcp ~/Pictures/shoot # 無介面
+ claude mcp add lightcraft-app -- "$PWD/target/release/lightcraft-cli" mcp --connect 127.0.0.1:7980 # 操作執行中的應用程式
+ ```
+- **可腳本化的 CLI:** `lightcraft-cli run --import in.dng develop.set control=light.exposure value=0.7 app.export
+ path=out.jpg longEdge=2048` 可執行任意指令鏈(無介面、對已儲存的照片庫,或對執行中的應用程式),
+ 每條指令印出一行 JSON 結果;`lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.7 --preset …`。
+- **一切皆可復原**,包括代理的操作:一次滑桿拖曳(或腳本中一連串的更新)算一步復原。
+- **縮圖目錄(原生應用程式/CLI):** 選取照片,然後 **檔案 → 縮圖目錄 PDF…**。可選擇 A4 或 Letter、橫向、
+ 列數與欄數,以及檔名圖說;儲存分頁 PDF,再用你的 PDF 檢視器列印。匯出會套用目前的編輯,每張照片完整放入
+ 而不再次裁切,以 150 dpi 和 sRGB 算繪。背景匯出支援取消,並保護原始檔案與 XMP 附屬檔案。從 CLI 呼叫:
+
+ ```sh
+ lightcraft-cli run --import ~/Pictures/shoot library.selectAll export.contactSheet path=Contact.pdf paper=a4 columns=3 rows=4 captions=true
+ ```
+
+ `export.contactSheet` 也接受明確的 `ids`、`landscape=true` 和 `paper=letter`。只有在整份 PDF 成功產生之後,
+ 它才會取代既有的輸出檔案。限制:1,000 張照片、100 頁、256 MiB;圖說使用與浮水印相同的字型涵蓋範圍來點陣化
+ (CJK 文字需要 craft-fonts),過長的名稱會在中間省略。
+ 這是一套縮圖目錄工作流程,
+ 不是印表機驅動程式或自訂拼版編輯器。
+- **列印版面(原生應用程式/CLI):** **檔案 → 列印…** 會開啟「列印」模組的版面編輯器:單張照片、
+ 縮圖目錄、相片包(自動版面會把格子排進頁面)與自訂相片包(拖曳並調整格子大小、堆疊順序、對齊格子或
+ 格線),縮放填滿/旋轉以符合、相片邊框與內框線,紙張尺寸從 4 × 6 到 13 × 19 以及 A3 到 A5 或自訂,
+ 邊界、以英吋/公分/公釐/點為單位的尺規、頁面背景、72–720 ppi,以及已儲存的範本。**列印為 PDF…**
+ 會在背景算繪。一切都是 `print.*` 指令
+ (`print.get`、`print.set`、`print.cell.add`、`print.layout.autoLayout`、`print.exportPdf`、
+ `print.template.save`……),所以 CLI 和 MCP 也能操作它:
+
+ ```sh
+ lightcraft-cli run --import ~/Pictures/shoot print.set 'layout={"style":"picturePackage","page":{"paper":"8x10"}}' \
+ print.cell.add width=5 height=7 print.cell.add width=2.5 height=3.5 print.layout.autoLayout print.exportPdf path=Package.pdf
+ ```
+
+ 印表機色彩管理與頁面疊加層(身分標識、浮水印、頁碼)目前還沒有。
+- **每個控制項都可定址**(`ui.widgets`)且可依名稱點擊,所以代理操作的是真實介面,而不是走旁門。
+- 本 README 中的截圖全部由 [`docs/showcase/`](docs/showcase/) 腳本全自動產生。
+ 協定參考:[docs/control-protocol.md](docs/control-protocol.md)。
+
+
+
+## Fast, native, private
+
+- **純 Rust,無 C。** 我們自己的 RAW 解碼器(DNG、Canon CR2/CR3、Sony ARW、Nikon NEF、Fujifilm RAF 含 X-Trans、
+ Panasonic RW2/Leica RWL、Pentax PEF、Olympus ORF),我們自己的色彩科學,我們自己的管線。JPEG、PNG、TIFF、WebP、
+ PSD 合成圖與 JPEG XL 現在就能開啟;加上 `--features heif` 還能開啟 HEIC/HEIF 照片(HEVC 於建置時選用,
+ 與 PhotoCraft 中一致)。
+- **場景參考與寬色域。** 內部使用線性 Rec.2020 浮點、Bradford 色彩適應的白平衡、以色域映射取代裁剪,
+ raw 走底片式肩部曲線,未經改動的 JPEG 則採像素級直通。
+- **解析度無關的編輯。** 半徑與筆刷大小都相對於影像,所以 400 px 的預覽、你的 5K 顯示器和 60 MP 的匯出
+ 看起來完全一致。
+- **GPU 加速,與 CPU 結果一致。** 整條顯影管線都以 wgpu 計算核心執行(Metal/Vulkan/DX12),
+ 並與 CPU 管線比對到 1/255 以內的誤差。在 24 MP 的 raw 上(Apple M4 Pro):一次滑桿更新約 4 ms 重新算繪,
+ 冷啟動的 2.5 MP 放大檢視約 30 ms,全尺寸匯出約 0.3 秒(含平行 JPEG 編碼)。
+ 沒有 GPU 時,同一條管線會在所有 CPU 核心上執行,只重做受滑桿影響的那些階段。批次匯出會同時算繪多張照片,
+ 所以一張的解碼與編碼能和另一張的算繪重疊。
+- **即時挑片。** 開啟一張 raw 會在約 0.1 秒內顯示其內嵌的相機預覽或快取算繪結果,完整算繪隨後跟上
+ (24 MP 約 0.2–0.5 秒)。上一張與下一張會在背景準備好,因此瀏覽整批照片時每張約 50 ms。
+- **背景算繪。** 一個工作執行緒池在 UI 執行緒之外算繪放大檢視、編輯前/後對比與每一個可見縮圖:
+ 拖曳時用草稿品質,放開後以完整品質算繪。
+- **本機優先。** 沒有帳號、沒有雲端、沒有遙測、沒有訂閱。你的目錄是一份只會附加寫入、可讀的操作紀錄,
+ 可以比對差異、備份或重播。
+
+
+
+## Feature status
+
+LightCraft 還很年輕,而且進展飛快:**Alpha 階段**。**老實說,我們目前的進度是**(細節見[路線圖](ROADMAP.md)和
+[docs/target-app-parity.md](docs/target-app-parity.md)):
+
+- **依功能數量計算,約達 Lightroom 的 81%**(核心功能 98%),逐行記錄在
+ [docs/parity-checklist.md](docs/parity-checklist.md)。
+- **作為日常取代 Lightroom 的工具,整體產品更接近 61%,對一般全職攝影師約 51%,一般休閒用途約 65%。**
+ 在單機上處理 JPEG/DNG,以及大多數 Sony/Panasonic/
+ Fujifilm/Nikon/較舊款 Canon 的 raw 都很出色;各機型詳情見 [docs/raw-parity.md](docs/raw-parity.md)。
+- **最大的缺口:**
+ - **相機色彩校正:** Sony、Nikon、Panasonic、Fujifilm 和 Canon CR3 的 raw 會從各自的相機 JPEG 得到受保護的估計值,內建 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、Nikon Z 6II、Z 8 和 D5300 描述檔;仍缺少實測校正,其他 raw 或被拒絕的擬合會保留中性矩陣;
+ - **壓縮的 Olympus raw 與不受支援的 CR3 變體:** 這些在有內嵌 JPEG 預覽時會改用預覽畫面。Fujifilm 無損/有損壓縮 RAF 現在能解碼感光元件資料;[驗證與現有照片庫重新載入說明](docs/raf-compression.md);
+ - **AI 遮色片與降噪:** 主體與天空選取目前是傳統啟發式演算法;
+ - **網路照片庫:** 預覽只讀取 raw 的標頭 — 或者,對於 CR3 或預覽標籤位於其後的 TIFF raw,只讀取稀疏規劃器指定的範圍 — 以及其內嵌的 JPEG(56.5 MB → 5.9 MB,10 秒 → 1 秒,測得於測試用的網路共用資料夾),但*顯影後*的畫面仍需要透過網路傳輸感光元件資料;詳見 [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
+ - **螢幕上的 HDR 顯示、影片,以及 Lightroom Classic 的列印、書冊與地圖模組。**
+- **接下來做什麼:** 見[依影響力排序的缺口](docs/gaps.md)和[路線圖](docs/roadmap.md)。
+
+| 領域 | 狀態 |
+|---|---|
+| 照片庫:相簿、資料夾、智慧型相簿、堆疊(含自動堆疊)、虛擬副本、評分、旗標、標籤、篩選列、搜尋、排序、格狀檢視、底片顯示窗格 | ✅ |
+| 挑片:比較(同步縮放)與總覽檢視、自動前進、即時預覽 | ✅ |
+| 光線、色彩、效果(暗角樣式)、色調曲線(+ 細化飽和度、目標調整)、混色器(+ 目標調整)、點色彩、色彩分級、校正、黑白 | ✅ |
+| 遮色片:筆刷、線性、放射狀、明度/色彩範圍、新增/減去/交集 | ✅(AI 主體/天空目前使用傳統啟發式演算法) |
+| 裁切、拉直工具 + 自動拉直、翻轉、旋轉、長寬比、裁切疊加參考線 | ✅ |
+| 描述檔(色彩、中性、鮮豔、風景、人像、單色:我們自己的風格)、預設集、版本、歷史紀錄、複製/貼上/同步設定 | ✅ |
+| 相機色彩:DNG 檔案使用自帶矩陣 | ✅ DNG · 🚧 自家 Sony/Fujifilm 描述檔;缺少實測校正資料庫 |
+| 原生 macOS 選單列(由指令註冊表產生)、控制通道 + 每個控制項可定址、無介面 UI 快照 | ✅ |
+| RAW:DNG、CR2、CR3(無損 CRX Bayer 以及版本 0x100/0x200 的 C-RAW)、ARW、NEF(未壓縮 + 無損/有損壓縮,含有損壓縮經分割後的格式)、Fujifilm RAF(未壓縮 + 無損/有損壓縮,Bayer + X-Trans)、Panasonic RW2/Leica RWL/Panasonic RAW(所有 raw 格式,從 DMC-LX1 到 DC-S1RM2)、Pentax PEF、Olympus ORF(未壓縮,12.8 bpp)、Samsung SRW(未壓縮);所有格式皆支援內嵌預覽,含 CR3 | 🚧 · 其他 CR3 變體、壓縮 ORF/SRW 和 Nikon HE NEF 解碼 ⬜([raw 相容度](docs/raw-parity.md)) |
+| 細節:銳利化、明度 + 色彩降噪 | ✅ · AI 降噪 🚧(Bayer,使用者自行安裝權重)、超解析度 ⬜ |
+| 斑點移除/修復/仿製(自動取樣來源)、顯示斑點、紅眼與寵物眼校正(自動瞳孔偵測、眼神光) | ✅ · 內容感知填色、斑點圖釘編輯 🚧 |
+| 匯出:JPEG/PNG/TIFF/WebP/AVIF/DNG/原始格式,尺寸調整、檔案大小上限、輸出銳利化、命名範本、批次處理、中繼資料政策、文字或影像浮水印 | ✅ |
+| HDR:附餘裕上限的 HDR 編輯、SDR 呈現、視覺化 HDR;匯出為 ISO 21496-1 增益圖 JPEG、PQ AVIF 或 32 位元浮點 TIFF | ✅ · HDR 顯示 ⬜ |
+| 照片庫持久化(防當機的操作紀錄 + 快照、背景壓縮、儲存失敗會回報)、磁碟縮圖快取 | ✅ |
+| 匯入:原位置加入/複製/移動、重新命名與資料夾範本、裝置、重複檢測、監看資料夾;本機資料夾瀏覽 | ✅ |
+| MCP 伺服器(無介面或執行中的應用程式、持久化照片庫)、CLI、控制通道 | ✅ |
+| XMP 附屬檔案(讀取/寫入、自動寫入)、讀取 `crs:` 顯影設定、預設集檔案(`.lcpreset`、XMP 預設集) | ✅ |
+| 鏡頭校正(變形、暗角、自動 + 手動色差、去除邊緣色差、內嵌於 DNG 檔案的鏡頭校正資料、Panasonic/Leica RW2/RWL 變形資料以及 Nikon Z 6II/Z 8/Z f 變形資料)、幾何(變換、限制裁切範圍)、透視校正(自動/水平/垂直/完整/引導式) | ✅ · 相機鏡頭描述檔(我們自己的)⬜ |
+| 照片合併:HDR(自動對齊、去除殘影)、全景(球面/圓柱/透視、邊界變形、自動裁切)、HDR 全景 → DNG | ✅ |
+| GPU 管線(wgpu 計算,與 CPU 一致到 1/255 以內)、裝置限制/發生錯誤時改用 CPU | ✅ · 瀏覽器中的 WebGPU 🚧 |
+| AI:分割遮色片、AI 降噪、超解析度、人臉;影片 | 🚧(見[缺口清單](docs/gaps.md)) |
+| 網頁版(透過 WASM 在瀏覽器中使用同一套介面):照片庫持久化於 OPFS/IndexedDB、Web Worker 算繪、匯出下載 | ✅ · WebGPU、Safari/Firefox 測試 🚧 |
+
+✅ 現已可用 · 🚧 進行中 · ⬜ 尚未開始
+
+
+
+## Quick start
+
+建置整個工作區(含桌面、網頁與 CLI 應用程式)需要 Rust 1.95 或更新版本。
+
+```sh
+git clone https://github.com/storytold/lightcraft && cd lightcraft
+cargo run --release -p lightcraft # 開啟你的照片庫(~/Pictures/LightCraft Library;新照片庫會先帶示範照片)
+cargo run --release -p lightcraft -- ~/Pictures/trip # 匯入你的照片(掃描資料夾,跳過重複項目)
+cargo run --release -p lightcraft -- --memory # 用完即丟的記憶體示範工作階段(不寫入任何內容)
+cargo run --release -p lightcraft -- --control 7980 # 附帶自動化通道
+cargo xtask web --serve # 在瀏覽器中執行同一個應用程式: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、測試、分層、wasm 檢查
+```
+
+CI 預設使用行表除錯資訊,並依啟動時可用記憶體每 1.5 GB 開一個建置工作
+(每個 CPU 最多一個;讀不到記憶體時為 4 個),所以一台 8 GB、剩餘 6 GB 可用的機器會用 4 個任務建置,
+而一台忙碌、剩餘記憶體很少的機器會用更少。測試執行緒數隨之調整,上限為 4。若不限制,每個 CPU 各跑
+一個帶完整除錯資訊的連結器,測試還沒開始記憶體就會耗盡。GPU 測試的涵蓋範圍不受影響。明確設定
+`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS` 與 `RUST_TEST_THREADS` 會覆寫這些預設值。
+日常開發指令保留各自的設定。
+
+**記憶體受限機器上的 release 建置:** [#1196](https://github.com/storytold/lightcraft/issues/1196)
+回報在一台 16 GB 的 Arch/AUR 機器上,同時建置桌面應用程式與 CLI,並啟用完整除錯資訊、thin LTO
+及單一程式碼產生單元時,連結過程遭到 OOM 終止。上述 CI 預設值並不適用於一般的
+`cargo build --release`。建議先用一個建置工作;若記憶體充足,可嘗試兩個:
+
+```sh
+CARGO_BUILD_JOBS=1 cargo build --release -p lightcraft -p lightcraft-cli
+# 或者:CARGO_BUILD_JOBS=2 cargo build --release -p lightcraft -p lightcraft-cli
+```
+
+如果打包環境啟用了完整除錯資訊或較耗記憶體的 LTO 設定,可以使用僅對本條指令生效的
+覆寫設定,降低原始碼建置所需的記憶體:
+
+```sh
+CARGO_BUILD_JOBS=1 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 cargo build --release -p lightcraft -p lightcraft-cli
+```
+
+這些 POSIX shell 賦值只影響這一條指令,不會變更全域 Cargo 設定或儲存庫的 release profile。
+停用 LTO 可能改變效能與二進位檔大小;停用除錯資訊會減少可用的除錯細節。這是建置建議,
+並不保證任何機器都有足夠的記憶體。下游打包者應逐一建置大型二進位檔、不要同時進行,並避免在低記憶體
+的建置機器上同時使用完整除錯資訊、LTO 與單一程式碼產生單元。自行匯出 profile 設定的
+PKGBUILD 可能會覆寫繼承的環境變數;請在本機調整打包腳本或聯絡維護者,而不要假設替
+`makepkg` 加上環境變數前綴就能優先生效。
+
+測試在缺少 GPU、語料庫、craft-fonts 字型或模型時會直接通過而不檢查任何內容(libtest 沒有「已跳過」
+這個狀態),所以 `cargo xtask ci` 會把它們列出來:測試會用 `lightcraft_testkit::skip!` 記錄跳過原因,
+執行結束時會列出機器提供的環境(作業系統、CPU、記憶體、每個後端的 GPU 配接器、語料庫、字型)以及每一項
+跳過的原因。它會寫入 `target/ci/report.json` 和 `target/ci/summary.md`,後者是可直接貼進 PR 的
+Markdown 區塊(`--summary-md` 會把它印出來);`--hardware` 還會在每個有實體配接器的後端上執行
+GPU 測試。不會上傳任何內容。
+
+**中文與日文文字**需要共用字型儲存庫,這是一個可選的建置輸入(所有正式發布版都會包含它):
+
+```sh
+git clone https://github.com/storytold/craft-fonts ../craft-fonts
+CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
+```
+
+不指定它時,LightCraft 一樣能建置與執行,只是中文和日文文字沒有字形。字型從不提交到
+本儲存庫,字型的收錄規範記載在該儲存庫:見 [craft-fonts README](https://github.com/storytold/craft-fonts#readme)。
+
+**介面語言:** **編輯 → 語言**(English、简体中文、繁體中文(台灣)、日本語、Português (Brasil)、Español、Deutsch、Русский、Français、Українська、Čeština、ქართული)或 **設定 → 一般 →
+語言**;選擇後立即生效並會保留。詳見 [docs/localization.md](docs/localization.md)。
+
+**記錄檔:** 桌面應用程式會把記錄檔寫到標準錯誤輸出,以及設定資料夾裡的 `logs/lightcraft.log`
+(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`,預設為 `~/.config/lightcraft/logs/`;macOS
+`~/Library/Application Support/LightCraft/logs/`;Windows `%APPDATA%\LightCraft\logs\`),絕不會寫進照片庫裡。
+**可攜模式:** 在執行檔旁放一個名為 `portable.txt`(或 `LightCraft.portable`)的檔案,會把設定
+資料夾移到執行檔旁的 `LightCraftData`。從桌面選單或 Dock 啟動沒有終端機,所以請把這個檔案附在
+錯誤回報裡(**說明 ▸ 開啟記錄資料夾** 會在檔案管理員中顯示它)。每次啟動會把
+上一個記錄檔移到 `lightcraft.1.log`,再上一個移到 `lightcraft.2.log`,這樣當機那次的記錄檔就能在下次啟動後
+保留下來;檔案到 16 MiB 就會停止增長,`--version` 和 `--help` 不會寫記錄檔,帶
+`LIGHTCRAFT_NO_PREFS` 執行時只會寫到標準錯誤輸出。預設情況下,LightCraft 自己的 crate 會記錄到 `info`
+層級,其他一切則記錄到 `warn`。`LIGHTCRAFT_LOG=info` 或 `debug` 的行為和以前一樣(LightCraft 自己的 crate
+用該層級,其餘只記警告與錯誤;其他值:只記警告與錯誤),並且優先於 `RUST_LOG`;未設定 `LIGHTCRAFT_LOG` 時,`RUST_LOG` 會把預設值換成
+env_logger 風格的指令,例如 `RUST_LOG=debug` 或 `RUST_LOG=warn,lightcraft_pipeline=trace`(以
+`*` 結尾的指令會涵蓋所有以它開頭的目標,如 `lightcraft*=debug`)。panic 一樣會記錄在那裡,
+並且仍然會寫在暫存資料夾裡的 `lightcraft-panics.log`。記錄器程式碼位於 `apps/lightcraft/src/logging.rs`。
+
+網頁版需要 `wasm32-unknown-unknown` target 以及對應版本的 `wasm-bindgen` CLI
+(`cargo xtask web` 會印出確切的安裝指令);詳見 [docs/web.md](docs/web.md)。
+
+**Nix** 會建置桌面應用程式與 `lightcraft-cli`(Nix 建置永遠包含 craft-fonts 輸入,所以日文
+文字有字形):
+
+```sh
+nix run github:storytold/lightcraft # 桌面應用程式
+nix build github:storytold/lightcraft # → ./result/bin/{lightcraft,lightcraft-cli}
+nix develop github:storytold/lightcraft # rust 工具鏈 + 原生相依套件 + 字型
+```
+
+在 NixOS 上,請從 `nix develop` 執行 `cargo` 建置:在它之外,應用程式找不到 `libvulkan.so.1`,
+偵測不到 GPU,會改用 CPU 算繪(啟動時會印出一次提示,說明這樣會慢很多)。`nix run` 和 `nix build`
+會自行設定好路徑。
+
+在 flake 設定中(NixOS、home-manager、nix-darwin):
+
+```nix
+# flake.nix
+inputs.lightcraft.url = "github:storytold/lightcraft";
+# 選用:使用你自己的 nixpkgs,而不是 LightCraft 鎖定的那個
+# inputs.lightcraft.inputs.nixpkgs.follows = "nixpkgs";
+
+# 然後在 NixOS 或 home-manager 模組中(`inputs` 在作用範圍內):
+nixpkgs.overlays = [ inputs.lightcraft.overlays.default ]; # 讓 `pkgs.lightcraft` 可用
+environment.systemPackages = [ pkgs.lightcraft ]; # home-manager:home.packages = [ pkgs.lightcraft ];
+```
+
+`nix build` 會安裝與 .deb/.rpm 相同的 desktop 檔案、hicolor 圖示和 AppStream 中繼資料,並執行
+`cargo test --workspace` 作為其檢查階段(用 `pkgs.lightcraft.overrideAttrs { doCheck = false; }` 跳過)。
+
+**鍵盤:** G 格狀檢視 · D 詳細檢視 · E 編輯 · C 裁切 · M 遮色片 ·
+Shift +P 預設集 · \\ 原圖 · Y 編輯前/後 · Z 縮放 ·
+J 顯示溢色 · ⌘Z 復原 · ⌘/ 全部快速鍵。
+
+## How it's built
+
+一個引擎優先的 Cargo 工作區,由一群小而經過測試的 crate 組成,並強制分層(`cargo xtask layers`):`geom`、
+`color`、`raster`、`tiff` → `raw`、`codecs`、`meta`、`develop` → `pipeline` → `catalog` → `engine` → `ui-egui`。
+egui 前端是一個可替換的 crate;它之下沒有任何東西知道 UI 的存在。`mcp` 和各個應用程式(`lightcraft`、
+`lightcraft-cli`)則坐落在 `engine` 之上。
+
+## Contributing
+
+人類與代理遵循同一套規則,所以請先讀 [AGENTS.md](AGENTS.md)。重點如下:
+
+- **淨室。** 絕不閱讀 Adobe 的二進位檔或 GPL 的 raw/照片程式碼(darktable、RawTherapee、LibRaw、rawspeed、dcraw……);
+ 只依據公開規格與黑盒觀察來工作。
+- **絕不使用 Adobe 資產:** 不使用 Adobe 產品的圖示、截圖、預設集、描述檔、LUT 或字型。儲存庫中的每一張
+ 影像、圖示和字型都是原創、公眾領域、創用 CC、OFL 或寬鬆授權的,並在同一次提交中
+ 於 [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 新增條目。新的字型要提交到
+ [storytold/craft-fonts](https://github.com/storytold/craft-fonts),而不是這裡。
+- **純 Rust**、強制的 crate 分層、一切皆指令,並且每次提交前 `cargo xtask ci` 都必須通過
+ (一次提交對應一個任務 ID)。
+- **絕不當機。** 非測試程式碼要回傳錯誤而不是 panic:不使用 `unwrap()`、`expect()`、`panic!` 或
+ `unsafe`,對任何來自輸入的資料都做受檢的索引存取,並且每一個當機修復都要附一個回歸測試。細節見
+ [AGENTS.md](AGENTS.md#never-crash-outranks-feature-work)。
+
+有問題、想法,或是想先討論一個 bug?來 [Discord](https://discord.gg/artcraft)。
+
+
+
+## Downloads
+
+**第一次使用 LightCraft?** 從 [getartcraft.com 的 LightCraft 頁面](https://getartcraft.com/apps/lightcraft)下載。那是最簡單的安裝方式。
+
+**想要特定的建置版本或格式?** 在 GitHub 上,[最新發布版](https://github.com/storytold/lightcraft/releases/latest)包含下面列出的所有建置版本,[所有發布版](https://github.com/storytold/lightcraft/releases)則有更早的版本與其說明。檔名中的 `` 是版本號,`SHA256SUMS.txt` 列出了每個檔案的檢查碼。
+
+### Windows
+
+| 建置版本 | 安裝程式 | 可攜版 |
+|---|---|---|
+| x64(64 位元 Intel/AMD) | `lightcraft--windows-x64.msi` | `lightcraft--windows-x64-portable.zip` |
+| arm64(Snapdragon 及其他 ARM PC) | `lightcraft--windows-arm64.msi` | `lightcraft--windows-arm64-portable.zip` |
+| x86(32 位元) | `lightcraft--windows-x86.msi` | `lightcraft--windows-x86-portable.zip` |
+
+安裝程式與執行檔都有程式碼簽章。安裝程式會詢問安裝位置(預設為
+`C:\Program Files\LightCraft`;升級時會留在你選擇的資料夾),結束時會有一頁確認 LightCraft 已安裝,
+並可選擇立即啟動。無訊息安裝:`msiexec /i lightcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`。
+
+### macOS
+
+| 建置版本 | 檔案 | 備註 |
+|---|---|---|
+| 應用程式,通用版(Apple 晶片 + Intel) | `lightcraft--macos-universal.dmg` | 已簽章並經過公證 |
+| 命令列工具,通用版 | `lightcraft-cli--macos-universal.zip` | 已簽章並經過公證 |
+
+### Linux
+
+| 格式 | x86_64 | aarch64(ARM64) | 備註 |
+|---|---|---|---|
+| AppImage | `lightcraft--linux-x86_64.AppImage` | `lightcraft--linux-aarch64.AppImage` | 隨處可執行;用 [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate)(`.zsync` 檔案)自我更新 |
+| Flatpak | `lightcraft--linux-x86_64.flatpak` | `lightcraft--linux-aarch64.flatpak` | 沙箱化;`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` | |
+| 壓縮檔 | `lightcraft--linux-x86_64.tar.gz` | `lightcraft--linux-aarch64.tar.gz` | 解壓到任意位置即可使用 |
+
+RISC-V(riscv64):`lightcraft--linux-riscv64.tar.gz`,僅提供壓縮檔(glibc 2.39 或更新版本,例如 Ubuntu 24.04+)。
+
+在 Wayland 桌面環境中,LightCraft 會在可用時透過 XWayland 執行,因為只有 X11 支援
+視窗的檔案拖放;設定 `LIGHTCRAFT_WAYLAND=1` 可改用原生 Wayland(更清晰的小數縮放,但無法拖放)。
+
+### FreeBSD
+
+| 建置版本 | 檔案 |
+|---|---|
+| x86_64 | `lightcraft--freebsd-x86_64.tar.gz` |
+
+### Web (WebAssembly)
+
+| 建置版本 | 檔案 | 備註 |
+|---|---|---|
+| 靜態網站 | `lightcraft-web-.zip` | 可在現代瀏覽器中執行;可託管在任何靜態伺服器上 |
+
+
+
+## The Crafting Apps
+
+LightCraft 是 **Crafting Apps** 之一:來自 [ArtCraft](https://getartcraft.com/) 團隊的免費開源創作工具,
+每一個都用 Rust 從零開始撰寫,每一個都能獨立使用。
+
+| | 應用程式 | 用途 | 程式碼 | 了解更多 |
+|:-:|---|---|---|---|
+| | **PhotoCraft** | 影像編輯:圖層、遮色片、文字和真正的 PSD 檔案 | [GitHub](https://github.com/storytold/photocraft) | [網站](https://getartcraft.com/apps/photocraft) |
+| | **VectorCraft** | 向量插畫 | [GitHub](https://github.com/storytold/vectorcraft) | [網站](https://getartcraft.com/apps/vectorcraft) |
+| | **FilmCraft** | 影片剪輯、調色與聲音 | [GitHub](https://github.com/storytold/filmcraft) | [網站](https://getartcraft.com/apps/filmcraft) |
+| | **LightCraft** | **照片庫與 raw 顯影 · 你現在在這裡** | [GitHub](https://github.com/storytold/lightcraft) | [網站](https://getartcraft.com/apps/lightcraft) |
+| | **PdfCraft** | 閱讀、整理與保護 PDF | [GitHub](https://github.com/storytold/pdfcraft) | [網站](https://getartcraft.com/apps/pdfcraft) |
+| | **EffectCraft** | 動態圖形與視覺特效 | [GitHub](https://github.com/storytold/effectcraft) | [網站](https://getartcraft.com/apps/effectcraft) |
+| | **DesignCraft** | 版面編排與出版 | [GitHub](https://github.com/storytold/designcraft) | [網站](https://getartcraft.com/apps/designcraft) |
+
+還有 [**ArtCraft**](https://getartcraft.com/) 本身,我們為想要真正掌控創作的人打造的 AI 影像與影片工作室。
+
+
+
+
+
+
+
+Come make things with us
+
+
+ 我們的 Discord 是各種創作者聚集的地方:畫畫的人、拍照的人、做動畫的人、剪片子的人、
+ 排版的人,還有仍在摸索自己喜歡做什麼的人。分享你在做的東西,
+ 尋求幫助,告訴我們哪裡壞了,或者告訴我們你希望這些工具能做到什麼。
+ 不管你用什麼媒材,也不管你做了多久,這裡都歡迎你。
+
+
+
+ discord.gg/artcraft ·
+ getartcraft.com ·
+ The Crafting Apps ·
+ LightCraft
+
+
+
+
+## License and credits
+
+LightCraft 採用 [MIT](LICENSE-MIT) 或 [Apache-2.0](LICENSE-APACHE) 雙重授權,任你選擇。
+Copyright (c) 2026 ArtCraft Team and the LightCraft contributors。必要的聲明見 [NOTICE](NOTICE)。
+
+隨附的字型、圖示、影像與其他資產保留各自的開放授權;每一項都在
+[assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 中列出了作者、來源與授權。
+
+展示用照片皆為公眾領域作品,取自 Wikimedia Commons:安瑟·亞當斯,*《提頓山脈與蛇河》*
+(1942,美國國家檔案館);桃樂絲·蘭格,*《移民母親》*(1936,美國國會圖書館);比爾·安德斯/NASA,
+*《地出》*(1968);NASA,*《藍色大理石》*(1972)。示範照片庫由 LightCraft 程式產生。介面字型:
+Inter(SIL OFL 1.1)。使用 [craft-fonts](https://github.com/storytold/craft-fonts) 建置的版本(所有正式發布版)
+還會內嵌其中文與日文字型(Noto Sans CJK SC、BIZ UDPGothic、BIZ UDMincho、Shippori Mincho;SIL OFL 1.1),列於其
+[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md) 中。所有圖示皆為原創。
+
+[`docs/brand/`](docs/brand/) 中的 ArtCraft 名稱、字標與標誌是
+ArtCraft 團隊的商標,不在本授權範圍內。它們只能以未經修改的形式,作為
+本儲存庫與 LightCraft 的一部分,依 [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) 使用。
+分支與修改版本必須移除它們。
+
+Adobe、Photoshop、Illustrator、Premiere Pro、Lightroom、Acrobat、After Effects 與 InDesign 是 Adobe Inc. 在美國及/或其他國家的商標或註冊商標。LightCraft 是一個獨立的開源專案,與 Adobe Inc. 沒有任何關係,也未獲其贊助或認可;這些名稱僅用於說明它所相容的工作流程。
+
+
+
+ 由 ArtCraft 團隊與社群製作。
+
+
+## Star history
+
+[](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
+
+照片庫、工具與預設集面板可以重新分組、拆分、浮動於應用程式視窗內、關閉與重新開啟。工具面板會保留目前的編輯/裁切/遮色片模式;照片檢視區與底片顯示窗格則維持在一起。拖曳面板標題到某個目標邊緣或另一個標題上即可調整。右鍵選單提供「浮動面板」、「移至群組」與「關閉面板」。既有的面板切換按鈕會在上次儲存的位置重新開啟面板;「重設面板配置」則會還原應用程式的版面配置。浮動面板會保持在目前的應用程式視窗之內。
diff --git a/ROADMAP.md b/ROADMAP.md
index b0e233f5b..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-17 · **Change:** minor (watermark completeness reassessed after #2052; current checklist recount synchronized) · **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
@@ -12,7 +12,7 @@ from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are
| | Value | Kind |
|---|---:|---|
-| **Feature breadth** (checklist) | **83.3%** of 539 rows (P0 98.3%, P1 94.7%, P2 55.7%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount |
+| **Feature breadth** (checklist) | **81.6%** of 532 rows (P0 98.0%, P1 95.2%, P2 50.0%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount |
| **Feature breadth** (weighted by use) | **~77%** | estimated |
| **Ready for real work** (full target) | **~61%** (56–66%) | estimated: weighted sum over the dimensions |
| **Mainstream practitioner** | **~51%** (46–56%) | estimated |
@@ -44,12 +44,12 @@ Hours are calibrated on this repo's PR history; see
| Dimension | Ready | Breadth | Remaining | Doc |
|---|---:|---:|---:|---|
-| Features | ~61% | ~77% (checklist 83.3%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) |
+| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) |
| ↳ RAW cameras, colour, lens profiles | ~60% | 83% of 1,446 Adobe models decode, 38% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
| 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,18 +186,21 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
| Date | Change | Summary |
|---|---|---|
-| 2026-10-17 | minor | LR-EXP-WATERMARK reassessed as partial for existing shaping, bidi and font limits (#2052); current checklist recount 83.4% to 83.3% of 539 rows. Stale headline figures synchronized to that recount; readiness and effort estimates unchanged |
+| 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-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs
index 01dae3055..c8371cbb9 100644
--- a/apps/lightcraft-cli/src/main.rs
+++ b/apps/lightcraft-cli/src/main.rs
@@ -732,12 +732,13 @@ fn render(args: &[String]) -> Result<(), String> {
for (k, v) in opts {
p[k] = v;
}
- let mut o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?;
if p.get("format").is_none() {
let ext = Path::new(&output).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
- o.format = ExportFormat::parse(&ext)
- .ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
+ ExportFormat::parse(&ext).ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
+ // given before validating, so `bitDepth` is checked against the format written (issue #1952)
+ p["format"] = json!(ext);
}
+ let o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?;
// Add to Apple Photos: refused before anything is written when it can't be done
let after = s.after_export(&o)?;
let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?;
diff --git a/apps/lightcraft-web/src/backend.rs b/apps/lightcraft-web/src/backend.rs
index e20f6dc9c..1dbc2266b 100644
--- a/apps/lightcraft-web/src/backend.rs
+++ b/apps/lightcraft-web/src/backend.rs
@@ -22,6 +22,12 @@ fn err(e: JsValue) -> String {
e.as_string().or_else(|| e.dyn_ref::().map(|e| String::from(e.message()))).unwrap_or_else(|| format!("{e:?}"))
}
+/// Did an OPFS call fail because the entry doesn't exist (`NotFoundError`)? Any other failure
+/// (security, type mismatch, an unreadable or evicted store) is an error, never "absent" (#1507).
+fn not_found(e: &JsValue) -> bool {
+ crate::files::is_not_found(Reflect::get(e, &"name".into()).ok().and_then(|n| n.as_string()).as_deref())
+}
+
/// `navigator.storage` in a window or a worker.
fn storage_manager() -> Option {
let g = js_sys::global();
@@ -134,10 +140,7 @@ impl Backend {
opts.set_create(create);
match JsFuture::from(dir.get_directory_handle_with_options(p, &opts)).await {
Ok(d) => dir = d.unchecked_into(),
- Err(e) if !create => {
- let _ = e;
- return Ok(None);
- }
+ Err(e) if !create && not_found(&e) => return Ok(None),
Err(e) => return Err(err(e)),
}
}
@@ -150,7 +153,7 @@ impl Backend {
opts.set_create(create);
match JsFuture::from(dir.get_file_handle_with_options(&name, &opts)).await {
Ok(h) => Ok(Some(h.unchecked_into())),
- Err(_) if !create => Ok(None),
+ Err(e) if !create && not_found(&e) => Ok(None),
Err(e) => Err(err(e)),
}
}
@@ -250,7 +253,7 @@ impl Backend {
let Some((dir, name)) = Self::parent(root, path, false).await? else { return Ok(()) };
match JsFuture::from(dir.remove_entry(&name)).await {
Ok(_) => Ok(()),
- Err(e) if Reflect::get(&e, &"name".into()).ok().and_then(|n| n.as_string()).as_deref() == Some("NotFoundError") => Ok(()),
+ Err(e) if not_found(&e) => Ok(()),
Err(e) => Err(err(e)),
}
}
diff --git a/apps/lightcraft-web/src/backup.rs b/apps/lightcraft-web/src/backup.rs
index 00ec12a41..281a32296 100644
--- a/apps/lightcraft-web/src/backup.rs
+++ b/apps/lightcraft-web/src/backup.rs
@@ -52,6 +52,18 @@ pub fn valid_hash(h: &str) -> bool {
!h.is_empty() && h.len() <= 128 && h.chars().all(|c| c.is_ascii_alphanumeric())
}
+/// Why a backup can't start yet, given [`lightcraft_engine::Session::unsaved`]: changes applied in
+/// the app but not yet written to browser storage (a save failed). The zip copies the stored
+/// library files, so it would report success while missing them; `None` when everything is saved.
+pub fn unsaved_refusal(unsaved: Option<(usize, &str)>) -> Option {
+ let (ops, why) = unsaved?;
+ let changes = if ops == 1 { "1 change".to_string() } else { format!("{ops} changes") };
+ Some(format!(
+ "Backup not started: {changes} could not be saved to browser storage yet ({why}), so the backup would miss them. \
+ Free up storage space; LightCraft retries the save with every change. Back up once nothing is left unsaved."
+ ))
+}
+
/// Zip entry name for an original.
pub fn original_entry(hash: &str, file_name: &str) -> String {
let name: String = file_name.chars().map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c }).collect();
diff --git a/apps/lightcraft-web/src/files.rs b/apps/lightcraft-web/src/files.rs
index d0c9f348b..9cd934ce5 100644
--- a/apps/lightcraft-web/src/files.rs
+++ b/apps/lightcraft-web/src/files.rs
@@ -198,6 +198,27 @@ impl Store for FilesStore {
/// Names of the library files read at start-up.
pub const LIBRARY_FILES: [&str; 6] = ["catalog.snap", "catalog.log", "presets.json", "view.json", "prefs.json", "ui.json"];
+/// Is a browser storage error, by its `DOMException` name, "the entry doesn't exist"? Only
+/// `NotFoundError` is; anything else (security, type mismatch, an unreadable store) is a failure.
+pub fn is_not_found(dom_exception_name: Option<&str>) -> bool {
+ dom_exception_name == Some("NotFoundError")
+}
+
+/// Preload the library files read at start-up (`None`: the file doesn't exist yet). A file that
+/// exists but couldn't be read stops start-up instead (#1507): opening the library without it
+/// would show an incomplete catalog and then save over the stored copy.
+pub fn preload_library(files: &Files, reads: Vec<(&str, Result>, String>)>) -> Result<(), String> {
+ let mut failed = Vec::new();
+ for (name, read) in reads {
+ match read {
+ Ok(Some(data)) => files.preload(name, data),
+ Ok(None) => {}
+ Err(e) => failed.push(format!("{name}: {e}")),
+ }
+ }
+ if failed.is_empty() { Ok(()) } else { Err(format!("couldn't read the library from browser storage ({})", failed.join("; "))) }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -282,6 +303,66 @@ mod tests {
assert_eq!(s2.catalog.photo(id).unwrap().rating, 5, "the rating survived");
}
+ /// A backup copies the stored library files, so while a save is failing it would miss the
+ /// queued changes: it is refused with the reason until they are written (#1508).
+ #[test]
+ fn backup_is_refused_while_changes_are_unsaved() {
+ let files = Files::default();
+ let mut s = Session::new();
+ let stores = LibraryStores { dir: "browser".into(), catalog: Box::new(files.store()), files: Box::new(files.store()), on_disk: false };
+ s.open_library_in(stores, true).unwrap();
+ assert_eq!(crate::backup::unsaved_refusal(s.unsaved()), None, "everything saved: the backup may start");
+ files.set_error(Some("QuotaExceededError".into()));
+ let _ = s.execute("photo.rate", &json!({"rating": 5}));
+ let refusal = crate::backup::unsaved_refusal(s.unsaved()).expect("refused while the rating is unsaved");
+ assert!(refusal.contains("could not be saved") && refusal.contains("QuotaExceeded"), "{refusal}");
+ files.set_error(None);
+ s.persist_if_dirty();
+ s.persist().unwrap();
+ assert_eq!(crate::backup::unsaved_refusal(s.unsaved()), None, "allowed again once the change is written");
+ }
+
+ #[test]
+ fn unsaved_refusal_counts_changes() {
+ use crate::backup::unsaved_refusal;
+ assert!(unsaved_refusal(Some((1, "quota"))).unwrap().contains("1 change could not"));
+ assert!(unsaved_refusal(Some((3, "quota"))).unwrap().contains("3 changes could not"));
+ }
+
+ /// Start-up only treats a missing file as absent: a stored file that couldn't be read stops
+ /// it, naming the file, instead of opening an incomplete library (#1507).
+ #[test]
+ fn unreadable_library_file_stops_start_up() {
+ let files = Files::default();
+ let reads = vec![
+ ("catalog.snap", Ok(Some(b"snap".to_vec()))),
+ ("catalog.log", Err("NotReadableError: the file could not be read".to_string())),
+ ("presets.json", Ok(None)),
+ ];
+ let e = preload_library(&files, reads).unwrap_err();
+ assert!(e.contains("catalog.log") && e.contains("NotReadableError"), "{e}");
+ }
+
+ /// A new library (nothing stored yet) still starts.
+ #[test]
+ fn missing_library_files_are_a_new_library() {
+ let files = Files::default();
+ let reads = LIBRARY_FILES.iter().map(|n| (*n, Ok(None))).collect();
+ preload_library(&files, reads).unwrap();
+ assert!(files.get("catalog.snap").is_none());
+ let reads = vec![("catalog.snap", Ok(Some(b"snap".to_vec()))), ("catalog.log", Ok(None))];
+ preload_library(&files, reads).unwrap();
+ assert_eq!(files.get("catalog.snap").as_deref(), Some(&b"snap"[..]));
+ }
+
+ #[test]
+ fn only_not_found_errors_mean_absent() {
+ assert!(is_not_found(Some("NotFoundError")));
+ for name in [Some("SecurityError"), Some("TypeMismatchError"), Some("NotReadableError"), Some("InvalidStateError"), None] {
+ assert!(!is_not_found(name), "{name:?}");
+ }
+ }
+
#[test]
fn library_survives_reload_through_flushes() {
let mut disk = BTreeMap::new();
diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs
index 6a4487d09..14a5d050b 100644
--- a/apps/lightcraft-web/src/web.rs
+++ b/apps/lightcraft-web/src/web.rs
@@ -173,6 +173,9 @@ fn services(originals: Originals, backend: Option, files: Files, frozen
screen_profile: None,
backup_library: Some(Box::new(move |session: &mut Session| {
let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
+ if let Some(why) = crate::backup::unsaved_refusal(session.unsaved()) {
+ return Err(why);
+ }
// the photos' own names for the originals in the zip
let names: std::collections::HashMap = session
.catalog
@@ -280,6 +283,9 @@ struct Boot {
index: ThumbIndex,
/// The library folder in storage (`library`, or a restored backup's).
lib_dir: String,
+ /// Library files that exist but couldn't be read (#1507): the library isn't opened and
+ /// nothing is saved, so the stored copy stays as it was.
+ load_error: Option,
}
async fn boot(opts: Options) -> Boot {
@@ -298,6 +304,7 @@ async fn boot(opts: Options) -> Boot {
let files = Files::default();
let mut index = ThumbIndex::default();
let mut lib_dir = DEFAULT_LIBRARY_DIR.to_string();
+ let mut load_error = None;
if let Some(b) = &backend {
if opts.reset {
// everything this site keeps in the browser, photos included: never without asking
@@ -315,21 +322,32 @@ async fn boot(opts: Options) -> Boot {
}
}
// a restored backup lives in its own folder (see `backup`)
- if let Ok(Some(d)) = b.read(ACTIVE_LIBRARY).await {
- let d = String::from_utf8_lossy(&d).trim().to_string();
- if valid_library_dir(&d) {
- lib_dir = d;
+ match b.read(ACTIVE_LIBRARY).await {
+ Ok(Some(d)) => {
+ let d = String::from_utf8_lossy(&d).trim().to_string();
+ if valid_library_dir(&d) {
+ lib_dir = d;
+ }
+ }
+ Ok(None) => {}
+ // not knowing which library is in use, any library opened could be the wrong one
+ Err(e) => load_error = Some(format!("couldn't read which library is in use from browser storage ({e})")),
+ }
+ if load_error.is_none() {
+ let mut reads = Vec::new();
+ for name in LIBRARY_FILES {
+ reads.push((name, b.read(&library_key(&lib_dir, name)).await));
+ }
+ if let Err(e) = crate::files::preload_library(&files, reads) {
+ log::error!("{e}");
+ load_error = Some(e);
}
}
- for name in LIBRARY_FILES {
- match b.read(&library_key(&lib_dir, name)).await {
- Ok(Some(data)) => files.preload(name, data),
- Ok(None) => {}
- Err(e) => log::error!("reading {name}: {e}"),
- }
- }
- if let Ok(Some(j)) = b.read(THUMB_INDEX).await {
- index = ThumbIndex::from_json(&j);
+ let index_read = b.read(THUMB_INDEX).await;
+ match &index_read {
+ Ok(Some(j)) => index = ThumbIndex::from_json(j),
+ Ok(None) => {}
+ Err(e) => log::warn!("reading the thumbnail index: {e}"),
}
crate::backend::request_persistence(|granted| {
if !granted {
@@ -339,7 +357,8 @@ async fn boot(opts: Options) -> Boot {
);
}
});
- // thumbnails stored without an index entry (index saved late, or lost): delete them
+ // thumbnails stored without an index entry (index saved late, or lost): delete them; not
+ // when the index couldn't be read, which would make every thumbnail look unindexed
let b2 = b.clone();
let known: std::collections::HashSet = b
.list("thumbs")
@@ -349,7 +368,7 @@ async fn boot(opts: Options) -> Boot {
.filter(|n| n.ends_with(".jpg"))
.map(|n| n.trim_end_matches(".jpg").to_string())
.collect();
- let orphans: Vec = known.into_iter().filter(|k| !index.contains(k)).collect();
+ let orphans: Vec = if index_read.is_ok() { known.into_iter().filter(|k| !index.contains(k)).collect() } else { Vec::new() };
if !orphans.is_empty() {
wasm_bindgen_futures::spawn_local(async move {
for k in &orphans {
@@ -366,7 +385,7 @@ async fn boot(opts: Options) -> Boot {
index.len(),
index.total() as f64 / 1e6
);
- Boot { opts, backend, files, index, lib_dir }
+ Boot { opts, backend, files, index, lib_dir, load_error }
}
struct WebApp {
@@ -389,7 +408,7 @@ struct WebApp {
impl WebApp {
fn new(cc: &eframe::CreationContext<'_>, boot: Boot, chinese_font: Vec) -> Self {
- let Boot { opts, backend, files, index, lib_dir } = boot;
+ let Boot { opts, backend, files, index, lib_dir, load_error } = boot;
let originals = Originals::default();
let t = perf_now();
let mut session = Session::new();
@@ -401,7 +420,13 @@ impl WebApp {
on_disk: false,
};
let mut problem = None;
- match session.open_library_in(stores, true).cloned() {
+ // a library that couldn't be read is not opened: it would show incomplete, then save over the stored copy
+ let read_failed = load_error.is_some();
+ let opened = match load_error {
+ Some(e) => Err(format!("{e}. Nothing was changed in storage")),
+ None => session.open_library_in(stores, true).cloned().map_err(|e| e.to_string()),
+ };
+ match opened {
Ok(r) => log::info!(
"lightcraft: library {} in {:.0} ms ({} photos; snapshot seq {}, {} ops replayed)",
if r.created { "created" } else { "loaded" },
@@ -432,7 +457,8 @@ impl WebApp {
}
// previews are large (≤ 2560 px, f32): keep few in a 32-bit address space
session.media.preview_capacity = 3;
- let frozen = Rc::new(Cell::new(false));
+ // nothing is saved while the stored library couldn't be read
+ let frozen = Rc::new(Cell::new(read_failed));
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone()))
.with_chinese_font(chinese_font);
let ui_written = files.get("ui.json").unwrap_or_default();
@@ -667,11 +693,40 @@ impl eframe::App for WebApp {
}
}
+/// "Auto" follows the browser's languages (issue #2085): the first of `navigator.languages` (else
+/// `navigator.language`) that LightCraft ships becomes the UI's default. Every step is fallible
+/// (as in [`crate::safety::acquire_tab_lock`]): without a usable navigator Auto stays English,
+/// as before.
+fn install_browser_language() {
+ let Ok(navigator) = js_sys::Reflect::get(&js_sys::global(), &"navigator".into()) else { return };
+ let mut tags: Vec = Vec::new();
+ if let Ok(languages) = js_sys::Reflect::get(&navigator, &"languages".into())
+ && let Some(languages) = languages.dyn_ref::()
+ {
+ for tag in languages.iter() {
+ if let Some(tag) = tag.as_string() {
+ tags.push(tag);
+ }
+ }
+ }
+ if tags.is_empty()
+ && let Ok(language) = js_sys::Reflect::get(&navigator, &"language".into())
+ && let Some(language) = language.as_string()
+ {
+ tags.push(language);
+ }
+ if let Some(language) = lightcraft_ui_egui::i18n::locale_from_tags(&tags) {
+ lightcraft_ui_egui::i18n::set_default_language(language);
+ log::info!("lightcraft: default language {language:?} from the browser's languages");
+ }
+}
+
/// Start the app with the separately fetched Simplified Chinese font (empty in font-free builds).
#[wasm_bindgen]
pub fn start(chinese_font: Vec) {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
safety::install_panic_hook();
+ install_browser_language();
log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now());
if chinese_font.is_empty() {
log::warn!("lightcraft: no Simplified Chinese font supplied; Chinese-only glyphs will show as boxes");
diff --git a/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 bec67597c..4eb002cef 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -45,3 +45,22 @@ sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
+
+[people.nledford]
+real_name = "Nathaniel Ledford"
+
+[people.marcoarnulfo]
+real_name = "Marco Arnulfo"
+display_name = "Marco Arnulfo"
+sync_github_name = false
+
+[people.egegl]
+real_name = "Ege Gursel"
+sync_github_name = true
+
+[people.mahmatias]
+display_name = "Márcio Matias de Andrade"
+sync_github_name = true
+
+[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 d180d013d..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.
@@ -49,7 +50,9 @@ pub fn shift_iso(iso: &str, secs: i64) -> Option {
if !(first..=last).contains(&t) {
return None;
}
- let tail = iso.trim().get(19..).unwrap_or("");
+ let iso = iso.trim();
+ // the seconds are optional (`10:00+02:00`): without them the tail starts right after the minutes
+ let tail = iso.get(if iso.get(16..17) == Some(":") { 19 } else { 16 }..).unwrap_or("");
Some(format!("{}{tail}", civil(t)))
}
@@ -154,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)]
@@ -197,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"));
@@ -222,6 +251,24 @@ mod tests {
assert_eq!(normalize_iso("2026-02-30"), None);
}
+ #[test]
+ fn shift_keeps_the_zone_of_a_time_without_seconds() {
+ assert_eq!(shift_iso("2026-01-01T10:00+02:00", 3600).as_deref(), Some("2026-01-01T11:00:00+02:00"));
+ assert_eq!(shift_iso("2026-01-01T10:00Z", 60).as_deref(), Some("2026-01-01T10:01:00Z"));
+ assert_eq!(shift_iso("2026-01-01T10:00", 3600).as_deref(), Some("2026-01-01T11:00:00"));
+ assert_eq!(shift_iso("2026-01-01T10:00:00.5-05:00", 3600).as_deref(), Some("2026-01-01T11:00:00.5-05:00"));
+ assert_eq!(iso_seconds("2026-01-01T10:00+02:00"), iso_seconds("2026-01-01T10:00"));
+ }
+
+ #[test]
+ fn impossible_times_are_rejected_not_rolled_over() {
+ for bad in ["2026-04-01T10:75:00", "2026-04-01 10:00:75", "2026-04-01T10:60", "2026-04-01T24:00:00", "2026-04-01T10:59:60"] {
+ assert_eq!(normalize_iso(bad), None, "{bad}");
+ assert_eq!(shift_iso(bad, 5), None, "{bad}");
+ }
+ assert_eq!(normalize_iso("2026-04-01T23:59:59").as_deref(), Some("2026-04-01T23:59:59"));
+ }
+
#[test]
fn runs_by_day_month_year_keep_stacks_together() {
let mut c = Catalog::new();
diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs
index 289ee4fb8..edcfae22a 100644
--- a/crates/catalog/src/journal.rs
+++ b/crates/catalog/src/journal.rs
@@ -271,7 +271,7 @@ fn resync(line: &[u8], last: u64) -> Option<(usize, (u64, Op))> {
const START: &[u8] = b"{\"seq\":";
(1..line.len().saturating_sub(START.len() - 1)).filter(|&i| line[i..].starts_with(START)).find_map(|i| {
let rec = std::str::from_utf8(&line[i..]).ok().map(|l| l.trim_end_matches('\r')).and_then(decode_record)?;
- (rec.0 <= last + 1).then_some((i, rec))
+ (rec.0 <= last.saturating_add(1)).then_some((i, rec))
})
}
@@ -298,6 +298,9 @@ fn write_snapshot(store: &mut dyn Store, seq: u64, catalog: &Catalog) -> std::io
Ok(SnapshotTiming { serialize_ms, write_sync_ms: (ms_since(t0) - serialize_ms).max(0.0), bytes, ..Default::default() })
}
+/// The highest `seq` a snapshot may hold; beyond it the counter is treated as corrupt, far from overflow.
+const MAX_SEQ: u64 = u64::MAX / 2;
+
/// Just the identification of `catalog.snap` (read before the catalog itself).
#[derive(serde::Deserialize)]
struct SnapHeader {
@@ -331,6 +334,10 @@ impl Journal {
return Err(newer(format!("{SNAPSHOT} is catalog format v{}", h.version)));
}
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
+ // a seq this high can't come from real use; the next record's seq would wrap
+ if s.seq > MAX_SEQ {
+ return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: seq {} is out of range", s.seq)));
+ }
let mut catalog = s.catalog;
report.repaired = catalog.repair();
for r in &report.repaired {
@@ -392,7 +399,7 @@ impl Journal {
let _ = op;
report.stale += 1;
}
- Some((seq, op)) if seq == j.seq + 1 => {
+ Some((seq, op)) if j.seq.checked_add(1) == Some(seq) => {
if catalog.apply(op).is_err() {
report.failed += 1;
} else {
@@ -476,7 +483,7 @@ impl Journal {
let mut buf = String::new();
let mut seq = self.seq;
for op in ops {
- seq += 1;
+ seq = seq.checked_add(1).ok_or_else(|| CatalogError::Corrupt(format!("{LOG}: op counter overflow")))?;
buf.push_str(&encode_record(seq, op));
buf.push('\n');
}
@@ -527,7 +534,7 @@ impl Journal {
log::warn!("catalog: background snapshot failed ({e}); writing one now");
}
let t0 = web_time::Instant::now();
- let seq = self.seq + unlogged;
+ let seq = self.seq.checked_add(unlogged).ok_or_else(|| CatalogError::Corrupt(format!("{SNAPSHOT}: op counter overflow")))?;
let mut timing = match write_snapshot(self.store.as_mut(), seq, catalog) {
Ok(t) => t,
Err(e) => {
diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs
index 0c7ca5112..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");
@@ -643,6 +644,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),
@@ -652,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 d1548f8bd..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..]),
@@ -157,13 +188,20 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
Some((_, v)) => (x - v).abs() < 1e-9,
None => false,
};
+ // Fields stored as `f32` compare at f32 precision: 2.8f32 widened to f64 is not 2.8.
+ let cmp32 = |x: f32, s: &str| match num(s) {
+ Some(('>', v)) => x > v as f32,
+ Some(('<', v)) => x < v as f32,
+ Some((_, v)) => x == v as f32,
+ None => false,
+ };
return match field {
"rating" | "stars" => cmp(p.rating as f64, val),
"flag" => Flag::parse(val) == Some(p.flag),
"label" | "color" => p.label.is_some_and(|l| format!("{l:?}").eq_ignore_ascii_case(val)),
"iso" => p.meta.iso.is_some_and(|i| cmp(i as f64, val)),
- "f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp(a as f64, val)),
- "focal" => p.meta.focal_mm.is_some_and(|a| cmp(a as f64, val)),
+ "f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp32(a, val)),
+ "focal" => p.meta.focal_mm.is_some_and(|a| cmp32(a, val)),
"camera" => p.meta.camera.to_lowercase().contains(val),
"lens" => p.meta.lens.to_lowercase().contains(val),
"keyword" | "kw" => p.meta.keywords.iter().any(|k| crate::keywords::is_under(k, val)),
@@ -173,11 +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),
- _ => false,
+ // 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, unquote(&t))
+}
+
+/// Whether the lower-cased word `t` is in the file name, title, caption, camera, lens, location,
+/// 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).
@@ -395,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 a713b7736..84e89d124 100644
--- a/crates/catalog/src/stacks.rs
+++ b/crates/catalog/src/stacks.rs
@@ -294,15 +294,17 @@ pub fn iso_seconds(s: &str) -> Option {
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
- let (hh, mm, ss) = if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, num(17, 19).unwrap_or(0)) } else { (0, 0, 0) };
- // 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;
+ let (hh, mm, ss) =
+ if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, if s.get(16..17) == Some(":") { num(17, 19).unwrap_or(0) } else { 0 }) } else { (0, 0, 0) };
+ if hh >= 24 || mm >= 60 || ss >= 60 {
+ return None;
+ }
+ // Days 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/store.rs b/crates/catalog/src/store.rs
index 2f3dc4912..553eb241f 100644
--- a/crates/catalog/src/store.rs
+++ b/crates/catalog/src/store.rs
@@ -242,7 +242,7 @@ impl Store for MemStore {
}
}
-#[cfg(test)]
+#[cfg(all(test, unix))]
mod tests_staging {
use super::*;
diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs
index 8f1aaf151..f8b72dad8 100644
--- a/crates/catalog/src/tests.rs
+++ b/crates/catalog/src/tests.rs
@@ -123,6 +123,48 @@ fn filter_search_sort() {
assert_eq!(c.keywords(), vec![("ocean".to_string(), 1), ("summer".to_string(), 1)]);
}
+/// A search word with a colon whose left part is not a known field ("10:30", "Re:") is plain
+/// text, not a fielded token that matches nothing.
+#[test]
+fn free_text_with_a_colon_searches_the_text_fields() {
+ let mut c = Catalog::new();
+ let a = photo(&mut c, "meeting.jpg", "2026-05-01T10:00:00");
+ let _b = photo(&mut c, "other.jpg", "2026-04-01T10:00:00");
+ let mut m = c.photo(a).unwrap().meta.clone();
+ m.caption = "Meeting at 10:30 with Re: notes".into();
+ m.iso = Some(1600);
+ c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap();
+ let q = |text: &str| c.query(&Filter { text: text.into(), ..Default::default() }, &Sort::default());
+ assert_eq!(q("meeting"), vec![a]);
+ assert_eq!(q("10:30"), vec![a], "a free-text token with a colon");
+ assert_eq!(q("re:"), vec![a]);
+ assert_eq!(q("RE: notes"), vec![a], "case-insensitive, one token per word");
+ assert_eq!(q("iso:>800"), vec![a], "known fields stay fielded");
+ assert_eq!(q("iso:>2000"), Vec::::new());
+ assert_eq!(q("11:45"), Vec::::new(), "an unknown field matching no text finds nothing");
+}
+
+/// `f:`/`aperture:` and `focal:` are stored as `f32`; a typed value that isn't exact in binary
+/// (2.8, 5.6) must still match, and `<`/`>` must not count the photo's own value.
+#[test]
+fn search_tokens_match_f32_aperture_and_focal() {
+ let mut c = Catalog::new();
+ let a = photo(&mut c, "a.jpg", "2026-05-01T10:00:00");
+ let mut m = c.photo(a).unwrap().meta.clone();
+ m.aperture = Some(2.8);
+ m.focal_mm = Some(5.6);
+ c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap();
+ let q = |t: &str| c.query(&Filter { text: t.into(), ..Default::default() }, &Sort::default());
+ assert_eq!(q("f:2.8"), vec![a], "f:2.8");
+ assert_eq!(q("aperture:=2.8"), vec![a], "aperture:=2.8");
+ assert_eq!(q("focal:5.6"), vec![a], "focal:5.6");
+ assert!(q("f:2.9").is_empty(), "f:2.9");
+ assert!(q("f:<2.8").is_empty(), "f:<2.8");
+ assert!(q("f:>2.8").is_empty(), "f:>2.8");
+ assert!(q("focal:<5.6").is_empty(), "focal:<5.6");
+ assert_eq!(q("f:>2.7"), vec![a], "f:>2.7");
+}
+
/// A `captured` string that is not ASCII (the Lightroom importer copies `captureTime` into an
/// `AddPhoto` op verbatim, and a JSON snapshot is valid UTF-8 whatever it holds) must not be
/// sliced on a byte index in `date_groups`: grouping counts the well-formed dates and ignores
@@ -794,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/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs
index 8570bd077..26b4e902a 100644
--- a/crates/catalog/src/tests_journal.rs
+++ b/crates/catalog/src/tests_journal.rs
@@ -376,3 +376,18 @@ fn a_damaged_process_value_loads_as_v1_with_the_edits() {
let d = &loaded.photo(id).unwrap().develop;
assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.8));
}
+
+/// A snapshot whose `seq` is near `u64::MAX` is refused as corrupt: the next record's seq would
+/// wrap (and every later edit would be skipped as stale on reopen) or overflow-panic in debug.
+#[test]
+fn snapshot_with_seq_near_u64_max_is_corrupt() {
+ for seq in [u64::MAX, u64::MAX - 1, u64::MAX / 2 + 1] {
+ let m = MemStore::new();
+ m.set(
+ SNAPSHOT,
+ format!("{{\"format\":\"lightcraft-catalog\",\"version\":1,\"seq\":{seq},\"catalog\":{}}}\n", Catalog::new().to_snapshot()).into_bytes(),
+ );
+ let e = Journal::open(Box::new(m)).err();
+ assert!(matches!(e, Some(CatalogError::Corrupt(_))), "seq {seq}: {e:?}");
+ }
+}
diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml
index 1ae016bc8..589d285fa 100644
--- a/crates/codecs/Cargo.toml
+++ b/crates/codecs/Cargo.toml
@@ -55,3 +55,6 @@ jxl-oxide = { workspace = true }
proptest = { workspace = true }
# Lossless synthetic HEIC files for the `heif` feature's tests.
lightcraft-heif = { workspace = true, features = ["testdata"] }
+
+[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
+dhat = { workspace = true }
diff --git a/crates/codecs/README.md b/crates/codecs/README.md
index 6ea5866b7..5902243fc 100644
--- a/crates/codecs/README.md
+++ b/crates/codecs/README.md
@@ -73,6 +73,10 @@ ICC profiles are parsed with `moxcms` (v2/v4, `curv`/`para` TRCs, `chad`); sRGB,
Adobe RGB (1998), ProPhoto (ROMM) and Rec.2020 are recognised by colorants. `icc::write_matrix_trc`
emits v4 display profiles (D50 PCS, Bradford `chad`) for export embedding.
+Before colour conversion, positive-infinity float samples become 1.0; NaN and negative infinity
+become 0.0. This also applies to alpha. Finite samples keep their existing range: matrix/TRC paths
+preserve values above 1.0 and below 0.0, while CMS inputs and alpha remain bounded to 0..1.
+
## Dependencies and licences
All pure Rust, verified with `cargo info` / `cargo tree`:
diff --git a/crates/codecs/src/convert.rs b/crates/codecs/src/convert.rs
index 02652db5a..2cdf67381 100644
--- a/crates/codecs/src/convert.rs
+++ b/crates/codecs/src/convert.rs
@@ -44,11 +44,23 @@ impl Buf {
match self {
Buf::U8(v) => v[i] as f32 / 255.0,
Buf::U16(v) => v[i] as f32 / 65535.0,
- Buf::F32(v) => v[i],
+ Buf::F32(v) => normalize_float(v[i]),
}
}
}
+/// Clip non-finite device samples before colour conversion, preserving finite extended range.
+#[inline]
+fn normalize_float(value: f32) -> f32 {
+ if value == f32::INFINITY {
+ 1.0
+ } else if value.is_finite() {
+ value
+ } else {
+ 0.0
+ }
+}
+
/// Interleaved device samples as produced by a format decoder.
pub(crate) struct Raw {
pub width: usize,
@@ -87,9 +99,10 @@ pub(crate) fn check_size(format: Format, w: u64, h: u64, opts: &DecodeOptions) -
}
pub(crate) fn finish(format: Format, raw: Raw, meta: Meta, source_dims: (u32, u32), opts: &DecodeOptions) -> Result {
- let n = raw.width * raw.height;
let ch = raw.model.channels() + raw.alpha as usize;
- if n == 0 || raw.buf.len() < n * ch {
+ let buf_len =
+ crate::checked_sample_buf_len(raw.width, raw.height, ch).ok_or_else(|| Error::Malformed(format, "sample buffer too short".into()))?;
+ if (raw.buf.len() as u64) < buf_len {
return Err(Error::Malformed(format, "sample buffer too short".into()));
}
let float = matches!(raw.buf, Buf::F32(_));
@@ -129,7 +142,9 @@ pub(crate) fn finish(format: Format, raw: Raw, meta: Meta, source_dims: (u32, u3
let (image, alpha) = match opts.max_size {
Some((mw, mh)) if (image.width as u32 > mw || image.height as u32 > mh) && mw > 0 && mh > 0 => {
let factor = (image.width as f64 / mw as f64).max(image.height as f64 / mh as f64);
- let f = if factor >= 2.0 { Filter::Box } else { Filter::Mitchell };
+ // (Lanczos at 2:1 and more as well: a box average there halves the contrast of
+ // detail near the output's resolution limit and aliases what is finer)
+ let f = if factor >= 2.0 { Filter::Lanczos3 } else { Filter::Mitchell };
let img = fit(&image, mw as usize, mh as usize, f);
let alpha = alpha.map(|a| fit(&a, mw as usize, mh as usize, f));
(img, alpha)
@@ -218,10 +233,7 @@ fn linearize(raw: &Raw, ch: usize, trc: &[Trc; 3]) -> Rgb32f {
par_rows(&mut img.data, w, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let i = (y * w + x) * ch;
- let s = |c: usize| {
- let val = v[i + c];
- if val.is_finite() { val } else { 0.0 }
- };
+ let s = |c: usize| normalize_float(v[i + c]);
*o = if gray {
let l = if lin { s(0) } else { trc[1].to_linear(s(0)) };
[l, l, l]
diff --git a/crates/codecs/src/jxl.rs b/crates/codecs/src/jxl.rs
index 26e144aed..adf59c0aa 100644
--- a/crates/codecs/src/jxl.rs
+++ b/crates/codecs/src/jxl.rs
@@ -8,7 +8,7 @@ use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
use crate::space::{NamedSpace, SourceSpace, SpaceOrigin, Trc};
use crate::{DecodeOptions, Decoded, Error, Format, Result};
use jxl_oxide::color::{ColourSpace, EnumColourEncoding, Primaries, RenderingIntent, TransferFunction, WhitePoint};
-use jxl_oxide::{AuxBoxData, JxlImage};
+use jxl_oxide::{AllocTracker, AuxBoxData, InitializeResult, JxlImage};
const F: Format = Format::Jxl;
@@ -16,10 +16,60 @@ fn err(e: impl std::fmt::Display) -> Error {
Error::Malformed(F, e.to_string())
}
+/// Tracked allocation budget per allowed pixel for frames, extra channels and modular trees.
+/// This bounds allocations covered by `AllocTracker`, not every allocation made by the decoder (#1517).
+const TRACKED_BYTES_PER_PIXEL: u64 = 4 * 4 * 8;
+
+/// The allocation budget never drops below this, so small images keep the decoder's fixed
+/// overhead (group buffers, entropy tables) whatever `max_pixels` is.
+const MIN_TRACKED_BYTES: u64 = 64 << 20;
+
+/// Bytes handed to the decoder at a time (the size `JxlImageBuilder::read` reads in).
+const INPUT_CHUNK: usize = 4096;
+
+/// Initializes from bounded input, checks dimensions before rendering, then loads remaining frames
+/// within a tracked allocation budget.
+fn open(bytes: &[u8], opts: &DecodeOptions) -> Result {
+ let budget = opts.max_pixels.saturating_mul(TRACKED_BYTES_PER_PIXEL).max(MIN_TRACKED_BYTES);
+ let tracker = AllocTracker::with_limit(usize::try_from(budget).unwrap_or(usize::MAX));
+ let mut uninit = JxlImage::builder().alloc_tracker(tracker).build_uninit();
+ // Initialization can load already-buffered frame data, so small feeds limit work before
+ // dimension checking.
+ let mut rest = bytes;
+ let mut window = INPUT_CHUNK;
+ let mut img = loop {
+ let consumed = uninit.feed_bytes(rest.get(..window).unwrap_or(rest)).map_err(err)?;
+ rest = rest.get(consumed..).unwrap_or_default();
+ match uninit.try_init().map_err(err)? {
+ InitializeResult::Initialized(img) => break img,
+ InitializeResult::NeedMoreData(more) => uninit = more,
+ }
+ if consumed == 0 {
+ // the parser wants more than the window holds at once
+ if window >= rest.len() {
+ return Err(err("truncated JPEG XL header"));
+ }
+ window = window.saturating_mul(2);
+ }
+ };
+ check_size(F, img.width() as u64, img.height() as u64, opts)?;
+ // Match `JxlImageBuilder::read`: bounded feeds until the final frame, not merely the first
+ // keyframe. `feed_bytes` copies codestream data into an untracked Vec, including any tail
+ // after the last frame; feeding the remaining file at once bypasses the allocation budget.
+ while !img.is_loading_done() && !rest.is_empty() {
+ let consumed = img.feed_bytes(rest.get(..INPUT_CHUNK).unwrap_or(rest)).map_err(err)?;
+ if consumed == 0 {
+ break;
+ }
+ rest = rest.get(consumed..).unwrap_or_default();
+ }
+ img.finalize().map_err(err)?;
+ Ok(img)
+}
+
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
- let mut img = JxlImage::builder().read(std::io::Cursor::new(bytes)).map_err(err)?;
+ let mut img = open(bytes, opts)?;
let (w, h) = (img.width(), img.height());
- check_size(F, w as u64, h as u64, opts)?;
let header = img.image_header();
let bits = header.metadata.bit_depth.bits_per_sample() as u8;
let float = matches!(header.metadata.bit_depth, jxl_oxide::image::BitDepth::FloatSample { .. });
diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs
index d14b1812f..a20a47d14 100644
--- a/crates/codecs/src/lib.rs
+++ b/crates/codecs/src/lib.rs
@@ -338,3 +338,29 @@ pub fn decode_thumbnail_with(bytes: &[u8], opts: &ThumbnailOptions) -> Result Option {
+ (width as u64).checked_mul(height as u64).and_then(|n| n.checked_mul(ch as u64)).filter(|&n| n > 0)
+}
+
+#[cfg(test)]
+mod checked_arithmetic_tests {
+ use super::*;
+
+ #[test]
+ fn checked_sample_buf_len_wrap_inputs_rejected() {
+ assert_eq!(checked_sample_buf_len(usize::MAX, usize::MAX, 4), None);
+ assert_eq!(checked_sample_buf_len(0, 1, 3), None);
+ }
+
+ #[test]
+ fn checked_sample_buf_len_normal_accepted() {
+ assert_eq!(checked_sample_buf_len(100, 1, 3), Some(300));
+ assert_eq!(checked_sample_buf_len(1, 1, 1), Some(1));
+ // 300 Mpx RGBA is within the default pixel limit, so it must not be refused by sample count.
+ assert_eq!(checked_sample_buf_len(20_000, 15_000, 4), Some(1_200_000_000));
+ }
+}
diff --git a/crates/codecs/src/tiff_codec.rs b/crates/codecs/src/tiff_codec.rs
index b077c214a..1bbfa8e6c 100644
--- a/crates/codecs/src/tiff_codec.rs
+++ b/crates/codecs/src/tiff_codec.rs
@@ -95,6 +95,11 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
fn limits(opts: &DecodeOptions) -> Limits {
let mut limits = Limits::default();
limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize;
+ // A strip or tile whose declared byte count exceeds this is refused: the default 128 MiB refuses a
+ // single-strip uncompressed 16-bit export of ~22 MP (#1983). Raised to the decoded image's cap
+ // (32 bytes per allowed pixel), never lowered: a strip or tile can outgrow the pixels it shows
+ // (JPEG headers, padding to the tile size).
+ limits.intermediate_buffer_size = limits.intermediate_buffer_size.max(limits.decoding_buffer_size);
limits.ifd_value_size = 32 << 20;
limits
}
diff --git a/crates/codecs/tests/float_tiff.rs b/crates/codecs/tests/float_tiff.rs
new file mode 100644
index 000000000..fe1cd2411
--- /dev/null
+++ b/crates/codecs/tests/float_tiff.rs
@@ -0,0 +1,128 @@
+//! Non-finite samples in procedural 32-bit float TIFFs (issue #1952, item 6).
+
+use lightcraft_codecs::{icc, *};
+
+fn tiff(samples: &[f32], channels: u8, profile: Option<&[u8]>) -> Vec {
+ encode_tiff(
+ &EncodeImage::new((samples.len() / channels as usize) as u32, 1, channels, Samples::F32(samples)),
+ TiffCompression::None,
+ &EncodeMeta { icc: profile, ..Default::default() },
+ )
+ .unwrap()
+}
+
+#[test]
+fn float_tiff_nonfinite_rgb_clips_without_losing_finite_hdr() {
+ let samples =
+ [[f32::INFINITY; 3], [f32::NAN; 3], [f32::NEG_INFINITY; 3], [-0.25; 3], [0.25, 0.5, 0.75], [2.0, 4.0, 8.0], [f32::INFINITY, 0.5, f32::NAN]];
+ let bytes = tiff(samples.as_flattened(), 3, None);
+ let decoded = decode(&bytes, DecodeOptions::default()).unwrap();
+ assert!(decoded.float);
+ assert_eq!(decoded.bit_depth, 32);
+ assert_eq!(decoded.image.data, [[1.0; 3], [0.0; 3], [0.0; 3], [-0.25; 3], [0.25, 0.5, 0.75], [2.0, 4.0, 8.0], [1.0, 0.5, 0.0]]);
+ let display = decoded.to_srgb8();
+ assert_eq!(display.data[0], [255; 4]);
+ for pixel in &display.data[1..4] {
+ assert_eq!(*pixel, [0, 0, 0, 255]);
+ }
+ assert_eq!(display.data[6], [255, 188, 0, 255]);
+ assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite()));
+
+ // A box-filtered pair must average clipped white with its ordinary neighbor.
+ let pair = tiff(&[f32::INFINITY, f32::INFINITY, f32::INFINITY, 0.25, 0.25, 0.25], 3, None);
+ let small = decode(&pair, DecodeOptions::fit(1, 1)).unwrap();
+ assert_eq!(small.image.data, [[0.625; 3]]);
+}
+
+#[test]
+fn float_tiff_nonfinite_gray_and_icc_curves_match_clipped_samples() {
+ let samples = [f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.25, 1.0, 2.0, -0.25];
+ let clipped = [1.0, 0.0, 0.0, 0.25, 1.0, 2.0, -0.25];
+ let profiles = [
+ None,
+ Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Linear)),
+ Some(icc::write_named(NamedSpace::Srgb)),
+ Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Gamma(2.0))),
+ Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Table(vec![0, 16384, 65535]))),
+ Some(moxcms::ColorProfile::new_gray_with_gamma(2.0).encode().unwrap()),
+ Some(b"invalid ICC profile".to_vec()),
+ ];
+ for channels in [1, 3] {
+ let samples: Vec<_> = samples.iter().flat_map(|v| std::iter::repeat_n(*v, channels as usize)).collect();
+ let clipped: Vec<_> = clipped.iter().flat_map(|v| std::iter::repeat_n(*v, channels as usize)).collect();
+ for profile in &profiles {
+ let profile = profile.as_deref();
+ let decoded = decode(&tiff(&samples, channels, profile), DecodeOptions::default()).unwrap();
+ let reference = decode(&tiff(&clipped, channels, profile), DecodeOptions::default()).unwrap();
+ assert_eq!(decoded.grayscale, channels == 1);
+ assert_eq!(decoded.image, reference.image, "channels={channels}, space={:?}", decoded.space);
+ assert_eq!(decoded.to_srgb8(), reference.to_srgb8());
+ assert!(decoded.image.data[5].iter().all(|v| *v > 1.0), "finite HDR survives the curve");
+ assert!(decoded.image.data[6].iter().all(|v| *v < 0.0), "finite negative samples survive the curve");
+ }
+ }
+}
+
+#[test]
+fn float_tiff_nonfinite_alpha_is_finite_and_bounded() {
+ let samples = [
+ [f32::INFINITY, f32::INFINITY, f32::INFINITY, f32::INFINITY],
+ [f32::NAN, f32::NEG_INFINITY, 0.5, f32::NAN],
+ [0.25, 0.5, 2.0, f32::NEG_INFINITY],
+ [2.0, 3.0, 4.0, 0.5],
+ ];
+ let decoded = decode(&tiff(samples.as_flattened(), 4, None), DecodeOptions::default()).unwrap();
+ assert_eq!(decoded.alpha.as_ref().unwrap().data, [1.0, 0.0, 0.0, 0.5]);
+ assert_eq!(decoded.image.data, [[1.0; 3], [0.0, 0.0, 0.5], [0.25, 0.5, 2.0], [2.0, 3.0, 4.0]]);
+ assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite()));
+
+ // Associated alpha takes the same path before unpremultiplication.
+ let mut cursor = std::io::Cursor::new(Vec::new());
+ {
+ use tiff::encoder::{TiffEncoder, colortype};
+ let mut encoder = TiffEncoder::new(&mut cursor).unwrap();
+ let mut image = encoder.new_image::(2, 1).unwrap();
+ image.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[1u16][..]).unwrap();
+ image.write_data(&[f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.5, 1.0, 2.0, 3.0, 0.5]).unwrap();
+ }
+ let decoded = decode(&cursor.into_inner(), DecodeOptions::default()).unwrap();
+ assert_eq!(decoded.alpha.as_ref().unwrap().data, [0.5, 0.5]);
+ assert_eq!(decoded.image.data, [[2.0, 0.0, 0.0], [2.0, 4.0, 6.0]]);
+}
+
+fn lut_profile() -> Vec {
+ use moxcms::{ColorProfile, LutMultidimensionalType, LutWarehouse, Matrix3d, ToneReprCurve, Vector3d, Xyzd};
+ let mut profile = ColorProfile::new_srgb();
+ profile.cicp = None;
+ profile.red_colorant = Xyzd::default();
+ profile.green_colorant = Xyzd::default();
+ profile.blue_colorant = Xyzd::default();
+ profile.red_trc = None;
+ profile.green_trc = None;
+ profile.blue_trc = None;
+ // A procedural mAB profile without a CLUT: linear RGB to D50 XYZ, then identity curves.
+ profile.lut_a_to_b_colorimetric = Some(LutWarehouse::Multidimensional(LutMultidimensionalType {
+ num_input_channels: 3,
+ num_output_channels: 3,
+ grid_points: [0; 16],
+ clut: None,
+ a_curves: vec![],
+ m_curves: vec![ToneReprCurve::Parametric(vec![1.0]); 3],
+ matrix: Matrix3d { v: NamedSpace::Srgb.to_xyz_d50().0.map(|r| r.map(|v| v * 32768.0 / 65535.0)) },
+ b_curves: vec![ToneReprCurve::Parametric(vec![1.0]); 3],
+ bias: Vector3d::default(),
+ }));
+ profile.encode().unwrap()
+}
+
+#[test]
+fn float_tiff_nonfinite_cms_samples_match_clipped_samples() {
+ let profile = lut_profile();
+ let samples = [f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.25, 0.5, 0.75];
+ let clipped = [1.0, 0.0, 0.0, 0.25, 0.5, 0.75];
+ let decoded = decode(&tiff(&samples, 3, Some(&profile)), DecodeOptions::default()).unwrap();
+ let reference = decode(&tiff(&clipped, 3, Some(&profile)), DecodeOptions::default()).unwrap();
+ assert_eq!(decoded.space.origin, SpaceOrigin::IccCms);
+ assert_eq!(decoded.image, reference.image);
+ assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite()));
+}
diff --git a/crates/codecs/tests/jxl_streaming.rs b/crates/codecs/tests/jxl_streaming.rs
new file mode 100644
index 000000000..298034656
--- /dev/null
+++ b/crates/codecs/tests/jxl_streaming.rs
@@ -0,0 +1,98 @@
+#![cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
+
+use lightcraft_codecs::exif::minimal_exif;
+use lightcraft_codecs::*;
+use lightcraft_raster::Rgba8;
+
+fn codestream(bytes: &[u8]) -> Vec {
+ if bytes.starts_with(b"\xff\x0a") {
+ return bytes.to_vec();
+ }
+ let mut raw = Vec::new();
+ let mut at = 12;
+ while at + 8 <= bytes.len() {
+ let len = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
+ let end = if len == 0 { bytes.len() } else { at + len };
+ match &bytes[at + 4..at + 8] {
+ b"jxlc" => raw.extend_from_slice(&bytes[at + 8..end]),
+ b"jxlp" => raw.extend_from_slice(&bytes[at + 12..end]),
+ _ => {}
+ }
+ at = end;
+ }
+ assert!(raw.starts_with(b"\xff\x0a"));
+ raw
+}
+
+fn box_bytes(out: &mut Vec, kind: &[u8; 4], data: &[u8]) {
+ out.extend_from_slice(&u32::try_from(data.len() + 8).unwrap().to_be_bytes());
+ out.extend_from_slice(kind);
+ out.extend_from_slice(data);
+}
+
+#[test]
+fn metadata_in_final_feed_matches_builder() {
+ let pixels = Rgba8::from_fn(8, 8, |x, y| [(x * 20) as u8, (y * 20) as u8, 80, 255]);
+ let encoded = encode_jxl(&EncodeImage::rgba8(&pixels), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
+ let raw = codestream(&encoded);
+ let exif = minimal_exif(1);
+ let mut exif_box = vec![0; 4]; // TIFF header offset, relative to the end of this field
+ exif_box.extend_from_slice(&exif);
+ // Split the codestream across two feeds, with Exif and XMP following its last frame.
+ let mut bytes = b"\0\0\0\x0cJXL \x0d\x0a\x87\x0a".to_vec();
+ box_bytes(&mut bytes, b"ftyp", b"jxl \0\0\0\0jxl ");
+ let split = raw.len() / 2;
+ let mut part = 0u32.to_be_bytes().to_vec();
+ part.extend_from_slice(&raw[..split]);
+ box_bytes(&mut bytes, b"jxlp", &part);
+ let padding = vec![0; 4096 - bytes.len() - 8];
+ box_bytes(&mut bytes, b"free", &padding);
+ let mut part = 0x8000_0001u32.to_be_bytes().to_vec();
+ part.extend_from_slice(&raw[split..]);
+ box_bytes(&mut bytes, b"jxlp", &part);
+ box_bytes(&mut bytes, b"Exif", &exif_box);
+ box_bytes(&mut bytes, b"xml ", b" ");
+ assert!(bytes.len() < 8192, "metadata must fit in the completing input chunk");
+ let old = jxl_oxide::JxlImage::builder().read(bytes.as_slice()).unwrap();
+ let old_exif = match old.aux_boxes().first_exif().unwrap() {
+ jxl_oxide::AuxBoxData::Data(ex) => ex.payload().to_vec(),
+ _ => panic!("builder must preserve Exif in its final feed"),
+ };
+ let old_xml = match old.aux_boxes().first_xml() {
+ jxl_oxide::AuxBoxData::Data(xml) => xml.to_vec(),
+ _ => panic!("builder must preserve XMP in its final feed"),
+ };
+ let decoded = decode(&bytes, DecodeOptions::default()).unwrap();
+ assert_eq!(decoded.exif.as_deref(), Some(old_exif.as_slice()));
+ assert_eq!(decoded.xmp.as_deref(), Some(std::str::from_utf8(&old_xml).unwrap()));
+ assert_eq!(decoded.to_srgb8(), pixels);
+}
+
+#[test]
+fn progressive_truncation_matches_builder() {
+ let pixels = Rgba8::from_fn(96, 64, |x, y| {
+ let mut seed = (y * 96 + x + 1) as u32;
+ let mut rgb = [0; 4];
+ for channel in &mut rgb[..3] {
+ seed ^= seed << 13;
+ seed ^= seed >> 17;
+ seed ^= seed << 5;
+ *channel = seed as u8;
+ }
+ rgb[3] = 255;
+ rgb
+ });
+ for lossless in [false, true] {
+ let encoded =
+ encode_jxl(&EncodeImage::rgba8(&pixels), &JxlOptions { lossless, progressive: true, ..Default::default() }, &EncodeMeta::default())
+ .unwrap();
+ let raw = codestream(&encoded);
+ assert!(raw.len() > 4096, "fixture must exercise post-header feeds");
+ for cut in [0, 1, 2, 12, 4095, 4096, 4097, raw.len() / 2, raw.len() - 1, raw.len()] {
+ let bytes = &raw[..cut];
+ // `finalize` permits partial images: rendering determines whether frame 0 is usable.
+ let old_ok = jxl_oxide::JxlImage::builder().read(bytes).is_ok_and(|image| image.render_frame(0).is_ok());
+ assert_eq!(decode(bytes, DecodeOptions::default()).is_ok(), old_ok, "lossless={lossless}, cut={cut}/{}", raw.len());
+ }
+ }
+}
diff --git a/crates/codecs/tests/jxl_tail_heap.rs b/crates/codecs/tests/jxl_tail_heap.rs
new file mode 100644
index 000000000..f2200e862
--- /dev/null
+++ b/crates/codecs/tests/jxl_tail_heap.rs
@@ -0,0 +1,43 @@
+#![cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
+
+use lightcraft_codecs::*;
+use lightcraft_raster::Rgba8;
+
+#[global_allocator]
+static ALLOC: dhat::Alloc = dhat::Alloc;
+
+fn codestream(bytes: &[u8]) -> Vec {
+ if bytes.starts_with(b"\xff\x0a") {
+ return bytes.to_vec();
+ }
+ let mut raw = Vec::new();
+ let mut at = 12;
+ while at + 8 <= bytes.len() {
+ let len = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
+ let end = if len == 0 { bytes.len() } else { at + len };
+ match &bytes[at + 4..at + 8] {
+ b"jxlc" => raw.extend_from_slice(&bytes[at + 8..end]),
+ b"jxlp" => raw.extend_from_slice(&bytes[at + 12..end]),
+ _ => {}
+ }
+ at = end;
+ }
+ assert!(raw.starts_with(b"\xff\x0a"));
+ raw
+}
+
+#[test]
+fn tiny_jxl_tail_does_not_allocate_a_tail_sized_buffer() {
+ let image = Rgba8::from_fn(8, 8, |x, y| [(x * 20) as u8, (y * 20) as u8, 80, 255]);
+ let encoded = encode_jxl(&EncodeImage::rgba8(&image), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
+ let mut raw = codestream(&encoded);
+ let opts = DecodeOptions { max_pixels: 64, ..Default::default() };
+ decode(&raw, opts).unwrap();
+ raw.resize(raw.len() + (1 << 20), 0);
+ let _profiler = dhat::Profiler::builder().testing().build();
+ let decoded = decode(&raw, opts).unwrap();
+ assert_eq!((decoded.width, decoded.height), (8, 8));
+ let peak = dhat::HeapStats::get().max_bytes;
+ eprintln!("peak heap bytes: {peak}");
+ assert!(peak < (128 << 10), "tiny image used {peak} heap bytes");
+}
diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs
index af689ae5d..d802366c3 100644
--- a/crates/codecs/tests/roundtrip.rs
+++ b/crates/codecs/tests/roundtrip.rs
@@ -263,6 +263,90 @@ fn tiff_cmyk_and_orientation() {
assert!(red[0] > 0.99 && red[1] < 0.01 && red[2] < 0.01, "{red:?}");
}
+/// An uncompressed 16-bit RGB export in a single strip, the shape of the file in #1983 (3923 × 5877,
+/// 138 MB). Its one strip is larger than the `tiff` crate's default 128 MiB intermediate buffer, so
+/// the thumbnail (and the full decode) failed with "decoder limits exceeded".
+#[test]
+fn tiff_single_strip_larger_than_128_mib() {
+ use tiff::encoder::{TiffEncoder, colortype};
+ let (w, h) = (3923u32, 5877u32);
+ let mut cur = std::io::Cursor::new(Vec::new());
+ {
+ let mut enc = TiffEncoder::new(&mut cur).unwrap();
+ let mut im = enc.new_image::(w, h).unwrap();
+ im.rows_per_strip(h).unwrap();
+ im.write_data(&vec![0x8000u16; (w * h * 3) as usize]).unwrap();
+ }
+ let bytes = cur.into_inner();
+ assert!(bytes.len() > 128 << 20);
+ let t = decode_thumbnail(&bytes, 256).unwrap();
+ assert_eq!((t.source_width, t.source_height), (w, h));
+ let px = t.image.get(10, 10);
+ assert!(px[0].abs_diff(128) <= 2 && px[0] == px[1] && px[1] == px[2], "{px:?}");
+}
+
+/// A little-endian TIFF with one IFD of single-value `(tag, type, value)` entries, followed by `data`.
+fn ifd_tiff(entries: &[(u16, u16, u32)], data: &[u8]) -> Vec {
+ let mut b = b"II*\0".to_vec();
+ b.extend(8u32.to_le_bytes());
+ b.extend((entries.len() as u16).to_le_bytes());
+ for &(tag, ty, value) in entries {
+ b.extend(tag.to_le_bytes());
+ b.extend(ty.to_le_bytes());
+ b.extend(1u32.to_le_bytes());
+ b.extend(value.to_le_bytes());
+ }
+ b.extend(0u32.to_le_bytes());
+ b.extend_from_slice(data);
+ b
+}
+
+/// A strip or tile can be larger than the pixels it shows (a JPEG header, padding to the tile size), so
+/// a small `max_pixels` must not shrink the intermediate buffer below the `tiff` crate's 128 MiB
+/// default. Both files below were refused with "decoder limits exceeded" when the buffer was capped at
+/// 32 bytes per allowed pixel. Fixtures from AlexChambers' review of #1989.
+#[test]
+fn tiff_small_pixel_budget_reads_headers_and_padded_tiles() {
+ let opts = |max_pixels| DecodeOptions { max_size: None, max_pixels };
+
+ // 1 × 1 gray, one JPEG-compressed strip (~400 bytes for one pixel), with a one-pixel budget.
+ let mut jpeg = Vec::new();
+ jpeg_encoder::Encoder::new(&mut jpeg, 90).encode(&[128], 1, 1, jpeg_encoder::ColorType::Luma).unwrap();
+ let at = 8 + 2 + 10 * 12 + 4;
+ let entries = [
+ (256, 4, 1),
+ (257, 4, 1),
+ (258, 3, 8),
+ (259, 3, 7),
+ (262, 3, 1),
+ (273, 4, at),
+ (277, 3, 1),
+ (278, 4, 1),
+ (279, 4, jpeg.len() as u32),
+ (284, 3, 1),
+ ];
+ let d = decode(&ifd_tiff(&entries, &jpeg), opts(1)).unwrap();
+ assert_eq!((d.image.width, d.image.height), (1, 1));
+
+ // 16 × 16 gray stored in one 256 × 256 tile (64 KiB), with a budget of exactly 256 pixels.
+ let at = 8 + 2 + 11 * 12 + 4;
+ let entries = [
+ (256, 4, 16),
+ (257, 4, 16),
+ (258, 3, 8),
+ (259, 3, 1),
+ (262, 3, 1),
+ (277, 3, 1),
+ (284, 3, 1),
+ (322, 4, 256),
+ (323, 4, 256),
+ (324, 4, at),
+ (325, 4, 65536),
+ ];
+ let d = decode(&ifd_tiff(&entries, &[128; 65536]), opts(256)).unwrap();
+ assert_eq!((d.image.width, d.image.height), (16, 16));
+}
+
/// A one-strip, uncompressed, single-sample TIFF (little-endian) whose samples are `bits` wide and
/// already packed into `strip`, one padded row after another.
fn packed_tiff(width: u16, height: u16, bits: u16, photometric: u16, strip: &[u8]) -> Vec {
@@ -734,6 +818,21 @@ fn jxl_lossless_exact_with_metadata() {
assert!(max_diff(&d.image, &want.image) < 1e-5, "16-bit lossless differs by {}", max_diff(&d.image, &want.image));
}
+/// #1517: the pixel limit rejects an oversized image; selected truncations never panic.
+#[test]
+#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
+fn jxl_size_limit_and_truncation() {
+ let b = encode_jxl(&EncodeImage::rgba8(&gradient(97, 61)), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
+ let r = decode(&b, DecodeOptions { max_pixels: 97 * 61 - 1, ..Default::default() });
+ assert!(matches!(r, Err(Error::TooLarge(97, 61))), "{r:?}");
+ let d = decode(&b, DecodeOptions { max_pixels: 97 * 61, ..Default::default() }).unwrap();
+ assert_eq!((d.width, d.height), (97, 61));
+ for cut in [0, 1, 2, 5, 12, 40, b.len() / 2, b.len() - 1] {
+ let _ = decode(&b[..cut], DecodeOptions::default());
+ }
+ assert!(decode(&b[..12.min(b.len())], DecodeOptions::default()).is_err());
+}
+
#[test]
#[cfg(feature = "jxl")]
fn jxl_lossy_quality_and_effort() {
diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs
index 855c3d049..89fe93c9f 100644
--- a/crates/color/src/lib.rs
+++ b/crates/color/src/lib.rs
@@ -180,8 +180,8 @@ pub struct Rgb8(pub u8, pub u8, pub u8);
impl Rgb8 {
pub fn from_hex(s: &str) -> Option {
- let s = s.trim_start_matches('#');
- if s.len() != 6 {
+ let s = s.strip_prefix('#').unwrap_or(s);
+ if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let v = u32::from_str_radix(s, 16).ok()?;
@@ -196,6 +196,15 @@ impl Rgb8 {
mod tests {
use super::*;
+ #[test]
+ fn rgb8_from_hex_rejects_signs_and_extra_hashes() {
+ assert_eq!(Rgb8::from_hex("#0f0f0f"), Some(Rgb8(15, 15, 15)));
+ assert_eq!(Rgb8::from_hex("ff8000"), Some(Rgb8(255, 128, 0)));
+ for bad in ["+12345", "#+12345", "#+f+f+f", "-12345", "##123456", "#12345", ""] {
+ assert_eq!(Rgb8::from_hex(bad), None, "{bad}");
+ }
+ }
+
#[test]
fn srgb_matrix_matches_standard() {
let m = SRGB.to_xyz();
diff --git a/crates/denoise-core/src/run.rs b/crates/denoise-core/src/run.rs
index 987a859b9..4314ed8c3 100644
--- a/crates/denoise-core/src/run.rs
+++ b/crates/denoise-core/src/run.rs
@@ -14,7 +14,7 @@ use crate::manifest::Gain;
use crate::tiles::{starts, weight};
/// Most pixels one picture may have (guards the allocations that follow from its size).
-pub const MAX_PIXELS: usize = 100_000_000;
+pub const MAX_PIXELS: usize = 110_000_000;
/// A tile whose mean signal is below this (white = 1) is too dark to measure its scale from.
const MIN_SIGNAL: f32 = 0.003;
@@ -642,4 +642,19 @@ mod tests {
assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![0.0; 5]), &p, &Control::default()), Err(Error::Model(_))));
assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![f32::NAN; 3 * 64 * 64]), &p, &Control::default()), Err(Error::Model(_))));
}
+
+ #[test]
+ fn accepts_102_mp_inputs_and_rejects_inputs_above_the_limit() {
+ let layout = Layout { dx: 0, dy: 0 };
+ let model = Mock { tile: 32, scale: 1.0 };
+ let p = params(32, 8, Gain::None);
+ assert!(matches!(
+ denoise_bayer(&[], 11_648, 8_736, layout, &model, &p, &Control::default()),
+ Err(Error::Input(message)) if message == "the mosaic does not have width × height samples"
+ ));
+ assert!(matches!(
+ denoise_bayer(&[], MAX_PIXELS + 1, 1, layout, &model, &p, &Control::default()),
+ Err(Error::Input(message)) if message == "the picture has no size, or is too large"
+ ));
+ }
}
diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs
index f376b3c67..4631e5e49 100644
--- a/crates/develop/src/lib.rs
+++ b/crates/develop/src/lib.rs
@@ -41,7 +41,7 @@ impl DevelopSettings {
/// Merge a partial JSON object into these settings (fields not mentioned are kept). A white balance that
/// names no [`WbScale`] keeps the meaning it always had (`Legacy`), whatever scale these settings were on: a
/// partial from before the scale existed, or one that never said, must not become Kelvin by landing on
- /// Kelvin settings.
+ /// Kelvin settings. Out-of-range values are brought back into range ([`DevelopSettings::keep_in_range`]).
pub fn merged(&self, partial: &Value) -> Result {
let mut v = self.to_json();
presets::deep_merge(&mut v, partial);
@@ -52,9 +52,34 @@ impl DevelopSettings {
{
out.wb.scale = WbScale::Legacy;
}
+ out.keep_in_range(self);
Ok(out)
}
+ /// What crs XMP import does to the values it reads, for settings that arrive as JSON (presets, `develop.merge`,
+ /// `render --settings`; issue #1946): every slider clamped through its control spec, point curves in 0..1, and a
+ /// crop with no area left as it was in `before`. Unrotated, the crop is clipped to the photo first; a turned crop
+ /// may reach past 0..1 and is left to the crop tools, which know the photo's size.
+ fn keep_in_range(&mut self, before: &DevelopSettings) {
+ for c in controls::CONTROLS {
+ if let Some(v) = controls::get(self, c.id) {
+ controls::set(self, c.id, v);
+ }
+ }
+ let c = &mut self.curve;
+ for p in [&mut c.master, &mut c.red, &mut c.green, &mut c.blue].into_iter().flatten() {
+ *p = lightcraft_geom::Point::new(p.x.clamp(0.0, 1.0), p.y.clamp(0.0, 1.0));
+ }
+ let g = &mut self.crop.geometry;
+ if g.angle == 0.0 {
+ g.rect = g.rect.intersect(&lightcraft_geom::Rect::UNIT);
+ }
+ if g.rect.is_empty() {
+ let old = before.crop.geometry.rect;
+ g.rect = if old.is_empty() { lightcraft_geom::Rect::UNIT } else { old };
+ }
+ }
+
/// Stable 64-bit hash of the settings (FNV-1a over canonical JSON), for preview cache keys.
pub fn hash64(&self) -> u64 {
let s = serde_json::to_string(self).unwrap_or_default();
@@ -255,6 +280,30 @@ mod tests {
assert_eq!(m.light.exposure, 0.0);
}
+ #[test]
+ fn merged_settings_are_kept_in_range() {
+ // issue #1946: `develop.merge`, `render --settings` and presets stored whatever the JSON said
+ let mut base = DevelopSettings::default();
+ base.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.2, 0.8, 0.9);
+ let m = base
+ .merged(&json!({"light": {"exposure": 1e308}, "vignette": {"amount": -1e308}, "geometry": {"scale": 0}, "optics": {"distortion": 1e308}}))
+ .unwrap();
+ assert_eq!((m.light.exposure, m.vignette.amount, m.geometry.scale, m.optics.distortion), (5.0, -100.0, 50.0, 100.0));
+ // point curves stay in 0..1, as an imported XMP curve does
+ let m = base.merged(&json!({"curve": {"master": [{"x": -3, "y": 0}, {"x": 1, "y": 1e308}]}})).unwrap();
+ assert_eq!(m.curve.master, vec![lightcraft_geom::Point::new(0.0, 0.0), lightcraft_geom::Point::new(1.0, 1.0)]);
+ // a crop with no area is left out, as XMP import leaves out one whose edges are inverted
+ for rect in
+ [json!({"x0": 0.9, "x1": 0.1, "y0": 0, "y1": 1}), json!({"x0": 1e308, "x1": -1e308, "y0": 0, "y1": 1}), json!({"x0": 0.5, "x1": 0.5})]
+ {
+ let m = base.merged(&json!({"crop": {"geometry": {"rect": rect}}})).unwrap();
+ assert_eq!(m.crop.geometry.rect, base.crop.geometry.rect, "{rect}");
+ }
+ // unrotated, the crop stays inside the photo
+ let m = base.merged(&json!({"crop": {"geometry": {"rect": {"x0": -1e308, "y0": 0.25, "x1": 1e308, "y1": 0.75}}}})).unwrap();
+ assert_eq!(m.crop.geometry.rect, lightcraft_geom::Rect::new(0.0, 0.25, 1.0, 0.75));
+ }
+
#[test]
fn unedited_and_reset() {
let mut s = DevelopSettings::default();
diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs
index 85f27d321..b3edc801c 100644
--- a/crates/develop/src/presets.rs
+++ b/crates/develop/src/presets.rs
@@ -184,17 +184,11 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value {
}
/// Apply a partial settings object with an amount (1.0 = 100 %). Out-of-range values are clamped
-/// through the control specs.
+/// through the control specs, as every merge does ([`DevelopSettings::merged`]).
pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings {
let patch = if (amount - 1.0).abs() < 1e-9 { Cow::Borrowed(partial) } else { Cow::Owned(scale_patch(&s.to_json(), partial, amount)) };
// (a white balance without a scale keeps its old meaning: `DevelopSettings::merged`)
- let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone());
- for c in crate::controls::CONTROLS {
- if let Some(v) = crate::controls::get(&out, c.id) {
- crate::controls::set(&mut out, c.id, v);
- }
- }
- out
+ s.merged(&patch).unwrap_or_else(|_| s.clone())
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index ed20cd605..9caa8cdb0 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -9,6 +9,8 @@ description = "LightCraft engine: session, command registry, history, render job
workspace = true
[dependencies]
+# Paths stored in a Lightroom catalog are followed only to local files or the catalog's share.
+craft-docpath = { workspace = true }
lightcraft-geom = { workspace = true }
lightcraft-color = { workspace = true }
lightcraft-raster = { workspace = true }
@@ -44,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/build.rs b/crates/engine/build.rs
index 056c7adc8..7f5aa7777 100644
--- a/crates/engine/build.rs
+++ b/crates/engine/build.rs
@@ -3,7 +3,7 @@
//! Recipe from craft-fonts' docs/integration.md; craft-fonts is never a Cargo dependency, and this
//! script only reads a local directory (no network).
//!
-//! wasm32 embeds BIZ UDPGothic Regular (~4.5 MB) and Noto Sans Thai (~214 KiB), to keep the
+//! wasm32 embeds BIZ UDPGothic Regular (~4.5 MB) and Noto Sans Thai (~214 KiB) and Noto Sans Georgian, to keep the
//! `.wasm` within the web size budget (Cloudflare serves at most 25 MiB per file); the export
//! watermark falls back to it there. Native builds embed every font.
use std::fmt::Write as _;
@@ -78,7 +78,7 @@ fn craft_fonts(dir: &Path, required: bool) -> Result {
has_japanese_ui |= (*family, *style) == ("BIZ UDPGothic", "Regular");
has_chinese_ui |= (*family, *style) == ("Noto Sans CJK SC", "Regular") && scripts.split(',').any(|script| script.trim() == "Hans");
has_thai_ui |= (*family, *style) == ("Noto Sans Thai", "Regular") && scripts.split(',').any(|script| script.trim() == "Thai");
- if wasm && !matches!((*family, *style), ("BIZ UDPGothic" | "Noto Sans Thai", "Regular")) {
+ if wasm && !matches!((*family, *style), ("BIZ UDPGothic" | "Noto Sans Thai" | "Noto Sans Georgian", "Regular")) {
continue;
}
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs
index 614c58c60..d074567a7 100644
--- a/crates/engine/src/camera_preview.rs
+++ b/crates/engine/src/camera_preview.rs
@@ -34,7 +34,12 @@ use lightcraft_raw::{
/// 7: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected.
/// 8: the bundled NIKON Z 6_2 profile.
/// 9: the bundled NIKON Z 8 and NIKON D5300 profiles.
-pub const LOOK_VERSION: u32 = 9;
+/// 10: DNG tone fit for profiles with a look table and for 1024 px (Lightroom) previews.
+/// 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 {
@@ -67,6 +72,7 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool {
| RawFormat::Pef
| RawFormat::Srw
| RawFormat::Orf
+ | RawFormat::Mrw
| RawFormat::Erf
)
}
@@ -88,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);
@@ -107,22 +124,22 @@ fn identity_tone_curve(curve: Option<&lightcraft_raw::profile::ToneCurve>) -> bo
}
/// A DNG with its own colour model whose tone is not (yet) defined by the file: a matrix, no
-/// non-identity `ProfileToneCurve`, no `LookTable` (a look table is the file's own rendering).
+/// non-identity `ProfileToneCurve` and no `ProfileGainTableMap`. A `LookTable` does not define the tone: an
+/// Adobe Standard profile carries one without a tone curve and is rendered with Lightroom's default curve on
+/// top (Lightroom Classic's lossy DNGs rendered flat and dull without a fit), and the fit applies the tables
+/// to its proxy, so it fits only the tone around them.
pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool {
let p = &raw.color.profile;
- raw.format == RawFormat::Dng
- && !t.matrix_is_fallback
- && identity_tone_curve(p.tone_curve.as_ref())
- && p.look_table.is_none()
- && p.gain_table_map.is_none()
+ raw.format == RawFormat::Dng && !t.matrix_is_fallback && identity_tone_curve(p.tone_curve.as_ref()) && p.gain_table_map.is_none()
}
/// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white
-/// 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
@@ -424,9 +456,9 @@ const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000;
/// A DNG's tone and chroma curves fitted to its own camera JPEG ([`dng_tone_eligible`]); the file's colour model
/// (matrix, profile tables, white balance) stays as it is: the proxy goes through the file's tables and the given
-/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must be near
-/// the raw's size, show the same picture ([`structure_agrees`]), and the curve must not clip more highlights than
-/// the plain render ([`clips_more`]).
+/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must not be a
+/// thumbnail ([`MIN_PREVIEW_FRACTION`]), must show the same picture ([`structure_agrees`]), and the curve must not
+/// clip more highlights than the plain render ([`clips_more`]).
fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&lightcraft_develop::EmbeddedLens>) -> Option {
// Logged under LIGHTCRAFT_PROFILE so a corpus run can count the outcome per gate.
let refuse = |why: &str| {
@@ -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) {
@@ -500,8 +533,9 @@ fn correct_vignetting(sensor: &mut Rgb32f, lens: Option<&lightcraft_develop::Emb
}
/// The embedded preview's long edge must be at least this share of the raw's (and 384 px): a DNG whose preview is a
-/// small thumbnail is not fitted.
-const MIN_PREVIEW_FRACTION: f64 = 0.9;
+/// small thumbnail is not fitted. The fit itself runs on a proxy of at most 384 px, so the 1024 px preview
+/// Lightroom writes into its DNGs (15% of a 33 MP raw's long edge) is plenty.
+const MIN_PREVIEW_FRACTION: f64 = 0.1;
/// Lowest preview luminance and channel value a tone-only fit trains on (the other fits use 0.015 and 0.004): a dark
/// night scene has most of its pixels below 0.015, and a curve fitted without them overshoots its shadows.
@@ -927,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,
})
}
@@ -984,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;
@@ -1146,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
@@ -1176,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(
@@ -1200,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.
@@ -1227,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);
@@ -1341,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);
@@ -1369,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) {
@@ -1419,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) {
@@ -1429,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!(
@@ -1449,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;
}
@@ -1470,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);
@@ -1485,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 { "" }
@@ -1779,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
@@ -1806,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)
@@ -1834,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.
@@ -2557,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());
@@ -2578,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");
@@ -2600,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));
@@ -2630,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);
@@ -2643,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());
@@ -2733,6 +2976,7 @@ mod tests {
RawFormat::Pef,
RawFormat::Srw,
RawFormat::Orf,
+ RawFormat::Mrw,
RawFormat::Erf,
];
assert!(local.into_iter().all(file_local_look));
@@ -3503,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");
}
@@ -3590,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.
@@ -3635,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];
@@ -3739,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);
}
@@ -3894,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));
@@ -3958,8 +4311,29 @@ 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, no look table; and no other raw format.
+ /// identity, with or without a look table; and no other raw format.
#[test]
fn dng_tone_gates_follow_the_files_own_tags() {
use lightcraft_raw::profile::{HsvTable, ToneCurve};
@@ -3981,6 +4355,192 @@ mod tests {
assert!(curved.profile.tone_curve.is_some() && !eligible(RawFormat::Dng, curved), "the file's own tone curve is kept");
let mut looked = color;
looked.profile.look_table = HsvTable::from_tags(&[1, 2, 1], &[0.0, 1.0, 1.0, 0.0, 1.0, 1.0], 0);
- assert!(looked.profile.look_table.is_some() && !eligible(RawFormat::Dng, looked), "a look table is the file's own rendering");
+ assert!(looked.profile.look_table.is_some() && eligible(RawFormat::Dng, looked), "a look table defines colour, not tone");
+ }
+
+ /// The 1024 px preview Lightroom Classic writes into a 7008 px DNG is fitted; a 256 px thumbnail is not.
+ #[test]
+ fn dng_tone_fit_takes_lightroom_sized_previews() {
+ let floor = |raw_long: f64| (MIN_PREVIEW_FRACTION * raw_long).max(384.0);
+ assert!(1024.0 >= floor(7008.0));
+ assert!(256.0 < floor(7008.0));
+ assert!(1024.0 >= floor(9504.0), "a 61 MP raw too");
+ }
+
+ /// 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/convert.rs b/crates/engine/src/cmd/convert.rs
index eae955cce..0871e4352 100644
--- a/crates/engine/src/cmd/convert.rs
+++ b/crates/engine/src/cmd/convert.rs
@@ -313,30 +313,15 @@ pub(crate) fn reload_ops(
}
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
-/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
+/// dimensions and content hash, cached sources dropped. The app runs the same steps
+/// ([`crate::cmd::reload`]) with the files read on a worker thread. → {reloaded: [ids]}
fn reload(s: &mut Session, p: &Value) -> Result {
- let ids = s.targets(p)?;
- let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids
- .iter()
- .filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) {
- Some(Source::File { path }) => Some((*id, path)),
- _ => None,
- })
- .collect();
- let probed = crate::import::probe_paths(s, &paths.iter().map(|(_, p)| p.clone()).collect::>());
- let mut ops = Vec::new();
- let mut reloaded = Vec::new();
- for ((id, _), info) in paths.into_iter().zip(probed) {
- let Ok(info) = info else { continue };
- reload_ops(&s.catalog, id, &info, &mut ops, &mut reloaded);
- }
- for id in &reloaded {
- s.media.forget(*id);
- }
- if !ops.is_empty() {
- s.commit("Reload Changed Files", Op::Batch { ops })?;
- }
- Ok(json!({"reloaded": reloaded.iter().map(|i| i.0).collect::>()}))
+ use crate::cmd::reload as r;
+ let items = r::plan(s, p)?;
+ let probe = r::file_probe(s);
+ let results = r::probe(probe.as_ref(), &items, &std::sync::atomic::AtomicBool::new(false), &|_| {});
+ let reloaded = r::apply(s, items, results)?;
+ Ok(r::to_json(&reloaded))
}
/// Duplicate: copy the file next to itself (`-copy`, never overwriting) and add it with the
@@ -401,7 +386,7 @@ pub fn edit_specs() -> Vec {
"Reload from Disk",
["Photo"],
None,
- "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings) → {reloaded}",
+ "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings); in the app the files are read in the background (activity row `reload`, cancellable) and the reply is {background: true, count} unless `wait: true` → {reloaded}",
has_selection,
reload
),
diff --git a/crates/engine/src/cmd/cull.rs b/crates/engine/src/cmd/cull.rs
index 676519d29..142accd5a 100644
--- a/crates/engine/src/cmd/cull.rs
+++ b/crates/engine/src/cmd/cull.rs
@@ -16,11 +16,14 @@ const SAME: f32 = 0.93;
fn secs(iso: &str) -> Option {
let d = lightcraft_meta::DateTime::parse_iso(iso)?;
- Some(((d.year as i64 * 372 + d.month as i64 * 31 + d.day as i64) * 24 + d.hour as i64) * 3600 + d.minute as i64 * 60 + d.second as i64)
+ Some(d.unix_seconds())
}
fn analyze(s: &mut Session, p: &Value) -> Result {
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 e3d59c567..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};
@@ -77,13 +77,22 @@ fn aspect_tuple(c: &str, x: f64, y: f64) -> Result<(u32, u32)> {
Ok((a as u32, b as u32))
}
-fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>) {
+/// How a new aspect ratio is turned (issue #2253).
+#[derive(Clone, Copy, PartialEq)]
+enum Turn {
+ /// As the crop is now: a named ratio ("2x3", "original"…) lands landscape on a landscape crop
+ /// and portrait on a portrait one (after X), as in Lightroom. A square crop follows the image.
+ LikeCrop,
+ /// As given: a custom `[w, h]` taller than wide is a portrait crop.
+ AsGiven,
+}
+
+fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>, turn: Turn) {
+ let r = d.crop.geometry.rect_px(w, h);
+ let landscape = if (r.width() - r.height()).abs() > 1e-6 * r.width().max(r.height()) { r.width() > r.height() } else { w >= h };
+ let aspect = aspect.map(|(aw, ah)| if turn == Turn::LikeCrop && (aw >= ah) != landscape { (ah, aw) } else { (aw, ah) });
d.crop.aspect = aspect;
- let a = aspect.map(|(aw, ah)| {
- // orientation of the crop follows the image (landscape image → landscape crop)
- let (aw, ah) = (aw as f64, ah as f64);
- if (w >= h) == (aw >= ah) { aw / ah } else { ah / aw }
- });
+ let a = aspect.map(|(aw, ah)| aw as f64 / ah as f64);
let angle = d.crop.geometry.angle;
d.crop.geometry = crop_fit_angle(w, h, angle, a);
}
@@ -199,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)))
@@ -524,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) {
@@ -538,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(())
})
}),
@@ -628,11 +653,19 @@ pub fn specs() -> Vec {
"Crop Aspect",
[],
None,
- "{aspect: \"original\"|\"free\"|\"current\" (lock the crop's present shape)|\"toggle\" (lock ↔ free)|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h]}",
+ "{aspect: \"original\"|\"free\"|\"current\" (lock the crop's present shape)|\"toggle\" (lock ↔ free)|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h], orientation?: \"crop\"|\"given\"} — a named ratio takes the crop's present orientation (landscape, or portrait after X); [w,h] is used as given (taller than wide → portrait); `orientation` overrides either",
has_active,
|s, p| {
let id = active(s, "crop.aspect")?;
let (w, h) = crop_dims(s, id);
+ // checked first, whatever the aspect: a bad value is an error on every path
+ let turn = match p.get("orientation") {
+ Some(Value::String(o)) if o == "crop" => Turn::LikeCrop,
+ Some(Value::String(o)) if o == "given" => Turn::AsGiven,
+ Some(_) => return Err(bad("crop.aspect", "orientation is \"crop\" or \"given\"")),
+ None if matches!(p.get("aspect"), Some(Value::Array(_))) => Turn::AsGiven,
+ None => Turn::LikeCrop,
+ };
let locked = s.develop_of(id).is_some_and(|d| d.crop.aspect.is_some());
let lock_current = match p.get("aspect").and_then(Value::as_str) {
Some("current") => true,
@@ -667,7 +700,7 @@ pub fn specs() -> Vec {
if aspect.is_none() {
d.crop.aspect = None;
} else {
- set_aspect(d, w, h, aspect);
+ set_aspect(d, w, h, aspect, turn);
}
Ok(())
})
@@ -1103,11 +1136,8 @@ pub fn specs() -> Vec {
|s, p| {
let c = "history.restore";
let id = active(s, c)?;
- let i = f64_req(p, "index", c)?;
- if !(i.is_finite() && i >= 0.0) {
- return Err(bad(c, "no such step"));
- }
- go_to_history_step(s, c, id, i as usize)
+ let i = index_req(p, "index", c)?;
+ go_to_history_step(s, c, id, i)
}
),
cmd!("history.previous", "Previous History Step", [], None, "{} — steps the History cursor back one step", has_history_before, |s, _| {
diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs
index e93d04175..949c08f1b 100644
--- a/crates/engine/src/cmd/export.rs
+++ b/crates/engine/src/cmd/export.rs
@@ -17,6 +17,12 @@ impl Session {
/// `app.export` params with a named `preset` expanded: the preset's params, overridden by the
/// call's own (`preset` itself removed). Unknown preset names are an error.
pub fn export_params(&self, p: &Value) -> std::result::Result {
+ self.export_params_or(p, crate::export::ExportFormat::default())
+ }
+
+ /// [`Session::export_params`] for a caller that writes `fallback` to a `path` naming no format
+ /// (see [`crate::export::ExportOptions::export_format`]).
+ pub fn export_params_or(&self, p: &Value, fallback: crate::export::ExportFormat) -> std::result::Result {
let Some(name) = p.get("preset").and_then(Value::as_str) else { return Ok(p.clone()) };
let (preset, _) = self
.all_export_presets()
@@ -24,12 +30,17 @@ impl Session {
.find(|(x, _)| x.name.eq_ignore_ascii_case(name.trim()))
.ok_or_else(|| format!("unknown export preset `{name}` (see export.presets)"))?;
// a preset saved by another version may hold keys this one doesn't know: not the caller's typo
- let mut out = crate::export::ExportOptions::known_keys_only(&preset.params);
+ let mut out = crate::export::ExportOptions::known_keys(&preset.params);
if let (Some(o), Some(own)) = (out.as_object_mut(), p.as_object()) {
for (k, v) in own.iter().filter(|(k, _)| *k != "preset") {
o.insert(k.clone(), v.clone());
}
}
+ // the preset's depth is judged against the format written, known only now (`path`); a
+ // depth the call passes itself is the caller's, and validated as such
+ if !p.get("bitDepth").is_some_and(|d| !d.is_null()) {
+ crate::export::ExportOptions::drop_unfit_depth(&mut out, fallback);
+ }
Ok(out)
}
}
@@ -50,13 +61,25 @@ fn save(s: &mut Session, p: &Value) -> Result {
let mut params = match p.get("params") {
Some(v @ Value::Object(_)) => v.clone(),
Some(_) => return Err(bad(ID, "`params` must be an object of app.export params")),
- None => s.last_export.clone().ok_or_else(|| bad(ID, "no `params` and no previous export to save"))?,
+ // a stored setting, not the caller's typo: a depth it holds that its `format` lacks was
+ // written before #1952 and is dropped, as Export with Previous does; without `format`
+ // it is judged when the preset exports
+ None => {
+ let last = s.last_export.as_ref().ok_or_else(|| bad(ID, "no `params` and no previous export to save"))?;
+ let mut last = crate::export::ExportOptions::known_keys(last);
+ if last.get("format").is_some_and(Value::is_string) {
+ crate::export::ExportOptions::drop_unfit_depth(&mut last, Default::default());
+ }
+ last
+ }
};
if let Some(o) = params.as_object_mut() {
o.retain(|k, _| !NOT_SETTINGS.contains(&k.as_str()));
}
- // a preset that app.export would refuse is refused here, where the typo is on screen
- crate::export::ExportOptions::validate(ID, ¶ms)?;
+ // a preset that app.export would refuse is refused here, where the typo is on screen; without
+ // `format`, its `bitDepth` is judged when it exports, against the format written then
+ let format = params.get("format").and_then(Value::as_str).map(|_| crate::export::ExportOptions::export_format(¶ms, Default::default()));
+ crate::export::ExportOptions::validate_as(ID, ¶ms, format)?;
let preset = ExportPreset { name: name.to_string(), params };
match s.export_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) {
Some(x) => *x = preset,
diff --git a/crates/engine/src/cmd/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/geocode.rs b/crates/engine/src/cmd/geocode.rs
index 085183d58..c9ffb6b7e 100644
--- a/crates/engine/src/cmd/geocode.rs
+++ b/crates/engine/src/cmd/geocode.rs
@@ -115,6 +115,12 @@ fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place,
fn reverse(s: &mut Session, p: &Value) -> Result {
const C: &str = "metadata.reverseGeocode";
let overwrite = bool_or(p, "overwrite", false);
+ // the place list holds towns of 15 000 people or more: a photo in a smaller one finds the nearest
+ // larger town, which may be another municipality; past this many metres City is left empty (#2242)
+ let max_distance = match p.get("maxDistance") {
+ None | Some(Value::Null) => None,
+ Some(v) => Some(v.as_f64().filter(|m| m.is_finite() && *m > 0.0).ok_or_else(|| bad(C, "`maxDistance` must be a positive number of metres"))?),
+ };
let ids = s.targets(p)?;
let todo: Vec<(PhotoId, LatLon)> = ids
.iter()
@@ -140,14 +146,24 @@ fn reverse(s: &mut Session, p: &Value) -> Result {
}
t.progress(n as u64, todo.len() as u64);
}
- let Some(place) = names.reverse(*at) else { continue };
+ let Some(mut place) = names.reverse(*at) else { continue };
+ // too far from the town found: State and Country still hold, City (and below) doesn't
+ let too_far = max_distance.is_some_and(|m| place.distance > m);
+ let nearest_city = too_far.then(|| std::mem::take(&mut place.city));
+ if too_far {
+ place.sublocation.clear();
+ }
let Some(ph) = s.catalog.photo(*id) else { continue };
let mut meta = ph.meta.clone();
fill(&mut meta, &place, overwrite);
if meta != ph.meta {
ops.push(Op::SetMeta { id: *id, meta: Box::new(meta) });
}
- found.push(json!({"id": id.0, "place": place}));
+ let mut entry = json!({"id": id.0, "place": place});
+ if let Some(city) = nearest_city {
+ entry["nearestCity"] = json!(city);
+ }
+ found.push(entry);
}
drop(task);
let changed = ops.len();
@@ -271,7 +287,7 @@ pub fn specs() -> Vec {
"Fill Location from GPS",
["Photo"],
None,
- "{ids?, overwrite?: bool (also replace place fields that already have text; default false: only empty ones are filled), dryRun?, dataset?: folder} → {looked, found, changed, photos: [{id, place: {sublocation, city, state, country, iso, distance}}], attribution} — fills City / State / Country (and Sublocation when known) from GPS with the offline GeoNames place names (download them first); one undo step",
+ "{ids?, overwrite?: bool (also replace place fields that already have text; default false: only empty ones are filled), dryRun?, dataset?: folder, maxDistance?: metres (past this distance from the town found, City is left empty and the town is given as nearestCity)} → {looked, found, changed, photos: [{id, place: {sublocation, city, state, country, iso, distance}, nearestCity?}], attribution} — fills City / State / Country (and Sublocation when known) from GPS with the offline GeoNames place names (download them first); one undo step. The place names list towns of 15 000 people or more (GeoNames cities15000): a photo taken in a smaller town gets the nearest larger one, up to 50 km away (`distance` says how far); `maxDistance` keeps such a town out of City",
has_selection,
reverse
),
@@ -343,6 +359,36 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
+ /// Issue #2242: the place names hold only towns of 15 000 people or more, so a photo in a
+ /// village finds the nearest larger town. `maxDistance` leaves City empty past that distance
+ /// (State and Country still fill) and says which town it was.
+ #[test]
+ fn max_distance_leaves_a_far_town_out_of_city() {
+ let mut s = Session::with_demo();
+ let ids = s.visible_cloned();
+ let dir = dataset("maxdist");
+ // about 5 km north of Paris (like Portofino and Rapallo in the issue), and right in Paris
+ set_gps(&mut s, ids[0], Some((48.90, 2.35)), "");
+ set_gps(&mut s, ids[1], Some((48.855, 2.35)), "");
+ let r = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0, ids[1].0], "dataset": dir, "maxDistance": 2000})).unwrap();
+ let m0 = s.catalog.photo(ids[0]).unwrap().meta.clone();
+ assert_eq!((m0.city.as_str(), m0.state.as_str(), m0.country.as_str()), ("", "Île-de-France", "France"));
+ assert_eq!(s.catalog.photo(ids[1]).unwrap().meta.city, "Paris", "close enough");
+ let far = r["photos"].as_array().unwrap().iter().find(|p| p["id"] == json!(ids[0].0)).unwrap();
+ assert_eq!((far["place"]["city"].as_str(), far["nearestCity"].as_str()), (Some(""), Some("Paris")), "{far}");
+ assert!(far["place"]["distance"].as_f64().unwrap() > 2000.0);
+ // without it, the nearest town fills City as before
+ s.undo_step().unwrap();
+ s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": dir})).unwrap();
+ assert_eq!(s.catalog.photo(ids[0]).unwrap().meta.city, "Paris");
+ // hostile values are refused, not taken as "no limit"
+ for bad in [json!(-1), json!(0), json!("far"), json!(true)] {
+ let e = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": dir, "maxDistance": bad})).unwrap_err();
+ assert!(e.to_string().contains("maxDistance"), "{e}");
+ }
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
#[test]
fn explains_missing_data_and_gps() {
let mut s = Session::with_demo();
diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs
index 7b0e1e562..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"));
@@ -1488,7 +1488,7 @@ pub fn specs() -> Vec {
always, |_, p| {
let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?;
let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?;
- Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p|&p.path).collect::>(),"warnings":data.warnings}))
+ Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !p.original_is_file()).map(|p|&p.path).collect::>(),"warnings":data.warnings,"skipped":data.skipped}))
}),
cmd!(
"library.importLightroom",
@@ -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/library/tests_relative_ratings.rs b/crates/engine/src/cmd/library/tests_relative_ratings.rs
index 1a1b8b23c..8af63cd9b 100644
--- a/crates/engine/src/cmd/library/tests_relative_ratings.rs
+++ b/crates/engine/src/cmd/library/tests_relative_ratings.rs
@@ -77,6 +77,7 @@ fn relative_ratings_and_history_use_auto_write() {
std::fs::create_dir_all(&dir).unwrap();
// Metadata-only fixtures: no import, image decoding or model/service dependency.
for id in 1..=4 {
+ std::fs::write(dir.join(format!("{id}.jpg")), b"original").unwrap();
s.catalog
.apply(Op::SetFile {
id: PhotoId(id),
diff --git a/crates/engine/src/cmd/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/manage.rs b/crates/engine/src/cmd/manage.rs
index 66ddfe9fb..339b20b90 100644
--- a/crates/engine/src/cmd/manage.rs
+++ b/crates/engine/src/cmd/manage.rs
@@ -9,8 +9,13 @@ use crate::Result;
fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec, String, usize)> {
let template = str_param(p, "template").ok_or_else(|| bad(c, "missing `template`"))?.to_string();
- let start = p.get("start").and_then(Value::as_u64).unwrap_or(1) as usize;
- Ok((s.targets(p)?, template, start))
+ let start = p.get("start").and_then(Value::as_u64).unwrap_or(1);
+ let ids = s.targets(p)?;
+ // every number of the sequence `start..start + n` must fit
+ let last = start.checked_add((ids.len() as u64).saturating_sub(1));
+ let start = last.and_then(|l| usize::try_from(l).ok()).and_then(|_| usize::try_from(start).ok());
+ let start = start.ok_or_else(|| bad(c, "start out of range"))?;
+ Ok((ids, template, start))
}
/// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name).
diff --git a/crates/engine/src/cmd/mask_effects.rs b/crates/engine/src/cmd/mask_effects.rs
index 387909a6d..d791b4260 100644
--- a/crates/engine/src/cmd/mask_effects.rs
+++ b/crates/engine/src/cmd/mask_effects.rs
@@ -287,12 +287,7 @@ fn effect_index(s: &Session, name: &str, c: &str) -> Result {
/// The selected mask's adjustments on the active photo.
fn selected_mask(s: &Session, p: &Value, c: &str) -> Result {
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
- let mid = p
- .get("id")
- .and_then(Value::as_u64)
- .and_then(|v| u32::try_from(v).ok())
- .or(s.active_mask)
- .ok_or_else(|| bad(c, "no mask selected (give `id`)"))?;
+ let mid = super::masks::mask_id(p, s.active_mask, c)?;
let d = s.develop_of(id).unwrap_or_default();
d.masks.iter().find(|m| m.id == mid).cloned().ok_or_else(|| bad(c, format!("no mask {mid}")))
}
@@ -341,10 +336,12 @@ fn apply_effect(s: &mut Session, p: &Value) -> Result {
// the masks: `ids`, `all`, else `id` or the selected one
let ids: Vec = if p.get("all").and_then(Value::as_bool).unwrap_or(false) {
d.masks.iter().map(|m| m.id).collect()
- } else if let Some(a) = p.get("ids").and_then(Value::as_array) {
- a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).collect()
} else {
- vec![selected_mask(s, p, C)?.id]
+ match p.get("ids") {
+ None | Some(Value::Null) => vec![selected_mask(s, p, C)?.id],
+ Some(Value::Array(a)) => a.iter().map(|v| super::masks::parse_mask_id(v, C)).collect::>()?,
+ Some(_) => return Err(bad(C, "`ids` must be a list of mask IDs")),
+ }
};
if ids.is_empty() {
return Err(bad(C, "no masks to apply the effect to"));
diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs
index 0efb3b658..dc403df9b 100644
--- a/crates/engine/src/cmd/masks.rs
+++ b/crates/engine/src/cmd/masks.rs
@@ -199,8 +199,28 @@ fn connected_strokes(from: Point, last: &BrushStroke, to: BrushStroke) -> Vec , c: &str) -> Result {
- p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)"))
+pub(super) fn parse_mask_id(v: &Value, c: &str) -> Result {
+ v.as_u64().and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(c, "`id` must be a non-negative integer that fits in u32"))
+}
+
+/// Omitted or null targets keep the command's default; malformed explicit targets never do.
+fn optional_mask_id(p: &Value, c: &str) -> Result> {
+ p.get("id").filter(|v| !v.is_null()).map(|v| parse_mask_id(v, c)).transpose()
+}
+
+pub(super) fn mask_id(p: &Value, active: Option, c: &str) -> Result {
+ optional_mask_id(p, c)?.or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)"))
+}
+
+fn optional_index(p: &Value, key: &str, c: &str) -> Result> {
+ p.get(key)
+ .filter(|v| !v.is_null())
+ .map(|v| {
+ v.as_u64()
+ .and_then(|v| usize::try_from(v).ok())
+ .ok_or_else(|| bad(c, format!("`{key}` must be a non-negative integer that fits in usize")))
+ })
+ .transpose()
}
fn find(masks: &mut [Mask], id: u32, c: &str) -> Result {
@@ -307,7 +327,7 @@ pub fn specs() -> Vec {
let connect = bool_or(p, "connect", false);
let mut connected = false;
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
- let want = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask);
+ let want = optional_mask_id(p, "mask.brushStroke")?.or(s.active_mask);
let adjust = super::mask_effects::new_mask_adjust(s, "brush");
let mut out = masks_edit(s, "mask.brushStroke", "Brush", |masks, active| {
let i = match want.and_then(|m| masks.iter().position(|x| x.id == m)) {
@@ -363,7 +383,7 @@ pub fn specs() -> Vec {
cmd!("mask.update", "Update Mask Shape", [], None, "{id?, component?: index (0), shape: MaskShape JSON}", has_active, |s, p| {
let shape: MaskShape =
serde_json::from_value(p.get("shape").cloned().unwrap_or_default()).map_err(|e| bad("mask.update", e.to_string()))?;
- let comp = p.get("component").and_then(Value::as_u64).unwrap_or(0) as usize;
+ let comp = optional_index(p, "component", "mask.update")?.unwrap_or(0);
let mid = mask_id(p, s.active_mask, "mask.update")?;
masks_edit(s, "mask.update", "Edit Mask", |masks, _| {
let i = find(masks, mid, "mask.update")?;
@@ -381,6 +401,8 @@ pub fn specs() -> Vec {
has_active,
|s, p| {
let c = "mask.sampleColor";
+ let mid = mask_id(p, s.active_mask, c)?;
+ let comp = optional_index(p, "component", c)?;
let (x, y) = (super::f64_req(p, "x", c)?, super::f64_req(p, "y", c)?);
let add = p.get("add").and_then(Value::as_bool).unwrap_or(false);
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
@@ -390,8 +412,6 @@ pub fn specs() -> Vec {
let req = lightcraft_pipeline::RenderRequest::fit(384, 384);
let lab = lightcraft_pipeline::color_range_sample(&src, &info, &d, &req, lightcraft_geom::Point::new(x, y))
.ok_or_else(|| bad(c, "the point is outside the photo"))?;
- let mid = mask_id(p, s.active_mask, c)?;
- let comp = p.get("component").and_then(Value::as_u64).map(|v| v as usize);
let mut out = Vec::new();
masks_edit(s, c, "Color Range", |masks, _| {
let i = find(masks, mid, c)?;
@@ -482,8 +502,8 @@ pub fn specs() -> Vec {
let mid = mask_id(p, s.active_mask, c)?;
let d = s.develop_of(id).unwrap_or_default();
let m = d.masks.iter().find(|m| m.id == mid).ok_or_else(|| bad(c, format!("no mask {mid}")))?;
- let k = match p.get("component").and_then(Value::as_u64) {
- Some(k) => k as usize,
+ let k = match optional_index(p, "component", c)? {
+ Some(k) => k,
None => m
.components
.iter()
@@ -604,7 +624,7 @@ pub fn specs() -> Vec {
}
),
cmd!("mask.select", "Select Mask", [], None, "{id|null}", has_active, |s, p| {
- s.active_mask = p.get("id").and_then(Value::as_u64).map(|v| v as u32);
+ s.active_mask = optional_mask_id(p, "mask.select")?;
ok()
}),
cmd!("mask.delete", "Delete Mask", [], None, "{id?}", has_active, |s, p| {
@@ -684,7 +704,7 @@ pub fn specs() -> Vec {
|s, p| {
let c = "mask.component";
let mid = mask_id(p, s.active_mask, c)?;
- let k = p.get("component").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing component"))? as usize;
+ let k = optional_index(p, "component", c)?.ok_or_else(|| bad(c, "missing component"))?;
let action = str_param(p, "action").ok_or_else(|| bad(c, "missing action"))?.to_string();
let op: Option = match p.get("op") {
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(c, e.to_string()))?),
@@ -879,8 +899,8 @@ pub fn specs() -> Vec {
),
cmd!("spot.select", "Select Spot", [], None, "{index|null}", has_active, |s, p| {
let n = spots_len(s);
- s.active_spot = match p.get("index").and_then(Value::as_u64) {
- Some(i) if (i as usize) < n => Some(i as usize),
+ s.active_spot = match optional_index(p, "index", "spot.select")? {
+ Some(i) if i < n => Some(i),
Some(_) => return Err(bad("spot.select", "no such spot")),
None => None,
};
@@ -1040,7 +1060,7 @@ fn spots_len(s: &Session) -> usize {
/// `index` from the params, else the selected spot.
fn spot_index(s: &Session, p: &Value, c: &str) -> Result {
- let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_spot).ok_or_else(|| bad(c, "no spot selected (give `index`)"))?;
+ let i = optional_index(p, "index", c)?.or(s.active_spot).ok_or_else(|| bad(c, "no spot selected (give `index`)"))?;
if i >= spots_len(s) {
return Err(bad(c, "no such spot"));
}
diff --git a/crates/engine/src/cmd/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 4b86d4012..9b8dc3dbd 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -38,6 +38,7 @@ pub mod previews;
mod print;
mod query;
pub mod recheck;
+pub mod reload;
mod slideshow;
mod sync;
mod xmp;
@@ -192,18 +193,50 @@ 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>> {
+ match p.get("ids") {
+ None | Some(Value::Null) => Ok(None),
+ Some(Value::Array(a)) => a
+ .iter()
+ .map(|v| v.as_u64().map(lightcraft_catalog::PhotoId).ok_or_else(|| bad(cmd, "`ids` must be photo ids (whole numbers)")))
+ .collect::>>()
+ .map(Some),
+ Some(_) => Err(bad(cmd, "`ids` must be a list of photo ids")),
+ }
+}
+
+/// The photo `id` names: `None` when it is missing or null (the command then uses the active
+/// photo), else that id. An `id` that isn't a whole number is an error, never `None`: falling back
+/// would act on the active photo instead of the one the call meant (`{"id": "5"}`).
+pub(crate) fn photo_id(p: &Value, cmd: &str) -> Result> {
+ match p.get("id") {
+ None | Some(Value::Null) => Ok(None),
+ Some(v) => v.as_u64().map(|n| Some(lightcraft_catalog::PhotoId(n))).ok_or_else(|| bad(cmd, "`id` must be a photo id (a whole number)")),
+ }
+}
+
pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result {
p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`")))
}
-/// A non-negative integer parameter: `index: -1` must be an error, not element 0 (`-1.0 as usize`
-/// saturates to 0), just as `history.restore` already refuses a negative step index.
+/// A non-negative whole number that fits `usize`. Keep integer JSON exact; accept integral
+/// floating-point values for compatibility, without truncating or saturating their conversion.
pub(crate) fn index_req(p: &Value, key: &str, cmd: &str) -> Result {
+ let invalid = || bad(cmd, format!("`{key}` must be a non-negative integer"));
+ if let Some(i) = p.get(key).and_then(Value::as_u64) {
+ return usize::try_from(i).map_err(|_| invalid());
+ }
let i = f64_req(p, key, cmd)?;
- if !(i.is_finite() && i >= 0.0) {
- return Err(bad(cmd, format!("`{key}` must be a non-negative integer")));
+ // The exclusive bound is exactly representable, unlike usize::MAX on 64-bit targets.
+ let upper_bound = 2.0_f64.powi(usize::BITS as i32);
+ if !(i.is_finite() && i >= 0.0 && i.fract() == 0.0 && i < upper_bound) {
+ return Err(invalid());
}
Ok(i as usize)
}
@@ -223,3 +256,29 @@ pub(crate) fn point(p: &Value, key: &str) -> Option {
let a = p.get(key)?.as_array()?;
Some(lightcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::json;
+
+ use super::index_req;
+
+ #[test]
+ fn command_indices_preserve_integer_precision_and_check_usize_bounds() {
+ for i in [0, 1, usize::MAX] {
+ assert_eq!(index_req(&json!({"index": i}), "index", "test").unwrap(), i);
+ }
+ #[cfg(target_pointer_width = "64")]
+ {
+ let i = 9_007_199_254_740_993usize;
+ assert_eq!(index_req(&json!({"index": i}), "index", "test").unwrap(), i, "integer JSON must not round through f64");
+ }
+ let upper_bound = 2.0_f64.powi(usize::BITS as i32);
+ assert!(index_req(&json!({"index": upper_bound}), "index", "test").is_err(), "2^usize::BITS must not saturate");
+ #[cfg(target_pointer_width = "32")]
+ {
+ assert!(index_req(&json!({"index": u64::from(u32::MAX) + 1}), "index", "test").is_err());
+ assert_eq!(index_req(&json!({"index": f64::from(u32::MAX)}), "index", "test").unwrap(), usize::MAX);
+ }
+ }
+}
diff --git a/crates/engine/src/cmd/organize.rs b/crates/engine/src/cmd/organize.rs
index 24cc2061c..dbd0aa842 100644
--- a/crates/engine/src/cmd/organize.rs
+++ b/crates/engine/src/cmd/organize.rs
@@ -161,17 +161,17 @@ pub fn specs() -> Vec {
Ok(json!({"changed": commit_ops(s, "Remove from Stack", ops)?}))
}),
cmd!("stack.setTop", "Set as Top of Stack", ["Photo", "Stack"], Some("Shift+S"), "{id?} (default: the active photo)", in_stack, |s, p| {
- let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.setTop", "no photo"))?;
+ let id = super::photo_id(p, "stack.setTop")?.or(s.active()).ok_or_else(|| bad("stack.setTop", "no photo"))?;
let op = s.catalog.set_top_ops(id);
Ok(json!({"changed": commit_ops(s, "Set Top of Stack", op.into_iter().collect())?}))
}),
cmd!("stack.moveUp", "Move Up in Stack", ["Photo", "Stack"], None, "{id?} — one place towards the top", in_stack, |s, p| {
- let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.moveUp", "no photo"))?;
+ let id = super::photo_id(p, "stack.moveUp")?.or(s.active()).ok_or_else(|| bad("stack.moveUp", "no photo"))?;
let op = s.catalog.move_in_stack_ops(id, -1);
Ok(json!({"changed": commit_ops(s, "Move in Stack", op.into_iter().collect())?}))
}),
cmd!("stack.moveDown", "Move Down in Stack", ["Photo", "Stack"], None, "{id?} — one place away from the top", in_stack, |s, p| {
- let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.moveDown", "no photo"))?;
+ let id = super::photo_id(p, "stack.moveDown")?.or(s.active()).ok_or_else(|| bad("stack.moveDown", "no photo"))?;
let op = s.catalog.move_in_stack_ops(id, 1);
Ok(json!({"changed": commit_ops(s, "Move in Stack", op.into_iter().collect())?}))
}),
@@ -183,17 +183,32 @@ pub fn specs() -> Vec {
"{id?} — this photo and the ones after it become their own stack",
in_stack,
|s, p| {
- let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.split", "no photo"))?;
+ let id = super::photo_id(p, "stack.split")?.or(s.active()).ok_or_else(|| bad("stack.split", "no photo"))?;
let op = s.catalog.split_stack_ops(id).ok_or_else(|| bad("stack.split", "pick a photo below the top of a stack"))?;
Ok(json!({"changed": commit_ops(s, "Split Stack", vec![op])?}))
}
),
- cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| {
- let stacks = stacks_of(s, &s.targets(p)?);
- let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false);
- let collapsed = bool_or(p, "collapsed", !first);
- set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" })
- }),
+ // `ids` names the stacks' photos without selecting them (a click on a stack badge)
+ CommandSpec {
+ explicit_targets: true,
+ ..cmd!(
+ "stack.toggle",
+ "Expand/Collapse Stack",
+ ["Photo", "Stack"],
+ Some("S"),
+ "{ids?, collapsed?: bool} — the stacks of the named photos (else of the selection)",
+ in_stack,
+ |s, p| {
+ let stacks = stacks_of(s, &s.targets(p)?);
+ if stacks.is_empty() {
+ return Err(bad("stack.toggle", "none of the photos is in a stack"));
+ }
+ let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false);
+ let collapsed = bool_or(p, "collapsed", !first);
+ set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" })
+ }
+ )
+ },
cmd!("stack.expandAll", "Expand All Stacks", ["View", "Stacks"], None, "{}", has_stacks, |s, _| {
let all: Vec = s.catalog.stacks().map(|st| st.id).collect();
set_collapsed(s, all, false, "Expand All Stacks")
diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs
index b63ccc2b4..bef292f8f 100644
--- a/crates/engine/src/cmd/previews.rs
+++ b/crates/engine/src/cmd/previews.rs
@@ -176,12 +176,18 @@ fn build(s: &mut Session, p: &Value) -> Result {
}
/// One photo's smart preview to build or discard: (photo, its file in the smart previews folder,
-/// how to load the original at preview size, whether the photo takes the per-file camera look —
-/// `camera_preview::file_local_look`, [`lightcraft_catalog::Photo::fitted_look`]: only those
-/// proxies go stale when the fit changes).
+/// how to load the original at preview size, whether the photo can take a preview-fitted look:
+/// vendor raws and DNGs (whose tone can come from their preview); only those proxies go stale when
+/// the fit changes; an older encoding makes any proxy stale).
#[cfg(not(target_arch = "wasm32"))]
type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option, bool);
+/// DNG tone fits also go stale, although DNGs keep their own matrix and Kelvin white-balance scale.
+#[cfg(not(target_arch = "wasm32"))]
+fn preview_fitted_look(photo: &lightcraft_catalog::Photo) -> bool {
+ photo.fitted_look() || (photo.develops_raw() && photo.format.eq_ignore_ascii_case("DNG"))
+}
+
/// Held while one proxy is checked and (re)written, so Build Smart Previews and the opening look
/// refresh ([`refresh_stale_smart_previews`]), on their own threads, never write the same file at once.
#[cfg(not(target_arch = "wasm32"))]
@@ -205,15 +211,18 @@ struct SmartCounts {
failed: Vec,
}
-/// `source` if it reads the photo's original, else `None`: a cached source decoded from a smart
-/// preview (it has no decoder facts), a proxy standing in for an offline original, and the proxy
-/// fallback of a file source are not the original.
+/// `source` if it reads the photo's original, else `None`. A source decoded from a smart preview
+/// (it now carries the proxy's stored facts), a proxy standing in for an offline original, and the
+/// proxy fallback of a file source are not the original: refreshing a stale proxy from one of them
+/// would stamp its older look as current.
#[cfg(not(target_arch = "wasm32"))]
fn original_only(source: crate::media::SourceRef) -> Option {
use crate::media::SourceRef;
match source {
- SourceRef::File { path, max_edge, loader, denoise, .. } => Some(SourceRef::File { path, max_edge, loader, fallback: None, denoise }),
- SourceRef::Loaded(d) if d.info.is_some() => Some(SourceRef::Loaded(d)),
+ SourceRef::File { path, max_edge, loader, denoise, .. } => {
+ Some(SourceRef::File { path, max_edge, loader, fallback: None, denoise, reduced: None })
+ }
+ SourceRef::Loaded(d) if d.info.is_some() && !d.from_proxy && !d.reduced => Some(SourceRef::Loaded(d)),
_ => None,
}
}
@@ -252,10 +261,11 @@ fn smart_run(
}
} else {
// a complete, current proxy is kept; a missing or damaged one (cut short by a crash or a
- // full drive) is (re)built, and so is one written by an older camera-look fit (see
- // `camera_preview::LOOK_VERSION`) when its original can be read
+ // full drive) is (re)built, and so is one written by an older encoding or camera-look
+ // fit (see `smart::ENCODE_VERSION` and `camera_preview::LOOK_VERSION`) when its
+ // original can be read
let damaged = path.exists();
- let stale = look && damaged && crate::smart::is_stale(&path);
+ let stale = damaged && crate::smart::is_stale_for(&path, look);
if damaged && !stale && crate::smart::is_valid(&path) {
n.built += 1;
} else {
@@ -268,7 +278,10 @@ fn smart_run(
let r = source
.ok_or_else(|| "nothing to build from".to_string())
.and_then(|s| s.load_source())
- .and_then(|src| crate::smart::encode(&src.image, src.info_or(Default::default()).camera_tone.as_ref()))
+ .and_then(|src| {
+ let info = src.info_or(Default::default());
+ crate::smart::encode(&src.image, Some(&info))
+ })
.and_then(|b| {
let parent = path.parent().ok_or_else(|| format!("{}: no parent folder", path.display()))?;
// Only create the leaf shard: do not recreate a missing external drive.
@@ -333,7 +346,7 @@ fn smart(s: &mut Session, p: &Value) -> Result {
}
let path = crate::smart::sharded_file_path(&dir, &ph);
let source = (!discard).then(|| s.media.source_ref(&ph, crate::media::SourceLevel::Preview));
- jobs.push((id, path, source, ph.fitted_look()));
+ jobs.push((id, path, source, preview_fitted_look(&ph)));
}
let custom = s.smart_previews_dir.is_some();
if background {
@@ -360,14 +373,14 @@ fn smart(s: &mut Session, p: &Value) -> Result {
)
}
-/// Bring smart previews written by an older camera-look fit up to date, once per fit version: when
-/// the smart previews folder has not been checked at this `LOOK_VERSION` (its marker,
-/// [`crate::smart::look_checked`]), a worker thread (the UI never waits on the drives) looks at the
-/// proxies of the raws that take the per-file camera look, the only ones the fit reaches (JPEG,
-/// PNG… proxies are never rebuilt), and rebuilds the stale ones from their originals. One whose
-/// original can't be read (offline) is kept as it is, still marked stale in its header: Build
-/// Smart Previews rebuilds it once the original is back. Only existing proxies are touched: none
-/// is built, and a missing folder is never created. Returns the thread, if started.
+/// Bring smart previews up to date, once per fit-and-encoding version: when the smart previews
+/// folder has not been checked at this build's versions (its marker, [`crate::smart::look_checked`]),
+/// a worker thread (the UI never waits on the drives) looks at the proxies of the photos on disk
+/// and rebuilds the stale ones from their originals — one whose stored pixels are an older encoding
+/// (every photo), or, for a raw that takes the per-file camera look or a DNG, one written by an older fit.
+/// One whose original can't be read (offline) is kept as it is, still marked stale in its header:
+/// Build Smart Previews rebuilds it once the original is back. Only existing proxies are touched:
+/// none is built, and a missing folder is never created. Returns the thread, if started.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option