Merge remote-tracking branch 'upstream/main' into feat/nikon-z6ii-camera-profile

# Conflicts:
#	README.md
#	README.zh-hans.md
#	crates/engine/src/camera_preview.rs
#	docs/camera-preview-colour.md
#	docs/parity-checklist.md
#	docs/whats-new.md
This commit is contained in:
Michael Anderson
2026-10-14 23:26:01 -05:00
363 changed files with 48382 additions and 2364 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
+28
View File
@@ -0,0 +1,28 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
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
+1
View File
@@ -21,6 +21,7 @@ concurrency:
jobs:
lint:
timeout-minutes: 15
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
+14 -6
View File
@@ -37,6 +37,7 @@ env:
jobs:
version:
timeout-minutes: 10
name: version
runs-on: ubuntu-latest
# No `environment: release` here or on the other jobs that sign nothing: no release secrets.
@@ -65,6 +66,7 @@ jobs:
echo "### LightCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
timeout-minutes: 90
name: macOS universal
needs: version
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
@@ -78,7 +80,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
@@ -114,6 +116,7 @@ jobs:
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
timeout-minutes: 90
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
@@ -136,7 +139,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
@@ -173,6 +176,7 @@ jobs:
if-no-files-found: error
linux:
timeout-minutes: 90
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
@@ -194,7 +198,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately
path: craft-fonts
- name: Install build and packaging tools
run: |
@@ -237,6 +241,7 @@ jobs:
if-no-files-found: error
flatpak:
timeout-minutes: 90
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
@@ -274,6 +279,7 @@ jobs:
if-no-files-found: error
linux-riscv64:
timeout-minutes: 90
name: Linux riscv64
needs: version
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
@@ -291,7 +297,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
@@ -362,7 +368,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately
path: craft-fonts
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
@@ -398,6 +404,7 @@ jobs:
if-no-files-found: error
web:
timeout-minutes: 90
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
@@ -411,7 +418,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
@@ -441,6 +448,7 @@ jobs:
if-no-files-found: error
release:
timeout-minutes: 10
name: Draft GitHub Release
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
runs-on: ubuntu-latest
+2 -2
View File
@@ -131,8 +131,8 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
→ `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` +
`nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the
desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not
in CI: it may lag behind the workspace; see README → Quick start.
desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained; CI only
evaluates it (`.github/workflows/nix.yml`), so the build may lag behind the workspace; see README → Quick start.
## Contributor credits (About window)
+2 -2
View File
@@ -139,5 +139,5 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
→ `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` +
`nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the
desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not
in CI: it may lag behind the workspace; see README → Quick start.
desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained; CI only
evaluates it (`.github/workflows/nix.yml`), so the build may lag behind the workspace; see README → Quick start.
Generated
+11
View File
@@ -3239,6 +3239,7 @@ dependencies = [
"lightcraft-raw",
"lightcraft-scenes",
"lightcraft-segment",
"lightcraft-slideshow",
"lightcraft-tiff",
"log",
"miniz_oxide 0.8.9",
@@ -3460,6 +3461,15 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "lightcraft-slideshow"
version = "0.8.0"
dependencies = [
"lightcraft-raster",
"serde",
"serde_json",
]
[[package]]
name = "lightcraft-sysmem"
version = "0.8.0"
@@ -3493,6 +3503,7 @@ dependencies = [
"lightcraft-pipeline",
"lightcraft-preview",
"lightcraft-raster",
"lightcraft-slideshow",
"log",
"serde",
"serde_json",
+1
View File
@@ -39,6 +39,7 @@ lightcraft-fetch = { path = "crates/fetch" }
lightcraft-heif = { path = "crates/heif" }
lightcraft-segment = { path = "crates/segment" }
lightcraft-geocode = { path = "crates/geocode" }
lightcraft-slideshow = { path = "crates/slideshow" }
# foundation
thiserror = "2"
+4 -3
View File
@@ -51,9 +51,10 @@ no darktable, RawSpeed, LibRaw or other GPL decoder/runtime code is included.
Builds made with craft-fonts (the optional CRAFT_FONTS_DIR build input; all official releases)
also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md
(the Japanese fonts BIZ UDPGothic, BIZ UDMincho and Shippori Mincho; the web build embeds only BIZ
UDPGothic Regular), under the SIL Open Font License 1.1. Release packages include each font's
licence as OFL-<family>.txt.
(BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic and Noto Sans
Thai), under the SIL Open Font License 1.1. The web build embeds BIZ UDPGothic Regular and Noto
Sans Thai Regular, and loads Noto Sans CJK SC separately. Release packages include each font's
licence as OFL-<family>.txt; Windows installers include their texts in FONT-LICENCES.txt.
Place names for reverse geocoding (Photo > Fill Location from GPS) are not part of LightCraft and
are never distributed with it. On the user's request (Photo > Download Place Names) the app
+3 -3
View File
@@ -307,11 +307,11 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
- **By feature count we're at ~81%** of Lightroom (core features 98%), tracked row by row in
[docs/parity-checklist.md](docs/parity-checklist.md).
- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~49% for a typical working photographer and ~64% for
- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~51% for a typical working photographer and ~65% for
casual use.** It's great for JPEG/DNG and most Sony / Panasonic /
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 ILCE-7CR, ILCE-7M4, X-H2S, X-T4 and Nikon Z 6II profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **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 and Nikon Z 6II 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;
- **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);
@@ -405,7 +405,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) or **Settings → General →
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Čeština) 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
+1 -1
View File
@@ -304,7 +304,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(
- **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
较老佳能的 raw 都很出色。
- **最大的缺口:**
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S、X-T4 和尼康 Z 6II 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4 和尼康 Z 6II 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
- **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md);
- **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法;
- **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。**
+38 -10
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Leica Q3 verified, 236 models; EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (RAW: 82% of Adobe's models decode, 36% verified) · **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
@@ -15,8 +15,8 @@ from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are
| **Feature breadth** (checklist) | **81.1%** of 517 rows (P0 98.0%, P1 95.1%, P2 46.9%) | 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** | **~49%** (44–54%) | estimated |
| **Essentials user** | **~64%** (59–69%) | estimated |
| **Mainstream practitioner** | **~51%** (46–56%) | estimated |
| **Essentials user** | **~65%** (60–70%) | estimated |
| Remaining to **beta** | **~340–600 h** (Opus 5.5 agent hours, ~60% parallel) | estimated |
| Remaining to **full parity** | **~1,000–1,800 h** (~70% parallel) | estimated |
@@ -25,15 +25,15 @@ Each audience with its own hours (Opus 5.5 agent wall-clock hours to ~95%):
| Audience | Ready | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---:|---:|---|
| Full target (ready for real work) | **~61%** | **1,000–1,800 h** (~70% parallelizes) | raw colour, lens and camera data; AI models; Classic output modules; localization; plug-in ecosystem |
| Mainstream practitioner | **~49%** | **450–840 h** (~60% parallelizes) | camera colour and render fidelity; CR3 / ORF / HE NEF; lens database; subject / sky masks; Lightroom XMP exchange; stability |
| Essentials user | **~64%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send |
| Mainstream practitioner | **~51%** | **450–840 h** (~60% parallelizes) | camera colour and render fidelity; CR3 / ORF / HE NEF; lens database; subject / sky masks; Lightroom XMP exchange; stability |
| Essentials user | **~65%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send |
The gap between breadth and readiness is quality and coverage, not missing buttons: camera colour is
estimated rather than measured, CR3 decodes on four verified Canon bodies, compressed ORF and HE NEF
estimated rather than measured, CR3 decodes on 26 verified Canon bodies (rarer CRX variants still open as previews), compressed ORF and HE NEF
open as previews, rendering is tuned by eye, AI masks are heuristics, there is no lens database, and
tests run in CI on FreeBSD only. **Why alpha:** core workflows run end to end, but readiness is below
the ~75% beta bar (it is above the ~40% alpha bar) and Lightroom's main inputs, raw files, still have blocking gaps (Canon, ~30% of
raw users, decodes CR3 on four verified bodies). It passes the alpha gate: all six core workflows
the ~75% beta bar (it is above the ~40% alpha bar) and Lightroom's main inputs, raw files, still have blocking gaps (compressed ORF and HE NEF open as previews; Canon,
~30% of raw users, decodes CR3 on 26 verified bodies). It passes the alpha gate: all six core workflows
(import, cull, develop, save and reopen, batch looks, export) work end to end on macOS
([docs/roadmap.md](docs/roadmap.md#alpha-gate)). The three readiness numbers and their weights and discounts are in
[target-app-parity.md](docs/target-app-parity.md#ready-for-real-work-mainstream-practitioner-and-essentials-user).
@@ -45,11 +45,11 @@ Hours are calibrated on this repo's PR history; see
| Dimension | Ready | Breadth | Remaining | Doc |
|---|---:|---:|---:|---|
| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) |
| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
| ↳ RAW cameras, colour, lens profiles | ~60% | 82% of 1,446 Adobe models decode, 36% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
| UI/UX fidelity | ~70% | ~85% | 34–58 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 | ~52% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-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) |
| 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) |
@@ -114,8 +114,23 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
## Progress log
- 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.
- 2026-10-12: Mamiya MEF (ZD) decodes from sensor data and is imported; verified on a CC0 file. 241 models
verified, 35 unsupported; headline numbers unchanged.
- 2026-10-12: Epson ERF (R-D1, R-D1s, R-D1x) decodes from sensor data, white balance measured from each
file's preview; 3 bodies verified on CC0 files. 240 models verified; headline numbers unchanged.
- 2026-10-11: Leica Q3 verified by a recorded check of 38 private DNGs (all decode and develop at full
size; mean ΔE to the camera JPEG 6.3–13.9). 236 models verified; headline numbers unchanged.
- 2026-10-12: 22 more Canon CR3 bodies verified (EOS R, RP, R3, R5, R6, R6 Mark II / III, R7, R10, R50,
R50 V, 90D, 250D, 850D, M200, M50 Mark II, M6 Mark II, 1D X Mark III, PowerShot G5 X II, G7 X III, SX70,
V1): one CC0 file each pinned and checked against the camera JPEG; all 110 CC0 CR3s on raw.pixls.us
decode. The EOS R6 Mark III gets its as-shot white balance (`ColorData` 66). 259 models verified; RAW
decoding 77% → 83%; mainstream ~49% → ~51%, essentials ~64% → ~65%, full ~61%.
- 2026-10-12: 117 Nikon and Fujifilm bodies verified, one CC0 raw.pixls.us file each pinned and checked
against the camera JPEG (`crates/raw/tests/body_corpus.rs`); the sweep found COOLPIX 5700 / 8400 / 8800
decoding wrongly. 354 models verified, ~58% of photographers on a verified body; headline numbers unchanged.
- 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a
preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files
pinned, the QP maps of 33,665 private C-RAW files decode exactly ([cr3.md](docs/cr3.md)). 235 models
@@ -132,6 +147,8 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
/ localization parity); `docs/parity.md` renamed `docs/parity-checklist.md`. Landed today: Nikon
lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map JPEG / PQ AVIF / float TIFF export
(#646), Lightroom-like Auto (#628), activity stack.
- 2026-10-11: Sony ILCA-99M2 (α99 II) camera colour: bundled profile from 144 photos; on 30 withheld photos
mean ΔE vs the camera JPEG 5.11 → 4.87, 25 of them closer.
- 2026-10-09: Sony ILCE-7CR camera colour: bundled profile from 125 photos; on 26 withheld photos mean
ΔE vs the camera JPEG 4.07 → 3.29. HEIC in release builds (#609), Samsung uncompressed SRW,
contact-sheet PDF export, Ukrainian and French UI, release v0.5.0.
@@ -163,7 +180,16 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
| Date | Change | Summary |
|---|---|---|
| 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-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 | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); 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 |
| 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged |
| 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points |
| 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away |
@@ -173,3 +199,5 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
| 2026-10-08 | minor | *Where we stand* by dimension and by kind of user |
| 2026-10-02 | major | Parity estimate and effort table |
| 2026-09-30 | major | Created |
| 2026-10-14 | minor | 97 Canon CR2, Pentax and Ricoh models verified (334 models, 23%); headline numbers unchanged |
| 2026-10-14 | minor | RAW: 82% of Adobe's models decode, 36% verified (integration batch 36's CC0 per-model checks) |
+68 -21
View File
@@ -3,9 +3,9 @@
//! ```text
//! lightcraft-cli run [--demo | --library DIR | --connect [ADDR]] [--import PATH]… CMD [key=value…]…
//! lightcraft-cli mcp [--connect [ADDR]] [--demo] [--compact] [FILES/FOLDERS…]
//! lightcraft-cli render <in> -o <out> [--set control=value]… [--settings FILE.json] [--preset ID] [--size N] [--quality Q]
//! lightcraft-cli render <in> -o <out> [--set control=value]… [--settings FILE.json|FILE.lcpreset] [--preset ID] [--size N] [--quality Q]
//! lightcraft-cli snapshot [--library DIR | --demo] [--script FILE.jsonl] [-o OUT.png] [--size WxH] [--scale S] [FILES…]
//! lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES…
//! lightcraft-cli merge hdr|panorama|hdr-panorama|hdr-batch [OPTIONS] FILES…
//! lightcraft-cli synth-merge hdr|panorama -o DIR
//! lightcraft-cli denoise-serve [--model ID] [--models DIR] [--listen ADDR] [--token-env VAR] [--run-on auto|gpu|cpu]
//! lightcraft-cli commands [--json]
@@ -17,7 +17,7 @@
mod alloc_release;
use std::io::{BufReader, Write};
use std::io::Write;
use std::path::Path;
use std::process::ExitCode;
@@ -55,7 +55,8 @@ USAGE:
Develop one file and export it (.jpg, .png, .tif, .webp, .avif, .jxl, .psd or .dng by extension) with the
same encoder as the app's Export dialog. Options:
--set CONTROL=VALUE set a develop slider, repeatable (e.g. --set light.exposure=0.5)
--settings FILE merge a partial develop-settings JSON file
--settings FILE merge a partial develop-settings JSON file, or apply the one preset of a
.lcpreset (preset.export) or XMP preset
--preset ID apply a preset (see `commands`/presets.list)
--size N long edge in pixels (default: full size, cropped)
--quality Q JPEG/AVIF/JPEG XL quality 1..100 (default 92)
@@ -79,9 +80,10 @@ USAGE:
-o, --output OUT PNG path (a final screenshot is written here if the script took none)
--size WxH window size in points (default 1600x1000)
--scale S pixels per point (default 1)
lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES…
lightcraft-cli merge hdr|panorama|hdr-panorama|hdr-batch [OPTIONS] FILES…
Photo Merge: writes <first>-HDR.dng / -Pano.dng / -HDR-Pano.dng next to the first file and
prints the result as JSON. Options:
prints the result as JSON. hdr-batch merges each bracket set among the files (photos taken
less than a second apart at different exposures), e.g. a whole folder. Options:
--deghost none|low|medium|high --no-align --bracket N (HDR panorama)
--projection auto|spherical|cylindrical|perspective --boundary-warp 0..100
--auto-crop --fill-edges --no-auto-settings
@@ -364,8 +366,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
};
eprintln!("lightcraft-cli mcp: serving MCP on stdio ({})", backend.describe());
let mut server = Server::new(backend).with_command_tools(!compact);
let stdin = std::io::stdin();
server.serve(BufReader::new(stdin), std::io::stdout()).map_err(|e| e.to_string())
server.serve_stdio(std::io::stdout()).map_err(|e| e.to_string())
}
fn merge(args: &[String]) -> Result<(), String> {
@@ -373,7 +374,8 @@ fn merge(args: &[String]) -> Result<(), String> {
Some("hdr") => "merge.hdr",
Some("panorama" | "pano") => "merge.panorama",
Some("hdr-panorama") => "merge.hdrPanorama",
_ => return Err("merge: expected hdr, panorama or hdr-panorama".into()),
Some("hdr-batch") => "merge.hdrBatch",
_ => return Err("merge: expected hdr, panorama, hdr-panorama or hdr-batch".into()),
};
let mut p = serde_json::Map::new();
let mut files = Vec::new();
@@ -635,11 +637,41 @@ fn connect_port(addr: &str) -> &str {
addr.rsplit_once(':').map_or(addr, |(_, port)| port)
}
/// What `render --settings FILE` applies: a partial develop-settings object, or the one preset of
/// a preset file (`.lcpreset` from `preset.export`, XMP), applied as the app applies it. Merging a
/// `.lcpreset` as settings dropped its wrapper and applied nothing (#1730).
enum SettingsFile {
Partial(Value),
Preset(lightcraft_engine::develop::Preset),
}
fn read_settings(p: &str) -> Result<SettingsFile, String> {
use lightcraft_engine::presets::{LCPRESET_EXT, parse_preset_file};
let bytes = std::fs::read(p).map_err(|e| format!("{p}: {e}"))?;
let ext = Path::new(p).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
let json = serde_json::from_slice::<Value>(&bytes);
// a settings object never has a top-level `presets` or `settings` key; a preset file always does
let preset_file =
ext == LCPRESET_EXT || ext == "xmp" || json.as_ref().is_ok_and(|v| v.is_array() || v.get("presets").is_some() || v.get("settings").is_some());
if preset_file {
let mut list = parse_preset_file(p, &bytes).map_err(|e| format!("{p}: {e}"))?;
return match (list.pop(), list.len()) {
(Some(preset), 0) => Ok(SettingsFile::Preset(preset)),
(None, _) => Err(format!("{p}: no presets in this file")),
(Some(_), n) => Err(format!("{p}: {} presets in this file; --settings applies one, export it on its own", n + 1)),
};
}
match json.map_err(|e| format!("{p}: {e}"))? {
v @ Value::Object(_) => Ok(SettingsFile::Partial(v)),
_ => Err(format!("{p}: not a develop-settings object or a preset file")),
}
}
fn render(args: &[String]) -> Result<(), String> {
let mut input = None;
let mut output = None;
let mut values = serde_json::Map::new();
let mut settings: Option<Value> = None;
let mut settings = None;
let mut preset = None;
let mut size: Option<u64> = None;
let mut quality = 92u8;
@@ -654,11 +686,7 @@ fn render(args: &[String]) -> Result<(), String> {
let v: f64 = v.trim().parse().map_err(|_| format!("--set {k}: `{v}` is not a number"))?;
values.insert(k.trim().to_string(), json!(v));
}
"--settings" => {
let p = take_value(args, &mut i, "--settings")?;
let text = std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}"))?;
settings = Some(serde_json::from_str(&text).map_err(|e| format!("{p}: {e}"))?);
}
"--settings" => settings = Some(read_settings(take_value(args, &mut i, "--settings")?)?),
"--preset" => preset = Some(take_value(args, &mut i, "--preset")?.to_string()),
"--size" => size = Some(take_value(args, &mut i, "--size")?.parse().map_err(|_| "--size expects a number")?),
"--quality" => quality = take_value(args, &mut i, "--quality")?.parse().map_err(|_| "--quality expects 1..100")?,
@@ -684,8 +712,14 @@ fn render(args: &[String]) -> Result<(), String> {
if let Some(p) = preset {
run(&mut s, "preset.apply", json!({"id": p}))?;
}
if let Some(st) = settings {
run(&mut s, "develop.merge", json!({"settings": st}))?;
match settings {
Some(SettingsFile::Partial(st)) => run(&mut s, "develop.merge", json!({"settings": st}))?,
Some(SettingsFile::Preset(mut p)) => {
p.id = "cli.settings".into();
s.presets.push(p);
run(&mut s, "preset.apply", json!({"id": "cli.settings"}))?;
}
None => {}
}
if !values.is_empty() {
run(&mut s, "develop.set", json!({"values": values}))?;
@@ -763,12 +797,15 @@ fn snapshot(args: &[String]) -> Result<(), String> {
let t0 = Instant::now();
let mut session = match &library {
Some(dir) => {
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models();
let mut s =
Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates();
s.open_library(dir, false).map_err(|e| library_error(dir, e))?;
s
}
None if files.is_empty() => Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(),
None => Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(),
None if files.is_empty() => {
Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates()
}
None => Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates(),
};
// Apply compute policy before imports or the first scripted query can discover an adapter.
session.execute("app.gpu", &json!({"enabled": false})).map_err(|e| e.to_string())?;
@@ -832,7 +869,17 @@ fn snapshot(args: &[String]) -> Result<(), String> {
match next_path(&mut shots) {
Some(p) => {
wrote_output = true;
params["path"] = json!(p);
// `params` is untrusted script JSON: `params["path"] = …` panics on
// an array or a string. Only an object (or null, like an absent
// `params`) may take the path.
if params.is_null() {
params = json!({});
}
if let Some(o) = params.as_object_mut() {
o.insert("path".into(), json!(p));
} else {
return Err(format!("{script}:{}: ui.screenshot: `params` must be a JSON object", n + 1));
}
}
None => return Err(format!("{script}:{}: ui.screenshot needs a `path` (or give -o)", n + 1)),
}
+63
View File
@@ -139,6 +139,51 @@ fn render_subcommand() {
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown control"));
}
/// Issue #1730: `render --settings` took a `.lcpreset` from `preset.export`, ignored its wrapper
/// and rendered the photo unedited.
#[test]
fn render_settings_applies_an_lcpreset_and_refuses_unknown_files() {
let input = tmp("lcpreset-in.png");
gradient_png(&input);
let preset = tmp("bright.lcpreset");
let (ok, lines, err) = run_cli(
&[
"--import",
input.to_str().unwrap(),
"develop.set",
"control=light.exposure",
"value=1",
"preset.create",
"name=Bright",
"preset.export",
&format!("path={}", preset.display()),
],
None,
);
assert!(ok, "{err}: {lines:?}");
let plain = tmp("lcpreset-plain.png");
let st = Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", plain.to_str().unwrap()]).status().unwrap();
assert!(st.success());
let bright = tmp("lcpreset-bright.png");
let o = Command::new(BIN)
.args(["render", input.to_str().unwrap(), "-o", bright.to_str().unwrap(), "--settings", preset.to_str().unwrap()])
.output()
.unwrap();
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
assert!(mean(&bright) > mean(&plain) + 10.0, "the preset's exposure was not applied");
let other = tmp("other.lcpreset");
std::fs::write(&other, r#"{"format": "other", "version": 1, "presets": []}"#).unwrap();
let out = tmp("lcpreset-other.png");
let o = Command::new(BIN)
.args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--settings", other.to_str().unwrap()])
.output()
.unwrap();
assert!(!o.status.success(), "an unreadable settings file was accepted");
assert!(String::from_utf8_lossy(&o.stderr).contains("other.lcpreset"), "{}", String::from_utf8_lossy(&o.stderr));
assert!(!out.exists(), "nothing is written when the settings can't be read");
}
/// Issue #93: `render IMG -o IMG` replaced its own input with the render.
#[test]
fn render_refuses_to_overwrite_its_input() {
@@ -273,6 +318,24 @@ fn snapshot_script_failure_exits_non_zero() {
assert_eq!((d.width, d.height), (320, 200));
}
/// A script line whose `params` is not an object must be an error, never a panic: the
/// `ui.screenshot`-without-a-path branch used to index (`params["path"] = …`) an array and abort
/// the whole script.
#[test]
fn snapshot_script_with_non_object_params_is_an_error() {
let script = tmp("snap-bad-params.jsonl");
let out = tmp("snap-bad-params.png");
std::fs::write(&script, format!("{}\n", json!({"method": "ui.screenshot", "params": [1]}))).unwrap();
let o = Command::new(BIN)
.args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", "320x200"])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&o.stderr);
assert!(!o.status.success(), "{stderr}");
assert!(!stderr.contains("panicked"), "a bad `params` must be an error, not a panic: {stderr}");
assert!(stderr.contains("`params` must be a JSON object"), "{stderr}");
}
#[test]
fn snapshot_ui_zoom_keeps_requested_pixel_dimensions() {
let script = tmp("snap-ui-zoom.jsonl");
+38
View File
@@ -0,0 +1,38 @@
//! A broken protocol output must not require the client to close its input pipe.
use std::io::{Read, Write};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
#[test]
fn broken_stdout_exits_with_stdin_still_open() {
let mut child = Command::new(env!("CARGO_BIN_EXE_lightcraft-cli"))
.arg("mcp")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut input = child.stdin.take().unwrap();
drop(child.stdout.take());
input.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n{\"jsonrpc\":").unwrap();
input.flush().unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break status;
}
if Instant::now() >= deadline {
child.kill().unwrap();
child.wait().unwrap();
panic!("MCP did not exit after stdout failed while stdin stayed open");
}
std::thread::sleep(Duration::from_millis(10));
};
let mut stderr = String::new();
child.stderr.take().unwrap().read_to_string(&mut stderr).unwrap();
assert!(stderr.contains("serving MCP on stdio"), "CLI must reach the MCP transport: {stderr}");
assert!(!status.success(), "broken protocol output must be reported: {stderr}");
assert!(stderr.contains("lightcraft-cli:"), "CLI must report the transport error: {stderr}");
drop(input);
}
+2
View File
@@ -219,6 +219,8 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
save_keyword_list: None,
pick_curve_preset_files: None,
save_curve_preset_file: None,
pick_slideshow_templates: None,
save_slideshow_template: None,
write: Some(Box::new(download)),
// downloads happen on the main thread: exports run in the foreground on the web
write_shared: None,
+6
View File
@@ -41,6 +41,9 @@ pub struct WireJob {
pub as_shot_temp: f64,
pub as_shot_tint: f64,
pub lens: Option<EmbeddedLens>,
/// The original's long edge (`SourceInfo::native_long`).
#[serde(default)]
pub native_long: Option<usize>,
pub settings: Arc<DevelopSettings>,
pub max_w: usize,
pub max_h: usize,
@@ -80,6 +83,7 @@ impl WireJob {
as_shot_temp: job.info.as_shot_temp,
as_shot_tint: job.info.as_shot_tint,
lens: job.info.lens,
native_long: job.info.native_long,
settings: job.settings.clone(),
max_w: job.request.max_w,
max_h: job.request.max_h,
@@ -101,6 +105,7 @@ impl WireJob {
lens: self.lens,
relative_wb: self.relative_wb,
legacy_relative_wb: self.legacy_relative_wb,
native_long: self.native_long,
..Default::default()
}
}
@@ -469,6 +474,7 @@ mod tests {
as_shot_temp: 6500.0,
as_shot_tint: 0.0,
lens: None,
native_long: None,
settings: Arc::new(DevelopSettings::default()),
max_w: 32,
max_h: 32,
+10
View File
@@ -161,6 +161,8 @@ struct Busy {
level: SourceLevel,
key: u64,
cache: Option<(Arc<PreviewCache>, Hash128)>,
/// A slideshow slide: the worker renders the photo, it is composed here when it comes back.
slide: Option<Arc<lightcraft_engine::slide::SlideCompose>>,
}
struct W {
@@ -370,6 +372,13 @@ impl Workers {
});
}
g.remote_done += 1;
let rendered = match (rendered, &b.slide) {
(Ok(mut r), Some(slide)) => {
r.image = slide.compose(&r.image);
Ok(r)
}
(r, _) => r,
};
g.done.push((
b.slot,
RenderResult {
@@ -493,6 +502,7 @@ impl RenderOffload for Workers {
level: job.level,
key: job.key,
cache: job.cache,
slide: job.slide,
});
None
}
+28 -1
View File
@@ -51,7 +51,7 @@ fn decode(code: u32) -> Option<egui::Theme> {
/// absolute path, and is never repeated.
#[cfg(target_os = "linux")]
fn gtk_theme() -> Option<egui::Theme> {
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
let exe = gsettings_exe(std::env::var_os("PATH").as_deref())?;
let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?;
if !output.status.success() {
return None;
@@ -59,6 +59,15 @@ fn gtk_theme() -> Option<egui::Theme> {
parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?)
}
/// `gsettings` by absolute path: the absolute `PATH` entries first (NixOS has no /usr/bin/gsettings),
/// then the usual places for a sparse `PATH`. Relative entries are skipped, so nothing runs from
/// the working folder.
#[cfg(target_os = "linux")]
fn gsettings_exe(path: Option<&std::ffi::OsStr>) -> Option<std::path::PathBuf> {
let listed = path.map(std::env::split_paths).into_iter().flatten().filter(|dir| dir.is_absolute());
listed.chain(["/usr/bin", "/bin", "/usr/local/bin"].map(std::path::PathBuf::from)).map(|dir| dir.join("gsettings")).find(|exe| exe.is_file())
}
#[cfg(target_os = "linux")]
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
match s.trim().trim_matches('\'') {
@@ -174,6 +183,24 @@ mod tests {
assert_eq!(calls.load(Ordering::Relaxed), after_settle);
}
/// NixOS keeps `gsettings` in /run/current-system/sw/bin or the per-user profile: it is found
/// on `PATH`, but never through a relative entry, even one that leads to a real file.
#[cfg(target_os = "linux")]
#[test]
fn gsettings_is_found_on_absolute_path_entries_only() {
let dir = std::env::temp_dir().join(format!("lightcraft-gsettings-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("gsettings"), "").unwrap();
let path = std::env::join_paths([std::path::Path::new("relative/bin"), &dir]).unwrap();
assert_eq!(super::gsettings_exe(Some(&path)), Some(dir.join("gsettings")));
let cwd = std::env::current_dir().unwrap();
let relative = std::path::PathBuf::from("../".repeat(cwd.components().count() - 1)).join(dir.strip_prefix("/").unwrap());
assert!(relative.join("gsettings").is_file());
let found = super::gsettings_exe(Some(relative.as_os_str()));
assert!(found.as_ref().is_none_or(|p| p.is_absolute()), "{found:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(target_os = "linux")]
#[test]
fn portal_values_and_gsettings_output_decode() {
+289 -8
View File
@@ -76,10 +76,13 @@ impl eframe::App for App {
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.0.perf.host_frame_ms = frame.info().cpu_usage.map(|s| f64::from(s) * 1000.0);
self.0.ui(ui);
}
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
// (a size the window was just given is remembered too)
self.0.ui.window = self.1.window_at_exit(self.0.ui.window);
if let Err(e) = self.1.save(&self.0) {
log::error!("{e}");
}
@@ -162,6 +165,46 @@ fn gpu_crash_notice(what: &str) -> String {
)
}
/// Written when the window stopped drawing after the GPU device was lost (issue #1337): the next
/// launch renders on the CPU and says why.
fn gpu_lost_marker_path() -> Option<std::path::PathBuf> {
gpu_marker_path().map(|p| p.with_file_name("gpu-lost.marker"))
}
/// Why the GPU was lost, when the GPU pipeline stopped after a device failure in this process (a
/// driver reset, a hang, the Windows watchdog): `None` for other reasons it is off, or when it
/// never failed.
fn gpu_lost_reason() -> Option<String> {
lightcraft_engine::gpu::unavailable_reason().and_then(|r| r.strip_prefix("stopped after a GPU failure: ").map(str::to_string))
}
/// Record that LightCraft stopped after the GPU was lost, for the next launch.
fn record_gpu_lost(marker: Option<&std::path::Path>, reason: &str) {
let Some(m) = marker else { return };
if let Some(d) = m.parent() {
let _ = std::fs::create_dir_all(d);
}
if let Err(e) = std::fs::write(m, format!("{reason}\n")) {
log::warn!("could not write {}: {e}", m.display());
}
}
/// A GPU loss the last launch stopped on: its notice, once (the marker is removed, except by a
/// `--memory` session, which leaves the disk as it found it).
fn gpu_lost_check_at(marker: Option<&std::path::Path>, in_memory: bool) -> Option<String> {
let m = marker?;
let reason = std::fs::read_to_string(m).ok()?;
if !in_memory {
let _ = std::fs::remove_file(m);
}
Some(format!(
"LightCraft stopped last time after the graphics driver reset the GPU ({}) while photos rendered on it, so GPU rendering \
is now off and photos render on the CPU. To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; \
an up-to-date graphics driver may help.",
reason.trim()
))
}
/// Where the system keeps monitor profiles, for the Choose Profile dialog to start in: the user's
/// own profiles first (Linux: colord / DisplayCAL; macOS: ColorSync), then the system's.
fn display_profile_dir() -> Option<std::path::PathBuf> {
@@ -183,6 +226,16 @@ fn config_dir() -> Option<std::path::PathBuf> {
lightcraft_engine::config::config_dir()
}
/// The window at the size it was last used at, zoomed if it was (`window`), or at the usual size
/// when nothing is remembered. A zoomed window is given the size it goes back to. Where it
/// opens is the system's to say.
fn as_remembered(builder: egui::ViewportBuilder, window: Option<lightcraft_ui_egui::window_place::WindowPlace>) -> egui::ViewportBuilder {
let builder = builder.with_inner_size(window.map_or(lightcraft_ui_egui::window_place::DEFAULT_SIZE, |w| w.size));
// (only ever asked to zoom: asked not to, macOS un-zooms a window whose size fills the screen
// to a size of its own choosing)
if window.is_some_and(|w| w.zoomed) { builder.with_maximized(true) } else { builder }
}
/// `<config>/ui.json`, the saved UI state and app settings — `None` when nothing is read or
/// written: `LIGHTCRAFT_NO_PREFS` (tests, scripts) or no home folder.
fn prefs_path() -> Option<std::path::PathBuf> {
@@ -301,6 +354,11 @@ struct PrefsWriter {
failing: bool,
/// `ui.json` couldn't be read at launch: never overwrite it this session.
keep_file: bool,
/// Follows the window, to remember the size it rests at (`UiState::window`).
window: lightcraft_ui_egui::window_place::WindowWatch,
/// When the window was first looked at: its watch goes by the clock from then on, not by
/// the interface's time, which stands still while the window is hidden.
window_since: Option<std::time::Instant>,
}
impl PrefsWriter {
@@ -331,8 +389,33 @@ impl PrefsWriter {
Ok(())
}
/// What to remember of the window as the app quits ([`WindowWatch::at_exit`]).
///
/// [`WindowWatch::at_exit`]: lightcraft_ui_egui::window_place::WindowWatch::at_exit
fn window_at_exit(
&mut self,
remembered: Option<lightcraft_ui_egui::window_place::WindowPlace>,
) -> Option<lightcraft_ui_egui::window_place::WindowPlace> {
match self.window_since {
Some(since) => self.window.at_exit(remembered, since.elapsed().as_secs_f64()),
None => remembered,
}
}
fn tick(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
let now = ctx.input(|i| i.time);
// (asked for before the input is read: the context is not asked from inside its own lock)
let zoom = ctx.zoom_factor();
let (now, seen) = ctx.input(|i| (i.time, lightcraft_ui_egui::window_place::Seen::of(i.viewport(), i.content_rect(), zoom)));
// how the window is, to open like that next time (written with the settings below). What
// the settings hold is checked first: a script may have set it (`ui.set`)
let remembered = app.ui.window.and_then(lightcraft_ui_egui::window_place::WindowPlace::checked);
let at = self.window_since.get_or_insert_with(std::time::Instant::now).elapsed().as_secs_f64();
app.ui.window = self.window.note(remembered, &seen, at);
if let Some(wait) = self.window.waits(at) {
// (a size is only remembered once the window has rested at it: look again then, though
// nothing else asks for a frame)
ctx.request_repaint_after(wait);
}
let moved = app.ui.settings.library_path != self.library;
if !moved && now - self.checked < 3.0 {
return;
@@ -603,6 +686,24 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_slideshow_templates: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Import Slideshow Templates"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"])
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
save_slideshow_template: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Export Slideshow Template"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
// atomic (temp file + sync + rename): a failed write never leaves a truncated file
write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)),
write: Some(Box::new(lightcraft_engine::export::write_file)),
@@ -616,10 +717,12 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
}
}
/// The library session with the folder face models are kept in (`<config>/models`).
/// The library session with the folders face models (`<config>/models`) and slideshow templates
/// (`<config>/slideshow-templates`) are kept in.
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
let (mut s, problem) = open_library_session(in_memory, dir, seed_demo);
s.face_models_dir = lightcraft_engine::config::default_face_models_dir();
s.slide_templates_dir = lightcraft_engine::config::config_dir().map(|d| d.join(lightcraft_engine::slide_template::DIR_NAME));
// Control requests are answered on the UI thread: Add to Apple Photos must not wait there
s.apple_photos_background = true;
(s, problem)
@@ -808,7 +911,7 @@ fn main() -> eframe::Result {
let library_dir = library_dir.or_else(lightcraft_engine::library::default_dir);
// GPU compute: off if the preference says so, or if the last launch died creating the device
// (issue #136) — before anything can create it
let gpu_crash = gpu_crash_check(in_memory);
let gpu_crash = gpu_crash_check(in_memory).or_else(|| gpu_lost_check_at(gpu_lost_marker_path().as_deref(), in_memory));
let gpu_on = prefs.as_ref().is_none_or(|u| u.settings.gpu) && gpu_crash.is_none();
lightcraft_engine::gpu::set_enabled(gpu_on);
// the window's renderer: probed (Windows) and with a fallback chain, so a driver that can't
@@ -817,12 +920,15 @@ fn main() -> eframe::Result {
let window_gpu::Chosen { renderer, wgpu_options, which: window_renderer, notice: window_notice } = window_gpu::choose(&win_files);
win_files.arm(window_renderer);
let win_files_app = win_files.clone();
// the window came up: a failure after this is LightCraft stopping, not a start that failed
let came_up = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let came_up_app = came_up.clone();
// how the window was when the app was last used (checked: the settings file is a file)
let opens_as = prefs.as_ref().and_then(|ui| ui.window).and_then(lightcraft_ui_egui::window_place::WindowPlace::checked);
let options = eframe::NativeOptions {
renderer,
viewport: egui::ViewportBuilder::default()
.with_title("LightCraft")
.with_inner_size([1600.0, 1000.0])
.with_min_inner_size([900.0, 560.0])
viewport: as_remembered(egui::ViewportBuilder::default().with_title("LightCraft"), opens_as)
.with_min_inner_size(lightcraft_ui_egui::window_place::MIN_SIZE)
.with_drag_and_drop(true)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
@@ -854,6 +960,7 @@ fn main() -> eframe::Result {
Box::new(move |cc| {
// the window's renderer is up (eframe calls this after creating it)
win_files_app.started(window_renderer);
came_up_app.store(true, std::sync::atomic::Ordering::Relaxed);
let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
// AI masks: the SAM 3 checkpoint (facebook/sam3) in <config>/models/sam3, or LIGHTCRAFT_SAM3_DIR
// (never required: without it, AI masks offer to download it; see docs/ai-masks.md)
@@ -915,6 +1022,31 @@ fn main() -> eframe::Result {
}),
)
}));
if came_up.load(std::sync::atomic::Ordering::Relaxed) {
// the window was up: a panic that escaped it now is LightCraft stopping, typically the
// window's renderer after the graphics driver reset the GPU (issue #1337: egui-wgpu panics
// on the lost device). Not a failed start: no other renderer is tried, and the message
// says what happened.
let stopped = match started {
Ok(r) => r,
Err(_) => Err(eframe::Error::AppCreation("its window stopped drawing (the renderer panicked)".into())),
};
if let Err(e) = &stopped {
let lost = gpu_lost_reason();
if let Some(reason) = &lost
&& !in_memory
{
record_gpu_lost(gpu_lost_marker_path().as_deref(), reason);
}
// (not "app creation error: …": the app had been created)
let what = match e {
eframe::Error::AppCreation(m) => m.to_string(),
e => e.to_string(),
};
stopped_while_running(&stopped_text(&what, lost.as_deref()), log_file.as_deref());
}
return stopped;
}
// a panic that still escaped the renderer's start (the probe and error handler should
// prevent it): an error like any other failed start
let started = started.unwrap_or_else(|_| Err(eframe::Error::AppCreation("the window's renderer panicked while starting".into())));
@@ -929,6 +1061,34 @@ fn main() -> eframe::Result {
started
}
/// What to tell the user when LightCraft stopped while running (`error`), and why when the GPU
/// was lost (`lost`): GPU rendering is off from the next start.
fn stopped_text(error: &str, lost: Option<&str>) -> String {
match lost {
Some(reason) => format!(
"{error}. The graphics driver reset the GPU ({reason}). LightCraft renders on the CPU from its next start; \
Settings ▸ Performance ▸ Use the GPU for rendering turns the GPU back on"
),
None => error.to_string(),
}
}
/// LightCraft stopped while its window was up: say so in the log and in a native message box
/// naming the log file (as [`startup_failed`] does for a start that failed).
fn stopped_while_running(text: &str, log_file: Option<&std::path::Path>) {
log::error!("LightCraft stopped: {text}");
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
return; // tests and scripts: no dialog to click away
}
let (title, text) = lightcraft_ui_egui::i18n::stopped_message(text, log_file.map(|p| p.display().to_string()).as_deref());
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title(title)
.set_description(text)
.set_buttons(rfd::MessageButtons::Ok)
.show();
}
/// The window could not start (e.g. no usable graphics device): say so in the log and in a native
/// message box naming the log file, instead of exiting without a trace — the release builds have
/// no console on Windows, and an app started from Finder shows no standard error (issue #260).
@@ -1131,6 +1291,37 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
/// Issue #1337: the window stopped drawing after the graphics driver reset the GPU during an
/// export, and the dialog said the window "could not open". The stop is recorded; the next
/// launch says why once and renders on the CPU (a `--memory` session leaves the record).
#[test]
fn a_gpu_lost_while_running_turns_the_gpu_off_next_time() {
let d = dir("gpu-lost");
let m = d.join("gpu-lost.marker");
assert_eq!(gpu_lost_check_at(Some(&m), false), None, "nothing recorded");
assert_eq!(gpu_lost_check_at(None, false), None, "no settings folder");
record_gpu_lost(Some(&m), "device lost (Unknown): Device is lost");
let notice = gpu_lost_check_at(Some(&m), true).unwrap();
assert!(
notice.contains("(device lost (Unknown): Device is lost)")
&& notice.contains("GPU rendering is now off")
&& notice.contains("Use the GPU for rendering"),
"{notice}"
);
assert!(m.exists(), "a --memory session leaves it");
assert!(gpu_lost_check_at(Some(&m), false).is_some());
assert!(!m.exists());
assert_eq!(gpu_lost_check_at(Some(&m), false), None, "said once");
// the message box: LightCraft stopped (not "could not open its window"), and why
let text = stopped_text("its window stopped drawing (the renderer panicked)", Some("device lost (Unknown): Device is lost"));
assert!(text.contains("reset the GPU (device lost (Unknown)") && text.contains("renders on the CPU from its next start"), "{text}");
assert_eq!(stopped_text("its window stopped drawing", None), "its window stopped drawing");
let (title, body) = lightcraft_ui_egui::i18n::stopped_message(&text, Some("/logs/lightcraft.log"));
assert_eq!(title, "LightCraft stopped");
assert!(body.starts_with("LightCraft stopped unexpectedly: its window stopped drawing") && body.ends_with("/logs/lightcraft.log"), "{body}");
let _ = std::fs::remove_dir_all(&d);
}
/// Issue #136: a GPU init marker left by a crashed launch is reported once and removed.
#[test]
fn gpu_crash_marker_is_reported_once() {
@@ -1237,6 +1428,96 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
/// Given what the settings remember of the window, when the app's window is built, then it
/// opens at that size, zoomed if it was, where the system puts it; with nothing remembered it
/// opens at the usual size.
#[test]
fn the_window_opens_as_it_was() {
use lightcraft_ui_egui::window_place::{DEFAULT_SIZE, WindowPlace};
let fresh = as_remembered(egui::ViewportBuilder::default(), None);
assert_eq!((fresh.inner_size, fresh.position, fresh.maximized), (Some(egui::Vec2::from(DEFAULT_SIZE)), None, None));
let b = as_remembered(egui::ViewportBuilder::default(), Some(WindowPlace { size: [1300.0, 800.0], zoomed: true }));
assert_eq!((b.inner_size, b.position, b.maximized), (Some(egui::vec2(1300.0, 800.0)), None, Some(true)));
// (not asked to un-zoom: macOS would then resize a window that fills its screen)
let b = as_remembered(egui::ViewportBuilder::default(), Some(WindowPlace { size: [1300.0, 800.0], zoomed: false }));
assert_eq!((b.inner_size, b.position, b.maximized), (Some(egui::vec2(1300.0, 800.0)), None, None));
}
/// Given a window that rests at a size, when the settings are written and read again, then
/// they hold how it was. A window in the file that is not one (a hand edit, another version's)
/// reads as nothing remembered and costs no other setting; and a window resized just before
/// the app quits is remembered at that size.
#[test]
fn the_settings_keep_how_the_window_was() {
use lightcraft_ui_egui::window_place::{Seen, WindowPlace};
let d = dir("window");
let path = d.join("ui.json");
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
app.ui.settings.library_path = "/pictures/library".into();
let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() };
writer.save(&app).unwrap();
assert!(!std::fs::read_to_string(&path).unwrap().contains("\"window\""), "nothing to remember yet");
let window = WindowPlace { size: [1300.0, 800.0], zoomed: true };
app.ui.window = Some(window);
writer.save(&app).unwrap();
let (restored, warning, _) = load_prefs_at(&path, false);
assert_eq!((restored.unwrap().window, warning), (Some(window), None));
// a window that is no window: the file is read all the same, with its other settings
let text = std::fs::read_to_string(&path).unwrap();
for odd in [r#""window": {}"#, r#""window": "wide""#, r#""window": {"size": [1, 2, 3]}"#, r#""window": {"size": [null, 800.0]}"#] {
let start = text.find("\"window\"").unwrap();
let end = start + text[start..].find('}').unwrap() + 1;
std::fs::write(&path, format!("{}{odd}{}", &text[..start], &text[end..])).unwrap();
let (restored, warning, keep) = load_prefs_at(&path, true);
let restored = restored.unwrap_or_else(|| panic!("{odd}: {warning:?}"));
assert_eq!((restored.window, restored.settings.library_path.as_str(), warning, keep), (None, "/pictures/library", None, false), "{odd}");
}
// resized, and the app quits a moment later: the new size is what is written
let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() };
let rest = Some(WindowPlace { size: [1300.0, 800.0], zoomed: false });
writer.window.note(rest, &Seen { size: Some([1500.0, 900.0]), ..Default::default() }, 0.0);
writer.window_since = std::time::Instant::now().checked_sub(std::time::Duration::from_millis(400));
assert!(writer.window_since.is_some());
assert_eq!(writer.window_at_exit(rest), Some(WindowPlace { size: [1500.0, 900.0], zoomed: false }));
assert_eq!(PrefsWriter::default().window_at_exit(rest), rest, "a window that was never seen");
let _ = std::fs::remove_dir_all(&d);
}
/// Given the app's window at a size, when the settings writer looks at it frame after frame,
/// then it asks to be woken while the size has still to rest, remembers the size once it has,
/// and asks for nothing more. A window in the settings that is not one (a script set it) is
/// put right before it is gone by.
#[test]
fn the_writer_follows_the_window_from_frame_to_frame() {
use lightcraft_ui_egui::window_place::WindowPlace;
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
let mut writer = PrefsWriter::default();
let ctx = egui::Context::default();
let pass = |look: &mut dyn FnMut(&egui::Context)| {
let raw =
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1300.0, 800.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| look(ui.ctx()));
out.textures_delta.clear();
out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.repaint_delay)
};
// (egui's own first frames ask for another: let them pass)
for _ in 0..3 {
pass(&mut |_| {});
}
app.ui.window = Some(WindowPlace { size: [f32::NAN, 800.0], zoomed: true });
let asked = pass(&mut |ctx| writer.tick(&mut app, ctx));
assert_eq!(app.ui.window, None, "no window, and not yet at rest");
assert!(asked.is_some_and(|wait| wait <= std::time::Duration::from_secs(1)), "{asked:?}");
// a second later
writer.window_since = writer.window_since.and_then(|since| since.checked_sub(std::time::Duration::from_secs(1)));
pass(&mut |ctx| writer.tick(&mut app, ctx));
assert_eq!(app.ui.window, Some(WindowPlace { size: [1300.0, 800.0], zoomed: false }));
let asked = pass(&mut |ctx| writer.tick(&mut app, ctx));
assert!(asked.is_none_or(|wait| wait > std::time::Duration::from_secs(3600)), "at rest and remembered: {asked:?}");
}
/// #1331: on macOS the process ends inside `on_exit`, before eframe would tear the window down.
/// The check runs in a child (this test binary again), since on macOS it ends the process.
#[test]
+4 -2
View File
@@ -26,8 +26,9 @@ const QUIT: &str = "app.quit";
const SETTINGS: &str = "app.settings";
const SETTINGS_KEY: &str = "Cmd+,";
/// Keys whose meaning depends on the panel (X = reject or swap crop aspect, ⌫ = delete the photo
/// or the active mask): left to the egui handler, which knows the context.
const CONTEXTUAL: &[&str] = &["X", "Delete"];
/// or the active mask; ⌘↑/⌘↓ flag status follows Compare/Survey's active photo): left to the egui
/// handler, which knows the context.
const CONTEXTUAL: &[&str] = &["X", "Delete", "Cmd+Up", "Cmd+Down"];
/// Shortcuts text fields need while they have focus.
const TEXT_EDIT: &[&str] = &["Cmd+A", "Cmd+C", "Cmd+V", "Cmd+X", "Cmd+Z", "Cmd+Shift+Z"];
@@ -126,6 +127,7 @@ fn accelerator(sc: &str) -> Option<Accelerator> {
"=" => Code::Equal,
"-" => Code::Minus,
"'" => Code::Quote,
"`" => Code::Backquote,
"," => Code::Comma,
"Delete" => Code::Backspace,
// with modifiers only: a bare Enter belongs to text fields and tools
+4 -1
View File
@@ -12,6 +12,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` |
| `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none |
| `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
| `assets/camera-profiles/ILCA-99M2.json` | Sony ILCA-99M2 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @PiMinusMax | original work: fitted by `lightcraft-cli calibrate` to 144 usable ILCA-99M2 ARWs (of 150, seven shooting days, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-11 | none |
| `assets/camera-profiles/ILCE-7CR.json` | Sony ILCE-7CR camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @AlexChambers | original work: fitted by `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131, September 2025 to September 2026, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-09 | none |
| `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none |
| `assets/camera-profiles/NIKON_Z_6_2.json` | Nikon Z 6II camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 296 usable Z 6II NEFs of my own (of 299, 2024 to 2026, Standard and Auto picture controls; NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
@@ -19,14 +20,16 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none |
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `crates/ui-egui/src/panels/person.rs` | Up arrow on the People page bulk-confirm control, drawn in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-10-13 | n/a |
| `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
| `docs/images/*-migrant-mother.jpg`, `docs/images/grid-pd.jpg` | "Migrant Mother" | Dorothea Lange (1936), Farm Security Administration / Library of Congress | https://commons.wikimedia.org/wiki/File:Lange-MigrantMother02.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
| `docs/images/*-earthrise.jpg`, `docs/images/grid-pd.jpg` | "Earthrise" (AS08-14-2383) | NASA / Bill Anders, Apollo 8 (1968) | https://commons.wikimedia.org/wiki/File:NASA-Apollo8-Dec24-Earthrise.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
| `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and ships Noto Sans CJK SC Regular as a separate font file |
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular, Noto Sans Arabic Regular, Noto Sans Thai Regular | The Noto Project Authors (Arabic and Thai); Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and Noto Sans Thai Regular and ships Noto Sans CJK SC Regular as a separate font file |
| `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 |
Notes
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
File diff suppressed because it is too large Load Diff
+14
View File
@@ -25,9 +25,23 @@ sync_github_name = false
[people.rock-meister]
sync_github_name = true
[people.Th4nat0s]
display_name = "Thanat0s"
[people.sanyamsingh001]
display_name = "sanyamsingh001"
[people.ArekatlaNishanthchowdary]
real_name = "Arekatla Nishanth Chowdary"
sync_github_name = true
[people.Losminek]
sync_github_name = true
[people.FooVoo]
real_name = "Aleksandr Batyrev"
sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
+2 -1
View File
@@ -51,6 +51,7 @@
//! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` |
//! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` |
//! | 8 | History cursor: Go to History Step adds no step | `Op::SetHistoryCursor`, `Photo.history_cursor` |
//! | 9 | preview-only recheck | `Photo.preview_checked`, `Op::SetContent.preview_checked` |
//!
//! Rules:
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
@@ -75,7 +76,7 @@ pub const LOG: &str = "catalog.log";
const FORMAT: &str = "lightcraft-catalog";
/// The catalog format this build writes (and the newest it reads). See the module docs →
/// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added.
pub const VERSION: u32 = 8;
pub const VERSION: u32 = 9;
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
#[derive(Clone, Copy, Debug)]
+162 -1
View File
@@ -25,6 +25,7 @@ pub mod rules;
pub mod safe_file;
pub mod stacks;
pub mod store;
pub mod tally;
use std::collections::BTreeMap;
use std::sync::Arc;
@@ -214,6 +215,10 @@ pub enum Op {
/// `None` = its raw data decodes (or it isn't a raw).
#[serde(default, skip_serializing_if = "Option::is_none")]
preview_only: Option<String>,
/// The app version that decided `preview_only` (see [`Photo::preview_checked`]); empty
/// when it is `None`.
#[serde(default, skip_serializing_if = "String::is_empty")]
preview_checked: String,
},
/// The lens data a photo's file carries (what Reload finds when it was read after import).
SetEmbeddedLens {
@@ -281,6 +286,71 @@ pub struct Catalog {
/// Increments on every applied op.
#[serde(skip)]
pub revision: u64,
/// Which photos the latest ops touched ([`Catalog::touched_since`]).
#[serde(skip)]
changes: Changes,
}
/// What of a photo an op changed: enough to tell which tallies of the library an edit leaves as they were (a
/// rating changes no folder's count; a slider changes none at all).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Aspects(u16);
impl Aspects {
pub const NONE: Aspects = Aspects(0);
pub const RATING: Aspects = Aspects(1);
pub const FLAG: Aspects = Aspects(1 << 1);
pub const LABEL: Aspects = Aspects(1 << 2);
/// Develop settings, versions and history.
pub const DEVELOP: Aspects = Aspects(1 << 3);
/// Title, caption, keywords, people, place and the rest of the metadata.
pub const META: Aspects = Aspects(1 << 4);
pub const CAPTURED: Aspects = Aspects(1 << 5);
/// Where its file is.
pub const FILE: Aspects = Aspects(1 << 6);
pub const ANALYSIS: Aspects = Aspects(1 << 7);
/// Whether it is in the library at all: added, removed, deleted, restored, taken in from Local.
pub const PRESENCE: Aspects = Aspects(1 << 8);
/// Everything: the photo may count differently for any tally.
pub const ALL: Aspects = Aspects(u16::MAX);
pub const fn with(self, other: Aspects) -> Aspects {
Aspects(self.0 | other.0)
}
/// Whether any of `other` is among these.
pub const fn any_of(self, other: Aspects) -> bool {
self.0 & other.0 != 0
}
}
/// The most photos remembered as touched; older changes are then answered with "anything may have changed".
const MAX_TOUCHED: usize = 8192;
/// A short memory of what the latest ops changed, so that what is derived from the catalog (the grid's list) can
/// follow a small edit without being worked out again from every photo.
#[derive(Clone, Debug, Default)]
struct Changes {
/// The photos ops changed, each with the revision the op made and what of the photo it changed. Oldest first.
touched: std::collections::VecDeque<(u64, PhotoId, Aspects)>,
/// What the op being applied changes of its photo.
now: Aspects,
/// The latest revision made by an op that changed more than photos of its own (an album, a stack, a keyword of
/// the list…): what it changed for which photos is not known.
broad: u64,
/// Changes up to this revision are no longer all in `touched`.
floor: u64,
/// The revision the last op recorded here made. When the catalog's revision is another, it was set from
/// outside (a library just loaded takes a range of its own, so that nothing kept from another library is
/// taken for this one's): nothing is known of how the catalog got to where it is.
seen: u64,
}
impl PartialEq for Changes {
/// Two catalogs are the same catalog whatever each remembers of how it got there.
fn eq(&self, _: &Self) -> bool {
true
}
}
impl Catalog {
@@ -479,20 +549,110 @@ impl Catalog {
// ---- writes
fn photo_mut(&mut self, id: PhotoId) -> Result<&mut Photo> {
self.touch(id);
self.photos.get_mut(&id).map(Arc::make_mut).ok_or(CatalogError::NoPhoto(id))
}
/// The op being applied changes photo `id` (or adds or removes it).
fn touch(&mut self, id: PhotoId) {
// (the revision the op will make: `apply` counts up once it is done)
self.changes.touched.push_back((self.revision.saturating_add(1), id, self.changes.now));
while self.changes.touched.len() > MAX_TOUCHED {
if let Some((revision, _, _)) = self.changes.touched.pop_front() {
self.changes.floor = revision;
}
}
}
/// What of its photos the ops since `revision` changed, taken together ([`Aspects::NONE`] when there were
/// none). `None` when that is not known ([`Catalog::touched_since`]). A tally of the library that reads only
/// other things of a photo is still right.
pub fn aspects_since(&self, revision: u64) -> Option<Aspects> {
if !self.remembers(revision) {
return None;
}
Some(self.changes.touched.iter().filter(|(r, _, _)| *r > revision).fold(Aspects::NONE, |all, (_, _, a)| all.with(*a)))
}
/// Whether every change since the catalog was at `revision` is remembered photo by photo.
fn remembers(&self, revision: u64) -> bool {
self.changes.seen == self.revision && revision <= self.revision && revision >= self.changes.floor && self.changes.broad <= revision
}
/// The photos changed, added or removed since the catalog was at `revision`, each once. `None` when that is not
/// known: an op since then changed more than photos of its own, or it was too long ago. Then anything may
/// have changed.
pub fn touched_since(&self, revision: u64) -> Option<Vec<PhotoId>> {
if !self.remembers(revision) {
return None;
}
let mut ids: Vec<PhotoId> = self.changes.touched.iter().filter(|(r, _, _)| *r > revision).map(|(_, id, _)| *id).collect();
ids.sort_unstable();
ids.dedup();
Some(ids)
}
fn album_mut(&mut self, id: AlbumId) -> Result<&mut Album> {
self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id))
}
/// Apply an op; returns its inverse. On error nothing changes.
pub fn apply(&mut self, op: Op) -> Result<Op> {
if self.changes.seen != self.revision {
// the revision was set from outside: what is remembered is of another past
self.changes = Changes { floor: self.revision, seen: self.revision, ..Default::default() };
}
let inv = self.apply_inner(op)?;
self.revision += 1;
self.changes.seen = self.revision;
Ok(inv)
}
fn apply_inner(&mut self, op: Op) -> Result<Op> {
// An op that changes one photo says which (`photo_mut`, and adding or removing it below). Every other kind
// can change what any photo matches or where it sorts: an album's photos or rules, a stack, a keyword's
// name. Listed kind by kind, so a new one has to be put on one side.
match &op {
// (a photo that comes, goes, or changes what it is to the library counts for every tally; so do the
// rare kinds whose reach nobody has to think about)
Op::AddPhoto { photo } => {
self.changes.now = Aspects::ALL;
self.touch(photo.id);
}
Op::RemovePhoto { id } => {
self.changes.now = Aspects::ALL;
self.touch(*id);
}
Op::SetDeleted { .. } | Op::SetLocal { .. } | Op::SetContent { .. } | Op::SetEmbeddedLens { .. } | Op::SetAsShotWb { .. } => {
self.changes.now = Aspects::ALL
}
Op::SetRating { .. } => self.changes.now = Aspects::RATING,
Op::SetFlag { .. } => self.changes.now = Aspects::FLAG,
Op::SetLabel { .. } => self.changes.now = Aspects::LABEL,
Op::SetMeta { .. } => self.changes.now = Aspects::META,
Op::SetCaptured { .. } => self.changes.now = Aspects::CAPTURED,
Op::SetFile { .. } | Op::Relink { .. } => self.changes.now = Aspects::FILE,
Op::SetAnalysis { .. } => self.changes.now = Aspects::ANALYSIS,
Op::SetDevelop { .. } | Op::SetVersions { .. } | Op::SetHistory { .. } | Op::PushHistory { .. } | Op::SetHistoryCursor { .. } => {
self.changes.now = Aspects::DEVELOP
}
// (its ops say for themselves, one by one)
Op::Batch { .. } => {}
Op::AddAlbum { .. }
| Op::RemoveAlbum { .. }
| Op::RenameAlbum { .. }
| Op::MoveAlbum { .. }
| Op::SetAlbumOrder { .. }
| Op::SetAlbumPhotos { .. }
| Op::SetAlbumCover { .. }
| Op::SetAlbumRules { .. }
| Op::AddStack { .. }
| Op::RemoveStack { .. }
| Op::SetStack { .. }
| Op::SetLabelName { .. }
| Op::SetBrowsed { .. }
| Op::SetKeyword { .. }
| Op::SetFolderRecord { .. } => self.changes.broad = self.revision.saturating_add(1),
}
Ok(match op {
Op::AddPhoto { photo } => {
if self.photos.contains_key(&photo.id) {
@@ -700,7 +860,7 @@ impl Catalog {
let old_format = format.map(|f| std::mem::replace(&mut p.format, f));
Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source), format: old_format }
}
Op::SetContent { id, width, height, file_size, content_hash, preview_only } => {
Op::SetContent { id, width, height, file_size, content_hash, preview_only, preview_checked } => {
let p = self.photo_mut(id)?;
Op::SetContent {
id,
@@ -709,6 +869,7 @@ impl Catalog {
file_size: std::mem::replace(&mut p.file_size, file_size),
content_hash: std::mem::replace(&mut p.content_hash, content_hash),
preview_only: std::mem::replace(&mut p.preview_only, preview_only),
preview_checked: std::mem::replace(&mut p.preview_checked, preview_checked),
}
}
Op::SetEmbeddedLens { id, lens } => {
+25 -9
View File
@@ -183,15 +183,7 @@ impl Catalog {
let mut plan = ForgetPlan { now: now.to_string(), ..Default::default() };
let now_s = crate::stacks::iso_seconds(now);
let max_age = i64::from(days) * 86_400;
let mut referenced: HashSet<PhotoId> = HashSet::new();
for a in self.albums.values() {
referenced.extend(a.photos.iter().copied());
referenced.extend(a.cover);
}
for s in self.stacks.values() {
referenced.extend(s.photos.iter().copied());
}
referenced.extend(self.photos.values().filter_map(|p| p.copy_of));
let referenced = self.referenced();
// the fingerprints are the costly part: work them out for the candidates in parallel
let candidates: Vec<&Photo> = self
.photos
@@ -237,6 +229,30 @@ impl Catalog {
plan.unstamp = self.browsed.keys().filter(|f| !remaining.contains(*f)).cloned().collect();
plan
}
/// Of `ids`, the untouched Local records nothing refers to (no album, stack or virtual copy):
/// the ones [`Catalog::forget_local_plan`] could forget, whatever their folder's time.
pub fn forgettable_local(&self, ids: &[PhotoId]) -> Vec<PhotoId> {
let referenced = self.referenced();
let candidates: Vec<&Photo> =
ids.iter().filter(|id| !referenced.contains(id)).filter_map(|id| self.photos.get(id)).filter(|p| p.local).map(|p| &**p).collect();
let untouched = untouched_of(&candidates);
ids.iter().copied().filter(|id| untouched.contains(id)).collect()
}
/// Photos an album (as a member or its cover), a stack or a virtual copy refers to.
fn referenced(&self) -> HashSet<PhotoId> {
let mut referenced: HashSet<PhotoId> = HashSet::new();
for a in self.albums.values() {
referenced.extend(a.photos.iter().copied());
referenced.extend(a.cover);
}
for s in self.stacks.values() {
referenced.extend(s.photos.iter().copied());
}
referenced.extend(self.photos.values().filter_map(|p| p.copy_of));
referenced
}
}
/// A folder browsed at `at` is past the period (never when `days` is 0 or a time is unreadable).
+36 -2
View File
@@ -293,6 +293,12 @@ pub struct Photo {
/// picture style baked in — so it is treated as a rendered (non-raw) source.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview_only: Option<String>,
/// The app version (`CARGO_PKG_VERSION`) whose decoder set [`Photo::preview_only`]; empty when
/// it isn't set, and in catalogs from before format v9. A preview-only photo whose stamp
/// differs from the running version is rechecked when its library opens (issue #662): newer
/// builds may decode the file, older ones may decode less.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub preview_checked: String,
/// A Local record: the fingerprint of its state when the browse catalogued it, to tell
/// whether the user changed anything since (see [`crate::local`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -318,7 +324,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"].iter().any(|f| format.eq_ignore_ascii_case(f))
["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF", "ERF"].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
@@ -332,6 +338,16 @@ pub fn relative_wb_camera(format: &str, camera: &str) -> bool {
relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none() && !lightcraft_raw::kelvin_presets::covers_camera(camera)
}
/// This build's version: what a photo's [`Photo::preview_checked`] stamp is compared with. Every
/// workspace crate shares one version, so it is the app's.
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
/// The [`Photo::preview_checked`] stamp for a `preview_only` decided by this build: its version
/// when the photo is preview-only, empty otherwise.
pub fn preview_stamp(preview_only: &Option<String>) -> String {
if preview_only.is_some() { APP_VERSION.to_string() } else { String::new() }
}
impl Photo {
/// The History step the settings are at (the cursor, clamped to the list; `None` = no history).
pub fn history_index(&self) -> Option<usize> {
@@ -371,9 +387,20 @@ impl Photo {
import_look: None,
analysis: None,
preview_only: None,
preview_checked: String::new(),
local_baseline: None,
}
}
/// Sets [`Photo::preview_only`] as this build decided it, with its stamp.
pub fn set_preview_only(&mut self, preview_only: Option<String>) {
self.preview_checked = preview_stamp(&preview_only);
self.preview_only = preview_only;
}
/// A preview-only photo whose flag was set by another app version (or before stamps existed),
/// so this build should probe its file again (issue #662).
pub fn preview_only_stale(&self) -> bool {
self.preview_only.is_some() && self.preview_checked != APP_VERSION
}
/// A raw file developed from its sensor data: not a rendered image, and not a raw shown from
/// its embedded preview ([`Photo::preview_only`]).
pub fn develops_raw(&self) -> bool {
@@ -577,6 +604,7 @@ mod edited_tests {
("a.cr2", "CR2", true),
("a.pef", "pef", true),
("a.orf", "ORF", true),
("a.erf", "ERF", 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");
@@ -609,6 +637,8 @@ mod edited_tests {
("SONY ILCE-7RM4A", "ARW", false),
("NIKON CORPORATION NIKON D850", "NEF", false),
("Canon Canon EOS 5D Mark III", "CR2", false),
("Canon Canon EOS R6m2", "CR3", false),
("Canon Canon EOS R7", "CR3", true),
("FUJIFILM X-T4", "RAF", false),
("ILCE-7M3", "ARW", false),
("SONY ILCE-7M2", "ARW", true),
@@ -624,7 +654,11 @@ mod edited_tests {
p.as_shot_wb = Some((4986.0, -2.0));
assert_eq!(p.relative_wb(), relative, "{camera} {format}");
assert_eq!(relative_wb_camera(format, camera), relative, "{camera} {format}");
assert_eq!(p.fitted_look(), format == "ARW" || format == "NEF" || format == "CR2" || format == "RAF", "{camera} {format}");
assert_eq!(
p.fitted_look(),
format == "ARW" || format == "NEF" || format == "CR2" || format == "CR3" || format == "RAF",
"{camera} {format}"
);
let wb = p.camera_defaults().wb;
assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (4986.0, -2.0) }, "{camera} {format}");
assert_eq!(wb.scale, if relative { WbScale::Legacy } else { WbScale::Kelvin }, "{camera} {format}");
+70 -22
View File
@@ -94,6 +94,27 @@ pub struct Sort {
pub seed: u64,
}
impl Sort {
/// Which of two photos comes first in this order (never `Equal` for two photos: the id decides last).
pub fn order(&self, a: &Photo, b: &Photo) -> std::cmp::Ordering {
let o = match self.key {
SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)),
SortKey::ImportDate => a.imported.cmp(&b.imported),
SortKey::EditDate => a.edited.cmp(&b.edited),
SortKey::FileName => cmp_name(&a.file_name, &b.file_name),
SortKey::Rating => a.rating.cmp(&b.rating),
SortKey::FileSize => a.file_size.cmp(&b.file_size),
SortKey::Random => shuffle_rank(self.seed, a.id).cmp(&shuffle_rank(self.seed, b.id)),
}
// Ties (one import batch, a burst within a second, equal ratings) sort by file name, the
// order cameras number frames, so the order doesn't depend on how ids were allocated;
// the id only keeps it stable.
.then_with(|| if self.key == SortKey::FileName { Equal } else { cmp_name(&a.file_name, &b.file_name) })
.then(a.id.cmp(&b.id));
if self.ascending { o } else { o.reverse() }
}
}
impl Default for Sort {
fn default() -> Self {
Sort { key: SortKey::CaptureDate, ascending: false, group: crate::GroupBy::Auto, seed: 0 }
@@ -369,38 +390,65 @@ pub fn cmp_name(a: &str, b: &str) -> std::cmp::Ordering {
impl Catalog {
/// Photos matching `filter`, in `sort` order (ties broken by file name, then id).
pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec<PhotoId> {
self.query_keeping(filter, sort, &std::collections::HashSet::new(), filter)
}
/// [`Catalog::query`], and with them the photos of `keep` that still match `within`, each at its place in `sort`
/// order: a view that holds the photos it showed when an edit makes them stop matching `filter` (a smart
/// album's rules, the filter bar), as long as they are still where the view looks (`within`: in the library,
/// the album, the folder; not deleted).
pub fn query_keeping(&self, filter: &Filter, sort: &Sort, keep: &std::collections::HashSet<PhotoId>, within: &Filter) -> Vec<PhotoId> {
let root = filter.library_root();
let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, root.as_deref())).collect();
v.sort_by(|a, b| {
let o = match sort.key {
SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)),
SortKey::ImportDate => a.imported.cmp(&b.imported),
SortKey::EditDate => a.edited.cmp(&b.edited),
SortKey::FileName => cmp_name(&a.file_name, &b.file_name),
SortKey::Rating => a.rating.cmp(&b.rating),
SortKey::FileSize => a.file_size.cmp(&b.file_size),
SortKey::Random => shuffle_rank(sort.seed, a.id).cmp(&shuffle_rank(sort.seed, b.id)),
}
// Ties (one import batch, a burst within a second, equal ratings) sort by file name, the
// order cameras number frames, so the order doesn't depend on how ids were allocated;
// the id only keeps it stable.
.then_with(|| if sort.key == SortKey::FileName { Equal } else { cmp_name(&a.file_name, &b.file_name) })
.then(a.id.cmp(&b.id));
if sort.ascending { o } else { o.reverse() }
});
let within_root = within.library_root();
let mut v: Vec<&Photo> = self
.photos()
.map(|p| p.as_ref())
.filter(|p| filter.matches_in(p, self, root.as_deref()) || (keep.contains(&p.id) && within.matches_in(p, self, within_root.as_deref())))
.collect();
v.sort_by(|a, b| sort.order(a, b));
v.into_iter().map(|p| p.id).collect()
}
/// The list `was` (a [`Catalog::query`] of `filter` in `sort` order, or a [`Catalog::query_keeping`]), after ops
/// that touched only the photos `touched` (sorted by id, each once: [`Catalog::touched_since`]): those photos
/// taken out, and the ones that match `filter` now put back at their place in the order. With `within`, a
/// touched photo that was in the list and still matches `within` stays, as `query_keeping` would keep it. What
/// the query would give, without asking every photo again.
pub fn follow(&self, was: &[PhotoId], touched: &[PhotoId], filter: &Filter, sort: &Sort, within: Option<&Filter>) -> Vec<PhotoId> {
let listed: std::collections::HashSet<PhotoId> = was.iter().copied().filter(|id| touched.binary_search(id).is_ok()).collect();
let mut ids: Vec<PhotoId> = was.iter().copied().filter(|id| !listed.contains(id)).collect();
let (root, within_root) = (filter.library_root(), within.and_then(Filter::library_root));
for id in touched {
let Some(p) = self.photo(*id) else { continue };
let kept = within.is_some_and(|w| listed.contains(id) && w.matches_in(p, self, within_root.as_deref()));
if filter.matches_in(p, self, root.as_deref()) || kept {
// (the list is in order without the touched photos, and stays so as each is put back)
let at = ids.partition_point(|other| self.photo(*other).is_some_and(|o| sort.order(o, p).is_lt()));
ids.insert(at, *id);
}
}
ids
}
/// How many photos match `filter`: [`Catalog::query`]'s length without its sorting.
pub fn count(&self, filter: &Filter) -> usize {
let root = filter.library_root();
self.photos().filter(|p| filter.matches_in(p, self, root.as_deref())).count()
}
/// Year → month → day counts for the "By Date" section (newest first).
pub fn date_groups(&self) -> Vec<DateGroup> {
use std::collections::BTreeMap;
let mut years: BTreeMap<&str, (BTreeMap<&str, usize>, BTreeMap<&str, usize>)> = BTreeMap::new();
for p in self.photos().filter(|p| p.in_library()) {
let Some(d) = p.captured.as_deref() else { continue };
if d.len() >= 10 {
let (months, days) = years.entry(&d[..4]).or_default();
*months.entry(&d[..7]).or_default() += 1;
*days.entry(&d[..10]).or_default() += 1;
// `get`, not `[..]`: a corrupt or foreign `captured` (a Lightroom catalog's
// captureTime is copied verbatim) may not be ASCII, and slicing it on a byte index
// would panic — the same rule as `dates::group_label`.
if let (Some(year), Some(month), Some(day)) = (d.get(..4), d.get(..7), d.get(..10)) {
let (months, days) = years.entry(year).or_default();
*months.entry(month).or_default() += 1;
*days.entry(day).or_default() += 1;
}
}
let newest_first = |m: BTreeMap<&str, usize>| m.into_iter().rev().map(|(k, n)| (k.to_string(), n)).collect::<Vec<_>>();
+94
View File
@@ -231,6 +231,60 @@ impl Catalog {
}
}
impl Catalog {
/// The bracket sets among `ids`, an HDR merge each: every stack one of them is in (its photos, less earlier merge
/// results), then the unstacked ones in capture order, a set running while each starts within about a second of the
/// previous one's end (its shutter time; capture times are whole seconds). A run is a set when its exposures span at
/// least ½ EV: a burst at one exposure is no bracket. Photos without a capture time or shutter speed are left out.
pub fn bracket_sets(&self, ids: &[PhotoId]) -> Vec<Vec<PhotoId>> {
let merged = |p: &crate::Photo| crate::query::merged_kind(&p.file_name).is_some();
let mut sets: Vec<Vec<PhotoId>> = Vec::new();
let mut stacks = HashSet::new();
let mut loose = Vec::new();
for id in ids {
match self.stack_of(*id) {
Some(s) if stacks.insert(s.id) => {
let set: Vec<PhotoId> = s.photos.iter().copied().filter(|p| self.photos.get(p).is_some_and(|p| !merged(p))).collect();
if set.len() >= 2 {
sets.push(set);
}
}
Some(_) => {}
None => loose.push(*id),
}
}
// (capture second, file name, id, shutter seconds, EV)
let mut timed: Vec<(i64, &str, PhotoId, f64, f64)> = loose
.iter()
.filter_map(|id| self.photos.get(id))
.filter(|p| !p.deleted && !merged(p))
.filter_map(|p| {
let t = iso_seconds(p.captured.as_deref()?)?;
let s = crate::model::parse_shutter_seconds(&p.meta.shutter)?;
let n = p.meta.aperture.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0) as f64;
let iso = p.meta.iso.filter(|i| *i > 0).unwrap_or(100) as f64;
Some((t, p.file_name.as_str(), p.id, s, (s * iso / (n * n)).log2()))
})
.collect();
timed.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| cmp_name(a.1, b.1)).then(a.2.cmp(&b.2)));
timed.dedup_by_key(|x| x.2);
let mut runs: Vec<Vec<(i64, &str, PhotoId, f64, f64)>> = Vec::new();
for x in timed {
match runs.last_mut() {
Some(r) if r.last().is_some_and(|l| (x.0 - l.0) as f64 <= l.3.ceil() + 1.0) => r.push(x),
_ => runs.push(vec![x]),
}
}
for r in runs {
let (lo, hi) = r.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| (lo.min(x.4), hi.max(x.4)));
if r.len() >= 2 && hi - lo >= 0.5 {
sets.push(r.iter().map(|x| x.2).collect());
}
}
sets
}
}
/// Seconds since 1970-01-01 for an ISO 8601 local time (`2026-04-01T10:00:00`, `2026-04-01 10:00`,
/// `2026-04-01`; fractions and zone suffixes are ignored). `None` if it doesn't parse.
pub fn iso_seconds(s: &str) -> Option<i64> {
@@ -358,6 +412,46 @@ mod tests {
assert_eq!(back.clone().alloc_stack_id(), c.clone().alloc_stack_id());
}
#[test]
fn bracket_sets_by_time_and_exposure() {
// (time, shutter, ISO, file name)
let shots = [
("2026-08-01T09:20:36", "1/125", 640, "A1.ARW"), // a handheld bracket
("2026-08-01T09:20:36", "1/125", 200, "A2.ARW"),
("2026-08-01T09:20:37", "1/125", 2000, "A3.ARW"),
("2026-08-01T09:20:37", "", 0, "A3-HDR.dng"), // an earlier merge of it
("2026-08-01T09:20:44", "1/125", 800, "B1.ARW"), // 7 s later: the next bracket
("2026-08-01T09:20:44", "1/125", 250, "B2.ARW"),
("2026-08-01T09:20:50", "1/1000", 160, "C1.ARW"), // a burst at one exposure
("2026-08-01T09:20:50", "1/1000", 160, "C2.ARW"),
("2026-06-27T23:34:59", "2", 320, "N1.ARW"), // 2 s night frames, 2.1 s apart
("2026-06-27T23:35:01", "2", 100, "N2.ARW"),
("2026-06-27T23:35:04", "2", 800, "N3.ARW"),
("2026-06-27T23:36:00", "1/40", 250, "S1.ARW"), // stacked by hand: merged whatever its times
("2026-06-27T23:39:00", "1/40", 250, "S2.ARW"),
];
let mut c = Catalog::new();
let mut ids = Vec::new();
for (t, sh, iso, name) in shots {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 60, 40, "2026-01-01T00:00:00");
p.captured = Some(t.into());
p.meta.shutter = sh.into();
p.meta.iso = Some(iso);
p.meta.aperture = Some(5.6);
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
ids.push(id);
}
let op = c.group_ops(ids[11], &[ids[12]], true).unwrap();
c.apply(op).unwrap();
let sets = c.bracket_sets(&ids);
assert_eq!(sets, vec![vec![ids[11], ids[12]], vec![ids[8], ids[9], ids[10]], vec![ids[0], ids[1], ids[2]], vec![ids[4], ids[5]]]);
// a stack's merge result on top is left out; a stack selected twice is merged once
let op = c.stack_with_ops(ids[3], &[ids[0], ids[1], ids[2]]).unwrap();
c.apply(op).unwrap();
assert_eq!(c.bracket_sets(&[ids[3], ids[0]]), vec![vec![ids[0], ids[1], ids[2]]]);
}
#[test]
fn auto_stack_by_capture_time() {
let times = ["2026-04-01T10:00:00", "2026-04-01T10:00:02", "2026-04-01T10:00:09", "2026-04-01T11:00:00", "2026-04-01T11:00:01"];
+38 -2
View File
@@ -73,6 +73,16 @@ impl FsStore {
}
}
/// A new staging file at `tmp`: whatever is already there (a leftover from a crashed save, or a link) is removed first
/// (a link itself, never what it points to), then the file is created exclusively, so a write never follows a link
/// planted at that name into another file (issue #1502).
fn create_staging(tmp: &std::path::Path) -> io::Result<std::fs::File> {
if std::fs::symlink_metadata(tmp).is_ok() {
std::fs::remove_file(tmp)?;
}
std::fs::OpenOptions::new().write(true).create_new(true).open(tmp)
}
impl Store for FsStore {
fn read(&mut self, name: &str) -> io::Result<Option<Vec<u8>>> {
match std::fs::read(self.path(name)) {
@@ -89,7 +99,7 @@ impl Store for FsStore {
}
let tmp = self.path(&format!("{name}.tmp"));
{
let mut f = std::fs::File::create(&tmp)?;
let mut f = create_staging(&tmp)?;
f.write_all(data)?;
f.sync_all()?;
}
@@ -105,7 +115,7 @@ impl Store for FsStore {
}
let tmp = self.path(&format!("{name}.tmp"));
let written = (|| {
let mut w = Counter { inner: io::BufWriter::with_capacity(1 << 20, std::fs::File::create(&tmp)?), n: 0 };
let mut w = Counter { inner: io::BufWriter::with_capacity(1 << 20, create_staging(&tmp)?), n: 0 };
fill(&mut w)?;
w.flush()?;
let f = w.inner.into_inner().map_err(|e| e.into_error())?;
@@ -231,3 +241,29 @@ impl Store for MemStore {
self.background.then(|| Box::new(self.clone()) as Box<dyn Store>)
}
}
#[cfg(test)]
mod tests_staging {
use super::*;
/// A link planted at a staging name (`<name>.tmp`) is removed, never followed: the file it points to keeps its
/// bytes and the save still lands (issue #1502).
#[cfg(unix)]
#[test]
fn a_link_at_the_staging_name_is_never_written_through() {
let dir = std::env::temp_dir().join(format!("lc-staging-link-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut store = FsStore::open(&dir).unwrap();
let victim = dir.join("victim.txt");
std::fs::write(&victim, b"keep").unwrap();
std::os::unix::fs::symlink(&victim, dir.join("catalog.snap.tmp")).unwrap();
store.write_atomic("catalog.snap", b"{}").unwrap();
assert_eq!(std::fs::read(&victim).unwrap(), b"keep");
assert_eq!(std::fs::read(dir.join("catalog.snap")).unwrap(), b"{}");
std::os::unix::fs::symlink(&victim, dir.join("catalog.log.tmp")).unwrap();
store.write_atomic_with("catalog.log", &mut |w| w.write_all(b"[1]")).unwrap();
assert_eq!(std::fs::read(&victim).unwrap(), b"keep");
assert_eq!(std::fs::read(dir.join("catalog.log")).unwrap(), b"[1]");
let _ = std::fs::remove_dir_all(&dir);
}
}
+234
View File
@@ -0,0 +1,234 @@
//! How many photos each album shows, kept up to date from what the latest ops touched.
//!
//! A smart album's count asks every photo whether its rules hold. Asked again after every edit, that is a pass over
//! the library for each smart album each time a keyword is given. [`AlbumTally`] remembers which photos each smart
//! album holds, and after a small edit asks only the photos the edit touched ([`Catalog::touched_since`]). An op
//! that changes more than photos of its own (an album's rules or photos, a stack, the keyword list) has it counted
//! whole again, as before.
use std::collections::{HashMap, HashSet};
use crate::{AlbumId, Aspects, Catalog, PhotoId};
/// The most photos an edit may touch for the counts to follow it photo by photo.
const MAX_FOLLOWED: usize = 256;
/// Bits are kept while they cost no more than this many words a photo held, and these to start with. Past that
/// (photo ids far apart: a library whose ids don't count up from 1) a set is kept instead, so what an album
/// remembers is always in proportion to the photos it holds, never to the largest id in the library.
const WORDS_A_MEMBER: usize = 4;
const WORDS_TO_START: usize = 1024;
/// The photos a smart album holds.
#[derive(Clone, Debug)]
enum Members {
/// One bit a photo id, and how many are set.
Bits {
words: Vec<u64>,
len: usize,
},
Set(HashSet<PhotoId>),
}
impl Members {
fn none() -> Members {
Members::Bits { words: Vec::new(), len: 0 }
}
fn len(&self) -> usize {
match self {
Members::Bits { len, .. } => *len,
Members::Set(set) => set.len(),
}
}
#[cfg(test)]
fn holds(&self, id: PhotoId) -> bool {
match self {
Members::Bits { words, .. } => {
usize::try_from(id.0 / 64).ok().and_then(|at| words.get(at)).is_some_and(|word| word & (1u64 << (id.0 % 64)) != 0)
}
Members::Set(set) => set.contains(&id),
}
}
/// Put `id` in or take it out.
fn set(&mut self, id: PhotoId, held: bool) {
if let Members::Bits { words, len } = self {
let bit = 1u64 << (id.0 % 64);
match usize::try_from(id.0 / 64) {
Ok(at) if at < words.len() => {
if let Some(word) = words.get_mut(at) {
match (*word & bit != 0, held) {
(false, true) => {
*word |= bit;
*len = len.saturating_add(1);
}
(true, false) => {
*word &= !bit;
*len = len.saturating_sub(1);
}
_ => {}
}
}
return;
}
// (past the end: not held, so there is nothing to take out)
_ if !held => return,
// room for it, as long as the bits stay in proportion to the photos held
Ok(at) if at < len.saturating_mul(WORDS_A_MEMBER).saturating_add(WORDS_TO_START) => {
words.resize(at + 1, 0);
if let Some(word) = words.get_mut(at) {
*word |= bit;
*len = len.saturating_add(1);
}
return;
}
// ids too far apart for bits: a set from here on
_ => {
let ids = words
.iter()
.enumerate()
.flat_map(|(at, word)| (0..64u64).filter(move |b| word & (1u64 << b) != 0).map(move |b| PhotoId(at as u64 * 64 + b)));
*self = Members::Set(ids.collect());
}
}
}
if let Members::Set(set) = self {
if held {
set.insert(id);
} else {
set.remove(&id);
}
}
}
}
/// [`Catalog::album_count`] of every album, as of one revision of the catalog.
#[derive(Clone, Debug)]
pub struct AlbumTally {
revision: u64,
counts: HashMap<AlbumId, usize>,
/// The smart albums' photos.
members: HashMap<AlbumId, Members>,
}
impl AlbumTally {
/// Counted from every photo.
pub fn of(cat: &Catalog) -> AlbumTally {
let mut counts = HashMap::new();
let mut members = HashMap::new();
for album in cat.albums() {
if album.is_smart() {
let mut held = Members::none();
for p in cat.photos().filter(|p| cat.album_contains(album.id, p)) {
held.set(p.id, true);
}
counts.insert(album.id, held.len());
members.insert(album.id, held);
} else {
counts.insert(album.id, cat.album_count(album.id));
}
}
AlbumTally { revision: cat.revision, counts, members }
}
/// The count of each album.
pub fn counts(&self) -> &HashMap<AlbumId, usize> {
&self.counts
}
/// The catalog's revision the counts are of.
pub fn revision(&self) -> u64 {
self.revision
}
/// Bring the counts up to `cat` as it is now. `true` when that was done from the photos the ops since touched
/// (or nothing had changed), `false` when every photo was asked again.
///
/// `cat` has to be the catalog the counts were made of, further along. Another catalog is told apart only by
/// its revisions: a library that is loaded takes a range of its own (and then everything is counted again), but
/// two catalogs both counting from 0 look like one.
pub fn update(&mut self, cat: &Catalog) -> bool {
if self.revision == cat.revision {
return true;
}
let followed = match (cat.touched_since(self.revision), cat.aspects_since(self.revision)) {
(Some(touched), Some(changed)) if touched.len() <= MAX_FOLLOWED => {
self.follow(cat, &touched, changed);
true
}
_ => false,
};
if !followed {
*self = AlbumTally::of(cat);
}
followed
}
/// The counts after ops that touched only `touched`: each smart album asks those photos again; an album of
/// chosen photos counts again only when a photo came, went or was deleted (who is in it did not change: that
/// is an op on the album, and not followed).
fn follow(&mut self, cat: &Catalog, touched: &[PhotoId], changed: Aspects) {
for (album, held) in &mut self.members {
for id in touched {
held.set(*id, cat.photo(*id).is_some_and(|p| cat.album_contains(*album, p)));
}
self.counts.insert(*album, held.len());
}
if changed.any_of(Aspects::PRESENCE) {
for album in cat.albums().filter(|a| !a.is_smart()) {
self.counts.insert(album.id, cat.album_count(album.id));
}
}
self.revision = cat.revision;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ids(m: &Members, of: &[u64]) -> Vec<u64> {
of.iter().copied().filter(|id| m.holds(PhotoId(*id))).collect()
}
#[test]
fn members_are_kept_as_bits_at_the_edges_of_a_word_and_past_the_end() {
let mut m = Members::none();
let probe = [0, 1, 63, 64, 65, 127, 128, 5000];
assert!(ids(&m, &probe).is_empty() && m.len() == 0);
for id in [0, 63, 64, 5000] {
m.set(PhotoId(id), true);
}
assert_eq!((ids(&m, &probe), m.len()), (vec![0, 63, 64, 5000], 4));
// put in twice, taken out twice, taken out where nothing is, far past the end: counted once each way
m.set(PhotoId(63), true);
m.set(PhotoId(64), false);
m.set(PhotoId(64), false);
m.set(PhotoId(1), false);
m.set(PhotoId(900_000), false);
assert_eq!((ids(&m, &probe), m.len()), (vec![0, 63, 5000], 3));
assert!(matches!(m, Members::Bits { .. }));
}
#[test]
fn photo_ids_far_apart_are_kept_as_a_set_in_proportion_to_the_photos_held() {
// one photo with an id in the thousands of millions: no bit for every id up to it
let mut m = Members::none();
for id in [3, 70, 9_000_000_000] {
m.set(PhotoId(id), true);
}
assert!(matches!(m, Members::Set(_)), "a set once the ids are too far apart for bits");
assert_eq!((ids(&m, &[3, 70, 71, 9_000_000_000, u64::MAX]), m.len()), (vec![3, 70, 9_000_000_000], 3));
m.set(PhotoId(u64::MAX), true);
m.set(PhotoId(3), false);
assert_eq!((ids(&m, &[3, 70, 9_000_000_000, u64::MAX]), m.len()), (vec![70, 9_000_000_000, u64::MAX], 3));
// an album of many photos with ids counting up stays bits, however many
let mut dense = Members::none();
for id in 1..=200_000u64 {
dense.set(PhotoId(id), true);
}
assert!(matches!(&dense, Members::Bits { words, .. } if words.len() <= 200_000 / 64 + 1));
assert_eq!(dense.len(), 200_000);
}
}
+114 -2
View File
@@ -123,6 +123,23 @@ fn filter_search_sort() {
assert_eq!(c.keywords(), vec![("ocean".to_string(), 1), ("summer".to_string(), 1)]);
}
/// 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
/// the rest instead of panicking on a char boundary.
#[test]
fn date_groups_ignores_a_non_ascii_captured() {
let mut c = Catalog::new();
let ok = photo(&mut c, "ok.jpg", "2026-05-01T10:00:00");
// `AddPhoto` takes the field as it is, like `library.importLightroom` building the op
let bad = photo(&mut c, "bad.jpg", "日本語日本語");
assert_ne!(ok, bad);
let g = c.date_groups();
assert_eq!(g.len(), 1, "the one well-formed date still groups");
assert_eq!((g[0].year.as_str(), g[0].count), ("2026", 1));
assert_eq!(g[0].days.iter().map(|d| d.1).sum::<usize>(), 1);
}
#[test]
fn snapshot_and_log_replay() {
let mut c = Catalog::new();
@@ -265,13 +282,30 @@ fn preview_only_survives_serde_and_set_content_undo() {
let old: Catalog = serde_json::from_value(v).unwrap();
assert_eq!(old.photo(a).unwrap().preview_only, None);
assert!(old.photo(a).unwrap().develops_raw());
// Reload: the file decodes now → cleared; undo (through the journal's JSON) restores it
let op = Op::SetContent { id: a, width: 6016, height: 4000, file_size: 1, content_hash: None, preview_only: None };
// a newer build still can't decode it: the reason and the stamp of the version that decided
// it are recorded together (#662)
let op = Op::SetContent {
id: a,
width: 6000,
height: 4000,
file_size: 1,
content_hash: None,
preview_only: Some("Nikon Huffman-compressed NEF".into()),
preview_checked: "0.6.0".into(),
};
c.apply(op).unwrap();
assert_eq!(c.photo(a).unwrap().preview_checked, "0.6.0");
// Reload: the file decodes now → cleared, with its stamp; undo (through the journal's JSON)
// restores both
let op =
Op::SetContent { id: a, width: 6016, height: 4000, file_size: 1, content_hash: None, preview_only: None, preview_checked: String::new() };
let inv = c.apply(op).unwrap();
assert!(c.photo(a).unwrap().develops_raw());
assert_eq!(c.photo(a).unwrap().preview_checked, "");
let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap();
c.apply(inv).unwrap();
assert_eq!(c.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF"));
assert_eq!(c.photo(a).unwrap().preview_checked, "0.6.0");
assert_eq!(c.photo(a).unwrap().width, 6000);
}
@@ -737,3 +771,81 @@ fn cmp_name_ignores_case_without_allocating() {
assert_eq!(cmp_name("DSC_0001", "DSC_0002"), Less);
assert_eq!(cmp_name("a", "ab"), Less);
}
/// 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]
fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() {
let mut c = Catalog::default();
let photo = |c: &mut Catalog, name: &str| {
let id = c.alloc_photo_id();
c.apply(Op::AddPhoto {
photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{name}") }, name, "JPEG", 60, 40, "2026-01-01T10:00:00")),
})
.unwrap();
id
};
let (a, b) = (photo(&mut c, "a.jpg"), photo(&mut c, "b.jpg"));
let start = c.revision;
assert_eq!(c.touched_since(start), Some(vec![]));
assert_eq!(c.aspects_since(start), Some(Aspects::NONE));
// a rating, then a flag on another photo, as one batch
c.apply(Op::SetRating { id: a, rating: 3 }).unwrap();
assert_eq!(c.touched_since(start), Some(vec![a]));
assert_eq!(c.aspects_since(start), Some(Aspects::RATING));
let after_rating = c.revision;
c.apply(Op::Batch { ops: vec![Op::SetFlag { id: b, flag: Flag::Pick }, Op::SetRating { id: a, rating: 4 }] }).unwrap();
assert_eq!(c.touched_since(start), Some(vec![a, b]), "each once");
assert_eq!(c.touched_since(after_rating), Some(vec![a, b]));
assert_eq!(c.aspects_since(after_rating), Some(Aspects::FLAG.with(Aspects::RATING)));
assert_eq!(c.touched_since(c.revision), Some(vec![]));
// a photo that goes counts for everything
let before = c.revision;
c.apply(Op::RemovePhoto { id: b }).unwrap();
assert_eq!(c.touched_since(before), Some(vec![b]));
assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::PRESENCE) && x.any_of(Aspects::FILE)));
// an op that changes more than photos of its own: nothing is said of what came before it
let before = c.revision;
let album = c.alloc_album_id();
c.apply(Op::AddAlbum { album: Album::new(album, "Trip") }).unwrap();
assert_eq!(c.touched_since(before), None);
assert_eq!(c.aspects_since(start), None);
assert_eq!(c.touched_since(c.revision), Some(vec![]), "from after it, it is known again");
// a revision that is not this catalog's past
assert_eq!(c.touched_since(c.revision + 5), None);
// a revision set from outside (a library just loaded): nothing is known of how the catalog got there, and after
// the next op nothing of before it
let old = c.revision;
c.revision = 7 << 32;
assert_eq!(c.touched_since(old), None);
assert_eq!(c.aspects_since(c.revision), None);
let loaded = c.revision;
c.apply(Op::SetRating { id: a, rating: 1 }).unwrap();
assert_eq!(c.touched_since(loaded), Some(vec![a]));
assert_eq!(c.touched_since(old), None);
// an op that fails changes nothing, and what is remembered never says less than what happened
let before = c.revision;
assert!(c.apply(Op::SetRating { id: PhotoId(9_999), rating: 1 }).is_err());
assert_eq!(c.revision, before);
c.apply(Op::SetFlag { id: a, flag: Flag::Pick }).unwrap();
assert!(c.touched_since(before).is_some_and(|ids| ids.contains(&a)));
assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::FLAG)));
// a batch that fails midway is undone as a whole; a batch in a batch is remembered photo by photo; a batch
// with an op that changes more than photos is not
let before = c.revision;
assert!(c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 5 }, Op::SetRating { id: PhotoId(9_999), rating: 1 }] }).is_err());
assert_eq!((c.revision, c.photo(a).map(|p| p.rating)), (before, Some(1)));
c.apply(Op::Batch { ops: vec![Op::Batch { ops: vec![Op::SetRating { id: a, rating: 2 }] }] }).unwrap();
// (the photo the failed batch asked for and never found may be named too: more is said, never less)
assert!(c.touched_since(before).is_some_and(|ids| ids.contains(&a)));
let before = c.revision;
c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 3 }, Op::RenameAlbum { id: album, name: "Journey".into() }] }).unwrap();
assert_eq!(c.touched_since(before), None);
// more ops than are remembered: the oldest are answered with "anything"
let long_ago = c.revision;
for i in 0..9000u32 {
c.apply(Op::SetRating { id: a, rating: (i % 5) as u8 }).unwrap();
}
assert_eq!(c.touched_since(long_ago), None);
assert_eq!(c.touched_since(c.revision - 10), Some(vec![a]));
}
+28 -2
View File
@@ -74,6 +74,29 @@ fn v3_library_loads_and_is_upgraded() {
assert_eq!(snapshot_version(&m), u64::from(VERSION));
}
/// A v6 library (before preview-only photos recorded which version decided it, #662) opens with
/// an empty stamp on its preview-only photos, so they are rechecked once, and is rewritten in the
/// current format.
#[test]
fn v6_preview_only_photo_loads_unstamped_and_is_upgraded() {
let mut c = Catalog::new();
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::File { path: "/p/a.cr3".into() }, "a.cr3", "CR3", 160, 120, "2026-01-01");
p.kind = MediaKind::Raw;
p.preview_only = Some("Cr3 files are not decoded yet".into());
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
let snap = c.to_snapshot();
assert!(!snap.contains("preview_checked"), "an empty stamp isn't written");
let m = MemStore::new();
m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":6,\"seq\":1,\"catalog\":{snap}}}\n").into_bytes());
let (_, loaded, r) = Journal::open(Box::new(m.clone())).unwrap();
let p = loaded.photo(id).unwrap();
assert_eq!(p.preview_only.as_deref(), Some("Cr3 files are not decoded yet"));
assert_eq!(p.preview_checked, "");
assert_eq!(r.upgraded_from, Some(6));
assert_eq!(snapshot_version(&m), u64::from(VERSION));
}
#[test]
fn versionless_log_only_library_is_upgraded() {
let mut c = Catalog::new();
@@ -171,7 +194,8 @@ fn bad_crc_is_still_a_torn_tail() {
/// Guard for the version rule (see `journal` → *Format versions*): adding an [`Op`] variant breaks
/// this match. When it does, bump `journal::VERSION`, add a row to the format table, and add the
/// variant here under the new version.
/// variant here under the new version. A version that only adds serialized fields raises
/// `NEWEST_FIELDS` instead.
#[test]
fn op_variants_are_versioned() {
fn since(op: &Op) -> u32 {
@@ -214,6 +238,8 @@ fn op_variants_are_versioned() {
Op::SetHistoryCursor { .. } => 8,
}
}
/// v9: `Photo.preview_checked` and `Op::SetContent.preview_checked`.
const NEWEST_FIELDS: u32 = 9;
let newest = since(&Op::SetHistoryCursor { id: PhotoId(1), cursor: None });
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
assert_eq!(newest.max(NEWEST_FIELDS), VERSION, "the newest op's or fields' version must be the current format version");
}
+116 -15
View File
@@ -75,14 +75,12 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
let end = (p + 2 + len).min(b.len());
let seg = &b[start..end];
match marker {
0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC => {
if seg.len() >= 6 && m.components == 0 {
m.precision = seg[0];
m.sof = marker;
m.height = u16::from_be_bytes([seg[1], seg[2]]) as u32;
m.width = u16::from_be_bytes([seg[3], seg[4]]) as u32;
m.components = seg[5];
}
0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC && seg.len() >= 6 && m.components == 0 => {
m.precision = seg[0];
m.sof = marker;
m.height = u16::from_be_bytes([seg[1], seg[2]]) as u32;
m.width = u16::from_be_bytes([seg[3], seg[4]]) as u32;
m.components = seg[5];
}
0xE1 => {
if seg.starts_with(b"Exif\0\0") && m.exif.is_none() {
@@ -182,9 +180,11 @@ pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback:
check_size(F, m.width as u64, m.height as u64, opts)?;
let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= mw.saturating_mul(2) || m.height >= mh.saturating_mul(2)));
// zune-jpeg handles the common cases fastest; jpeg-decoder covers DCT scaling, CMYK/YCCK,
// 12-bit and non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling).
// 12-bit, non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling)
// and every lossless (SOF3) file, which zune-jpeg refuses outright.
let lossless = m.sof == 0xC3;
let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components;
let raw = if m.components == 4 || scale_to.is_some() || m.precision > 8 || non_interleaved {
let raw = if m.components == 4 || scale_to.is_some() || m.precision > 8 || non_interleaved || lossless {
decode_jpeg_decoder(bytes, &m, scale_to)?
} else {
decode_zune(bytes, &m)?
@@ -221,7 +221,9 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>)
use jpeg_decoder::PixelFormat;
let mut d = jpeg_decoder::Decoder::new(bytes);
d.read_info().map_err(|e| Error::Malformed(F, e.to_string()))?;
if let Some((mw, mh)) = scale_to {
// `scale` is the DCT size, not a lossless resample: on a lossless frame it would cut the
// output to the top-left of each plane. Those decode at full size; `finish` scales after.
if let Some((mw, mh)) = scale_to.filter(|_| m.sof != 0xC3) {
// Ask for the smallest DCT scale still covering the target box.
let (sw, sh) = scaled_request(m.width, m.height, mw, mh);
d.scale(sw, sh).map_err(|e| Error::Malformed(F, e.to_string()))?;
@@ -231,16 +233,25 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>)
let (w, h) = (info.width as usize, info.height as usize);
let (model, buf, depth) = match info.pixel_format {
PixelFormat::L8 => (Model::Gray, Buf::U8(px), 8),
PixelFormat::L16 => {
let v = px.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
(Model::Gray, Buf::U16(v), 16)
}
PixelFormat::L16 => (Model::Gray, Buf::U16(lossless_samples(&px, m.precision)), 16),
// A lossless frame over 8 bits per sample still reports `RGB24` (or `CMYK32`), but its
// buffer holds two native-endian bytes per sample like L16.
PixelFormat::RGB24 if m.precision > 8 => (Model::Rgb, Buf::U16(lossless_samples(&px, m.precision)), 16),
PixelFormat::RGB24 => (Model::Rgb, Buf::U8(px), 8),
PixelFormat::CMYK32 if m.precision > 8 => (Model::Cmyk, Buf::U16(lossless_samples(&px, m.precision)), 16),
PixelFormat::CMYK32 => (Model::Cmyk, Buf::U8(px), 8),
};
Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf, bit_depth: depth })
}
/// jpeg-decoder's lossless samples: one native-endian `u16` per sample holding the raw
/// `precision`-bit value. Left-justify it into the 16-bit range the rest of the pipeline reads,
/// as every 16-bit decoder feeding [`Buf::U16`] does.
fn lossless_samples(px: &[u8], precision: u8) -> Vec<u16> {
let shift = 16u8.saturating_sub(precision);
px.as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes([c[0], c[1]]) << shift).collect()
}
/// The first `0xFF` byte, eight bytes at a time (scan data has one every few hundred bytes: this
/// is most of [`header`]'s time).
fn find_ff(b: &[u8]) -> Option<usize> {
@@ -450,4 +461,94 @@ mod tests {
let m = parse_markers(&[0xFF, 0xD8, 0xFF, 0xE1, 0, 200, 1, 2]).unwrap();
assert!(m.exif.is_none());
}
// --- lossless (SOF3) synthetic files: one DHT table with a single 1-bit category-0 code, so
// every pixel is a zero difference and decodes to the predictor's initial sample. No such
// encoder exists here; the bytes are the smallest a decoder accepts.
fn lossless_header(out: &mut Vec<u8>, components: u8, w: u16, precision: u8) {
// DHT: one DC table (id 0) with a single 1-bit code for the category-0 symbol.
out.extend_from_slice(&[0xFF, 0xC4]);
out.extend_from_slice(&(2u16 + 1 + 16 + 1).to_be_bytes());
out.push(0x00);
let mut counts = [0u8; 16];
counts[0] = 1;
out.extend_from_slice(&counts);
out.push(0x00);
// SOF3
out.extend_from_slice(&[0xFF, 0xC3]);
out.extend_from_slice(&(8u16 + 3 * components as u16).to_be_bytes());
out.push(precision);
out.extend_from_slice(&1u16.to_be_bytes());
out.extend_from_slice(&w.to_be_bytes());
out.push(components);
for c in 1..=components {
out.extend_from_slice(&[c, 0x11, 0]);
}
// SOS: predictor Ra, Se = 0, Ah/Al = 0
out.extend_from_slice(&[0xFF, 0xDA]);
out.extend_from_slice(&(6u16 + 2 * components as u16).to_be_bytes());
out.push(components);
for c in 1..=components {
out.extend_from_slice(&[c, 0]);
}
out.extend_from_slice(&[1, 0, 0]);
}
/// A minimal lossless JPEG, `w` x 1, `components` samples per pixel, every sample the initial
/// prediction `1 << (precision - 1)`.
fn lossless_jpeg(components: u8, w: u16, precision: u8) -> Vec<u8> {
let mut out = vec![0xFF, 0xD8];
lossless_header(&mut out, components, w, precision);
// entropy: one zero bit per sample (a category-0 code), padded to whole bytes
out.extend_from_slice(&vec![0u8; (components as usize * w as usize).div_ceil(8)]);
out.extend_from_slice(&[0xFF, 0xD9]);
out
}
/// jpeg-decoder emits lossless samples native-endian (its `to_ne_bytes`) holding the raw
/// `precision`-bit value, so the old `from_be_bytes` swapped every sample (2048 -> 8) and the
/// raw value was never left-justified into the 16-bit buffer.
#[test]
fn lossless_grayscale_samples_are_native_endian_and_left_justified() {
let bytes = lossless_jpeg(1, 8, 12);
let m = parse_markers(&bytes).unwrap();
let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap();
let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") };
assert_eq!(v, &vec![2048u16 << 4; 8], "0x0800 native-endian, left-justified");
assert_eq!((raw.model, raw.bit_depth), (Model::Gray, 16));
}
/// A lossless frame over 8 bits per sample reports `RGB24`, but its buffer is two bytes per
/// sample: it must become `Buf::U16`, not `Buf::U8` over double-length rows.
#[test]
fn lossless_high_precision_rgb_is_16_bit() {
let bytes = lossless_jpeg(3, 2, 12);
let m = parse_markers(&bytes).unwrap();
let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap();
let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") };
assert_eq!(v, &vec![2048u16 << 4; 6]);
assert_eq!(raw.model, Model::Rgb);
}
/// The same 16-bit wrapping for four components: `CMYK32` over 8 bits is two bytes a sample.
#[test]
fn lossless_high_precision_cmyk_is_16_bit() {
let bytes = lossless_jpeg(4, 2, 12);
let m = parse_markers(&bytes).unwrap();
let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap();
let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") };
assert_eq!(v, &vec![2048u16 << 4; 8]);
assert_eq!((raw.model, raw.bit_depth), (Model::Cmyk, 16));
}
/// zune-jpeg refuses SOF3 ("Lossless Huffman"), so an 8-bit lossless file must route to
/// jpeg-decoder; `header` accepts it, so `decode` used to fail a file we listed as readable.
#[test]
fn eight_bit_lossless_jpeg_decodes() {
let bytes = lossless_jpeg(1, 8, 8);
let d = crate::decode(&bytes, DecodeOptions::default()).unwrap();
assert_eq!((d.width, d.height), (8, 1));
assert_eq!(d.bit_depth, 8);
}
}
+285 -34
View File
@@ -3,8 +3,11 @@
//! The image is cut into bands of MCU rows separated by restart markers (`DRI` + `RSTn`): DC
//! prediction restarts at every marker, so bands are entropy-coded independently on all cores and
//! concatenated. Quantization uses the example tables of T.81 Annex K scaled by the usual 1–100
//! quality convention; Huffman coding uses the Annex K example tables (interleaved baseline, readable
//! by every decoder). Supports grayscale and YCbCr 4:4:4 / 4:2:0.
//! quality convention. Huffman tables are made for each image from its own symbol counts (T.81
//! Annex K.2): a first pass over the bands counts the symbols, a second codes them. The file stays
//! interleaved baseline with the tables in its `DHT` segments, readable by every decoder, and is
//! smaller than with the Annex K example tables at the same pixels. Supports grayscale and YCbCr
//! 4:4:4 / 4:2:0.
use crate::encode::ChromaSubsampling;
@@ -61,16 +64,103 @@ impl Huff {
let (mut c, mut k) = (0u16, 0usize);
for (len, &n) in bits.iter().enumerate() {
for _ in 0..n {
code[vals[k] as usize] = (c, len as u8 + 1);
c += 1;
if let Some(&v) = vals.get(k) {
code[v as usize] = (c, len as u8 + 1);
}
c = c.wrapping_add(1);
k += 1;
}
c <<= 1;
c = c.wrapping_shl(1);
}
Huff { code }
}
}
/// Symbol counts of one Huffman table: index = symbol (DC size category, or AC run/size byte).
type Freq = [u64; 256];
/// The code lengths (`BITS`, counts per length 1..16) and symbols (`HUFFVAL`) of an optimal
/// Huffman table for `freq`, limited to 16-bit codes and never assigning the all-ones code
/// (T.81 Annex K.2, figures K.1–K.4). Symbols with a zero count get no code. At least one symbol
/// is always present (a table nothing used gets symbol 0), so the result is a valid `DHT`.
fn optimal_table(freq: &Freq) -> ([u8; 16], Vec<u8>) {
// 256 is the reserved pseudo-symbol: counted once, it takes the longest code, which is
// then dropped so no real code is all ones (K.2)
let mut f = [0u64; 257];
f[..256].copy_from_slice(freq);
if f[..256].iter().all(|&c| c == 0) {
f[0] = 1;
}
f[256] = 1;
let mut size = [0usize; 257];
let mut next: [Option<usize>; 257] = [None; 257];
loop {
// the two smallest non-zero counts; on equal counts the larger symbol goes first (K.1)
let mut c1: Option<usize> = None;
for i in 0..257 {
if f[i] > 0 && c1.is_none_or(|c| f[i] <= f[c]) {
c1 = Some(i);
}
}
let Some(c1) = c1 else { break };
let mut c2: Option<usize> = None;
for i in 0..257 {
if i != c1 && f[i] > 0 && c2.is_none_or(|c| f[i] <= f[c]) {
c2 = Some(i);
}
}
let Some(c2) = c2 else { break };
f[c1] = f[c1].saturating_add(f[c2]);
f[c2] = 0;
// every symbol in both trees goes one level deeper; c2's chain is appended to c1's
let mut k = c1;
size[k] += 1;
while let Some(n) = next[k] {
k = n;
size[k] += 1;
}
next[k] = Some(c2);
let mut k = c2;
size[k] += 1;
while let Some(n) = next[k] {
k = n;
size[k] += 1;
}
}
// counts per code length; 257 symbols need at most 256 levels
let mut bits = [0usize; 257];
for &s in &size {
if s > 0 {
bits[s.min(256)] += 1;
}
}
// limit to 16 bits (K.3): move pairs of the longest codes up, one level at a time (the
// deepest level of a full tree always holds an even number of codes)
for i in (17..257).rev() {
while bits[i] >= 2 {
let Some(j) = (1..i - 1).rev().find(|&j| bits[j] > 0) else { break };
bits[i] -= 2;
bits[i - 1] += 1;
bits[j + 1] += 2;
bits[j] -= 1;
}
}
// drop the reserved symbol's code: it is one of the longest
if let Some(i) = (1..=16).rev().find(|&i| bits[i] > 0) {
bits[i] -= 1;
}
// symbols by code length, then by value (K.4); the reserved symbol is not listed
let mut vals = Vec::new();
for len in 1..257 {
vals.extend((0..256).filter(|&s| size[s] == len).map(|s| s as u8));
}
let mut out = [0u8; 16];
for (o, &b) in out.iter_mut().zip(&bits[1..=16]) {
*o = b.min(255) as u8;
}
(out, vals)
}
fn scaled_quant(base: &[u16; 64], quality: u8) -> [u16; 64] {
let q = quality.clamp(1, 100) as u32;
let scale = if q < 50 { 5000 / q } else { 200 - 2 * q };
@@ -177,13 +267,15 @@ fn magnitude(v: i32) -> (u32, u32) {
(size, bits & ((1u32 << size) - 1))
}
fn encode_block(w: &mut Bits, q: &[i16; 64], pred: &mut i32, dc: &Huff, ac: &Huff) {
/// The Huffman symbols of one quantized block in coding order: `sym(ac, symbol, bits, n)` for the
/// DC size category (`ac` false) and each AC run/size, ZRL and EOB (`ac` true), each followed by
/// its `n` extra bits `bits`.
#[inline(always)]
fn block_symbols(q: &[i16; 64], pred: &mut i32, mut sym: impl FnMut(bool, usize, u32, u32)) {
let d = q[0] as i32 - *pred;
*pred = q[0] as i32;
let (s, b) = magnitude(d);
let (c, l) = dc.code[s as usize];
w.put(c as u32, l as u32);
w.put(b, s);
sym(false, s as usize, b, s);
let mut run = 0u32;
for &zi in &ZIGZAG[1..] {
let v = q[zi] as i32;
@@ -192,22 +284,45 @@ fn encode_block(w: &mut Bits, q: &[i16; 64], pred: &mut i32, dc: &Huff, ac: &Huf
continue;
}
while run >= 16 {
let (c, l) = ac.code[0xF0];
w.put(c as u32, l as u32);
sym(true, 0xF0, 0, 0);
run -= 16;
}
let (s, b) = magnitude(v);
let (c, l) = ac.code[((run << 4) | s) as usize];
w.put(c as u32, l as u32);
w.put(b, s);
sym(true, ((run << 4) | s) as usize, b, s);
run = 0;
}
if run > 0 {
let (c, l) = ac.code[0x00];
w.put(c as u32, l as u32);
sym(true, 0x00, 0, 0);
}
}
/// One coded symbol of the entropy-coded data: bits 0–7 the symbol, 8–9 its table (0 DC luma,
/// 1 AC luma, 2 DC chroma, 3 AC chroma), 10–13 the number of extra bits, 14–29 those bits (a
/// baseline coefficient needs at most 11).
type Token = u32;
/// Append one block's symbols to `out` and count them in `freq` (by table, as in [`Token`]).
fn tokenize_block(q: &[i16; 64], pred: &mut i32, chroma: bool, out: &mut Vec<Token>, freq: &mut [Freq; 4]) {
let base = if chroma { 2 } else { 0 };
block_symbols(q, pred, |is_ac, s, bits, n| {
let table = base + is_ac as usize;
freq[table][s & 0xFF] += 1;
out.push((s as u32 & 0xFF) | (table as u32) << 8 | (n & 0xF) << 10 | (bits & 0xFFFF) << 14);
});
}
/// Code a band's symbols with `tables` (indexed as in [`Token`]).
fn write_tokens(tokens: &[Token], tables: &[Huff; 4]) -> Vec<u8> {
let mut w = Bits::new();
for &t in tokens {
let (s, table, n, bits) = (t & 0xFF, (t >> 8) & 3, (t >> 10) & 0xF, t >> 14);
let (c, l) = tables[table as usize].code[s as usize];
w.put(c as u32, l as u32);
w.put(bits, n);
}
w.finish()
}
/// Planar component samples (level-shifted to −128..127) with edge replication on read.
struct Plane {
w: usize,
@@ -249,7 +364,8 @@ fn dht(out: &mut Vec<u8>, class_id: u8, bits: &[u8; 16], vals: &[u8]) {
segment(out, 0xC4, &p);
}
/// Encode interleaved 8-bit samples (`channels` 1, 3 or 4; alpha is dropped).
/// Encode interleaved 8-bit samples (`channels` 1, 3 or 4; alpha is dropped), with Huffman
/// tables made for the image.
pub fn encode(
data: &[u8],
width: usize,
@@ -258,6 +374,21 @@ pub fn encode(
quality: u8,
sub: ChromaSubsampling,
app_segments: &[(u8, Vec<u8>)],
) -> Vec<u8> {
encode_with(data, width, height, channels, quality, sub, app_segments, true)
}
/// [`encode`], with optimized Huffman tables or (`optimize` false) the Annex K example tables.
#[allow(clippy::too_many_arguments)]
fn encode_with(
data: &[u8],
width: usize,
height: usize,
channels: usize,
quality: u8,
sub: ChromaSubsampling,
app_segments: &[(u8, Vec<u8>)],
optimize: bool,
) -> Vec<u8> {
let gray = channels == 1;
let (hs, vs) = match (gray, sub) {
@@ -331,42 +462,67 @@ pub fn encode(
let ql = scaled_quant(&Q_LUMA, quality);
let qc = scaled_quant(&Q_CHROMA, quality);
let (rl, rc) = (recip_table(&ql), recip_table(&qc));
let (dcl, acl) = (Huff::new(&DC_LUMA_BITS, &DC_VALS), Huff::new(&AC_LUMA_BITS, &AC_LUMA_VALS));
let (dcc, acc) = (Huff::new(&DC_CHROMA_BITS, &DC_VALS), Huff::new(&AC_CHROMA_BITS, &AC_CHROMA_VALS));
let (mcu_w, mcu_h) = (8 * hs, 8 * vs);
let (mx, my) = (width.div_ceil(mcu_w), height.div_ceil(mcu_h));
// restart interval: whole MCU rows per band, ≤ 65535 MCUs, enough bands to keep all cores busy
let rows_per_band = (my / 64).clamp(1, (65_535 / mx.max(1)).max(1));
let bands: Vec<usize> = (0..my.div_ceil(rows_per_band)).collect();
let encode_band = |band: &usize| -> Vec<u8> {
let mut w = Bits::new();
let mut pred = [0i32; 3];
// first pass: each band's quantized blocks as symbols, with their counts per table (DC
// prediction restarts per band, as the restart markers say)
let tokenize_band = |band: &usize| -> (Vec<Token>, [Freq; 4]) {
let (mut blk, mut q) = ([0f32; 64], [0i16; 64]);
let (mut tokens, mut freq) = (Vec::new(), [[0u64; 256]; 4]);
let mut pred = [0i32; 3];
for mcu_y in band * rows_per_band..((band + 1) * rows_per_band).min(my) {
for mcu_x in 0..mx {
for vy in 0..vs {
for hx in 0..hs {
planes[0].block(mcu_x * hs + hx, mcu_y * vs + vy, &mut blk);
fdct_quant(&blk, &rl, &mut q);
encode_block(&mut w, &q, &mut pred[0], &dcl, &acl);
tokenize_block(&q, &mut pred[0], false, &mut tokens, &mut freq);
}
}
if !gray {
for k in 1..3 {
planes[k].block(mcu_x, mcu_y, &mut blk);
for (plane, pred) in planes.iter().zip(pred.iter_mut()).take(3).skip(1) {
plane.block(mcu_x, mcu_y, &mut blk);
fdct_quant(&blk, &rc, &mut q);
encode_block(&mut w, &q, &mut pred[k], &dcc, &acc);
tokenize_block(&q, pred, true, &mut tokens, &mut freq);
}
}
}
}
w.finish()
(tokens, freq)
};
#[cfg(feature = "parallel")]
let coded: Vec<Vec<u8>> = bands.par_iter().map(encode_band).collect();
let tokenized: Vec<(Vec<Token>, [Freq; 4])> = bands.par_iter().map(tokenize_band).collect();
#[cfg(not(feature = "parallel"))]
let coded: Vec<Vec<u8>> = bands.iter().map(encode_band).collect();
let tokenized: Vec<(Vec<Token>, [Freq; 4])> = bands.iter().map(tokenize_band).collect();
drop(planes);
// the tables: DC luma, AC luma, DC chroma, AC chroma
let tables: [([u8; 16], Vec<u8>); 4] = if optimize {
let mut total = [[0u64; 256]; 4];
for (_, f) in &tokenized {
for (t, f) in total.iter_mut().zip(f) {
for (t, f) in t.iter_mut().zip(f) {
*t = t.saturating_add(*f);
}
}
}
total.each_ref().map(optimal_table)
} else {
[
(DC_LUMA_BITS, DC_VALS.to_vec()),
(AC_LUMA_BITS, AC_LUMA_VALS.to_vec()),
(DC_CHROMA_BITS, DC_VALS.to_vec()),
(AC_CHROMA_BITS, AC_CHROMA_VALS.to_vec()),
]
};
let huff = tables.each_ref().map(|(bits, vals)| Huff::new(bits, vals));
// second pass: code the symbols
#[cfg(feature = "parallel")]
let coded: Vec<Vec<u8>> = tokenized.into_par_iter().map(|(t, _)| write_tokens(&t, &huff)).collect();
#[cfg(not(feature = "parallel"))]
let coded: Vec<Vec<u8>> = tokenized.into_iter().map(|(t, _)| write_tokens(&t, &huff)).collect();
// headers
let mut out = Vec::with_capacity(coded.iter().map(Vec::len).sum::<usize>() + 4096);
@@ -396,11 +552,12 @@ pub fn encode(
sof.extend_from_slice(&[3, 1, ((hs as u8) << 4) | vs as u8, 0, 2, 0x11, 1, 3, 0x11, 1]);
}
segment(&mut out, 0xC0, &sof);
dht(&mut out, 0x00, &DC_LUMA_BITS, &DC_VALS);
dht(&mut out, 0x10, &AC_LUMA_BITS, &AC_LUMA_VALS);
let [(dlb, dlv), (alb, alv), (dcb, dcv), (acb, acv)] = &tables;
dht(&mut out, 0x00, dlb, dlv);
dht(&mut out, 0x10, alb, alv);
if !gray {
dht(&mut out, 0x01, &DC_CHROMA_BITS, &DC_VALS);
dht(&mut out, 0x11, &AC_CHROMA_BITS, &AC_CHROMA_VALS);
dht(&mut out, 0x01, dcb, dcv);
dht(&mut out, 0x11, acb, acv);
}
let interval = (rows_per_band * mx) as u16;
if coded.len() > 1 {
@@ -495,6 +652,100 @@ mod tests {
}
}
/// The code lengths of `(bits, vals)`, checked: every listed symbol once, at most 16 bits,
/// and room left in the code space (the all-ones code is never assigned, Kraft sum < 1).
fn check_table(bits: &[u8; 16], vals: &[u8]) {
assert_eq!(bits.iter().map(|&b| b as usize).sum::<usize>(), vals.len());
let mut seen = [false; 256];
for &v in vals {
assert!(!seen[v as usize], "symbol {v} listed twice");
seen[v as usize] = true;
}
let kraft: f64 = bits.iter().enumerate().map(|(i, &n)| n as f64 / 2f64.powi(i as i32 + 1)).sum();
assert!(kraft < 1.0, "code space full: {kraft}");
let h = Huff::new(bits, vals);
for &v in vals {
let (c, l) = h.code[v as usize];
assert!((1..=16).contains(&l));
assert_ne!(c as u32, (1u32 << l) - 1, "symbol {v} has the all-ones code");
}
}
#[test]
fn optimal_tables_are_valid_and_bounded() {
// one symbol, two, a typical spread and every symbol
let mut one = [0u64; 256];
one[7] = 5;
let mut two = [0u64; 256];
two[0] = 1;
two[0xF0] = 1_000_000;
let typical: Freq = std::array::from_fn(|s| if s % 3 == 0 { (s as u64 * 7919) % 1000 + 1 } else { 0 });
let all: Freq = std::array::from_fn(|s| s as u64 + 1);
// Fibonacci counts make an unbounded Huffman tree ~40 levels deep: the 16-bit limit applies
let mut fib = [0u64; 256];
let (mut a, mut b) = (1u64, 1u64);
for f in fib.iter_mut().take(40) {
*f = a;
(a, b) = (b, a + b);
}
for (name, f) in [("one", one), ("two", two), ("typical", typical), ("all", all), ("fib", fib), ("none", [0; 256])] {
let (bits, vals) = optimal_table(&f);
check_table(&bits, &vals);
for s in 0..256 {
assert_eq!(vals.contains(&(s as u8)), f[s] > 0 || (name == "none" && s == 0), "{name}: symbol {s}");
}
}
// more frequent symbols never get longer codes
let (bits, vals) = optimal_table(&typical);
let h = Huff::new(&bits, &vals);
for &x in &vals {
for &y in &vals {
if typical[x as usize] > typical[y as usize] {
assert!(h.code[x as usize].1 <= h.code[y as usize].1, "{x} vs {y}");
}
}
}
// the Annex K example tables pass the same checks
check_table(&AC_LUMA_BITS, &AC_LUMA_VALS);
check_table(&DC_CHROMA_BITS, &DC_VALS);
}
/// Issue #1373: tables made for the image give a smaller file with exactly the same pixels as
/// the Annex K example tables (the quantized coefficients are the same), at every quality and
/// subsampling, and both decoders read it.
#[test]
fn optimized_tables_shrink_files_without_changing_pixels() {
let (w, h) = (517, 333);
// gradients plus deterministic noise, like a photo's grain
let mut seed = 0x2545_f491_u32;
let src: Vec<u8> = test_rgb(w, h)
.into_iter()
.map(|v| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
v.saturating_add((seed % 23) as u8)
})
.collect();
for (q, sub) in [(100, ChromaSubsampling::S444), (92, ChromaSubsampling::S444), (75, ChromaSubsampling::S420), (30, ChromaSubsampling::S420)]
{
let std = encode_with(&src, w, h, 3, q, sub, &[], false);
let opt = encode(&src, w, h, 3, q, sub, &[]);
assert!(opt.len() < std.len(), "q{q} {sub:?}: {} ≥ {}", opt.len(), std.len());
let a = jpeg_decoder::Decoder::new(&std[..]).decode().expect("standard tables");
let b = jpeg_decoder::Decoder::new(&opt[..]).decode().expect("optimized tables");
assert_eq!(a, b, "q{q} {sub:?}: pixels differ");
let z = zune_jpeg::JpegDecoder::new(zune_core::bytestream::ZCursor::new(&opt[..])).decode().expect("zune-jpeg");
assert_eq!(z.len(), b.len());
}
// grayscale too
let gray: Vec<u8> = src.iter().step_by(3).copied().collect();
let std = encode_with(&gray, w, h, 1, 90, ChromaSubsampling::S444, &[], false);
let opt = encode(&gray, w, h, 1, 90, ChromaSubsampling::S444, &[]);
assert!(opt.len() < std.len());
assert_eq!(jpeg_decoder::Decoder::new(&std[..]).decode().unwrap(), jpeg_decoder::Decoder::new(&opt[..]).decode().unwrap());
}
#[test]
fn grayscale_and_quality_monotone() {
let (w, h) = (300, 200);
+2
View File
@@ -12,6 +12,7 @@
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and, on native targets, AVIF (feature `avif`) and
//! JPEG XL ([`encode_jxl`], feature `jxl`), embedding
//! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export.
//! - [`video`] encodes AV1 video (native, feature `avif`) and writes it as MP4 or WebM.
//!
//! No decoder panics on malformed input (property-tested).
#![forbid(unsafe_code)]
@@ -36,6 +37,7 @@ mod psd;
mod sniff;
pub mod space;
mod tiff_codec;
pub mod video;
mod webp;
pub use encode::{
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -900,11 +900,24 @@ pub struct BrushStroke {
pub density: f64,
pub erase: bool,
pub auto_mask: bool,
/// Pen pressure (0..1) at each of `points`, recorded when the brush's pressure switch is on: it scales each dab's
/// radius and flow. Empty (the default, a mouse or the switch off) is full pressure everywhere; a point past the
/// end of a shorter list takes the last value.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub pressure: Vec<f32>,
}
impl BrushStroke {
/// The pressure at point `i` (0..1; 1 without pressure data, a hostile value clamped, NaN as 1).
pub fn pressure_at(&self, i: usize) -> f32 {
let p = self.pressure.get(i).or(self.pressure.last()).copied().unwrap_or(1.0);
if p.is_nan() { 1.0 } else { p.clamp(0.0, 1.0) }
}
}
impl Default for BrushStroke {
fn default() -> Self {
Self { points: Vec::new(), size: 0.03, feather: 50.0, flow: 100.0, density: 100.0, erase: false, auto_mask: false }
Self { points: Vec::new(), size: 0.03, feather: 50.0, flow: 100.0, density: 100.0, erase: false, auto_mask: false, pressure: Vec::new() }
}
}
+1
View File
@@ -29,6 +29,7 @@ lightcraft-faces = { workspace = true }
miniz_oxide = { workspace = true }
lightcraft-merge = { workspace = true }
lightcraft-geocode = { workspace = true }
lightcraft-slideshow = { workspace = true }
rayon = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+7 -2
View File
@@ -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 (the web build) embeds only the UI font, BIZ UDPGothic Regular (~4.5 MB), to keep the
//! wasm32 embeds BIZ UDPGothic Regular (~4.5 MB) and Noto Sans Thai (~214 KiB), 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 _;
@@ -69,6 +69,7 @@ fn craft_fonts(dir: &Path, required: bool) -> Result<String, String> {
let mut out = String::new();
let mut has_japanese_ui = false;
let mut has_chinese_ui = false;
let mut has_thai_ui = false;
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
let [family, style, file, scripts, ..] = f.as_slice() else {
@@ -76,7 +77,8 @@ fn craft_fonts(dir: &Path, required: bool) -> Result<String, String> {
};
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");
if wasm && (*family, *style) != ("BIZ UDPGothic", "Regular") {
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")) {
continue;
}
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
@@ -89,6 +91,9 @@ fn craft_fonts(dir: &Path, required: bool) -> Result<String, String> {
path.display().to_string(),
);
}
if required && !has_thai_ui {
return Err("required Thai UI face Noto Sans Thai Regular (Thai) is missing from fonts/manifest.txt".into());
}
if required && !has_japanese_ui {
return Err("required Japanese UI face BIZ UDPGothic Regular is missing from fonts/manifest.txt".into());
}
+499
View File
@@ -0,0 +1,499 @@
//! Measure where a camera's own JPEG sits inside its raw sensor data, and which edge bands of that data hold no
//! picture: `cargo xtask raw-frame [--json] FILE…` (or `cargo run --release -p lightcraft-engine --example raw_frame --
//! [--json] FILE…`). It checks the crop any decoder applies against the camera's own framing, and measures the entries
//! of Nikon `MEASURED_BODIES` (`crates/raw/src/vendor/nef.rs`) for bodies that record
//! no crop. It works for any colour-filter raw that embeds a full-size camera JPEG (checked on Nikon, Sony, Canon CR2 /
//! CR3, Pentax and Samsung files, where it finds the decoder's crop); bodies with a smaller embedded preview (most
//! Fujifilm, Panasonic and Olympus files) get the edge bands only.
//!
//! **Frame.** For each raw it aligns the largest embedded JPEG with the sensor data: both are reduced to half-size
//! luminance (the raw by summing each 2×2 colour-filter block), and every offset that keeps the JPEG inside the data is
//! scored by the correlation of their gradient magnitudes. The best offset is refined to sub-pixel precision; a peak
//! that is weak, ambiguous or between two pixels is flagged. Files are grouped by model and data size (bit depths
//! pooled: both read out the same area); when 3 clean files agree, the group prints its table entries (siblings of the
//! same readout that agree on the same frame count towards the 3; see `MIN_FILES`). Use several
//! files with detail across the frame: a blank sky, fog or a lens cap has nothing to align on (low score). The JPEG
//! must be the camera's full-size one, unrotated and without in-camera geometry corrections (distortion control off);
//! a smaller preview is not aligned.
//!
//! **Edge bands.** From each edge inwards (at most 128 lines) it counts lines that hold no picture: *empty or dark*
//! (mean below 1% of the range over black) or *masked* (flat, patternless and above black: even and odd samples
//! alike within 4 standard errors or within 0.4% of white, since some filler holds a small fixed offset between the
//! two, as on the D90 and D5000; spread no more than the line's own neighbour noise and under 0.4% of white). A
//! band that the decoder's crop still includes is developed as picture: the coloured bars of issue #976. This is a
//! survey aid: a dark band can also be deep shadow, so read it with the frame.
use std::collections::BTreeMap;
use lightcraft_codecs::DecodeOptions;
use lightcraft_raw::RawData;
/// Smallest correlation peak worth trusting, and how far it must stand above the best offset outside its 3×3
/// neighbourhood (measured: 0.75–0.88 on detailed D5100 / D7000 frames, runners-up ≥ 0.05 lower).
const MIN_SCORE: f64 = 0.5;
const MIN_MARGIN: f64 = 0.02;
/// Largest sub-pixel distance from a whole pixel accepted as a clean alignment.
const MAX_FRACTION: f64 = 0.35;
/// Search at most this many half-size offsets in each direction (384 px of margin around the JPEG: Canon bodies keep
/// up to ~280 px of masked border).
const MAX_SEARCH: usize = 192;
/// Windows of up to this many offsets are searched exhaustively (every Nikon body); larger ones coarse to fine.
const FULL_SEARCH: usize = 2048;
/// Edge bands are counted up to this many lines from each edge.
const MAX_BAND: usize = 128;
/// Clean, agreeing files an entry needs: of one model and data size (bit depths pooled: both read out the same area),
/// or, with at least one of its own, together with sibling models of the same readout (data and JPEG size) whose own
/// files agree on the same frame.
const MIN_FILES: usize = 3;
struct Frame {
jpeg: (usize, usize),
/// Sub-pixel offset of the JPEG's top left corner in sensor pixels.
at: (f64, f64),
score: f64,
margin: f64,
}
impl Frame {
fn whole(&self) -> (usize, usize) {
(self.at.0.round().max(0.0) as usize, self.at.1.round().max(0.0) as usize)
}
fn clean(&self) -> bool {
let frac = |v: f64| (v - v.round()).abs();
self.score >= MIN_SCORE && self.margin >= MIN_MARGIN && frac(self.at.0) <= MAX_FRACTION && frac(self.at.1) <= MAX_FRACTION
}
}
/// Lines without picture at one edge: empty or dark ones, then masked ones (or the other way round), from the edge.
#[derive(Clone, Copy, Default)]
struct Band {
dark: usize,
masked: usize,
}
impl Band {
fn total(self) -> usize {
self.dark + self.masked
}
}
struct Measured {
model: String,
size: (usize, usize),
bits: u32,
black: f64,
white: f64,
/// The crop the decoder applies, in sensor-data coordinates: x, y, width, height.
crop: (usize, usize, usize, usize),
/// Left, right, top, bottom.
bands: [Band; 4],
frame: Result<Frame, String>,
/// With a clean frame: what the crop holds beyond it.
beyond: Option<[Beyond; 4]>,
}
impl Measured {
/// Lines of each edge band (left, right, top, bottom) inside the decoder's crop: developed as picture.
fn in_crop(&self) -> [usize; 4] {
let (w, h) = self.size;
let (x, y, cw, ch) = self.crop;
let [l, r, t, b] = self.bands.map(Band::total);
[l.saturating_sub(x), (x + cw).saturating_sub(w - r.min(w)), t.saturating_sub(y), (y + ch).saturating_sub(h - b.min(h))]
}
}
/// What the decoder's crop holds outside the camera's JPEG frame, per side (left, right, top, bottom): lines that are
/// masked (flat above black: a coloured bar), dark (empty or optical black: a dark edge) or picture (trimmed by the
/// camera, harmless).
#[derive(Clone, Copy, Default)]
struct Beyond {
masked: usize,
dark: usize,
picture: usize,
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let json = args.iter().any(|a| a == "--json");
let files: Vec<&String> = args.iter().filter(|a| *a != "--json").collect();
if files.is_empty() {
eprintln!("usage: raw_frame [--json] FILE… (raw files whose largest embedded JPEG is the camera's full-size one)");
std::process::exit(2);
}
// model, data size, JPEG size -> (bit depth, frame) of each file
let mut groups: BTreeMap<(String, (usize, usize), (usize, usize)), Vec<(u32, Frame)>> = BTreeMap::new();
for f in files {
let m = match measure(f) {
Ok(m) => m,
Err(e) => {
if json {
println!("{}", serde_json::json!({ "file": f, "error": e }));
} else {
println!("{f}: skipped: {e}");
}
continue;
}
};
let in_crop = m.in_crop();
if json {
let band = |b: Band| serde_json::json!({ "dark": b.dark, "masked": b.masked });
let frame = match &m.frame {
Ok(fr) => serde_json::json!({
"jpeg": [fr.jpeg.0, fr.jpeg.1], "at": [fr.at.0, fr.at.1], "score": fr.score, "margin": fr.margin, "clean": fr.clean()
}),
Err(e) => serde_json::json!({ "error": e }),
};
println!(
"{}",
serde_json::json!({
"file": f, "model": m.model, "size": [m.size.0, m.size.1], "bits": m.bits, "black": m.black, "white": m.white,
"crop": [m.crop.0, m.crop.1, m.crop.2, m.crop.3],
"bands": { "left": band(m.bands[0]), "right": band(m.bands[1]), "top": band(m.bands[2]), "bottom": band(m.bands[3]) },
"in_crop": { "left": in_crop[0], "right": in_crop[1], "top": in_crop[2], "bottom": in_crop[3] },
"beyond_frame": m.beyond.map(|b| {
let side = |b: Beyond| serde_json::json!({ "masked": b.masked, "dark": b.dark, "picture": b.picture });
serde_json::json!({ "left": side(b[0]), "right": side(b[1]), "top": side(b[2]), "bottom": side(b[3]) })
}),
"frame": frame,
})
);
} else {
let frame = match &m.frame {
Ok(fr) => format!(
"JPEG {}×{} at ({:.2}, {:.2}), score {:.3}, margin {:.3}{}",
fr.jpeg.0,
fr.jpeg.1,
fr.at.0,
fr.at.1,
fr.score,
fr.margin,
if fr.clean() { "" } else { " (not clean: weak, ambiguous or between pixels)" }
),
Err(e) => format!("no frame: {e}"),
};
println!("{f}: {} {}×{} {}-bit, {frame}", m.model, m.size.0, m.size.1, m.bits);
let side = |name: &str, b: Band| match (b.dark, b.masked) {
(0, 0) => None,
(d, 0) => Some(format!("{name} {d} dark")),
(0, k) => Some(format!("{name} {k} masked")),
(d, k) => Some(format!("{name} {d} dark + {k} masked")),
};
let bands: Vec<String> = ["left", "right", "top", "bottom"].iter().zip(m.bands).filter_map(|(n, b)| side(n, b)).collect();
let shown: Vec<String> =
["left", "right", "top", "bottom"].iter().zip(in_crop).filter(|(_, n)| *n > 0).map(|(s, n)| format!("{s} {n}")).collect();
println!(
" edge bands: {}; decoder crop ({}, {}, {}, {}){}",
if bands.is_empty() { "none".to_string() } else { bands.join(", ") },
m.crop.0,
m.crop.1,
m.crop.2,
m.crop.3,
match m.beyond {
Some(b) => {
let parts: Vec<String> = ["left", "right", "top", "bottom"]
.iter()
.zip(b)
.filter(|(_, b)| b.masked + b.dark + b.picture > 0)
.map(|(s, b)| format!("{s} {} masked / {} dark / {} picture", b.masked, b.dark, b.picture))
.collect();
if parts.is_empty() {
"; nothing beyond the camera frame".to_string()
} else {
format!("; beyond the camera frame: {}", parts.join(", "))
}
}
None if shown.is_empty() => String::new(),
None => format!(" still includes edge bands: {} lines", shown.join(", ")),
}
);
}
if let Ok(fr) = m.frame {
groups.entry((m.model.clone(), m.size, fr.jpeg)).or_default().push((m.bits, fr));
}
}
if json {
return;
}
println!();
// each group's agreed frame: (model, data size, JPEG size) -> (offset, clean files), when its clean files agree
let agreed: BTreeMap<&(String, (usize, usize), (usize, usize)), ((usize, usize), usize)> = groups
.iter()
.filter_map(|(key, frames)| {
let offsets: Vec<(usize, usize)> = frames.iter().filter(|(_, f)| f.clean()).map(|(_, f)| f.whole()).collect();
let first = *offsets.first()?;
offsets.iter().all(|o| *o == first).then_some((key, (first, offsets.len())))
})
.collect();
for (key, frames) in &groups {
let (model, size, jpeg) = key;
let clean = frames.iter().filter(|(_, f)| f.clean()).count();
let mut depths: Vec<u32> = frames.iter().map(|(b, _)| *b).collect();
depths.sort_unstable();
depths.dedup();
let label = format!("{model} {}×{}", size.0, size.1);
let Some(&((x, y), _)) = agreed.get(key) else {
if clean == 0 {
println!("{label}: no clean alignment in {} files; no entry", frames.len());
} else {
let offsets: Vec<(usize, usize)> = frames.iter().filter(|(_, f)| f.clean()).map(|(_, f)| f.whole()).collect();
println!("{label}: files disagree: {offsets:?}; no entry");
}
continue;
};
let note = if clean < frames.len() { format!(", {} not clean", frames.len() - clean) } else { String::new() };
println!("{label}: {clean} of {} files agree on ({x}, {y}){note}", frames.len());
// siblings: other models whose sensor readout matches (same data and JPEG size) and whose own files agree on
// the same frame
let siblings: Vec<(&str, usize)> = agreed
.iter()
.filter(|((m, s, j), (o, _))| m != model && s == size && j == jpeg && *o == (x, y))
.map(|((m, _, _), (_, n))| (m.as_str(), *n))
.collect();
let pooled = clean + siblings.iter().map(|(_, n)| n).sum::<usize>();
if clean < MIN_FILES {
if pooled < MIN_FILES {
println!(" pending: {} more clean file(s) needed for an entry", MIN_FILES - pooled);
continue;
}
let names: Vec<String> = siblings.iter().map(|(m, n)| format!("{m} ({n})")).collect();
println!(" with siblings of the same readout and frame: {}", names.join(", "));
}
for bits in depths {
println!(
" MeasuredBody {{ model: {model:?}, size: ({}, {}), bits: {bits}, crop: ({x}, {y}, {}, {}), white: None }},",
size.0, size.1, jpeg.0, jpeg.1
);
}
}
}
fn measure(path: &str) -> Result<Measured, String> {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
let raw = lightcraft_raw::decode(&bytes).map_err(|e| e.to_string())?;
let RawData::U16(d) = &raw.data else { return Err("not 16-bit integer sensor data".into()) };
if raw.cpp != 1 || raw.cfa.is_none() {
return Err("not colour-filter sensor data".into());
}
let (w, h) = (raw.width, raw.height);
if w < 4 || h < 4 || d.len() < w.saturating_mul(h) {
return Err("sensor data shorter than its size".into());
}
let black = f64::from(raw.black.values.iter().sum::<f32>() / raw.black.values.len().max(1) as f32).max(0.0);
let white = f64::from(raw.white.first().copied().unwrap_or(((1u32 << raw.bits.min(16)) - 1) as f32)).max(black + 1.0);
let at = |x: usize, y: usize| f64::from(d.get(y * w + x).copied().unwrap_or(0));
let bands = [
band((0..w.min(MAX_BAND)).map(|x| line(h, |i| at(x, i))), black, white),
band((0..w.min(MAX_BAND)).map(|k| line(h, |i| at(w - 1 - k, i))), black, white),
band((0..h.min(MAX_BAND)).map(|y| line(w, |i| at(i, y))), black, white),
band((0..h.min(MAX_BAND)).map(|k| line(w, |i| at(i, h - 1 - k))), black, white),
];
// the decoder's crop is relative to the active area: in sensor-data coordinates here
let a = raw.active_area;
let c = lightcraft_raw::Rect::new(a.x + raw.crop.x, a.y + raw.crop.y, raw.crop.width, raw.crop.height);
let frame = frame(&bytes, d, w, h);
let beyond = frame.as_ref().ok().filter(|f| f.clean()).map(|f| {
let ((fx, fy), (fw, fh)) = (f.whole(), f.jpeg);
let kinds = |lines: Vec<Line>| {
lines.iter().fold(Beyond::default(), |mut b, l| {
match kind(l, black, white) {
Kind::Dark => b.dark += 1,
Kind::Masked => b.masked += 1,
Kind::Picture => b.picture += 1,
}
b
})
};
let col = |x: usize| line(h, |i| at(x, i));
let row = |y: usize| line(w, |i| at(i, y));
[
kinds((c.x..fx.min(c.x + c.width)).map(col).collect()),
kinds(((fx + fw).max(c.x)..c.x + c.width).map(col).collect()),
kinds((c.y..fy.min(c.y + c.height)).map(row).collect()),
kinds(((fy + fh).max(c.y)..c.y + c.height).map(row).collect()),
]
});
Ok(Measured {
model: raw.metadata.model.clone().unwrap_or_default().trim().to_string(),
size: (w, h),
bits: raw.bits,
black,
white,
crop: (c.x, c.y, c.width, c.height),
bands,
frame,
beyond,
})
}
/// Statistics of one line of sensor samples: mean, |even − odd| mean, spread, neighbour noise (same colour, 2 apart)
/// and the number of sample pairs.
struct Line {
mean: f64,
pattern: f64,
spread: f64,
noise: f64,
pairs: f64,
}
fn line(n: usize, at: impl Fn(usize) -> f64) -> Line {
let stride = ((n / 512).max(1)).div_ceil(2) * 2;
let (mut k, mut even, mut odd, mut var, mut noise) = (0f64, 0f64, 0f64, 0f64, 0f64);
let idx: Vec<usize> = (0..n.saturating_sub(2)).step_by(stride).collect();
for &i in &idx {
even += at(i);
odd += at(i + 1);
k += 1.0;
}
let (even, odd) = (even / k.max(1.0), odd / k.max(1.0));
for &i in &idx {
var += (at(i) - even).powi(2);
noise += (at(i + 2) - at(i)).powi(2) / 2.0;
}
Line { mean: (even + odd) / 2.0, pattern: (even - odd).abs(), spread: (var / k.max(1.0)).sqrt(), noise: (noise / k.max(1.0)).sqrt(), pairs: k }
}
enum Kind {
Dark,
Masked,
Picture,
}
fn kind(l: &Line, black: f64, white: f64) -> Kind {
let range = white - black;
let slack = white / 4096.0;
if l.mean < black + range * 0.01 {
Kind::Dark
} else if l.mean > black + range * 0.02
&& l.mean < white * 0.25
// patternless within the noise, or with a small fixed offset between the CFA phases (D90 / D5000 filler)
&& (l.pattern < 4.0 * l.noise * (2.0 / l.pairs.max(1.0)).sqrt() + slack || l.pattern < white * 0.004)
&& l.spread < 1.5 * l.noise + slack
&& l.spread < white * 0.004
{
Kind::Masked
} else {
Kind::Picture
}
}
fn band(lines: impl Iterator<Item = Line>, black: f64, white: f64) -> Band {
let mut b = Band::default();
for l in lines {
match kind(&l, black, white) {
Kind::Dark => b.dark += 1,
Kind::Masked => b.masked += 1,
Kind::Picture => break,
}
}
b
}
fn frame(bytes: &[u8], d: &[u16], w: usize, h: usize) -> Result<Frame, String> {
let jpeg = lightcraft_raw::embedded_preview(bytes).ok_or("no embedded preview")?;
let jpeg = lightcraft_codecs::decode(&jpeg, DecodeOptions::default()).map_err(|e| e.to_string())?;
let (jw, jh) = (jpeg.image.width, jpeg.image.height);
if jw > w || jh > h || jw * 10 < w * 9 || jh * 10 < h * 9 {
return Err(format!("the largest preview ({jw}×{jh}) is not a full-size JPEG of the {w}×{h} data"));
}
// half-size luminance, square-rooted so the linear raw and the JPEG weigh their tones alike
let (rw, rh) = (w / 2, h / 2);
let raw_y: Vec<f64> = (0..rh * rw)
.map(|i| {
let (x, y) = (2 * (i % rw), 2 * (i / rw));
let s: f64 =
[(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)].iter().map(|&(x, y)| f64::from(d.get(y * w + x).copied().unwrap_or(0))).sum();
s.sqrt()
})
.collect();
let (pw, ph) = (jw / 2, jh / 2);
let px = &jpeg.image.data;
let jpg_y: Vec<f64> = (0..ph * pw)
.map(|i| {
let (x, y) = (2 * (i % pw), 2 * (i / pw));
let s: f64 = [(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)]
.iter()
.map(|&(x, y)| px.get(y * jw + x).map_or(0.0, |p| f64::from(0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]).max(0.0)))
.sum();
s.sqrt()
})
.collect();
let (sx, sy) = ((rw - pw).min(MAX_SEARCH), (rh - ph).min(MAX_SEARCH));
let grad = |img: &[f64], stride: usize, x: usize, y: usize| {
let at = |x: usize, y: usize| img.get(y * stride + x).copied().unwrap_or(0.0);
(at(x + 1, y) - at(x, y)).hypot(at(x, y + 1) - at(x, y))
};
let normalise = |v: Vec<f64>| {
let n = v.len().max(1) as f64;
let mean = v.iter().sum::<f64>() / n;
let sd = (v.iter().map(|a| (a - mean).powi(2)).sum::<f64>() / n).sqrt().max(1e-12);
v.into_iter().map(|a| (a - mean) / sd).collect::<Vec<f64>>()
};
// gradient magnitudes on every `step`-th half-size pixel, normalised, and the correlation at one offset
let sample = |step: usize| {
let points: Vec<(usize, usize)> =
(0..ph.saturating_sub(1)).step_by(step).flat_map(|y| (0..pw.saturating_sub(1)).step_by(step).map(move |x| (x, y))).collect();
let gj = normalise(points.iter().map(|&(x, y)| grad(&jpg_y, pw, x, y)).collect());
(points, gj)
};
let score = |(points, gj): &(Vec<(usize, usize)>, Vec<f64>), dx: usize, dy: usize| {
let gr = normalise(points.iter().map(|&(x, y)| grad(&raw_y, rw, x + dx, y + dy)).collect());
gr.iter().zip(gj).map(|(a, b)| a * b).sum::<f64>() / gj.len().max(1) as f64
};
let fine = sample(2);
// every offset when the window is small (every Nikon body); else every 4th on sparser points, then each offset
// around the best of those
let mut scores = vec![vec![f64::NEG_INFINITY; sx + 1]; sy + 1];
let mut coarse_best = f64::NEG_INFINITY;
let (mut cx, mut cy) = (0, 0);
let window = if (sx + 1) * (sy + 1) <= FULL_SEARCH {
(0..=sx, 0..=sy)
} else {
let coarse = sample(8);
for dy in (0..=sy).step_by(4) {
for dx in (0..=sx).step_by(4) {
let v = score(&coarse, dx, dy);
if let Some(s) = scores.get_mut(dy).and_then(|r| r.get_mut(dx)) {
*s = v;
}
if v > coarse_best {
(coarse_best, cx, cy) = (v, dx, dy);
}
}
}
(cx.saturating_sub(5)..=(cx + 5).min(sx), cy.saturating_sub(5)..=(cy + 5).min(sy))
};
for dy in window.1.clone() {
for dx in window.0.clone() {
if let Some(s) = scores.get_mut(dy).and_then(|r| r.get_mut(dx)) {
*s = score(&fine, dx, dy);
}
}
}
let at = |dx: usize, dy: usize| scores.get(dy).and_then(|r| r.get(dx)).copied().unwrap_or(f64::NEG_INFINITY);
let (mut bx, mut by) = (0, 0);
for dy in 0..=sy {
for dx in 0..=sx {
if at(dx, dy) > at(bx, by) {
(bx, by) = (dx, dy);
}
}
}
let best = at(bx, by);
let runner_up = (0..=sy)
.flat_map(|dy| (0..=sx).map(move |dx| (dx, dy)))
.filter(|&(dx, dy)| dx.abs_diff(bx) > 1 || dy.abs_diff(by) > 1)
.map(|(dx, dy)| at(dx, dy))
.fold(f64::NEG_INFINITY, f64::max);
// parabola through the peak and its neighbours (none at the window's edge)
let refine = |a: f64, b: f64, c: f64| {
let k = a - 2.0 * b + c;
if a.is_finite() && c.is_finite() && k < 0.0 { (0.5 * (a - c) / k).clamp(-0.5, 0.5) } else { 0.0 }
};
let fx = if bx > 0 { refine(at(bx - 1, by), best, at(bx + 1, by)) } else { 0.0 };
let fy = if by > 0 { refine(at(bx, by - 1), best, at(bx, by + 1)) } else { 0.0 };
Ok(Frame {
jpeg: (jw, jh),
at: (2.0 * (bx as f64 + fx), 2.0 * (by as f64 + fy)),
score: best,
margin: if runner_up.is_finite() { best - runner_up } else { best },
})
}
+1 -1
View File
@@ -73,7 +73,7 @@ impl Unit {
pub struct TaskInfo {
pub id: u64,
/// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`,
/// `faces`, `denoise`, `findMissing`, `applePhotos`.
/// `faces`, `denoise`, `findMissing`, `applePhotos`, `cache`.
pub kind: &'static str,
/// English; the frontend translates it.
pub label: String,
+126 -22
View File
@@ -30,9 +30,10 @@ use lightcraft_raw::{
/// states, for every make (#778).
/// 4: a tone curve from black-and-white camera JPEGs (#577).
/// 5: dark JPEG luminance pairs for the camera toe (#1307).
/// 6: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected.
/// 7: the bundled NIKON Z 6_2 profile.
pub const LOOK_VERSION: u32 = 7;
/// 6: the ILCA-99M2 profile.
/// 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.
pub const LOOK_VERSION: u32 = 8;
#[derive(Clone, Debug)]
pub(crate) struct CameraLook {
@@ -65,6 +66,7 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool {
| RawFormat::Pef
| RawFormat::Srw
| RawFormat::Orf
| RawFormat::Erf
)
}
@@ -123,9 +125,30 @@ pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option<C
if lightcraft_raw::color::has_matrix(&raw.color) {
return None;
}
// (a merge frame arrives without its lens warp: `lightcraft_merge` removes it before calling)
// the white the source shows, decided before `starting_colour` may fill in measured matrices
let scale = crate::files::WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata);
let white = scale.dng_white(&raw.color, lightcraft_raw::color::as_shot_white_xy(raw));
// (a merge frame arrives without its lens warp: `lightcraft_merge` removes it before calling, and Export as DNG
// takes the lens corrections off first)
let (_, t, look) = starting_colour(raw, bytes, None);
dng_colour(&raw.color, &t, look.as_ref())
dng_colour(&raw.color, &white, &t, look.as_ref())
}
/// Give a raw about to be written as a DNG of its own (Export as DNG, Convert / Copy as DNG) the colour it opens
/// with, when the file has no colour matrices (issue #1745: a Z 9 NEF's DNG had no `ColorMatrix1`, and other raw
/// converters refused it). The lens corrections stay in the DNG; only the fit sees the raw without them, as when
/// the photo opens.
pub(crate) fn with_starting_colour(raw: &mut RawImage, bytes: &[u8]) {
if lightcraft_raw::color::has_matrix(&raw.color) {
return;
}
let list3 = raw.opcodes.list3.clone();
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
let tags = starting_colour_tags(raw, bytes);
raw.opcodes.list3 = list3;
if let Some(tags) = tags {
raw.color = tags;
}
}
/// DNG tags that render white-balanced camera RGB at its as-shot white as `t` with `look` on top does: one forward
@@ -134,25 +157,38 @@ pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option<C
/// doesn't keep each pixel's luminance as the fitted one does ([`HueSat`]), and the chroma curve has no tag; on two
/// CC0 CR2s (EOS 550D, 40D) an HDR merge of three copies renders within mean CIE76 ΔE 1.07 and 0.47 of the photo
/// (1.47 and 0.66 without the table; 26 and 23 with no colour tags at all).
/// The colour matrices declare `declared`, the white balance the source shows ([`crate::files::WbScaleOf::dng_white`]),
/// so the DNG opens at the source's As Shot temperature and tint and a white-balance edit starts from it. Matrices
/// derived from `forward` put the declared white wherever the look and the generic camera model took it: ARW merges
/// opened at about 7000 K / +115 where the ARWs show 4800 K / +8, and any white-balance edit turned them green.
/// `forward` is used only with the as-shot neutral, so the render at As Shot doesn't depend on them.
/// `None` when the white balance or the matrix can't give tags.
fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>) -> Option<ColorData> {
pub(crate) fn dng_colour(tags: &ColorData, declared: &crate::files::DngWhite, t: &CameraTransform, look: Option<&CameraLook>) -> Option<ColorData> {
let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix));
// the DNG model renders the camera's white at unit brightness: what a look does to white's brightness goes
// into the baseline exposure
let white = m.apply([1.0; 3]);
let k = (white[0] + white[1] + white[2]) / 3.0;
if !(k.is_finite() && k > 1e-6) || !t.wb.iter().all(|w| w.is_finite() && *w > 0.0) {
if !(k.is_finite() && k > 1e-6 && t.wb.iter().all(|w| w.is_finite() && *w > 0.0)) {
return None;
}
let m = Mat3(m.0.map(|r| r.map(|v| v / k)));
let forward = bradford(D65, D50).mul(&REC2020.to_xyz()).mul(&m);
let wb = t.wb.map(f64::from);
let neutral = wb.map(|w| wb.iter().cloned().fold(f64::MAX, f64::min) / w);
let camera_to_xyz = bradford(D50, t.white_xy).mul(&forward).mul(&Mat3::diag(1.0 / neutral[0], 1.0 / neutral[1], 1.0 / neutral[2]));
let (illuminant, color_matrix) = match declared {
crate::files::DngWhite::Matrices(c) if lightcraft_raw::color::has_matrix(c) => (c.illuminant, c.color_matrix),
crate::files::DngWhite::Matrices(_) => ([21, 0], [Some(declaring(t.white_xy, neutral)?), None]),
crate::files::DngWhite::At(xy) => ([21, 0], [Some(declaring(*xy, neutral)?), None]),
};
if !color_matrix.iter().flatten().all(|m| m.0.iter().flatten().all(|v| v.is_finite())) {
return None;
}
Some(ColorData {
illuminant: [21, 0],
color_matrix: [Some(camera_to_xyz.inverse()?), None],
forward_matrix: [Some(forward), None],
illuminant,
color_matrix,
// a second colour matrix comes with a second forward matrix (DNG spec): the same one, fitted at one white
forward_matrix: [Some(forward), color_matrix[1].map(|_| forward)],
camera_calibration: [None, None],
analog_balance: None,
as_shot_neutral: Some(neutral),
@@ -168,6 +204,17 @@ fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>)
})
}
/// An XYZ → camera colour matrix under which camera `neutral` is white `xy`: the generic camera model, scaled per
/// channel. `None` when the model gives no positive neutral there.
fn declaring(xy: Xy, neutral: [f64; 3]) -> Option<Mat3> {
let g = lightcraft_raw::color::xyz_to_camera(&ColorData::default(), xy);
let n = g.apply(xy.to_xyz());
if !n.iter().all(|v| v.is_finite() && *v > 1e-9) {
return None;
}
Some(Mat3::diag(neutral[0] / n[0], neutral[1] / n[1], neutral[2] / n[2]).mul(&g))
}
/// A camera tone curve as a DNG `ProfileToneCurve` (linear in, linear out, 1 = white): sampled densely over the
/// 12 stops below white, the range the curve is read back over (`files::dng_tone_curve`).
fn dng_tone_curve(tone: &CameraTone) -> Option<ToneCurve> {
@@ -2400,6 +2447,7 @@ mod tests {
RawFormat::Pef,
RawFormat::Srw,
RawFormat::Orf,
RawFormat::Erf,
];
assert!(local.into_iter().all(file_local_look));
// DNG carries its own colour model
@@ -2467,15 +2515,15 @@ mod tests {
assert_eq!(info.camera_tone.is_some(), look.is_some());
}
/// A public ILCE-7CR sample (skipped without the corpus) takes its colour from the bundled ILCE-7CR profile and
/// passes the acceptance gates with it: the fitted look keeps the profile's matrix and table, which the photo's
/// own fit (the fallback when a profile is rejected) would not.
#[test]
fn corpus_a7cr_uses_the_bundled_profile() {
/// A public sample of `model` (skipped without the corpus) takes its colour from the bundled profile for `model`
/// and passes the acceptance gates with it: the fitted look keeps the profile's matrix and table, which the
/// photo's own fit (the fallback when a profile is rejected) would not.
fn corpus_uses_the_bundled_profile(file: &str, model: &str) {
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw/arw-sony-a7cr-lossless-l.arw");
.join("raw")
.join(file);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
@@ -2484,12 +2532,12 @@ mod tests {
}
Err(e) => panic!("{}: {e}", path.display()),
};
let (_, json) = crate::camera_profiles::BUNDLED.iter().find(|(m, _)| *m == "ILCE-7CR").expect("ILCE-7CR is built in");
let (_, json) = crate::camera_profiles::BUNDLED.iter().find(|(m, _)| *m == model).expect("the model is built in");
let bundled: crate::camera_profiles::CameraProfile = serde_json::from_str(json).unwrap();
let used = crate::camera_profiles::get("ILCE-7CR").expect("ILCE-7CR profile");
assert_eq!(*used, bundled, "a local ILCE-7CR profile overrides the built-in one; remove it to run this test");
let used = crate::camera_profiles::get(model).expect("a profile for the model");
assert_eq!(*used, bundled, "a local {model} profile overrides the built-in one; remove it to run this test");
let raw = lightcraft_raw::decode(&bytes).unwrap();
assert_eq!(raw.metadata.model.as_deref(), Some("ILCE-7CR"));
assert_eq!(raw.metadata.model.as_deref(), Some(model));
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
let look = fit_preview(&raw, &bytes, &transform, None).0.expect("fit accepted");
let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap());
@@ -2534,6 +2582,16 @@ mod tests {
assert_eq!(look.hue_sat, bundled.hue_sat, "the look's table is not the profile's");
}
#[test]
fn corpus_a7cr_uses_the_bundled_profile() {
corpus_uses_the_bundled_profile("arw-sony-a7cr-lossless-l.arw", "ILCE-7CR");
}
#[test]
fn corpus_a99m2_uses_the_bundled_profile() {
corpus_uses_the_bundled_profile("arw-sony-a99m2.arw", "ILCA-99M2");
}
/// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white
/// balance is relative to the as-shot look.
#[test]
@@ -2622,7 +2680,7 @@ mod tests {
let samples = [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6], [0.01, 0.5, 0.03]];
let render = |m: &Mat3, wb: [f32; 3], ev: f64, c: [f64; 3]| m.apply(std::array::from_fn(|i| c[i] * wb[i] as f64)).map(|v| v * ev.exp2());
for look in [Some(&look), None] {
let dng = dng_colour(&ColorData::default(), &t, look).unwrap();
let dng = dng_colour(&ColorData::default(), &crate::files::DngWhite::At(t.white_xy), &t, look).unwrap();
let back = camera_transform_of(&dng, as_shot_white_of(&dng, None));
for i in 0..3 {
assert!((back.wb[i] - t.wb[i]).abs() < 1e-4 * t.wb[i], "{:?} vs {:?}", back.wb, t.wb);
@@ -2647,6 +2705,52 @@ mod tests {
}
}
/// The merged DNG declares the white balance its source shows, also when the look's matrix doesn't keep white
/// neutral, as fitted looks often don't, and renders at As Shot as the source does. Real ARW merges declared their
/// colour matrix from the look: As Shot about 7000 K / +115 where the ARWs show 4800 K / +8.
#[test]
fn dng_colour_declares_the_white_balance_the_source_shows() {
use crate::files::DngWhite;
use lightcraft_raw::color::{as_shot_white_of, camera_transform_of, wb_multipliers};
let tags = ColorData::default();
let as_shot = [2.5039f32, 1.0, 1.5078];
let t = camera_transform_of(&tags, as_shot_white_of(&tags, Some(as_shot)));
let mut look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1));
look.matrix = Mat3([[1.75, -0.4, -0.2], [-0.2, 1.35, -0.2], [0.05, -0.3, 1.3]]);
let tint = look.matrix.apply([1.0; 3]);
assert!((tint[0] - tint[1]).abs() > 0.1, "a look that tints white: {tint:?}");
let renders_as_shot = |dng: &ColorData| {
let r = camera_transform_of(dng, as_shot_white_of(dng, None));
assert!(r.wb.iter().zip(t.wb).all(|(a, b)| (a - b).abs() < 1e-4 * b), "{:?} vs {:?}", r.wb, t.wb);
let m = look.matrix.mul(&t.matrix);
for c in [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6]] {
let want = m.apply(std::array::from_fn(|i| c[i] * t.wb[i] as f64)).map(|v| v * t.baseline_exposure.exp2());
let got = r.matrix.apply(std::array::from_fn(|i| c[i] * r.wb[i] as f64)).map(|v| v * dng.baseline_exposure.exp2());
assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-4), "{c:?}: {want:?} vs {got:?}");
}
};
// a white the source's scale shows (Sony's presets, the relative scale's 6500 K / 0)
for xy in [lightcraft_color::cct::temp_tint_to_xy(5600.0, 3.0), lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0)] {
let dng = dng_colour(&tags, &DngWhite::At(xy), &t, Some(&look)).unwrap();
let back = as_shot_white_of(&dng, None);
assert!((back.x - xy.x).abs() < 1e-6 && (back.y - xy.y).abs() < 1e-6, "as shot {back:?}, shown {xy:?}");
renders_as_shot(&dng);
}
// a camera with measured matrices (the ILCE-7M3): every white read through them, as for the source
let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M3").expect("a covered camera");
let mut model = tags.clone();
assert!(camera.fill(&mut model));
let shown = lightcraft_raw::spectral::as_shot_white_through(camera, &tags, Some(as_shot)).unwrap();
let dng = dng_colour(&tags, &DngWhite::Matrices(Box::new(model.clone())), &t, Some(&look)).unwrap();
let back = as_shot_white_of(&dng, None);
assert!((back.x - shown.x).abs() < 1e-5 && (back.y - shown.y).abs() < 1e-5, "as shot {back:?}, shown {shown:?}");
for w in [Xy::new(0.3127, 0.329), Xy::new(0.4476, 0.4074), Xy::new(0.3457, 0.3585)] {
let (a, b) = (wb_multipliers(&model, w), wb_multipliers(&dng, w));
assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6 * a), "{w:?}: {a:?} vs {b:?}");
}
renders_as_shot(&dng);
}
/// A look with tone curve `f` at knots `xs` and a colour model and chroma curve to keep.
fn dro_test_look(xs: &[f32; 32], f: &dyn Fn(f32) -> f32) -> CameraLook {
CameraLook {
+1
View File
@@ -25,6 +25,7 @@ const MAX_FILE: u64 = 4 << 20;
/// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`):
/// `(model, JSON)`.
pub const BUNDLED: &[(&str, &str)] = &[
("ILCA-99M2", include_str!("../../../assets/camera-profiles/ILCA-99M2.json")),
("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")),
("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")),
("NIKON Z 6_2", include_str!("../../../assets/camera-profiles/NIKON_Z_6_2.json")),
+77 -4
View File
@@ -6,11 +6,14 @@
//! Each browse records when the folders it listed were browsed. Untouched records of folders
//! not browsed for [`Session::forget_local_days`] are forgotten (when the library opens, or with
//! `library.forgetLocal`); see [`lightcraft_catalog::local`] for what "untouched" means. Files
//! and sidecars on disk are never touched, and browsing the folder again brings them back.
//! and sidecars on disk are never touched, and browsing the folder again brings them back. A
//! browse also forgets at once the untouched records of the folder's files that are gone from
//! the disk (deleted or moved by another app), so the folder shows what is on disk.
use std::path::Path;
use lightcraft_catalog::{Op, PhotoId};
use lightcraft_catalog::query::in_folder;
use lightcraft_catalog::{Op, PhotoId, Source};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
@@ -42,11 +45,73 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> {
// looking at a folder never brings its deleted photos back (importing the files does)
let opts = ImportOptions { mode: ImportMode::Add, local: true, on_deleted: crate::import::OnDeleted::Skip, ..Default::default() };
let report = import_with(s, &files, &opts)?;
let forgotten = forget_gone(s, &dir_s, subfolders, &files);
stamp_browsed(s, &dir_s, &files);
s.browse = Some(Browse { path: dir_s.clone(), subfolders });
s.source = LibrarySource::Folder;
s.keep_active_in_view();
let shown = s.visible().len();
Ok(json!({"path": dir_s, "subfolders": subfolders, "photos": shown, "new": report.imported.len(), "failed": report.failed.len()}))
Ok(json!({
"path": dir_s,
"subfolders": subfolders,
"photos": shown,
"new": report.imported.len(),
"failed": report.failed.len(),
"forgotten": forgotten,
}))
}
impl Session {
/// Forget the untouched Local records of files gone from the folder `dir` (with its subfolders when
/// `subfolders`) that a browse just listed as `files`: what `library.browse` does after reading a folder, for a
/// UI that reads it on a worker thread instead. Returns how many went.
pub fn forget_gone_from(&mut self, dir: &str, subfolders: bool, files: &[String]) -> usize {
forget_gone(self, dir, subfolders, files)
}
}
/// Local records in the browsed folder whose files are no longer on disk (deleted or moved by
/// another app) and that nothing was changed on are forgotten, as `library.forgetLocal` forgets
/// them: the folder shows what is on disk, not photos that fail to load (and log an error) on
/// every visit (issue #1392). Records the user changed stay, shown as missing, and so do those
/// the session holds (selected, edited, in the undo history) or with a smart preview. Returns how
/// many went.
fn forget_gone(s: &mut Session, dir: &str, subfolders: bool, files: &[String]) -> usize {
let listed: std::collections::HashSet<&str> = files.iter().map(String::as_str).collect();
let held = held(s);
let smart = s.media.smart_dir.clone();
let gone: Vec<PhotoId> = s
.catalog
.photos()
.filter(|p| p.local && !p.deleted && !held.contains(&p.id))
// a smart preview may be all that is left of the picture
.filter(|p| !smart.as_ref().is_some_and(|d| crate::smart::file_path(d, p).exists()))
.filter(|p| match &p.source {
// a file left out of the listing is checked on disk: another spelling of its path, or
// a listing that missed it, keeps it
Source::File { path } => in_folder(path, dir, subfolders) && !listed.contains(path.as_str()) && !Path::new(path).exists(),
Source::Demo { .. } => false,
})
.map(|p| p.id)
.collect();
if gone.is_empty() {
return 0;
}
let mut forgotten = 0;
for chunk in s.catalog.forgettable_local(&gone).chunks(2000) {
let op = Op::Batch { ops: chunk.iter().map(|id| Op::RemovePhoto { id: *id }).collect() };
match s.catalog.apply(op.clone()) {
Ok(_) => {
s.pending_log.push(op);
forgotten += chunk.len();
}
Err(e) => log::warn!("forget Local records of files gone from {dir}: {e}"),
}
}
if forgotten > 0 {
log::info!("forgot {forgotten} untouched Local record(s) of files gone from {dir}");
}
forgotten
}
/// Folders whose browse time is refreshed at most this often (keeps repeated browsing from
@@ -82,7 +147,15 @@ fn stamp_browsed(s: &mut Session, root: &str, files: &[String]) {
/// Photos the session still needs, whatever their state: shown, selected, being edited, or
/// referred to by an undo / redo step.
fn in_use(s: &mut Session) -> std::collections::HashSet<PhotoId> {
let mut keep: std::collections::HashSet<PhotoId> = s.visible().iter().copied().collect();
let mut keep = held(s);
keep.extend(s.visible().iter().copied());
keep
}
/// Photos the session holds whatever is shown: selected, being edited, or referred to by an undo
/// / redo step.
fn held(s: &Session) -> std::collections::HashSet<PhotoId> {
let mut keep: std::collections::HashSet<PhotoId> = std::collections::HashSet::new();
keep.extend(s.selection.ids.iter().copied());
keep.extend(s.selection.active);
keep.extend(s.previous_active);
+1 -1
View File
@@ -156,7 +156,7 @@ fn targeted(s: &mut Session, p: &Value) -> Result<Value> {
}
fn index(p: &Value, c: &str) -> Result<usize> {
Ok(f64_req(p, "index", c)? as usize)
super::index_req(p, "index", c)
}
pub fn specs() -> Vec<CommandSpec> {
+42 -22
View File
@@ -35,7 +35,8 @@ pub(crate) fn write_dng_with(file_bytes: Option<&crate::merge::ByteReader>, path
Some(r) => r(path)?,
None => std::fs::read(path).map_err(|e| format!("{path}: {e}"))?,
};
let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{path}: {e}"))?;
let mut raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{path}: {e}"))?;
crate::camera_preview::with_starting_colour(&mut raw, &bytes);
drop(bytes);
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: (!packet.is_empty()).then_some(packet), ..Default::default() })
.map_err(|e| e.to_string())?;
@@ -201,12 +202,16 @@ fn edit_copy(s: &mut Session, p: &Value, id: lightcraft_catalog::PhotoId, opts:
/// The op that brings a photo up to date with what its file is now (`info`, a fresh probe), or
/// `None` when nothing changed. Covers a raw that became decodable (or stopped being: a different
/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`].
/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`], and a
/// preview-only photo this version hasn't checked yet: its stamp is refreshed so it isn't probed
/// again (see [`lightcraft_catalog::Photo::preview_checked`]).
pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: crate::media::ProbeInfo) -> Option<Op> {
let preview_checked = lightcraft_catalog::preview_stamp(&info.preview_only);
let same = info.content_hash == ph.content_hash
&& info.file_size == ph.file_size
&& (info.width, info.height) == (ph.width, ph.height)
&& info.preview_only == ph.preview_only;
&& info.preview_only == ph.preview_only
&& preview_checked == ph.preview_checked;
(!same).then_some(Op::SetContent {
id,
width: info.width,
@@ -214,6 +219,7 @@ pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalo
file_size: info.file_size,
content_hash: info.content_hash,
preview_only: info.preview_only,
preview_checked,
})
}
@@ -271,6 +277,37 @@ pub(crate) fn as_shot_wb_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_cat
(ph.develops_raw() && !ph.relative_wb() && ph.as_shot_wb != Some(read)).then_some(Op::SetAsShotWb { id, wb: Some(read) })
}
/// The ops that bring photo `id` and its virtual copies up to date with what their file is now
/// (`info`, a fresh probe), appended to `ops`; the photos they change are appended to `changed`.
/// Reload and the preview-only recheck (`library.recheckPreviewOnly`) share it.
pub(crate) fn reload_ops(
cat: &lightcraft_catalog::Catalog,
id: lightcraft_catalog::PhotoId,
info: &crate::media::ProbeInfo,
ops: &mut Vec<Op>,
changed: &mut Vec<lightcraft_catalog::PhotoId>,
) {
let Some(ph) = cat.photo(id) else { return };
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
// nothing already set is overwritten
let meta_ops = fill_missing_meta(id, ph, info);
let mut mine: Vec<Op> =
content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, info)).chain(as_shot_wb_op(id, ph, info)).collect();
if !mine.is_empty() {
ops.append(&mut mine);
changed.push(id);
}
// virtual copies share the file
for c in cat.photos().filter(|c| c.copy_of == Some(id)) {
let theirs: Vec<Op> =
content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, info)).chain(as_shot_wb_op(c.id, c, info)).collect();
if !theirs.is_empty() {
ops.extend(theirs);
changed.push(c.id);
}
}
}
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
@@ -286,25 +323,8 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
let mut ops = Vec::new();
let mut reloaded = Vec::new();
for ((id, _), info) in paths.into_iter().zip(probed) {
let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue };
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
// nothing already set is overwritten
let meta_ops = fill_missing_meta(id, ph, &info);
let mut mine: Vec<Op> =
content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).chain(as_shot_wb_op(id, ph, &info)).collect();
if !mine.is_empty() {
ops.append(&mut mine);
reloaded.push(id);
}
// virtual copies share the file
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
let theirs: Vec<Op> =
content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).chain(as_shot_wb_op(c.id, c, &info)).collect();
if !theirs.is_empty() {
ops.extend(theirs);
reloaded.push(c.id);
}
}
let Ok(info) = info else { continue };
reload_ops(&s.catalog, id, &info, &mut ops, &mut reloaded);
}
for id in &reloaded {
s.media.forget(*id);
+3 -4
View File
@@ -349,9 +349,8 @@ fn export_presets(s: &mut Session, p: &Value) -> Result<Value> {
if chosen.is_empty() {
return Err(bad(C, "no curve presets to export (save one first, or pass names)"));
}
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?;
}
std::fs::write(&path, to_file(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?;
let target = path.to_string_lossy();
s.check_write_target(&target).map_err(|e| bad(C, e))?;
crate::export::write_file_durable(&target, to_file(&chosen).as_bytes()).map_err(crate::EngineError::Other)?;
Ok(json!({"path": path.display().to_string(), "count": chosen.len()}))
}
+9 -8
View File
@@ -5,7 +5,7 @@ use lightcraft_develop::{DevelopSettings, Preset, ProcessVersion, Section, Setti
use lightcraft_geom::{CropGeometry, CropHandle, 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, ok, str_param};
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, index_req, ok, str_param};
use crate::{Result, Session, media::SourceLevel};
fn active(s: &Session, c: &str) -> Result<PhotoId> {
@@ -144,7 +144,7 @@ fn user_preset<'a>(s: &'a mut Session, p: &Value, c: &str) -> Result<&'a mut Pre
/// Change version `p.index` of the active photo (`f` gets it and the current settings).
fn edit_version(s: &mut Session, p: &Value, c: &str, label: &str, f: impl FnOnce(&mut Version, std::sync::Arc<DevelopSettings>)) -> Result<Value> {
let id = active(s, c)?;
let i = f64_req(p, "index", c)? as usize;
let i = index_req(p, "index", c)?;
let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?;
let mut versions = ph.versions.clone();
let v = versions.get_mut(i).ok_or_else(|| bad(c, "no such version"))?;
@@ -588,14 +588,14 @@ pub fn specs() -> Vec<CommandSpec> {
"Straighten",
[],
None,
"{angle: degrees -45..45} — keeps the largest crop of the current aspect",
"{angle: degrees -45..45} — keeps the crop if it still fits, else shrinks it about its centre (the largest crop of its aspect stays the largest)",
has_active,
|s, p| {
let id = active(s, "crop.straighten")?;
let (w, h) = crop_dims(s, id);
let angle = f64_req(p, "angle", "crop.straighten")?.clamp(-45.0, 45.0);
edit(s, "crop.straighten", "Straighten", |d| {
// keep the current crop if it still fits, else the largest of its aspect that does
// keep the current crop if it still fits, else shrink it about its centre until it does
d.crop.geometry = d.crop.geometry.with_angle(angle, w, h);
Ok(())
})
@@ -678,10 +678,11 @@ pub fn specs() -> Vec<CommandSpec> {
let (w, h) = crop_dims(s, id);
edit(s, "crop.rotateAspect", "Rotate Crop", |d| {
let r = d.crop.geometry.rect_px(w, h);
let a = r.height() / r.width();
// a degenerate crop has no aspect to rotate: fall back to the image's own
let a = Some(r.height() / r.width()).filter(|a| a.is_finite() && *a > 0.0);
d.crop.aspect = d.crop.aspect.map(|(x, y)| (y, x));
let angle = d.crop.geometry.angle;
d.crop.geometry = crop_fit_angle(w, h, angle, Some(a));
d.crop.geometry = crop_fit_angle(w, h, angle, a);
Ok(())
})
}),
@@ -1060,14 +1061,14 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!("version.restore", "Restore Version", [], None, "{index}", has_active, |s, p| {
let id = active(s, "version.restore")?;
let i = f64_req(p, "index", "version.restore")? as usize;
let i = index_req(p, "index", "version.restore")?;
let v = s.catalog.photo(id).and_then(|ph| ph.versions.get(i).cloned()).ok_or_else(|| bad("version.restore", "no such version"))?;
s.set_develop(id, (*v.settings).clone(), &format!("Restore {}", v.name))?;
ok()
}),
cmd!("version.delete", "Delete Version", [], None, "{index}", has_active, |s, p| {
let id = active(s, "version.delete")?;
let i = f64_req(p, "index", "version.delete")? as usize;
let i = index_req(p, "index", "version.delete")?;
let mut versions = s.catalog.photo(id).map(|ph| ph.versions.clone()).unwrap_or_default();
if i >= versions.len() {
return Err(bad("version.delete", "no such version"));
+142 -6
View File
@@ -1,10 +1,11 @@
//! Recognition commands: `faces.index` (embed the faces in the library), `faces.suggest` (who might an unnamed
//! face be?), `faces.person` (one person's faces, and the unnamed faces that look like them), `faces.unnamed` (every
//! unnamed face, look-alikes together) and `faces.setName` / `faces.nameFaces` (name one face, or many at once, which is
//! what teaches the suggestions).
//! what teaches the suggestions). `faces.renamePerson` renames every in-library face of one person, or merges two
//! people when asked.
//!
//! Everything here is a suggestion for the user to confirm; nothing is ever named automatically. All of it is
//! catalog-only (no sidecar is written) and `faces.setName` is undoable.
//! catalog-only (no sidecar is written) and naming is undoable.
use lightcraft_catalog::Op;
use serde_json::{Value, json};
@@ -92,6 +93,92 @@ fn set_name(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"id": id.0, "index": index, "name": name}))
}
/// `faces.renamePerson {from, to, merge?}`: give every in-library face of `from` the exact name `to`, as one undo
/// step. Names match the People view (trimmed, case-insensitive). A `to` that is already someone else is refused
/// unless `merge` is set, and then the two people become one. The sidecar is not touched, and a refusal changes nothing.
fn rename_person(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "faces.renamePerson";
let from = match p.get("from") {
Some(Value::String(v)) => {
let t = v.trim();
if t.is_empty() {
return Err(bad(C, "missing `from`"));
}
t.to_string()
}
Some(_) => return Err(bad(C, "`from` must be text")),
None => return Err(bad(C, "missing `from`")),
};
let to = match p.get("to") {
Some(Value::String(v)) => {
let t = v.trim();
if t.is_empty() {
return Err(bad(C, "`to` must be a name"));
}
if t.chars().count() > 200 || t.chars().any(char::is_control) {
return Err(bad(C, "a name is at most 200 characters, without control characters"));
}
t.to_string()
}
Some(_) => return Err(bad(C, "`to` must be text")),
None => return Err(bad(C, "missing `to`")),
};
let merge = super::bool_or(p, "merge", false);
let from_key = from.to_lowercase();
let to_key = to.to_lowercase();
let mut matched = 0usize;
let mut other = false;
let mut faces = 0usize;
let mut ops = Vec::new();
let ids: Vec<_> = s.catalog.photos().filter(|ph| ph.in_library()).map(|ph| ph.id).collect();
for id in ids {
let Some(ph) = s.catalog.photo(id) else { continue };
let mut indexes = Vec::new();
for (i, r) in ph.meta.regions.iter().enumerate() {
if r.kind != lightcraft_meta::RegionKind::Face {
continue;
}
let Some(n) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue };
let key = n.to_lowercase();
if key == from_key {
matched += 1;
if r.name.as_deref() != Some(to.as_str()) {
indexes.push(i);
}
} else if key == to_key {
other = true;
}
}
if indexes.is_empty() {
continue;
}
let mut meta = ph.meta.clone();
for i in indexes {
if let Some(region) = meta.regions.get_mut(i) {
region.name = Some(to.clone());
faces += 1;
}
}
ops.push(Op::SetMeta { id, meta: Box::new(meta) });
}
if matched == 0 {
return Err(bad(C, format!("no person named {from}")));
}
if faces == 0 {
return Err(bad(C, format!("{to} is already their name")));
}
if other && !merge {
return Err(bad(C, format!("{to} is already another person; pass merge: true to combine them")));
}
let photos = ops.len();
s.commit(if other { "Merge People" } else { "Rename Person" }, Op::Batch { ops })?;
s.skip_auto_write = true;
if s.filter.person.as_deref().is_some_and(|n| n.trim().to_lowercase() == from_key) {
s.filter.person = Some(to.clone());
}
Ok(json!({"faces": faces, "photos": photos, "from": from, "to": to, "merged": other}))
}
/// Most faces `faces.unnamed` lists.
const MAX_UNNAMED: usize = 3000;
@@ -275,7 +362,7 @@ mod imp {
}
let job = s.preview_job(id, EDGE, EDGE, false, &DevelopSettings::default())?;
let preview = s.scan_preview_of(&photo);
Some(Prepared { tag, epoch: s.faces.epoch, id, job, preview, todo, detect, detector })
Some(Prepared { tag, epoch: s.faces.epoch, id, content: crate::media::content_key(&photo), job, preview, todo, detect, detector })
}
/// The faces the detector found in a photo that had none become its face regions: unnamed, marked as detected (so a
@@ -299,13 +386,16 @@ mod imp {
}
}
/// Take a finished photo into the index (ignored when it was made for another model or another library). Returns
/// (embedded, failed).
/// Take a finished photo into the index (ignored when it was made for another model or another library, or from
/// a file the photo no longer has: issue #1524). Returns (embedded, failed).
fn ingest(s: &mut Session, d: Done) -> (usize, usize) {
s.faces.in_flight.remove(&d.id);
if d.epoch != s.faces.epoch || d.tag != s.faces.index.tag {
return (0, 0);
}
if s.catalog.photo(d.id).is_none_or(|p| crate::media::content_key(p) != d.content) {
return (0, 0);
}
if d.detect_wanted {
match &d.found {
Some(found) => {
@@ -830,7 +920,8 @@ mod imp {
}
fn finished(s: &Session, id: PhotoId, found: Option<Vec<Rect>>, results: Vec<(Rect, Option<Vec<f32>>, Rect)>) -> Done {
Done { tag: s.faces.index.tag.clone(), epoch: s.faces.epoch, id, results, detect_wanted: true, found }
let content = s.catalog.photo(id).map(|p| crate::media::content_key(p)).unwrap_or_default();
Done { tag: s.faces.index.tag.clone(), epoch: s.faces.epoch, id, content, results, detect_wanted: true, found }
}
#[test]
@@ -915,6 +1006,42 @@ mod imp {
let _ = std::fs::remove_dir_all(&d);
}
/// A Reload that finds the file replaced (an outside edit) gives the photo other pixels: what a worker made from
/// the old file is dropped when it comes back, the faces already embedded from it are forgotten and the photo is
/// searched again (issue #1524).
#[test]
fn faces_made_from_a_replaced_file_are_dropped() {
let (mut s, d) = loaded("replaced");
let (a, b) = two_photos(&s);
let rect = region(0.2, None).rect;
s.faces.index.insert(a.0, &rect, unit(&[(0, 1.0)]));
s.faces.scanned.insert(a.0);
s.faces.index.insert(b.0, &rect, unit(&[(1, 1.0)]));
s.faces.scanned.insert(b.0);
let old = finished(&s, a, Some(vec![rect]), vec![(rect, Some(unit(&[(2, 1.0)])), rect)]);
let (width, height) = s.catalog.photo(a).map(|p| (p.width, p.height)).unwrap();
let replace = Op::SetContent {
id: a,
width,
height,
file_size: 1234,
content_hash: Some("edited".into()),
preview_only: None,
preview_checked: String::new(),
};
s.commit("Reload Changed Files", replace).unwrap();
assert!(s.faces.index.get(a.0, &rect).is_none() && !s.faces.scanned.contains(a.0), "the old file's faces are forgotten");
assert!(needs_search(&s, s.catalog.photo(a).unwrap()), "and the photo is searched again");
assert!(s.faces.index.get(b.0, &rect).is_some() && s.faces.scanned.contains(b.0), "other photos keep theirs");
assert_eq!(ingest(&mut s, old), (0, 0), "a result made from the old file is dropped");
assert!(s.catalog.photo(a).unwrap().meta.regions.is_empty() && !s.faces.scanned.contains(a.0));
// what is made from the new file is taken in
let new = finished(&s, a, Some(vec![rect]), vec![(rect, Some(unit(&[(3, 1.0)])), rect)]);
assert_eq!(ingest(&mut s, new), (1, 0));
assert!(s.faces.scanned.contains(a.0) && s.faces.index.get(a.0, &rect).is_some());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_unnamed_faces_that_look_like_a_person_come_best_first() {
let (mut s, d) = loaded("similar");
@@ -1047,5 +1174,14 @@ pub fn specs() -> Vec<CommandSpec> {
super::always,
set_name
),
cmd!(
"faces.renamePerson",
"Rename Person",
[],
None,
"{from, to, merge?: bool} → {faces, photos, from, to, merged} — give every in-library face of `from` the name `to` (trimmed, any capitalization), one undo step; refused when `to` is already someone else unless `merge` is true, which combines the two; the sidecar is not touched",
super::always,
rename_person
),
]
}
+149 -19
View File
@@ -167,6 +167,19 @@ fn album_targets_or_err(s: &Session, command: &str, targets: Vec<PhotoId>) -> Re
Ok((kept, skipped))
}
/// Move to Recently Deleted and drop the photos from stacks so leftover members do not keep
/// showing `i/n` (issue #1493).
fn mark_deleted(s: &mut Session, ids: &[PhotoId], label: &str) -> Result<usize> {
let mut ops: Vec<Op> = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect();
let n = ops.len();
if n == 0 {
return Ok(0);
}
ops.extend(s.catalog.remove_from_stacks_ops(ids));
s.commit(label, Op::Batch { ops })?;
Ok(n)
}
/// Apply one op per target as a single undo step.
fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option<Op>) -> Result<Value> {
let targets = s.targets(p);
@@ -196,6 +209,25 @@ fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<V
Ok(json!({"changed": n}))
}
/// Move each target's own flag by `step`: cycling wraps unflagged -> pick -> reject -> unflagged,
/// raising/lowering clamps on reject < unflagged < pick. Unchanged photos make no history step.
fn change_flag(s: &mut Session, p: &Value, label: &str, step: impl Fn(Flag) -> Flag) -> Result<Value> {
let mut ops = Vec::new();
for id in s.targets(p) {
let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.flag;
let flag = step(old);
if flag != old {
ops.push(Op::SetFlag { id, flag });
}
}
let n = ops.len();
if n > 0 {
s.commit(label, Op::Batch { ops })?;
}
advance_if(s, p);
Ok(json!({"changed": n}))
}
fn advance_if(s: &mut Session, p: &Value) {
if bool_or(p, "advance", false) {
let _ = step(s, 1);
@@ -232,6 +264,10 @@ fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> {
if let Some(d) = s.develop_of(id) {
let mut d = (*d).clone();
d.orientation = d.orientation.rotated(cw);
// the crop lives in the oriented frame, so it turns with the image (else it keeps the old width and height and cuts the photo)
let turn = lightcraft_geom::Orientation::Normal.rotated(cw);
d.crop.geometry.rect = turn.map_norm_rect(d.crop.geometry.rect);
d.crop.aspect = d.crop.aspect.map(|(w, h)| (h, w));
ops.extend(s.develop_op(id, d, "Rotate"));
}
}
@@ -362,11 +398,8 @@ pub fn specs() -> Vec<CommandSpec> {
}
other => return Err(bad("library.source", format!("unknown source `{other}`"))),
};
let vis = s.visible_cloned();
if s.selection.active.is_none_or(|a| !vis.contains(&a)) {
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
}
Ok(json!({"count": vis.len()}))
s.keep_active_in_view();
Ok(json!({"count": s.visible().len()}))
}
),
cmd!(
@@ -436,8 +469,7 @@ pub fn specs() -> Vec<CommandSpec> {
if covers_other_disks(s, path, &ids) && !bool_or(p, "disk", false) {
return Err(bad(C, format!("{path} holds whole disks: pass `disk: true` to remove everything on them")));
}
let ops = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect();
s.commit("Remove Folder from Library", Op::Batch { ops })?;
mark_deleted(s, &ids, "Remove Folder from Library")?;
if s.filter.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) {
s.filter.library_folder = None;
}
@@ -510,6 +542,44 @@ pub fn specs() -> Vec<CommandSpec> {
ok()
}
),
cmd!(
"library.refresh",
"Refresh View",
["View"],
None,
"{} — show what matches now: a view that holds its photos (`library.holdView`) lets go of the ones that stopped matching a smart album's rules, Picks or the filter bar since it was opened, sorted or last refreshed",
always,
|s, _| {
s.refresh_view();
ok()
}
),
cmd!(
"library.holdView",
"Hold View Until Refreshed",
["View"],
None,
"{on?: bool (toggles when omitted)} → {hold} — when on, an edit that makes a photo stop matching the view (a keyword given in a smart album of photos without keywords, a flag taken off in Picks, a rating under the filter bar's) keeps it in the view until `library.refresh`, Reshuffle, or another source, filter or sort; photos that start matching still appear at once, and deleting a photo or taking it out of the album or folder shown still takes it out of the view. Changing a smart album's rules is another view too. Off (the default): the view follows every edit. Kept with the library's preferences; asking for the state it already has changes nothing",
always,
|s, p| {
let on = match p.get("on") {
None | Some(Value::Null) => !s.hold_view,
Some(Value::Bool(on)) => *on,
Some(_) => return Err(bad("library.holdView", "on must be true or false")),
};
if on != s.hold_view {
let was = s.hold_view;
s.hold_view = on;
if let Err(e) = s.save_prefs() {
// not kept: not changed either, so asking again does what was asked
s.hold_view = was;
return Err(e);
}
s.refresh_view();
}
Ok(json!({"hold": s.hold_view}))
}
),
cmd!(
"library.shuffle",
"Reshuffle",
@@ -687,6 +757,40 @@ pub fn specs() -> Vec<CommandSpec> {
advance_if(s, p);
Ok(v)
}),
// Classic: ` cycles the flag; Cmd+Up / Cmd+Down raise / lower it (Ctrl on Windows and Linux)
cmd!(
"photo.cycleFlag",
"Cycle Flag",
["Photo", "Set Flag"],
Some("`"),
"{ids?, advance?: bool} — each photo's own flag: unflagged -> pick -> reject -> unflagged",
has_selection,
|s, p| {
change_flag(s, p, "Cycle Flag", |f| match f {
Flag::None => Flag::Pick,
Flag::Pick => Flag::Reject,
Flag::Reject => Flag::None,
})
}
),
cmd!(
"photo.increaseFlag",
"Increase Flag Status",
["Photo", "Set Flag"],
Some("Cmd+Up"),
"{ids?, advance?: bool} — reject -> unflagged -> pick (stops at pick)",
has_selection,
|s, p| { change_flag(s, p, "Increase Flag Status", |f| if f == Flag::Reject { Flag::None } else { Flag::Pick }) }
),
cmd!(
"photo.decreaseFlag",
"Decrease Flag Status",
["Photo", "Set Flag"],
Some("Cmd+Down"),
"{ids?, advance?: bool} — pick -> unflagged -> reject (stops at reject)",
has_selection,
|s, p| { change_flag(s, p, "Decrease Flag Status", |f| if f == Flag::Pick { Flag::None } else { Flag::Reject }) }
),
cmd!("photo.pick", "Flag as Pick", ["Photo", "Set Flag"], Some("P"), "{ids?}", has_selection, |s, p| {
for_targets(s, p, "Flag as Pick", |id| Some(Op::SetFlag { id, flag: Flag::Pick }))
}),
@@ -779,9 +883,10 @@ pub fn specs() -> Vec<CommandSpec> {
// ---- delete / restore
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
let before = s.visible_cloned();
let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?;
let targets = s.targets(p);
let n = mark_deleted(s, &targets, "Delete")?;
select_after_deletion(s, &before);
Ok(v)
Ok(json!({"changed": n}))
}),
cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted {
id,
@@ -1334,17 +1439,38 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(result)
}
),
cmd!("album.removePhotos", "Remove from Album", [], None, "{id: albumId, ids?}", has_selection, |s, p| {
let id = album_param(p, "id", "album.removePhotos")?;
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
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"));
cmd!(
"album.removePhotos",
"Remove from Album",
["Photo"],
None,
"{id?: albumId (default: the album on screen), ids?} — takes the photos out of the album only: they stay in the library and in their folders (issue #1805; photo.delete removes them from the library) → {removed, album: name}",
has_selection,
|s, p| {
let id = match p.get("id") {
Some(_) => album_param(p, "id", "album.removePhotos")?,
None => match s.source {
LibrarySource::Album(a) => a,
_ => return Err(bad("album.removePhotos", "no album: pass `id` or show an album")),
},
};
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
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"));
}
if al.folder {
return Err(bad("album.removePhotos", "album folders don't hold photos"));
}
let name = al.name.clone();
let photos: Vec<PhotoId> = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect();
let removed = al.photos.len().saturating_sub(photos.len());
if removed > 0 {
s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?;
}
Ok(json!({"removed": removed, "album": name}))
}
let photos: Vec<PhotoId> = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect();
s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?;
ok()
}),
),
cmd!("album.setCover", "Set as Album Cover", [], None, "{id: albumId, photo?: photoId}", has_active, |s, p| {
let id = album_param(p, "id", "album.setCover")?;
let photo = p.get("photo").and_then(Value::as_u64).map(PhotoId).or(s.active());
@@ -1623,3 +1749,7 @@ mod gps_tests {
#[cfg(test)]
#[path = "library/tests_relative_ratings.rs"]
mod relative_rating_tests;
#[cfg(test)]
#[path = "library/tests_flag_cycle.rs"]
mod flag_cycle_tests;
@@ -0,0 +1,100 @@
//! `photo.cycleFlag` / `photo.increaseFlag` / `photo.decreaseFlag` through the real engine.
use lightcraft_catalog::{Flag, Op, Photo, PhotoId, Source};
use serde_json::json;
use crate::{Result, Selection, Session};
fn session() -> Result<Session> {
let mut s = Session::new();
for (id, flag) in [(1, Flag::None), (2, Flag::Pick), (3, Flag::Reject), (4, Flag::None)] {
let mut photo = Photo::new(
PhotoId(id),
Source::File { path: format!("/lightcraft-flag-cycle/{id}.jpg") },
&format!("{id}.jpg"),
"JPEG",
40,
30,
"2026-10-13",
);
photo.flag = flag;
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?;
}
s.execute("library.sort", &json!({"key": "fileName", "ascending": true}))?;
s.execute("library.select", &json!({"ids": [1, 2, 3], "active": 2}))?;
Ok(s)
}
fn flags(s: &Session) -> Vec<Flag> {
(1..=4).map(|id| s.catalog.photo(PhotoId(id)).unwrap().flag).collect()
}
#[test]
fn cycle_steps_unflagged_pick_reject_and_wraps() {
let mut s = session().unwrap();
s.execute("library.select", &json!({"ids": [1]})).unwrap();
let seen: Vec<Flag> = (0..4)
.map(|_| {
s.execute("photo.cycleFlag", &json!({})).unwrap();
flags(&s)[0]
})
.collect();
assert_eq!(seen, [Flag::Pick, Flag::Reject, Flag::None, Flag::Pick]);
}
#[test]
fn cycle_uses_each_photos_own_flag_in_one_undo_step() {
let mut s = session().unwrap();
let undo = s.undo.len();
assert_eq!(s.execute("photo.cycleFlag", &json!({})).unwrap()["changed"], 3);
assert_eq!(flags(&s), [Flag::Pick, Flag::Reject, Flag::None, Flag::None]);
assert_eq!(s.undo.len(), undo + 1);
assert_eq!(s.active(), Some(PhotoId(2)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(flags(&s), [Flag::None, Flag::Pick, Flag::Reject, Flag::None]);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(flags(&s), [Flag::Pick, Flag::Reject, Flag::None, Flag::None]);
}
#[test]
fn explicit_ids_active_fallback_advance_and_errors() {
let mut s = session().unwrap();
s.execute("photo.cycleFlag", &json!({"ids": [4]})).unwrap();
assert_eq!(flags(&s), [Flag::None, Flag::Pick, Flag::Reject, Flag::Pick]);
s.selection = Selection { ids: vec![], active: Some(PhotoId(2)) };
s.execute("photo.cycleFlag", &json!({})).unwrap();
assert_eq!(flags(&s)[1], Flag::Reject);
s.execute("photo.cycleFlag", &json!({"advance": true})).unwrap();
assert_eq!(flags(&s)[1], Flag::None);
assert_eq!(s.active(), Some(PhotoId(3)));
let undo = s.undo.len();
assert!(s.execute("photo.cycleFlag", &json!({"ids": [1, 999]})).is_err());
assert_eq!(flags(&s)[0], Flag::None);
assert_eq!(s.undo.len(), undo);
s.selection = Selection::default();
assert!(s.execute("photo.cycleFlag", &json!({})).is_err());
assert_eq!(s.undo.len(), undo);
}
#[test]
fn increase_and_decrease_clamp_without_wrapping() {
let mut s = session().unwrap();
assert_eq!(s.execute("photo.increaseFlag", &json!({})).unwrap()["changed"], 2);
assert_eq!(flags(&s), [Flag::Pick, Flag::Pick, Flag::None, Flag::None]);
assert_eq!(s.execute("photo.increaseFlag", &json!({"ids": [2]})).unwrap()["changed"], 0);
s.execute("photo.decreaseFlag", &json!({})).unwrap();
assert_eq!(flags(&s), [Flag::None, Flag::None, Flag::Reject, Flag::None]);
s.execute("photo.decreaseFlag", &json!({})).unwrap();
assert_eq!(flags(&s), [Flag::Reject, Flag::Reject, Flag::Reject, Flag::None]);
let undo = s.undo.len();
assert_eq!(s.execute("photo.decreaseFlag", &json!({})).unwrap()["changed"], 0);
assert_eq!(s.undo.len(), undo, "no change, no history step");
}
#[test]
fn registered_with_classic_shortcuts() {
let specs = crate::command_specs();
let key = |id: &str| specs.iter().find(|c| c.id == id).and_then(|c| c.shortcut);
assert_eq!(key("photo.cycleFlag"), Some("`"));
assert_eq!(key("photo.increaseFlag"), Some("Cmd+Up"));
assert_eq!(key("photo.decreaseFlag"), Some("Cmd+Down"));
}
+681
View File
@@ -0,0 +1,681 @@
//! Mask effect preset files: Lightroom Classic local adjustment presets (`.lrtemplate`, Lua,
//! `type = "LocalizedAdjustmentPreset"`), Camera Raw local correction presets (`.xmp` with
//! `crs:Local*` fields, from its `LocalCorrections` folder), our `.lceffect` JSON, and `.zip`
//! bundles of these. Folders, and Lightroom users' "divider" presets (`10 ----- DETAIL ----- 10`,
//! empty presets that head a numbered section of the menu), become groups.
//!
//! Read from the files' public structure and black-box observation; settings we have no slider
//! for are reported and kept with the effect (an export carries them).
use lightcraft_develop::LocalAdjustments;
use serde_json::{Map, Value, json};
use super::mask_effects::{MaskEffect, upsert};
use super::{bad, str_param};
use crate::preset_import::{Lua, group_from_dir, parse_lua, read_zip};
use crate::{Result, Session};
/// File extension of exported effect presets (JSON).
pub const EFFECT_EXT: &str = "lceffect";
/// `format` tag of an exported effect-preset file.
const FILE_FORMAT: &str = "lightcraft.maskEffects";
/// The Lua `type` of a Lightroom local adjustment preset.
const LR_LOCAL_TYPE: &str = "LocalizedAdjustmentPreset";
/// Larger files aren't presets (or bundles of them).
const MAX_FILE: u64 = 64 << 20;
/// How deep nested Lua tables are kept as JSON.
const MAX_DEPTH: usize = 16;
/// An effect read from a file, with the settings it couldn't keep and where it came from.
#[derive(Clone, Debug)]
pub struct Found {
pub effect: MaskEffect,
pub unmapped: Vec<String>,
pub file: String,
/// Effects of one folder (or folder inside a bundle) share this key: divider presets group
/// the effects next to them.
pub dir: String,
/// The group the folder gives (when the file names none).
pub dir_group: Option<String>,
/// The file named its group (`.lceffect`, `crs:Group`): kept.
pub has_group: bool,
}
/// A readable name for a Lightroom local setting we can't carry over.
fn setting_label(key: &str) -> String {
let k = key.trim_start_matches("Local");
let lower = k.to_ascii_lowercase();
match lower.as_str() {
"grain" => "Grain".into(),
"toningluminance" => "Color luminance".into(),
"pointcolors" => "Point Color".into(),
"refinesaturation" => "Refine Saturation".into(),
"curverefinesaturation" => "Refine Saturation".into(),
"maincurve" | "redcurve" | "greencurve" | "bluecurve" => "Curve".into(),
_ => k.to_string(),
}
}
/// A Lua value as JSON (tables nested at most [`MAX_DEPTH`] deep).
fn lua_json(v: &Lua, depth: usize) -> Value {
match v {
Lua::Nil => Value::Null,
Lua::Bool(b) => json!(b),
Lua::Num(n) => serde_json::Number::from_f64(*n).map(Value::Number).unwrap_or(Value::Null),
Lua::Str(s) => json!(s),
Lua::Table(..) if depth >= MAX_DEPTH => Value::Null,
Lua::Table(items, fields) if fields.is_empty() => Value::Array(items.iter().map(|x| lua_json(x, depth + 1)).collect()),
Lua::Table(_, fields) => Value::Object(fields.iter().map(|(k, x)| (k.clone(), lua_json(x, depth + 1))).collect()),
}
}
/// Does a setting value do anything (a non-zero number, a non-empty table, …)?
fn meaningful(v: &Value) -> bool {
match v {
Value::Null => false,
Value::Bool(b) => *b,
Value::Number(n) => n.as_f64().is_some_and(|x| x.abs() > 1e-9),
Value::String(s) => !s.is_empty(),
Value::Array(a) => !a.is_empty(),
Value::Object(o) => !o.is_empty(),
}
}
/// Is `text` a Lightroom local adjustment preset (`.lrtemplate`)?
pub fn is_lr_local_template(text: &str) -> bool {
parse_lua(text.trim_start_matches('\u{feff}'))
.ok()
.and_then(|root| root.get("type").and_then(Lua::str).map(|t| t == LR_LOCAL_TYPE))
.unwrap_or(false)
}
/// `$$$/Path/Key=Default` (a localisable string) → `Default`.
fn delocalize(t: &str) -> &str {
t.strip_prefix("$$$/").and_then(|r| r.split_once('=')).map_or(t, |(_, d)| d)
}
/// A Lightroom local adjustment preset → an effect named after its title (else `stem`), and the
/// settings we have no equivalent for (e.g. `Grain`, `Point Color`), which the effect keeps in
/// `extra`.
pub fn parse_lr_local_template(text: &str, stem: &str) -> std::result::Result<(MaskEffect, Vec<String>), String> {
let root = parse_lua(text.trim_start_matches('\u{feff}'))?;
if root.get("type").and_then(Lua::str) != Some(LR_LOCAL_TYPE) {
return Err("not a local adjustment preset".into());
}
let Some(Lua::Table(_, fields)) = root.get("value") else { return Err("no adjustments in this local preset".into()) };
// `exposure2012` → `LocalExposure2012`: the template's keys are the XMP `Local*` names, lower camel case
let crs = |k: &str| {
let mut c = k.chars();
c.next().map(|f| format!("Local{}{}", f.to_ascii_uppercase(), c.as_str())).unwrap_or_default()
};
let num = |k: &str| fields.iter().find(|(f, _)| crs(f) == k).and_then(|(_, v)| if let Lua::Num(n) = v { Some(*n) } else { None });
let values = crate::crs_masks::local_values(num);
let mut extra = Map::new();
for (k, v) in fields {
if crate::crs_masks::is_local_field(&crs(k)) {
continue;
}
let j = lua_json(v, 0);
if meaningful(&j) {
extra.insert(k.clone(), j);
}
}
let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(str::trim).filter(|t| !t.is_empty());
let name = title.map(|t| delocalize(t).trim().to_string()).filter(|t| !t.is_empty()).unwrap_or_else(|| stem.to_string());
build(name, values, extra)
}
fn build(name: String, values: Map<String, Value>, extra: Map<String, Value>) -> std::result::Result<(MaskEffect, Vec<String>), String> {
let adjust: LocalAdjustments = serde_json::from_value(Value::Object(values)).map_err(|e| format!("unreadable local preset: {e}"))?;
let mut unmapped: Vec<String> = extra.keys().map(|k| setting_label(k)).collect();
unmapped.sort();
unmapped.dedup();
let effect = MaskEffect { name, adjust, extra, ..Default::default() }.normalized();
Ok((effect, unmapped))
}
/// The local slider fields of a Camera Raw local correction preset: its top-level `crs:Local*`
/// properties, or (a preset typed `Local` without them) its first correction's.
fn xmp_local_fields(d: &lightcraft_meta::XmpData) -> Option<Map<String, Value>> {
let mut out = Map::new();
for (k, v) in &d.properties {
if let Some(name) = k.strip_prefix("crs:").filter(|n| n.starts_with("Local") && !n.contains('/'))
&& let Some(x) = v.first()
{
out.insert(name.to_string(), json!(x.trim()));
}
}
if !out.is_empty() {
return Some(out);
}
let typed_local = d.properties.get("crs:PresetType").and_then(|v| v.first()).is_some_and(|t| t.trim().eq_ignore_ascii_case("local"));
if !typed_local {
return None;
}
let correction = crate::crs_masks::CONTAINERS.iter().find_map(|c| d.values.get(*c).and_then(|l| l.items().first()))?;
let lightcraft_meta::XmpValue::Struct(m) = correction else { return None };
for (k, v) in m {
if let Some(name) = k.strip_prefix("crs:").filter(|n| n.starts_with("Local"))
&& let Some(x) = v.text()
{
out.insert(name.to_string(), json!(x));
}
}
Some(out)
}
/// Is `text` a Camera Raw local correction preset (XMP)?
pub fn is_local_xmp(text: &str) -> bool {
lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).ok().and_then(|d| xmp_local_fields(&d)).is_some()
}
/// A Camera Raw local correction preset (XMP) → an effect named after `crs:Name` (else `stem`),
/// in `crs:Group` if it names one.
pub fn parse_local_xmp(text: &str, stem: &str) -> std::result::Result<(MaskEffect, Vec<String>), String> {
let d = lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).map_err(|e| e.to_string())?;
let fields = xmp_local_fields(&d).ok_or("not a local correction preset (no crs:Local… settings)")?;
let num = |v: &Value| v.as_str().map(|s| s.strip_prefix('+').unwrap_or(s)).and_then(|s| s.parse::<f64>().ok()).filter(|x| x.is_finite());
let values = crate::crs_masks::local_values(|k| fields.get(k).and_then(num));
let mut extra = Map::new();
for (k, v) in &fields {
if crate::crs_masks::is_local_field(k) {
continue;
}
let v = num(v).map_or_else(|| v.clone(), |x| json!(x));
if meaningful(&v) {
extra.insert(k.clone(), v);
}
}
let first = |k: &str| d.properties.get(k).and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
let name = first("crs:Name").unwrap_or_else(|| stem.to_string());
let (mut e, unmapped) = build(name, values, extra)?;
e.group = first("crs:Group").unwrap_or_default();
Ok((e, unmapped))
}
/// Effects in an `.lceffect` file: `{format, version, effects: [...]}`, a bare array, or one effect.
pub fn parse_file(bytes: &[u8]) -> std::result::Result<Vec<MaskEffect>, String> {
let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not an effect preset file: {e}"))?;
let items = match v {
Value::Object(mut o) if o.contains_key("effects") => o.remove("effects").unwrap_or_default(),
v @ Value::Array(_) => v,
v @ Value::Object(_) => Value::Array(vec![v]),
_ => return Err("not an effect preset file".into()),
};
let list: Vec<MaskEffect> = serde_json::from_value(items).map_err(|e| format!("not an effect preset file: {e}"))?;
let list: Vec<MaskEffect> = list.into_iter().filter(|e| !e.name.trim().is_empty()).map(MaskEffect::normalized).collect();
if list.is_empty() {
return Err("no effect presets in the file".into());
}
Ok(list)
}
fn ext_of(name: &str) -> String {
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
}
fn stem_of(name: &str) -> String {
std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "Effect".into())
}
type Parsed = std::result::Result<Vec<(MaskEffect, Vec<String>)>, String>;
/// The effects in file `name` (`bytes`) if it is a local preset (`.lrtemplate` of the local type,
/// a local correction `.xmp`, an `.lceffect`); `None` for any other file (a develop preset…).
pub fn parse_bytes(name: &str, bytes: &[u8]) -> Option<Parsed> {
let stem = stem_of(name);
match ext_of(name).as_str() {
"lrtemplate" => {
let text = String::from_utf8_lossy(bytes);
is_lr_local_template(&text).then(|| parse_lr_local_template(&text, &stem).map(|e| vec![e]))
}
"xmp" => {
let text = String::from_utf8_lossy(bytes);
is_local_xmp(&text).then(|| parse_local_xmp(&text, &stem).map(|e| vec![e]))
}
EFFECT_EXT => Some(parse_file(bytes).map(|v| v.into_iter().map(|e| (e, Vec::new())).collect())),
_ => None,
}
}
/// The group effects in folder `dir` (relative to the imported folder) go to.
pub fn group_for_dir(dir: &str) -> Option<String> {
effect_group_from_dir(dir)
}
/// Folder names that only say "local presets" (`Local Adjustment Presets`, `LocalCorrections`…)
/// give no group.
fn effect_group_from_dir(dir: &str) -> Option<String> {
const FILLER: &[&str] = &[
"local",
"adjustment",
"adjustments",
"localcorrections",
"corrections",
"correction",
"brush",
"brushes",
"mask",
"masks",
"effect",
"effects",
];
let generic = |part: &str| {
let lower = part.to_ascii_lowercase();
let words: Vec<&str> = lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect();
words.iter().any(|w| FILLER.contains(w))
&& words.iter().all(|w| FILLER.contains(w) || matches!(*w, "presets" | "preset" | "lightroom" | "camera" | "raw" | "classic"))
};
let kept: Vec<&str> = dir.split(['/', '\\']).filter(|p| !p.is_empty() && !generic(p)).collect();
group_from_dir(&kept.join("/"))
}
/// Effects found in `bytes` of file `file` (a `.zip` is opened: the local presets inside).
/// `dir_group`: the group the file's folder gives. Errors are `(file, message)`.
pub fn found_in(file: &str, bytes: &[u8], dir_group: Option<String>, out: &mut Vec<Found>, failed: &mut Vec<(String, String)>) -> bool {
let dir = std::path::Path::new(file).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();
if ext_of(file) == "zip" {
let Ok(entries) = read_zip(bytes) else { return false };
let zip_group = effect_group_from_dir(&stem_of(file)).or(dir_group);
let mut any = false;
for (inner, data) in entries {
let inner_dir = inner.rsplit_once('/').map(|(d, _)| d.to_string()).unwrap_or_default();
let group = effect_group_from_dir(&inner_dir).or_else(|| zip_group.clone());
let label = format!("{file}/{inner}");
match parse_bytes(&inner, &data) {
Some(Ok(list)) => {
any = true;
push(out, list, &label, format!("{file}/{inner_dir}"), group, &inner);
}
Some(Err(e)) => {
any = true;
failed.push((label, e));
}
None => {}
}
}
return any;
}
match parse_bytes(file, bytes) {
Some(Ok(list)) => {
push(out, list, file, dir, dir_group, file);
true
}
Some(Err(e)) => {
failed.push((file.to_string(), e));
true
}
None => false,
}
}
fn push(out: &mut Vec<Found>, list: Vec<(MaskEffect, Vec<String>)>, file: &str, dir: String, dir_group: Option<String>, name: &str) {
let named = ext_of(name) == EFFECT_EXT;
for (effect, unmapped) in list {
let has_group = named || !effect.group.is_empty();
out.push(Found { effect, unmapped, file: file.to_string(), dir: dir.clone(), dir_group: dir_group.clone(), has_group });
}
}
/// The number a preset name starts with (`24a-Eye` → 24).
fn leading_number(name: &str) -> Option<u64> {
let digits: String = name.trim().chars().take_while(char::is_ascii_digit).take(18).collect();
digits.parse().ok()
}
/// The section title of a divider preset — an effect that sets nothing, named like
/// `10 ----- DETAIL ENHANCERS ----- 10` (a run of at least three `-`, `=`, `_`, `*`, `#`, `~`, `—`).
pub fn divider_title(e: &MaskEffect) -> Option<String> {
if !e.is_empty() {
return None;
}
let marks = ['-', '=', '_', '*', '#', '~', '—', '–', '•', '.'];
let chars: Vec<char> = e.name.chars().collect();
let has_run = chars.windows(3).any(|w| marks.contains(&w[0]) && w[0] == w[1] && w[1] == w[2]);
if !has_run {
return None;
}
let title: String = e.name.chars().map(|c| if marks.contains(&c) { ' ' } else { c }).collect();
let title = title.trim().trim_start_matches(|c: char| c.is_ascii_digit() || c.is_whitespace());
let title = title.trim_end_matches(|c: char| c.is_ascii_digit() || c.is_whitespace());
let title = title.split_whitespace().collect::<Vec<_>>().join(" ");
if !title.chars().any(char::is_alphanumeric) {
return None;
}
// "DETAIL ENHANCERS" → "Detail Enhancers"
let shouting = title.chars().any(char::is_alphabetic) && !title.chars().any(char::is_lowercase);
Some(if shouting {
title
.split(' ')
.map(|w| {
let mut c = w.chars();
c.next().map(|f| f.to_uppercase().chain(c.flat_map(char::to_lowercase)).collect::<String>()).unwrap_or_default()
})
.collect::<Vec<_>>()
.join(" ")
} else {
title
})
}
/// Give `found` their groups; returns the divider presets (which become groups, not effects) as
/// `(found, group)`. A numbered effect goes to the divider with the largest number not above its
/// own (`12 Sky` under `10 --- DETAIL --- 10`); others to their folder's group.
pub fn assign_groups(found: Vec<Found>, group: Option<&str>) -> (Vec<Found>, Vec<(Found, String)>) {
let (dividers, mut effects): (Vec<(Found, Option<String>)>, Vec<(Found, Option<String>)>) =
found.into_iter().map(|f| (divider_title(&f.effect).filter(|_| !f.has_group), f)).map(|(t, f)| (f, t)).partition(|(_, t)| t.is_some());
let dividers: Vec<(Found, String)> = dividers.into_iter().filter_map(|(f, t)| t.map(|t| (f, t))).collect();
for (f, _) in &mut effects {
if let Some(g) = group {
f.effect.group = g.to_string();
continue;
}
if f.has_group {
continue;
}
let section = leading_number(&f.effect.name).and_then(|n| {
dividers
.iter()
.filter(|(d, _)| d.dir == f.dir)
.filter_map(|(d, t)| leading_number(&d.effect.name).filter(|m| *m <= n).map(|m| (m, t)))
.max_by_key(|(m, _)| *m)
.map(|(_, t)| t.clone())
});
f.effect.group = section.or_else(|| f.dir_group.clone()).unwrap_or_default();
}
(effects.into_iter().map(|(f, _)| f).collect(), dividers)
}
/// Add the found effects (grouped; same-named ones replaced; effects left from an earlier import
/// of a divider dropped) and save → the report rows `(imported, dividers)`.
pub fn add_found(s: &mut Session, found: Vec<Found>, group: Option<&str>, dry: bool) -> Result<(Vec<Value>, Vec<Value>)> {
let (effects, dividers) = assign_groups(found, group);
let rows: Vec<Value> =
effects.iter().map(|f| json!({"name": f.effect.name, "group": f.effect.group, "file": f.file, "unmapped": f.unmapped})).collect();
let div_rows: Vec<Value> = dividers.iter().map(|(f, g)| json!({"name": f.effect.name, "group": g, "file": f.file})).collect();
if dry || (effects.is_empty() && dividers.is_empty()) {
return Ok((rows, div_rows));
}
s.mask_effects.retain(|e| !(e.is_empty() && dividers.iter().any(|(d, _)| d.effect.name.eq_ignore_ascii_case(&e.name))));
for f in effects {
upsert(s, f.effect);
}
s.save_prefs()?;
Ok((rows, div_rows))
}
/// The files under `paths` an effect import reads (folders walked, at most 8 levels), each with
/// the group its folder gives and whether it was named (not found in a folder).
fn effect_files(paths: &[String]) -> Vec<(String, Option<String>, bool)> {
fn walk(dir: &std::path::Path, base: &std::path::Path, depth: usize, out: &mut Vec<(String, Option<String>, bool)>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
let mut entries: Vec<_> = rd.flatten().map(|e| e.path()).collect();
entries.sort();
for p in entries {
if p.is_dir() {
if depth < 8 {
walk(&p, base, depth + 1, out);
}
} else if p.extension().is_some_and(|e| ["lrtemplate", EFFECT_EXT, "xmp", "zip"].iter().any(|x| e.eq_ignore_ascii_case(x))) {
let rel = p.parent().and_then(|d| d.strip_prefix(base).ok()).map(|d| d.to_string_lossy().to_string());
out.push((p.to_string_lossy().to_string(), rel.and_then(|d| effect_group_from_dir(&d)), false));
}
}
}
let mut out = Vec::new();
for x in paths {
let p = std::path::Path::new(x);
if p.is_dir() {
walk(p, p.parent().unwrap_or(std::path::Path::new("")), 0, &mut out);
} else {
out.push((x.clone(), None, true));
}
}
out
}
/// Read a file of at most [`MAX_FILE`] bytes.
pub fn read_capped(path: &str) -> std::result::Result<Vec<u8>, String> {
let len = std::fs::metadata(path).map_err(|e| e.to_string())?.len();
if len > MAX_FILE {
return Err(format!("too large for a preset file ({} MB)", len >> 20));
}
std::fs::read(path).map_err(|e| e.to_string())
}
pub fn import_effects(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.importEffects";
let mut paths: Vec<String> =
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
if let Some(x) = str_param(p, "path") {
paths.push(x.to_string());
}
if paths.is_empty() {
return Err(bad(C, "missing `path` / `paths`"));
}
let group = str_param(p, "group").map(str::trim).filter(|g| !g.is_empty());
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
let (mut found, mut failed) = (Vec::new(), Vec::new());
for (f, dir_group, named) in effect_files(&paths) {
match read_capped(&f) {
Ok(bytes) => {
if !found_in(&f, &bytes, dir_group, &mut found, &mut failed) && named {
failed.push((f, "not a local adjustment preset".into()));
}
}
Err(e) => failed.push((f, e)),
}
}
let (imported, dividers) = add_found(s, found, group, dry)?;
Ok(json!({"imported": imported, "dividers": dividers, "failed": failed.into_iter().map(|(f, e)| json!([f, e])).collect::<Vec<_>>()}))
}
pub fn export_effects(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.exportEffects";
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
let mut path = std::path::PathBuf::from(path);
if path.extension().is_none() {
path.set_extension(EFFECT_EXT);
}
let names: Option<Vec<String>> =
p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
let group = str_param(p, "group").map(str::trim);
let chosen: Vec<&MaskEffect> = s
.mask_effects
.iter()
.filter(|e| names.as_ref().is_none_or(|n| n.iter().any(|x| x.eq_ignore_ascii_case(&e.name))))
.filter(|e| group.is_none_or(|g| g.eq_ignore_ascii_case(&e.group)))
.collect();
if chosen.is_empty() {
return Err(bad(C, "no effect presets to export"));
}
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?;
}
let text = serde_json::to_string_pretty(&json!({"format": FILE_FORMAT, "version": 2, "effects": chosen}))
.map_err(|e| crate::EngineError::Other(e.to_string()))?;
std::fs::write(&path, text).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?;
Ok(json!({"path": path.display().to_string(), "count": chosen.len()}))
}
#[cfg(test)]
mod tests {
use super::*;
// A synthetic template in the shape Lightroom writes (our own values).
const TEMPLATE: &str = r#"s = {
id = "00000000-0000-0000-0000-000000000001",
internalName = "Soft Glow",
title = "Soft Glow",
type = "LocalizedAdjustmentPreset",
value = {
clarity = 0.5,
clarity2012 = -0.25,
exposure = 0,
exposure2012 = 0.125,
grain = 0.25,
highlights2012 = -0.5,
luminanceNoise = 0.1,
refineSaturation = 0,
temperature = 0.2,
texture = -0.3,
toningHue = 240,
toningLuminance = 0.1,
toningSaturation = 0,
pointColors = {
{
HueRange = { LowerFull = 0.3, LowerNone = 0, UpperFull = 0.6, UpperNone = 1, },
HueShift = -0.05,
RangeAmount = 0.5,
SrcHue = 0.8,
},
},
},
version = 0,
}"#;
#[test]
fn reads_a_lightroom_local_template() {
assert!(is_lr_local_template(TEMPLATE));
let (e, unmapped) = parse_lr_local_template(TEMPLATE, "file").unwrap();
assert_eq!(e.name, "Soft Glow");
assert!((e.adjust.exposure - 0.5).abs() < 1e-9, "exposure2012 is a quarter of the stops");
assert!((e.adjust.clarity + 25.0).abs() < 1e-9, "the 2012 field wins over the old one");
assert!((e.adjust.highlights + 50.0).abs() < 1e-9);
assert!((e.adjust.temp - 20.0).abs() < 1e-9);
assert!((e.adjust.texture + 30.0).abs() < 1e-9);
assert!((e.adjust.noise - 10.0).abs() < 1e-9);
assert!((e.adjust.amount - 100.0).abs() < 1e-9);
assert_eq!(unmapped, vec!["Color luminance".to_string(), "Grain".into(), "Point Color".into()]);
assert_eq!(e.extra.get("grain"), Some(&json!(0.25)), "kept for export");
assert_eq!(e.extra["pointColors"][0]["HueRange"]["UpperFull"], json!(0.6));
assert!(!e.extra.contains_key("refineSaturation"), "zero settings aren't kept");
}
#[test]
fn develop_templates_are_not_local_ones() {
let develop = r#"s = { title = "X", type = "Develop", value = { settings = { Exposure2012 = 1 } } }"#;
assert!(!is_lr_local_template(develop));
assert!(parse_lr_local_template(develop, "x").is_err());
assert!(parse_bytes("x.lrtemplate", develop.as_bytes()).is_none(), "left to the develop preset import");
}
#[test]
fn malformed_files_are_errors_not_panics() {
for bad in [
"garbage {",
"",
"s = {",
r#"s = { type = "LocalizedAdjustmentPreset", value = 3 }"#,
r#"s = { type = "LocalizedAdjustmentPreset" }"#,
r#"s = { type = "LocalizedAdjustmentPreset", value = { exposure2012 = "x", grain = {{{{}}}} } }"#,
"s = { type = \"LocalizedAdjustmentPreset\", value = { exposure2012 = 1e400, contrast2012 = -1e400 } }",
"\u{feff}s = { type = \"LocalizedAdjustmentPreset\", title = \"\", value = {} }",
] {
let _ = parse_lr_local_template(bad, "x");
let _ = parse_bytes("x.lrtemplate", bad.as_bytes());
}
// deeply nested tables
let deep = format!("s = {{ type = \"LocalizedAdjustmentPreset\", value = {{ pointColors = {}1{} }} }}", "{".repeat(200), "}".repeat(200));
let _ = parse_lr_local_template(&deep, "x");
let (e, _) = parse_lr_local_template("s = { type = \"LocalizedAdjustmentPreset\", value = { exposure2012 = 9 } }", "Stem").unwrap();
assert_eq!((e.name.as_str(), e.adjust.exposure), ("Stem", 4.0), "values clamp, the file name names it");
for bad in ["", "<x:xmpmeta", "<x:xmpmeta></x:xmpmeta>", "\u{0}\u{1}"] {
assert!(parse_bytes("x.xmp", bad.as_bytes()).is_none_or(|r| r.is_err()));
}
for bad in ["", "3", "[]", "{\"effects\": 3}", "[{\"name\": 4}]", "{\"effects\": [{\"name\": \" \"}]}"] {
assert!(parse_file(bad.as_bytes()).is_err(), "{bad}");
}
assert!(!found_in("x.zip", b"PK\x05\x06garbage", None, &mut Vec::new(), &mut Vec::new()));
}
/// A Camera Raw local correction preset (our own values).
const ACR: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:PresetType="Local" crs:LocalExposure2012="+0.25" crs:LocalShadows2012="0.4" crs:LocalGrain="0.2" crs:LocalTexture="0">
<crs:Name><rdf:Alt><rdf:li xml:lang="x-default">Lift Shadows</rdf:li></rdf:Alt></crs:Name>
<crs:Group><rdf:Alt><rdf:li xml:lang="x-default">Faces</rdf:li></rdf:Alt></crs:Group>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
#[test]
fn reads_a_camera_raw_local_preset() {
assert!(is_local_xmp(ACR));
let (e, unmapped) = parse_local_xmp(ACR, "file").unwrap();
assert_eq!((e.name.as_str(), e.group.as_str()), ("Lift Shadows", "Faces"));
assert_eq!((e.adjust.exposure, e.adjust.shadows), (1.0, 40.0));
assert_eq!(unmapped, vec!["Grain".to_string()]);
// a develop preset is not one
let develop = ACR
.replace(
"crs:LocalExposure2012=\"+0.25\" crs:LocalShadows2012=\"0.4\" crs:LocalGrain=\"0.2\" crs:LocalTexture=\"0\"",
"crs:Exposure2012=\"1\"",
)
.replace("PresetType=\"Local\"", "PresetType=\"Normal\"");
assert!(!is_local_xmp(&develop));
// a preset typed Local that keeps its sliders in a correction
let nested = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:PresetType="Local">
<crs:MaskGroupBasedCorrections><rdf:Seq><rdf:li><rdf:Description crs:What="Correction" crs:LocalClarity2012="-0.5"/></rdf:li></rdf:Seq></crs:MaskGroupBasedCorrections>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
let (e, _) = parse_local_xmp(nested, "Soft").unwrap();
assert_eq!((e.name.as_str(), e.adjust.clarity), ("Soft", -50.0));
}
fn found(name: &str, exposure: f64, dir: &str) -> Found {
Found {
effect: MaskEffect { name: name.into(), adjust: LocalAdjustments { exposure, ..Default::default() }, ..Default::default() },
unmapped: Vec::new(),
file: format!("{dir}/{name}.lrtemplate"),
dir: dir.into(),
dir_group: None,
has_group: false,
}
}
#[test]
fn dividers_become_groups() {
let e = |n: &str| MaskEffect { name: n.into(), ..Default::default() };
assert_eq!(divider_title(&e("10 ----- DETAIL ENHANCERS ----- 10")).as_deref(), Some("Detail Enhancers"));
assert_eq!(divider_title(&e("50--------MISC-----50")).as_deref(), Some("Misc"));
assert_eq!(divider_title(&e("=== Portraits ===")).as_deref(), Some("Portraits"));
assert_eq!(divider_title(&e("11_General_-_All_Purpose")), None, "no run of marks");
assert_eq!(divider_title(&e("-----")), None, "no title");
let busy = MaskEffect { adjust: LocalAdjustments { exposure: 1.0, ..Default::default() }, ..e("20 --- X --- 20") };
assert_eq!(divider_title(&busy), None, "a divider sets nothing");
let list = vec![
found("01 Burn", -1.0, "a"),
found("10 ----- DETAIL ENHANCERS ----- 10", 0.0, "a"),
found("12 Sky", 0.5, "a"),
found("20 ----- RETOUCHING ----- 20", 0.0, "a"),
found("24a-Eye-Cleaner", 0.1, "a"),
found("99 Late", 0.1, "a"),
found("Burn (Darken)", -0.5, "a"),
Found { dir_group: Some("Other".into()), ..found("15 Elsewhere", 0.2, "b") },
];
let (effects, dividers) = assign_groups(list.clone(), None);
let groups: Vec<(&str, &str)> = effects.iter().map(|f| (f.effect.name.as_str(), f.effect.group.as_str())).collect();
assert_eq!(
groups,
vec![
("01 Burn", ""),
("12 Sky", "Detail Enhancers"),
("24a-Eye-Cleaner", "Retouching"),
("99 Late", "Retouching"),
("Burn (Darken)", ""),
("15 Elsewhere", "Other"),
]
);
assert_eq!(dividers.len(), 2);
let (effects, _) = assign_groups(list, Some("Mine"));
assert!(effects.iter().all(|f| f.effect.group == "Mine"));
}
#[test]
fn folder_names_that_only_say_local_presets_give_no_group() {
assert_eq!(effect_group_from_dir("Local Adjustment Presets"), None);
assert_eq!(effect_group_from_dir("LocalCorrections"), None);
assert_eq!(effect_group_from_dir("Local Adjustment Presets/Portrait Brushes"), Some("Portrait Brushes".into()));
assert_eq!(effect_group_from_dir("Skin Kit"), Some("Skin Kit".into()));
}
}
+756
View File
@@ -0,0 +1,756 @@
//! Mask effect presets: named sets of local adjustment sliders picked from the Masking panel's
//! Effect menu (Lightroom's local adjustment / brush presets), saved with the library preferences.
//! Grouped (folders in the menu), favourites, partial presets (only some sliders, with or without
//! Amount) and optional brush settings. Files: [`super::mask_effect_files`].
use std::cmp::Ordering;
use lightcraft_catalog::PhotoId;
use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use super::mask_effect_files as files;
use super::{CommandSpec, always, bad, cmd, has_active, str_param};
use crate::{Result, Session};
/// The local sliders an effect can set (`LocalAdjustments` field names; Amount aside).
pub const FIELDS: &[&str] = &[
"temp",
"tint",
"exposure",
"contrast",
"highlights",
"shadows",
"whites",
"blacks",
"texture",
"clarity",
"dehaze",
"hue",
"saturation",
"sharpness",
"noise",
"moire",
"defringe",
"color_hue",
"color_sat",
];
/// Brush settings an effect can carry (Lightroom keeps these per brush, not in presets).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct BrushSettings {
/// Radius as a fraction of the image's long edge.
pub size: f64,
/// 0..100.
pub feather: f64,
pub flow: f64,
pub density: f64,
pub auto_mask: bool,
}
impl Default for BrushSettings {
fn default() -> Self {
Self { size: 0.04, feather: 50.0, flow: 100.0, density: 100.0, auto_mask: false }
}
}
impl BrushSettings {
fn clamped(self) -> Self {
let c = |v: f64, lo: f64, hi: f64, d: f64| if v.is_finite() { v.clamp(lo, hi) } else { d };
let d = Self::default();
Self {
size: c(self.size, 0.002, 0.5, d.size),
feather: c(self.feather, 0.0, 100.0, d.feather),
flow: c(self.flow, 1.0, 100.0, d.flow),
density: c(self.density, 1.0, 100.0, d.density),
auto_mask: self.auto_mask,
}
}
}
/// A named set of local adjustment sliders. Applying one sets the sliders it names (all of them
/// when `fields` is empty, as Lightroom's presets do: those it doesn't change go to 0); the mask's
/// Amount stays unless `with_amount`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct MaskEffect {
pub name: String,
pub adjust: LocalAdjustments,
/// The Effect menu folder ("" = none).
#[serde(skip_serializing_if = "String::is_empty")]
pub group: String,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub favorite: bool,
/// The sliders this effect sets ([`FIELDS`] names); empty = all of them.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<String>,
/// Applying sets the mask's Amount to `adjust.amount` too.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub with_amount: bool,
/// Brush settings the brush takes when the effect is applied.
#[serde(skip_serializing_if = "Option::is_none")]
pub brush: Option<BrushSettings>,
/// Settings of an imported preset that we have no slider for (Lightroom key → value), kept so
/// an export carries them.
#[serde(skip_serializing_if = "Map::is_empty")]
pub extra: Map<String, Value>,
}
impl Default for MaskEffect {
fn default() -> Self {
Self {
name: String::new(),
adjust: LocalAdjustments::default(),
group: String::new(),
favorite: false,
fields: Vec::new(),
with_amount: false,
brush: None,
extra: Map::new(),
}
}
}
/// Field `k` of `a` (0 for an unknown name).
pub fn get(a: &LocalAdjustments, k: &str) -> f64 {
serde_json::to_value(a).ok().and_then(|v| v.get(k).and_then(Value::as_f64)).unwrap_or(0.0)
}
/// `a` with field `k` set to `v` (unchanged for an unknown name).
pub fn set(a: &LocalAdjustments, k: &str, v: f64) -> LocalAdjustments {
let Ok(Value::Object(mut o)) = serde_json::to_value(a) else { return *a };
if !o.contains_key(k) || !v.is_finite() {
return *a;
}
o.insert(k.into(), json!(v));
serde_json::from_value(Value::Object(o)).unwrap_or(*a)
}
/// The sliders of `a` away from 0 (the colour's hue only with its saturation).
pub fn changed_fields(a: &LocalAdjustments) -> Vec<String> {
let mut out: Vec<String> = FIELDS.iter().filter(|k| get(a, k).abs() > 1e-9 && **k != "color_hue").map(|k| k.to_string()).collect();
if a.color_sat.abs() > 1e-9 {
out.push("color_hue".into());
}
out
}
impl MaskEffect {
/// The sliders this effect sets.
pub fn field_list(&self) -> Vec<&str> {
if self.fields.is_empty() { FIELDS.to_vec() } else { FIELDS.iter().copied().filter(|k| self.fields.iter().any(|f| f == k)).collect() }
}
/// `a` with this preset's sliders (and Amount, if it carries it).
pub fn apply(&self, a: &LocalAdjustments) -> LocalAdjustments {
let mut out = if self.fields.is_empty() {
LocalAdjustments { amount: a.amount, ..self.adjust }
} else {
self.field_list().iter().fold(*a, |acc, k| set(&acc, k, get(&self.adjust, k)))
};
if self.with_amount {
out.amount = self.adjust.amount;
}
out
}
/// Does `a` carry exactly this preset's sliders? A tint's hue doesn't count without its
/// saturation (it does nothing then).
pub fn matches(&self, a: &LocalAdjustments) -> bool {
let fields = self.field_list();
let colorless = |x: &LocalAdjustments| x.color_sat.abs() < 1e-9;
let same = |k: &str| (get(&self.adjust, k) - get(a, k)).abs() < 1e-6;
fields.iter().all(|k| *k == "color_hue" && colorless(&self.adjust) && colorless(a) || same(k))
&& (!self.with_amount || (self.adjust.amount - a.amount).abs() < 1e-6)
}
/// Does the effect set nothing (all its sliders at 0, nothing kept from an import)?
pub fn is_empty(&self) -> bool {
self.extra.is_empty() && changed_fields(&self.adjust).iter().all(|k| !self.field_list().contains(&k.as_str()))
}
/// The same effect with its values in range and its field list normalised.
pub fn normalized(mut self) -> Self {
self.name = self.name.trim().to_string();
self.group = self.group.trim().to_string();
let amount = if self.with_amount { self.adjust.amount } else { 100.0 };
self.adjust = super::masks::clamp_local(LocalAdjustments { amount, ..self.adjust });
self.fields.retain(|f| FIELDS.contains(&f.as_str()));
if self.fields.iter().any(|f| f == "color_sat") && !self.fields.iter().any(|f| f == "color_hue") {
self.fields.push("color_hue".into());
}
self.fields.sort_by_key(|f| FIELDS.iter().position(|k| k == f));
self.fields.dedup();
if self.fields.len() == FIELDS.len() {
self.fields.clear();
}
self.brush = self.brush.map(BrushSettings::clamped);
self
}
}
/// Compare names the way people number their presets: digit runs as numbers ("2 …" before
/// "10 …"), letters ignoring case.
pub fn natural_cmp(a: &str, b: &str) -> Ordering {
fn chunks(s: &str) -> Vec<(bool, String)> {
let mut out: Vec<(bool, String)> = Vec::new();
for c in s.chars() {
let d = c.is_ascii_digit();
match out.last_mut() {
Some((kind, text)) if *kind == d => text.push(c),
_ => out.push((d, c.to_string())),
}
}
out
}
let (x, y) = (chunks(a), chunks(b));
for (p, q) in x.iter().zip(y.iter()) {
let o = match (p, q) {
((true, m), (true, n)) => {
let (m, n) = (m.trim_start_matches('0'), n.trim_start_matches('0'));
m.len().cmp(&n.len()).then_with(|| m.cmp(n))
}
((_, m), (_, n)) => m.to_lowercase().cmp(&n.to_lowercase()),
};
if o != Ordering::Equal {
return o;
}
}
x.len().cmp(&y.len()).then_with(|| a.cmp(b))
}
/// The Effect menu's order: groups in the order they first appear (ungrouped first), each with
/// its effects (indices into `effects`) sorted by name.
pub fn menu_order(effects: &[MaskEffect]) -> Vec<(String, Vec<usize>)> {
let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
if effects.iter().any(|e| e.group.is_empty()) {
groups.push((String::new(), Vec::new()));
}
for (i, e) in effects.iter().enumerate() {
match groups.iter_mut().find(|(g, _)| g.eq_ignore_ascii_case(&e.group)) {
Some((_, v)) => v.push(i),
None => groups.push((e.group.clone(), vec![i])),
}
}
for (_, v) in &mut groups {
v.sort_by(|a, b| match (effects.get(*a), effects.get(*b)) {
(Some(x), Some(y)) => natural_cmp(&x.name, &y.name),
_ => Ordering::Equal,
});
}
groups
}
/// Name of the effect the mask adjustments `a` match, if any (full presets before partial ones).
pub fn matching_effect(s: &Session, a: &LocalAdjustments) -> Option<String> {
let full = s.mask_effects.iter().filter(|e| e.fields.is_empty()).find(|e| e.matches(a));
full.or_else(|| s.mask_effects.iter().find(|e| e.matches(a))).map(|e| e.name.clone())
}
fn position(s: &Session, name: &str) -> Option<usize> {
s.mask_effects.iter().position(|x| x.name.eq_ignore_ascii_case(name.trim()))
}
/// Add or replace (same name, ignoring case) an effect; a replaced one keeps its star.
pub(crate) fn upsert(s: &mut Session, e: MaskEffect) {
match position(s, &e.name).and_then(|i| s.mask_effects.get_mut(i)) {
Some(x) => {
let favorite = x.favorite || e.favorite;
*x = MaskEffect { favorite, ..e };
}
None => s.mask_effects.push(e),
}
}
fn name_param(p: &Value, c: &str) -> Result<String> {
name_of(p, "name", c)
}
fn name_of(p: &Value, key: &str, c: &str) -> Result<String> {
str_param(p, key).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).ok_or_else(|| bad(c, format!("missing `{key}`")))
}
fn strings(p: &Value, key: &str) -> Option<Vec<String>> {
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
}
/// The names a command acts on: `names: [...]` or `name`.
fn names_param(p: &Value, c: &str) -> Result<Vec<String>> {
match strings(p, "names").filter(|v| !v.is_empty()) {
Some(v) => Ok(v),
None => name_param(p, c).map(|n| vec![n]),
}
}
fn effect_index(s: &Session, name: &str, c: &str) -> Result<usize> {
position(s, name).ok_or_else(|| bad(c, format!("no effect `{name}`")))
}
/// The selected mask's adjustments on the active photo.
fn selected_mask(s: &Session, p: &Value, c: &str) -> Result<Mask> {
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 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}")))
}
/// The kind of a mask's first component (`brush`, `linear`, `radial`, `subject`…): the tool the
/// effect is remembered for.
pub fn mask_tool(m: &Mask) -> Option<String> {
m.components.first().and_then(|c| serde_json::to_value(&c.shape).ok()).and_then(|v| v.get("kind").and_then(Value::as_str).map(str::to_string))
}
/// The adjustments a new mask made with `tool` starts with: the effect last applied to a mask of
/// that kind (Lightroom carries the chosen effect over to the next mask), else all at 0.
pub fn new_mask_adjust(s: &Session, tool: &str) -> LocalAdjustments {
s.mask_tool_effects
.get(tool)
.and_then(|name| position(s, name))
.and_then(|i| s.mask_effects.get(i))
.map(|e| super::masks::clamp_local(e.apply(&LocalAdjustments::default())))
.unwrap_or_default()
}
fn list(s: &Session) -> Value {
let current = s
.active()
.and_then(|id| s.develop_of(id))
.and_then(|d| s.active_mask.and_then(|mid| d.masks.iter().find(|m| m.id == mid).map(|m| m.adjust)))
.and_then(|a| matching_effect(s, &a));
let groups: Vec<Value> = menu_order(&s.mask_effects)
.into_iter()
.map(|(g, ix)| json!({"name": g, "effects": ix.iter().filter_map(|i| s.mask_effects.get(*i)).map(|e| e.name.clone()).collect::<Vec<_>>()}))
.collect();
json!({"effects": s.mask_effects, "groups": groups, "current": current, "toolEffects": s.mask_tool_effects})
}
/// Masks on `d` named like one of `names` (ignoring case).
fn masks_named(d: &DevelopSettings, names: &[String]) -> Vec<u32> {
d.masks.iter().filter(|m| names.iter().any(|n| n.eq_ignore_ascii_case(&m.name))).map(|m| m.id).collect()
}
fn apply_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.applyEffect";
let name = name_param(p, C)?;
let effect = s.mask_effects.get(effect_index(s, &name, C)?).cloned().ok_or_else(|| bad(C, format!("no effect `{name}`")))?;
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
let d = s.develop_of(id).unwrap_or_default();
// the masks: `ids`, `all`, else `id` or the selected one
let ids: Vec<u32> = 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]
};
if ids.is_empty() {
return Err(bad(C, "no masks to apply the effect to"));
}
if let Some(missing) = ids.iter().find(|m| !d.masks.iter().any(|x| x.id == **m)) {
return Err(bad(C, format!("no mask {missing}")));
}
let set_masks = |d: &DevelopSettings, ids: &[u32]| {
let mut d = d.clone();
for m in d.masks.iter_mut().filter(|m| ids.contains(&m.id)) {
m.adjust = super::masks::clamp_local(effect.apply(&m.adjust));
}
d
};
let label = format!("Mask Effect: {}", effect.name);
// with `photos: "selected"`, the other selected photos' masks of the same names too
let others: Vec<(PhotoId, DevelopSettings)> = if str_param(p, "photos") == Some("selected") {
let names: Vec<String> = d.masks.iter().filter(|m| ids.contains(&m.id)).map(|m| m.name.clone()).collect();
s.selection
.ids
.iter()
.filter(|x| **x != id)
.filter_map(|x| s.develop_of(*x).map(|o| (*x, o)))
.filter_map(|(x, o)| {
let found = masks_named(&o, &names);
(!found.is_empty()).then(|| (x, set_masks(&o, &found)))
})
.collect()
} else {
Vec::new()
};
let mine = set_masks(&d, &ids);
let photos = 1 + others.len();
if others.is_empty() {
s.set_develop(id, mine, &label)?;
} else {
// one undo step for every photo
let mut ops: Vec<_> = s.develop_op(id, mine, &label).into_iter().collect();
ops.extend(others.into_iter().filter_map(|(x, o)| s.develop_op(x, o, &label)));
s.commit(&label, lightcraft_catalog::Op::Batch { ops })?;
}
// the next mask made with this tool starts with the effect
if p.get("remember").and_then(Value::as_bool).unwrap_or(true) {
for m in d.masks.iter().filter(|m| ids.contains(&m.id)) {
if let Some(tool) = mask_tool(m) {
s.mask_tool_effects.insert(tool, effect.name.clone());
}
}
s.save_prefs()?;
}
Ok(json!({"name": effect.name, "masks": ids, "photos": photos, "brush": effect.brush}))
}
/// The fields a save keeps: `fields: [..]` | `"changed"` | `"all"` (default).
fn fields_param(p: &Value, a: &LocalAdjustments, c: &str) -> Result<Vec<String>> {
match p.get("fields") {
None | Some(Value::Null) => Ok(Vec::new()),
Some(Value::String(x)) if x == "all" => Ok(Vec::new()),
Some(Value::String(x)) if x == "changed" => {
let f = changed_fields(a);
if f.is_empty() { Err(bad(c, "the mask changes no slider; nothing to save")) } else { Ok(f) }
}
Some(Value::Array(v)) => {
let f: Vec<String> = v.iter().filter_map(Value::as_str).filter(|k| FIELDS.contains(k)).map(str::to_string).collect();
if f.is_empty() { Err(bad(c, format!("`fields` names no slider (one of {})", FIELDS.join(", ")))) } else { Ok(f) }
}
Some(_) => Err(bad(c, "`fields` is \"all\", \"changed\" or a list of slider names")),
}
}
fn brush_param(p: &Value, c: &str) -> Result<Option<BrushSettings>> {
match p.get("brush") {
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None),
Some(v) => serde_json::from_value::<BrushSettings>(v.clone()).map(|b| Some(b.clamped())).map_err(|e| bad(c, format!("`brush`: {e}"))),
}
}
fn save_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.saveEffect";
let name = name_param(p, C)?;
let adjust = selected_mask(s, p, C)?.adjust;
let group = str_param(p, "group").map(|g| g.trim().to_string()).unwrap_or_default();
let e = MaskEffect {
name,
adjust,
group,
fields: fields_param(p, &adjust, C)?,
with_amount: p.get("amount").and_then(Value::as_bool).unwrap_or(false),
brush: brush_param(p, C)?,
..Default::default()
};
upsert(s, e.normalized());
s.save_prefs()?;
Ok(list(s))
}
/// Overwrite an effect's sliders with the selected mask's (keeping which sliders and whether
/// Amount it carries, its group and star).
fn update_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.updateEffect";
let name = name_param(p, C)?;
let i = effect_index(s, &name, C)?;
let adjust = selected_mask(s, p, C)?.adjust;
let brush = brush_param(p, C)?;
let e = s.mask_effects.get_mut(i).ok_or_else(|| bad(C, format!("no effect `{name}`")))?;
let mut next = MaskEffect { adjust, extra: Map::new(), ..e.clone() };
if let Some(f) = p.get("fields").filter(|v| !v.is_null()) {
next.fields = fields_param(&json!({"fields": f}), &adjust, C)?;
}
if let Some(a) = p.get("amount").and_then(Value::as_bool) {
next.with_amount = a;
}
if brush.is_some() {
next.brush = brush;
}
*e = next.normalized();
s.save_prefs()?;
Ok(list(s))
}
fn rename_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.renameEffect";
let name = name_param(p, C)?;
let to = name_of(p, "to", C)?;
let i = effect_index(s, &name, C)?;
if position(s, &to).is_some_and(|j| j != i) {
return Err(bad(C, format!("an effect named `{to}` exists already")));
}
if let Some(e) = s.mask_effects.get_mut(i) {
e.name = to.clone();
}
for v in s.mask_tool_effects.values_mut() {
if v.eq_ignore_ascii_case(&name) {
*v = to.clone();
}
}
s.save_prefs()?;
Ok(list(s))
}
/// A name like `base` that no effect has: `base`, `base 2`, `base 3`…
fn free_name(s: &Session, base: &str) -> String {
(1..10_000)
.map(|n| if n == 1 { base.to_string() } else { format!("{base} {n}") })
.find(|n| position(s, n).is_none())
.unwrap_or_else(|| base.into())
}
fn duplicate_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.duplicateEffect";
let name = name_param(p, C)?;
let e = s.mask_effects.get(effect_index(s, &name, C)?).cloned().ok_or_else(|| bad(C, format!("no effect `{name}`")))?;
let to = match str_param(p, "to").map(str::trim).filter(|t| !t.is_empty()) {
Some(t) if position(s, t).is_some() => return Err(bad(C, format!("an effect named `{t}` exists already"))),
Some(t) => t.to_string(),
None => free_name(s, &format!("{} copy", e.name)),
};
s.mask_effects.push(MaskEffect { name: to.clone(), favorite: false, ..e });
s.save_prefs()?;
let mut out = list(s);
out["name"] = json!(to);
Ok(out)
}
fn delete_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.deleteEffect";
let names = names_param(p, C)?;
if let Some(missing) = names.iter().find(|n| position(s, n).is_none()) {
return Err(bad(C, format!("no effect `{missing}`")));
}
s.mask_effects.retain(|x| !names.iter().any(|n| n.eq_ignore_ascii_case(&x.name)));
s.mask_tool_effects.retain(|_, v| !names.iter().any(|n| n.eq_ignore_ascii_case(v)));
s.save_prefs()?;
Ok(list(s))
}
fn favorite_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.favoriteEffect";
let names = names_param(p, C)?;
let mut idx = Vec::new();
for n in &names {
idx.push(effect_index(s, n, C)?);
}
let want = p.get("favorite").and_then(Value::as_bool);
for i in idx {
if let Some(e) = s.mask_effects.get_mut(i) {
e.favorite = want.unwrap_or(!e.favorite);
}
}
s.save_prefs()?;
Ok(list(s))
}
fn set_group(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.setEffectGroup";
let names = names_param(p, C)?;
let group = str_param(p, "group").map(|g| g.trim().to_string()).ok_or_else(|| bad(C, "missing `group` (\"\" = none)"))?;
let mut idx = Vec::new();
for n in &names {
idx.push(effect_index(s, n, C)?);
}
// an existing group's spelling wins
let group = s.mask_effects.iter().map(|e| &e.group).find(|g| g.eq_ignore_ascii_case(&group)).cloned().unwrap_or(group);
for i in idx {
if let Some(e) = s.mask_effects.get_mut(i) {
e.group = group.clone();
}
}
s.save_prefs()?;
Ok(list(s))
}
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.renameEffectGroup";
let group = name_of(p, "group", C)?;
let to = str_param(p, "to").map(|g| g.trim().to_string()).ok_or_else(|| bad(C, "missing `to` (\"\" = ungroup)"))?;
let mut n = 0;
for e in s.mask_effects.iter_mut().filter(|e| e.group.eq_ignore_ascii_case(&group)) {
e.group = to.clone();
n += 1;
}
if n == 0 {
return Err(bad(C, format!("no group `{group}`")));
}
s.save_prefs()?;
Ok(list(s))
}
fn tool_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.toolEffect";
let tool = name_of(p, "tool", C)?;
match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
Some(n) => {
let i = effect_index(s, n, C)?;
let name = s.mask_effects.get(i).map(|e| e.name.clone()).unwrap_or_default();
s.mask_tool_effects.insert(tool, name);
}
None => {
s.mask_tool_effects.remove(&tool);
}
}
s.save_prefs()?;
Ok(json!({"toolEffects": s.mask_tool_effects}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "mask.effects",
"Mask Effect Presets",
[],
None,
"{} → {effects: [{name, adjust, group?, favorite?, fields?, with_amount?, brush?, extra?}], groups: [{name, effects: [name]}] (menu order), current: the effect the selected mask matches or null, toolEffects: {tool: effect a new mask of that kind starts with}}",
always,
|s, _| Ok(list(s))
),
cmd!(
"mask.applyEffect",
"Apply Mask Effect",
[],
None,
"{name, id? | ids?: [maskId] | all?: bool, photos?: \"selected\" (also the masks of the same names on the other selected photos, one undo step), remember?: bool (true: new masks of this kind start with it)} — sets the effect's sliders (all, or the ones it keeps; Amount only if it carries it) → {name, masks, photos, brush}",
has_active,
apply_effect
),
cmd!(
"mask.saveEffect",
"Save Mask Effect",
[],
None,
"{name, id?, group?, fields?: \"all\" (default) | \"changed\" | [slider], amount?: bool (carry Amount), brush?: {size, feather, flow, density, auto_mask}} — the selected mask's sliders as an effect preset (replaces one of that name), saved with the library",
has_active,
save_effect
),
cmd!(
"mask.updateEffect",
"Update Mask Effect",
[],
None,
"{name, id?, fields?, amount?, brush?} — overwrite the effect with the selected mask's sliders (keeps its group, star and which sliders it carries unless given)",
has_active,
update_effect
),
cmd!("mask.renameEffect", "Rename Mask Effect", [], None, "{name, to}", always, rename_effect),
cmd!(
"mask.duplicateEffect",
"Duplicate Mask Effect",
[],
None,
"{name, to?} (default \"<name> copy\") → list + {name}",
always,
duplicate_effect
),
cmd!("mask.deleteEffect", "Delete Mask Effect", [], None, "{name | names: [name]} — removes effect presets", always, delete_effect),
cmd!("mask.favoriteEffect", "Favorite Mask Effect", [], None, "{name | names, favorite?: bool (default: toggle)}", always, favorite_effect),
cmd!("mask.setEffectGroup", "Move Mask Effect to Group", [], None, "{name | names, group: name (\"\" = none)}", always, set_group),
cmd!(
"mask.renameEffectGroup",
"Rename Mask Effect Group",
[],
None,
"{group, to} (\"\" ungroups; an existing name merges)",
always,
rename_group
),
cmd!(
"mask.toolEffect",
"Set Effect for New Masks",
[],
None,
"{tool: brush|linear|radial|subject|…, name?} — the effect new masks of that kind start with (no name: none)",
always,
tool_effect
),
cmd!(
"mask.importEffects",
"Import Mask Effects",
[],
None,
"{path | paths: [file or folder], group?, dryRun?} — Lightroom Classic local adjustment presets (.lrtemplate), Camera Raw local correction presets (.xmp), .lceffect files and .zip bundles of these; folders and Lightroom's divider presets (\"10 ----- DETAIL ----- 10\") become groups; same-named effects are replaced → {imported: [{name, group, file, unmapped}], dividers: [{name, group, file}], failed: [[file, error]]}",
always,
files::import_effects
),
cmd!(
"mask.exportEffects",
"Export Mask Effects",
[],
None,
"{path, names?: [name], group?: name (default: all)} — writes an .lceffect JSON file (groups, stars and kept Lightroom settings included) → {path, count}",
always,
files::export_effects
),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apply_keeps_amount_and_matches_ignore_it() {
let e = MaskEffect { name: "E".into(), adjust: LocalAdjustments { exposure: 1.0, ..Default::default() }, ..Default::default() };
let current = LocalAdjustments { shadows: 40.0, amount: 60.0, ..Default::default() };
let out = e.apply(&current);
assert_eq!((out.exposure, out.shadows, out.amount), (1.0, 0.0, 60.0));
assert!(e.matches(&out));
assert!(!e.matches(&current));
}
#[test]
fn partial_effects_leave_other_sliders_and_can_carry_amount() {
let e = MaskEffect {
name: "Lift".into(),
adjust: LocalAdjustments { shadows: 30.0, clarity: 99.0, amount: 70.0, ..Default::default() },
fields: vec!["shadows".into()],
with_amount: true,
..Default::default()
};
let current = LocalAdjustments { exposure: 0.5, clarity: 10.0, ..Default::default() };
let out = e.apply(&current);
assert_eq!((out.exposure, out.shadows, out.clarity, out.amount), (0.5, 30.0, 10.0, 70.0));
assert!(e.matches(&out));
assert!(!e.matches(&LocalAdjustments { amount: 100.0, ..out }), "Amount counts when carried");
}
#[test]
fn hue_without_saturation_still_matches() {
let e = MaskEffect {
name: "E".into(),
adjust: LocalAdjustments { color_hue: 240.0, exposure: 0.25, ..Default::default() },
..Default::default()
};
assert!(e.matches(&LocalAdjustments { exposure: 0.25, ..Default::default() }));
assert!(!e.matches(&LocalAdjustments { exposure: 0.25, color_sat: 10.0, ..Default::default() }));
}
#[test]
fn natural_order_and_groups() {
let mut v = vec!["10 b", "2 a", "Burn", "01 x", "1 y"];
v.sort_by(|a, b| natural_cmp(a, b));
assert_eq!(v, vec!["01 x", "1 y", "2 a", "10 b", "Burn"]);
let e = |n: &str, g: &str| MaskEffect { name: n.into(), group: g.into(), ..Default::default() };
let list = vec![e("12 b", "Detail"), e("3 a", ""), e("11 a", "detail"), e("20 r", "Retouch"), e("Burn", "")];
let order = menu_order(&list);
let names: Vec<(String, Vec<&str>)> = order.iter().map(|(g, ix)| (g.clone(), ix.iter().map(|i| list[*i].name.as_str()).collect())).collect();
assert_eq!(
names,
vec![("".to_string(), vec!["3 a", "Burn"]), ("Detail".to_string(), vec!["11 a", "12 b"]), ("Retouch".to_string(), vec!["20 r"]),]
);
}
#[test]
fn normalized_clamps_and_tidies_fields() {
let e = MaskEffect {
name: " X ".into(),
adjust: LocalAdjustments { exposure: 99.0, amount: 150.0, ..Default::default() },
fields: vec!["color_sat".into(), "bogus".into(), "exposure".into()],
brush: Some(BrushSettings { size: f64::NAN, flow: 0.0, ..Default::default() }),
..Default::default()
}
.normalized();
assert_eq!(e.name, "X");
assert_eq!(e.adjust.exposure, 4.0);
assert_eq!(e.adjust.amount, 100.0, "Amount isn't carried");
assert_eq!(e.fields, vec!["exposure", "color_hue", "color_sat"]);
let b = e.brush.unwrap();
assert_eq!((b.size, b.flow), (0.04, 1.0));
}
}
+25 -5
View File
@@ -178,11 +178,14 @@ pub fn specs() -> Vec<CommandSpec> {
return Ok(json!({"pending": true}));
}
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
// the effect last applied to a mask of this kind
let adjust = super::mask_effects::new_mask_adjust(s, &kind);
let out = masks_edit(s, "mask.add", "Add Mask", |masks, active| {
masks.push(Mask {
id: next,
name: name.unwrap_or_else(|| format!("Mask {next}")),
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
adjust,
..Default::default()
});
*active = Some(next);
@@ -230,7 +233,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Brush Stroke",
[],
None,
"{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool}",
"{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool, pressure?: [p,…] pen pressure 0..1 per point (scales each dab's size and flow; omitted: full)}",
has_active,
|s, p| {
let pts: Vec<Point> = p
@@ -240,6 +243,7 @@ pub fn specs() -> Vec<CommandSpec> {
.iter()
.filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?)))
.collect();
let pts_len = pts.len();
let d = BrushStroke::default();
let stroke = BrushStroke {
points: pts,
@@ -249,14 +253,16 @@ pub fn specs() -> Vec<CommandSpec> {
density: f64_or(p, "density", d.density),
erase: bool_or(p, "erase", false),
auto_mask: bool_or(p, "autoMask", false),
pressure: pressure_list(p.get("pressure"), pts_len)?,
};
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 adjust = super::mask_effects::new_mask_adjust(s, "brush");
masks_edit(s, "mask.brushStroke", "Brush", |masks, active| {
let i = match want.and_then(|m| masks.iter().position(|x| x.id == m)) {
Some(i) => i,
None => {
masks.push(Mask { id: next, name: format!("Mask {next}"), ..Default::default() });
masks.push(Mask { id: next, name: format!("Mask {next}"), adjust, ..Default::default() });
*active = Some(next);
masks.len() - 1
}
@@ -921,7 +927,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_active,
|s, p| {
let c = "redeye.catchlight";
let i = super::f64_req(p, "index", c)? as usize;
let i = super::index_req(p, "index", c)?;
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
let eye = dd.red_eye.get_mut(i).ok_or_else(|| bad(c, "no such eye"))?;
@@ -936,7 +942,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("redeye.delete", "Delete Red Eye Correction", [], None, "{index}", has_active, |s, p| {
let c = "redeye.delete";
let i = super::f64_req(p, "index", c)? as usize;
let i = super::index_req(p, "index", c)?;
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
if i >= dd.red_eye.len() {
@@ -979,7 +985,7 @@ fn pick_source(s: &mut Session, id: crate::PhotoId, d: &lightcraft_develop::Deve
lightcraft_pipeline::spots::pick_source(&src, &info, d, spot, avoid)
}
fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments {
pub(crate) fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments {
let c = |v: &mut f64, lo: f64, hi: f64| *v = if v.is_finite() { v.clamp(lo, hi) } else { 0.0 };
c(&mut a.exposure, -4.0, 4.0);
for v in [
@@ -1007,3 +1013,17 @@ fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments {
c(&mut a.amount, 0.0, 200.0);
a
}
/// `mask.brushStroke`'s `pressure`: one number 0..1 per point (clamped), at most one per point; absent or `null`
/// is no pressure data.
fn pressure_list(v: Option<&Value>, points: usize) -> Result<Vec<f32>> {
let Some(v) = v.filter(|v| !v.is_null()) else { return Ok(Vec::new()) };
let list = v.as_array().ok_or_else(|| bad("mask.brushStroke", "pressure must be a list of numbers 0..1"))?;
if list.len() > points {
return Err(bad("mask.brushStroke", "pressure has more values than points"));
}
list.iter()
.map(|q| q.as_f64().filter(|f| f.is_finite()).map(|f| f.clamp(0.0, 1.0) as f32))
.collect::<Option<Vec<f32>>>()
.ok_or_else(|| bad("mask.brushStroke", "pressure must be a list of numbers 0..1"))
}
+31 -2
View File
@@ -1,4 +1,4 @@
//! Photo Merge commands: `merge.hdr`, `merge.panorama`, `merge.hdrPanorama`.
//! Photo Merge commands: `merge.hdr`, `merge.panorama`, `merge.hdrPanorama`, `merge.hdrBatch`.
//!
//! Run synchronously here (CLI, MCP, control channel); the desktop UI plans the same job with
//! [`Session::plan_merge`] and runs it on a worker thread. `preview: true` merges ≤ 1024 px frames
@@ -40,6 +40,26 @@ fn run(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
Ok(info)
}
/// `merge.hdrBatch`: an HDR merge of each bracket set among the photos ([`lightcraft_catalog::Catalog::bracket_sets`]),
/// one after the other. A set that fails is reported and the others still run.
fn run_batch(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "merge.hdrBatch";
let (kind, finish) = crate::merge::parse("merge.hdr", p)?;
let ids: Vec<PhotoId> = s.targets(p);
let sets = s.catalog.bracket_sets(&ids);
if sets.is_empty() {
return Err(bad(ID, crate::merge::NO_BRACKET_SETS));
}
let (mut merged, mut failed) = (Vec::new(), Vec::new());
for set in sets {
match s.merge_now(kind.clone(), finish.clone(), &set) {
Ok(v) => merged.push(v),
Err(e) => failed.push(json!({"photos": set.iter().map(|p| p.0).collect::<Vec<_>>(), "error": e.to_string()})),
}
}
Ok(json!({"merged": merged, "failed": failed}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -56,7 +76,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Panorama Merge",
[],
None,
"{ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, stack=false, maxMegapixels=40, preview=false, previewPath?}",
"{ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, stack=false, maxMegapixels=full size (≤ 512, held to memory), preview=false, previewPath?}",
can_merge,
|s, p| run(s, "merge.panorama", p)
),
@@ -69,5 +89,14 @@ pub fn specs() -> Vec<CommandSpec> {
can_merge,
|s, p| run(s, "merge.hdrPanorama", p)
),
cmd!(
"merge.hdrBatch",
"HDR for Each Bracket Set",
["Photo", "Photo Merge"],
None,
"{ids?, align=true, deghost=none|low|medium|high, autoSettings=true, stack=false}: an HDR merge of each bracket set among the photos (each stack, then photos taken less than a second apart at exposures ≥ ½ EV apart) → {merged: [{id, path, …}], failed: [{photos, error}]}",
can_merge,
run_batch
),
]
}
+16
View File
@@ -24,6 +24,8 @@ pub mod keywords;
pub mod library;
pub mod lut_profiles;
pub mod manage;
pub mod mask_effect_files;
pub mod mask_effects;
mod masks;
mod merge;
pub mod metadata;
@@ -33,6 +35,8 @@ mod prefs;
mod preset_files;
pub mod previews;
mod query;
pub mod recheck;
mod slideshow;
mod sync;
mod xmp;
@@ -145,6 +149,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(manage::specs());
v.extend(previews::specs());
v.extend(lut_profiles::specs());
v.extend(mask_effects::specs());
v.extend(cull::specs());
v.extend(convert::specs());
v.extend(convert::edit_specs());
@@ -153,10 +158,12 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(xmp::specs());
v.extend(preset_files::specs());
v.extend(prefs::specs());
v.extend(slideshow::specs());
v.extend(export::specs());
v.extend(before::specs());
v.extend(browse::specs());
v.extend(missing::specs());
v.extend(recheck::specs());
v.extend(sync::specs());
v.extend(metadata::specs());
v.extend(geocode::specs());
@@ -187,6 +194,15 @@ pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result<f64> {
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.
pub(crate) fn index_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
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")));
}
Ok(i as usize)
}
pub(crate) fn f64_or(p: &Value, key: &str, d: f64) -> f64 {
p.get(key).and_then(Value::as_f64).unwrap_or(d)
}
+4 -1
View File
@@ -43,6 +43,9 @@ fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = i.get("perCamera").and_then(Value::as_bool) {
d.per_camera = v;
}
if let Some(v) = i.get("showOnCard").and_then(Value::as_bool) {
d.show_on_card = v;
}
if let Some(v) = i.get("copyright").and_then(Value::as_str) {
d.copyright = v.trim().to_string();
}
@@ -107,7 +110,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Library Preferences",
[],
None,
"{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}], copyright?: text, creator?: text (given to imported photos without one)}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default), forgetLocalDays?: n (forget untouched Local photos of folders not browsed for n days; 0 = never)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, forgetLocalDays, persistent}",
"{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}], copyright?: text, creator?: text (given to imported photos without one), showOnCard?: bool (open the import review when a camera or card is connected)}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default), forgetLocalDays?: n (forget untouched Local photos of folders not browsed for n days; 0 = never)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, forgetLocalDays, persistent}",
always,
prefs
)]
+33 -8
View File
@@ -2,6 +2,7 @@
use serde_json::{Value, json};
use super::mask_effect_files as mef;
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::preset_import::{group_from_dir, read_presets};
use crate::presets::{LCPRESET_EXT, expand_preset_paths, to_lcpreset};
@@ -18,6 +19,10 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
let mut imported = Vec::new();
let mut failed = Vec::new();
let mut skipped = 0usize;
// local adjustment (brush) presets — Lightroom's .lrtemplate, Camera Raw's local .xmp, our
// .lceffect, also inside zips — become mask effects
let mut effects = Vec::new();
let mut effect_failed = Vec::new();
for top in &paths {
let from_dir = std::path::Path::new(top).is_dir();
for f in expand_preset_paths(std::slice::from_ref(top)) {
@@ -25,8 +30,27 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
// the path from the imported folder (inclusive) to the file's folder
let base = std::path::Path::new(top).parent().unwrap_or(std::path::Path::new(""));
let rel = std::path::Path::new(&f).parent().and_then(|d| d.strip_prefix(base).ok()).map(|d| d.to_string_lossy().to_string());
let dir_group = rel.filter(|_| from_dir).and_then(|d| group_from_dir(&d));
let parsed = std::fs::read(&f).map_err(|e| e.to_string()).and_then(|b| read_presets(&f, &b, dir_group));
let rel = rel.filter(|_| from_dir);
let bytes = match mef::read_capped(&f) {
Ok(b) => b,
Err(e) => {
failed.push(json!([f, e]));
continue;
}
};
let lower = f.to_ascii_lowercase();
let maybe_local = [".lrtemplate", ".xmp", ".zip", ".lceffect"].iter().any(|x| lower.ends_with(x));
let before = effects.len() + effect_failed.len();
let local = maybe_local && mef::found_in(&f, &bytes, rel.as_deref().and_then(mef::group_for_dir), &mut effects, &mut effect_failed);
// a zip may hold develop presets as well
if local && !lower.ends_with(".zip") {
continue;
}
let dir_group = rel.and_then(|d| group_from_dir(&d));
let parsed = read_presets(&f, &bytes, dir_group);
if local && parsed.is_err() && effects.len() + effect_failed.len() > before {
continue;
}
match parsed {
Ok(list) => {
for mut it in list {
@@ -45,7 +69,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
Ok(json!({"imported": imported, "skipped": skipped, "failed": failed}))
failed.extend(effect_failed.into_iter().map(|(f, e)| json!([f, e])));
let (effects, dividers) = mef::add_found(s, effects, group.as_deref(), dry)?;
Ok(json!({"imported": imported, "skipped": skipped, "failed": failed, "effects": effects, "effectDividers": dividers}))
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
@@ -69,10 +95,9 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
if chosen.is_empty() {
return Err(bad("preset.export", "no presets to export (create one first, or pass ids/group)"));
}
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?;
}
std::fs::write(&path, to_lcpreset(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?;
let target = path.to_string_lossy();
s.check_write_target(&target).map_err(|e| bad("preset.export", e))?;
crate::export::write_file_durable(&target, to_lcpreset(&chosen).as_bytes()).map_err(crate::EngineError::Other)?;
Ok(json!({"path": path.display().to_string(), "count": chosen.len()}))
}
@@ -83,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Import Presets",
[],
None,
"{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md → {imported: [{id,name,group,file,unmapped}], skipped, failed}",
"{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md; local adjustment presets (Lightroom .lrtemplate, Camera Raw local .xmp, .lceffect) become mask effects (`mask.effects`; divider presets become their groups) → {imported: [{id,name,group,file,unmapped}], skipped, failed, effects: [{name,group,file,unmapped}], effectDividers: [{name,group,file}]}",
always,
import
),
+129
View File
@@ -0,0 +1,129 @@
//! Preview-only raws rechecked after an update (issue #662): a raw imported while its format or
//! variant couldn't be decoded keeps [`Photo::preview_only`] until something re-reads its file.
//! Each flag is stamped with the app version that set it ([`Photo::preview_checked`]); when a
//! library opens in another version, its stale preview-only photos are probed again and get
//! exactly what Reload from Disk would give them: a file that decodes now loses the flag, one that
//! still doesn't gets the new reason and a fresh stamp, so it isn't probed again.
//!
//! The work is split so the app can probe on a worker thread: [`stale`] lists what to probe (no
//! file-system calls), [`probe`] reads the files without the session, and [`apply`] applies the
//! results as LightCraft's own bookkeeping (journaled, not an undo step). Agents and tests use
//! `library.recheckPreviewOnly`, which does all three at once.
use lightcraft_catalog::{Catalog, Op, Photo, PhotoId};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd};
use crate::media::{FileProbe, ProbeInfo};
use crate::{Result, Session};
/// A preview-only photo to probe, and what the library knew about it when it was listed (so a
/// result for a photo that changed meanwhile is dropped).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Stale {
pub id: PhotoId,
pub path: String,
preview_only: Option<String>,
preview_checked: String,
}
impl Stale {
fn of(p: &Photo, path: &str) -> Stale {
Stale { id: p.id, path: path.to_string(), preview_only: p.preview_only.clone(), preview_checked: p.preview_checked.clone() }
}
/// The photo is still as it was listed: same file, flag and stamp.
fn current(&self, p: &Photo) -> bool {
crate::cmd::missing::checked_path(p) == Some(self.path.as_str())
&& p.preview_only == self.preview_only
&& p.preview_checked == self.preview_checked
}
}
/// The library's preview-only photos whose flag another app version set (or every preview-only
/// photo, with `all`): library photos with a file, not virtual copies (they follow the photo they
/// were copied from). No file-system calls.
pub fn stale(cat: &Catalog, all: bool) -> Vec<Stale> {
cat.photos()
.filter(|p| p.copy_of.is_none() && p.preview_only.is_some() && (all || p.preview_only_stale()))
.filter_map(|p| crate::cmd::missing::checked_path(p).map(|path| Stale::of(p, path)))
.collect()
}
/// The session's file probe, to [`probe`] with on a worker thread; `None` when the session has no
/// file access (a demo or browser session), where nothing can be rechecked.
pub fn file_probe(s: &Session) -> Option<FileProbe> {
s.media.file_probe.clone()
}
/// Probe each listed photo's file as Reload would. Doesn't touch the session.
pub fn probe(probe: &FileProbe, stale: Vec<Stale>) -> Vec<(Stale, std::result::Result<ProbeInfo, String>)> {
let paths: Vec<String> = stale.iter().map(|x| x.path.clone()).collect();
stale.into_iter().zip(crate::import::probe_with(probe, &paths)).collect()
}
/// What a recheck changed.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Rechecked {
/// Photos brought up to date (virtual copies included).
pub updated: Vec<PhotoId>,
/// Of those, the ones that develop from their raw data now.
pub cleared: Vec<PhotoId>,
}
impl Rechecked {
pub fn to_json(&self) -> Value {
json!({"updated": self.updated.iter().map(|i| i.0).collect::<Vec<_>>(), "cleared": self.cleared.iter().map(|i| i.0).collect::<Vec<_>>()})
}
}
/// Apply [`probe`]'s results: each photo gets the ops Reload would give it, all in one batch that is
/// journaled but isn't an undo step. A file that can't be read (missing, offline) changes nothing,
/// so it is tried again next time; a photo that changed since it was listed (relinked, reloaded,
/// removed) is skipped.
pub fn apply(s: &mut Session, results: Vec<(Stale, std::result::Result<ProbeInfo, String>)>) -> Result<Rechecked> {
let mut ops = Vec::new();
let mut out = Rechecked::default();
for (item, info) in results {
let Ok(info) = info else { continue };
if !s.catalog.photo(item.id).is_some_and(|p| item.current(p)) {
continue;
}
let first = out.updated.len();
crate::cmd::convert::reload_ops(&s.catalog, item.id, &info, &mut ops, &mut out.updated);
if info.preview_only.is_none() {
out.cleared.extend_from_slice(&out.updated[first..]);
}
}
if !ops.is_empty() {
s.apply_system(Op::Batch { ops })?;
}
for id in &out.updated {
s.media.forget(*id);
}
Ok(out)
}
fn recheck(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.recheckPreviewOnly";
let all = p.get("all").and_then(Value::as_bool).unwrap_or(false);
// without file access every probe "succeeds" with nothing known, which would clear every flag
let fp = file_probe(s).ok_or_else(|| bad(C, "this session has no file access"))?;
let listed = stale(&s.catalog, all);
let n = listed.len();
let mut r = apply(s, probe(&fp, listed))?.to_json();
r["checked"] = json!(n);
Ok(r)
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"library.recheckPreviewOnly",
"Recheck Preview-Only Raws",
[],
None,
"{all?: bool} — probe again the raws shown from their embedded preview whose flag another LightCraft version set (the app does this in the background when a library opens; `all` rechecks every preview-only raw, e.g. when testing a decoder build of the same version), and bring each up to date as Reload from Disk would; not an undo step, develop settings and history are kept; files that can't be read are left for next time → {checked, updated: [ids], cleared: [ids] (develop from their raw data now)}",
always,
recheck
)]
}
+513
View File
@@ -0,0 +1,513 @@
//! `slideshow.playback`: the slideshow's Playback settings (slide and fade durations, fade colour,
//! random order, repeat, preparing previews in advance). `slideshow.layout`: how a slide looks
//! (Layout, Options and Backdrop: margins, aspect preview, zoom to fill, stroke, shadow, backdrop
//! colour, colour wash, background image). Both are library preferences, saved in the library's
//! `prefs.json`; the app's slideshow (View ▸ Slideshow) plays with them. `slideshow.overlays`:
//! the identity plate, rating stars, text overlays with tokens and the watermark drawn on every
//! slide (also a library preference). `slideshow.titles`: the intro and ending screens (colour,
//! identity plate or text, scale, duration; a library preference). `slideshow.preview` composes
//! one slide, or a title screen, as a PNG (with the library's settings, or a template's).
//! `slideshow.templates.*`: the Template Browser (built-in and user templates; see
//! [`crate::slide_template`]). `slideshow.export`: the slideshow as a PDF, a JPEG sequence or an
//! AV1 video, in the background ([`crate::slideshow_export`]); `slideshow.exportStatus` reads how
//! it is going.
use lightcraft_catalog::PhotoId;
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::slide_template::{self as tpl, SlideLook};
use crate::{Result, Session};
/// Largest side `slideshow.preview` renders.
pub const PREVIEW_MAX: usize = 4096;
fn playback(s: &mut Session, p: &Value) -> Result<Value> {
let changes = p.as_object().is_some_and(|o| !o.is_empty());
if changes {
let next = s.slideshow.merged(p).map_err(|e| bad("slideshow.playback", e))?;
if next != s.slideshow {
s.slideshow = next;
s.save_prefs()?;
}
}
Ok(s.slideshow.to_json())
}
fn layout(s: &mut Session, p: &Value) -> Result<Value> {
let changes = p.as_object().is_some_and(|o| !o.is_empty());
if changes {
let next = s.slide_layout.merged(p).map_err(|e| bad("slideshow.layout", e))?;
if next != s.slide_layout {
s.slide_layout = next;
s.save_prefs()?;
}
}
Ok(s.slide_layout.to_json())
}
fn overlays(s: &mut Session, p: &Value) -> Result<Value> {
let changes = p.as_object().is_some_and(|o| !o.is_empty());
if changes {
let next = s.slide_overlays.merged(p).map_err(|e| bad("slideshow.overlays", e))?;
if next != s.slide_overlays {
s.slide_overlays = next;
s.save_prefs()?;
}
}
let mut out = s.slide_overlays.to_json();
out["warnings"] = json!(s.slide_overlays.warnings());
Ok(out)
}
fn titles(s: &mut Session, p: &Value) -> Result<Value> {
let changes = p.as_object().is_some_and(|o| !o.is_empty());
if changes {
let next = s.slide_titles.merged(p).map_err(|e| bad("slideshow.titles", e))?;
if next != s.slide_titles {
s.slide_titles = next;
s.save_prefs()?;
}
}
let mut out = s.slide_titles.to_json();
out["warnings"] = json!(s.slide_titles.warnings(&s.slide_overlays));
Ok(out)
}
/// A slide size from the request: both sides given, one (the other from the aspect preview), or
/// neither (1280 wide). Each side 16–[`PREVIEW_MAX`].
fn preview_size(layout: &lightcraft_slideshow::SlideSettings, p: &Value) -> Result<(usize, usize)> {
const C: &str = "slideshow.preview";
let side = |k: &str| -> Result<Option<usize>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(v) => match v.as_u64() {
Some(n) if (16..=PREVIEW_MAX as u64).contains(&n) => Ok(Some(n as usize)),
_ => Err(bad(C, format!("`{k}` must be a whole number of pixels from 16 to {PREVIEW_MAX}, not {v}"))),
},
}
};
let ratio = layout.aspect.ratio(f64::NAN);
let clamp = |v: f64| (v.round() as usize).clamp(16, PREVIEW_MAX);
Ok(match (side("width")?, side("height")?) {
(Some(w), Some(h)) => (w, h),
(Some(w), None) => (w, clamp(w as f64 / ratio)),
(None, Some(h)) => (clamp(h as f64 * ratio), h),
(None, None) => (1280, clamp(1280.0 / ratio)),
})
}
/// Write `png` to `path`, or put it in the reply as base64.
fn deliver(png: &[u8], path: Option<&str>, out: &mut Value) -> Result<()> {
match path {
Some(path) => {
crate::export::write_file(path, png).map_err(crate::EngineError::Other)?;
out["path"] = json!(path);
}
None => out["png"] = json!(base64(png)),
}
Ok(())
}
fn encode_png(img: &lightcraft_raster::Rgba8) -> Result<Vec<u8>> {
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default())
.map_err(|e| crate::EngineError::Other(format!("PNG: {e}")))
}
/// `slideshow.preview {title}`: a title screen (whether or not it is switched on).
fn preview_title(s: &mut Session, p: &Value, title: &Value, look: &SlideLook) -> Result<Value> {
const C: &str = "slideshow.preview";
let t = title
.as_str()
.and_then(crate::slideshow::Title::parse)
.ok_or_else(|| bad(C, format!("`title` must be \"intro\" or \"ending\", not {title}")))?;
if p.get("id").is_some_and(|v| !v.is_null()) {
return Err(bad(C, "pass `id` or `title`, not both"));
}
let (w, h) = preview_size(&look.layout, p)?;
let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty());
if let Some(path) = path {
s.check_write_target(path).map_err(|e| bad(C, e))?;
}
let c = crate::slide_title::TitleCompose::new(&look.titles, &look.overlays, t, w, h);
let img = c.compose();
let png = encode_png(&img)?;
let rect = |r: lightcraft_slideshow::Rect| json!([r.x0, r.y0, r.x1, r.y1]);
let mut out = json!({
"title": t.id(),
"enabled": look.titles.screen(t).enabled,
"width": img.width,
"height": img.height,
"plate": c.plate_box().map(rect),
});
deliver(&png, path, &mut out)?;
Ok(out)
}
fn preview(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "slideshow.preview";
// a template's look (the Template Browser's hover preview), else the library's settings
let template = match p.get("template") {
None | Some(Value::Null) => None,
Some(Value::String(id)) => Some(s.slide_template(id.trim()).map_err(|e| bad(C, e))?),
Some(v) => return Err(bad(C, format!("`template` must be a template id, not {v}"))),
};
let look = template.as_ref().map_or_else(|| SlideLook::of(s), |t| t.look.clone());
if let Some(t) = p.get("title").filter(|v| !v.is_null()) {
let mut out = preview_title(s, p, t, &look)?;
if let Some(t) = &template {
out["template"] = json!(t.id);
}
return Ok(out);
}
let id = match p.get("id") {
None | Some(Value::Null) => s.active().ok_or_else(|| bad(C, "no photo selected (select one or pass `id`)"))?,
Some(v) => PhotoId(v.as_u64().ok_or_else(|| bad(C, format!("`id` must be a photo id, not {v}")))?),
};
if s.catalog.photo(id).is_none() {
return Err(bad(C, format!("no photo {}", id.0)));
}
let (w, h) = preview_size(&look.layout, p)?;
let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty());
if let Some(path) = path {
s.check_write_target(path).map_err(|e| bad(C, e))?;
}
let settings = look.layout.clone();
let img = s.slide_now_look(&settings, &look.overlays, id, w, h).map_err(crate::EngineError::Other)?;
let png = encode_png(&img)?;
let aspect = s.slide_photo_aspect(id).unwrap_or(1.5);
let l = lightcraft_slideshow::layout(&settings, aspect, img.width, img.height);
let rect = |r: lightcraft_slideshow::Rect| json!([r.x0, r.y0, r.x1, r.y1]);
let mut out = json!({
"id": id.0,
"width": img.width,
"height": img.height,
"cell": rect(l.cell),
"photo": rect(l.visible),
});
let photo = s.catalog.photo(id).cloned();
let o = crate::slide_overlay::SlideOverlays::resolve(&look.overlays, photo.as_deref());
out["overlays"] = o.boxes(&l).into_iter().map(|(k, r)| json!({"kind": k.id(), "box": rect(r)})).collect();
if let Some(t) = &template {
out["template"] = json!(t.id);
}
deliver(&png, path, &mut out)?;
Ok(out)
}
/// Standard padded base64 (for an image returned in the reply).
fn base64(bytes: &[u8]) -> String {
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32;
let at = |shift: u32| A.get((n >> shift) as usize & 63).copied().unwrap_or(b'=') as char;
out.push(at(18));
out.push(at(12));
out.push(if chunk.len() > 1 { at(6) } else { '=' });
out.push(if chunk.len() > 2 { at(0) } else { '=' });
}
out
}
// ------------------------------------------------------------------------------- templates
/// The settings as the `slideshow.*` commands return them (for template replies).
fn settings_json(s: &Session) -> Value {
json!({
"playback": s.slideshow.to_json(),
"layout": s.slide_layout.to_json(),
"overlays": s.slide_overlays.to_json(),
"titles": s.slide_titles.to_json(),
})
}
fn tpl_list(s: &mut Session, _p: &Value) -> Result<Value> {
let l = s.slide_templates();
Ok(json!({
"templates": l.templates.iter().map(|t| t.info()).collect::<Vec<_>>(),
"previous": s.slide_templates_previous.is_some(),
"folder": s.slide_templates_dir.as_ref().map(|d| d.to_string_lossy().to_string()),
"problems": l.problems,
}))
}
/// The template `id` or `name` names.
fn named(s: &mut Session, p: &Value, c: &str) -> Result<std::sync::Arc<tpl::Template>> {
s.slide_template_named(str_param(p, "id"), str_param(p, "name")).map_err(|e| bad(c, e))
}
fn tpl_apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "slideshow.templates.apply";
let t = named(s, p, C)?;
let warnings = s.apply_slide_look(&t.look)?;
let mut out = json!({"applied": t.id, "name": t.name, "warnings": warnings, "previous": s.slide_templates_previous.is_some()});
out["settings"] = settings_json(s);
Ok(out)
}
fn tpl_reply(t: &tpl::Template) -> Value {
t.info()
}
fn tpl_save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "slideshow.templates.save";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let replace = match p.get("replace") {
None | Some(Value::Null) => false,
Some(v) => v.as_bool().ok_or_else(|| bad(C, format!("`replace` must be true or false, not {v}")))?,
};
let t = s.save_slide_template(name, replace).map_err(|e| bad(C, e))?;
Ok(tpl_reply(&t))
}
fn id_param<'a>(p: &'a Value, c: &str) -> Result<&'a str> {
str_param(p, "id").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(c, "missing `id` (a template id from slideshow.templates.list)"))
}
fn tpl_update(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "slideshow.templates.update";
let id = id_param(p, C)?;
let t = s.update_slide_template(id).map_err(|e| bad(C, e))?;
Ok(tpl_reply(&t))
}
fn tpl_rename(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "slideshow.templates.rename";
let id = id_param(p, C)?;
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let t = s.rename_slide_template(id, name).map_err(|e| bad(C, e))?;
Ok(tpl_reply(&t))
}
fn tpl_delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "slideshow.templates.delete";
let id = id_param(p, C)?;
let t = s.delete_slide_template(id).map_err(|e| bad(C, e))?;
Ok(json!({"deleted": t.id, "name": t.name}))
}
fn tpl_import(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "slideshow.templates.import";
let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
let (t, renamed) = s.import_slide_template(std::path::Path::new(path)).map_err(|e| bad(C, e))?;
let mut out = tpl_reply(&t);
out["renamed"] = json!(renamed);
out["warnings"] = json!(t.look.warnings(s));
Ok(out)
}
fn tpl_export(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "slideshow.templates.export";
let id = id_param(p, C)?;
let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
let t = s.slide_template(id).map_err(|e| bad(C, e))?;
s.check_write_target(path).map_err(|e| bad(C, e))?;
let bytes = serde_json::to_vec_pretty(&t.to_doc()).map_err(|e| crate::EngineError::Other(e.to_string()))?;
crate::export::write_file(path, &bytes).map_err(crate::EngineError::Other)?;
Ok(json!({"id": t.id, "name": t.name, "path": path}))
}
/// `slideshow.export`: prepare on this thread, run on a worker (or here with `wait`).
fn export(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "slideshow.export";
if cfg!(target_arch = "wasm32") {
return Err(bad(C, "slideshow export needs the desktop app or lightcraft-cli"));
}
if s.slideshow_export.as_ref().is_some_and(|r| r.running()) {
return Err(bad(C, "a slideshow export is already running (activity.cancel stops it)"));
}
let prepared = crate::slideshow_export::prepare(s, p).map_err(|e| bad(C, e))?;
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false);
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let task = s.activity.start("export", "Exporting slideshow", crate::activity::Cancel::Flag(cancel.clone()));
let total = prepared.total();
task.progress(0, total);
task.detail(&prepared.request.path);
let settings = &prepared.request.settings;
let run = std::sync::Arc::new(crate::slideshow_export::ExportRun {
task_id: task.id(),
format: settings.format.id().into(),
path: prepared.request.path.clone(),
total: total.into(),
cancel,
..Default::default()
});
s.slideshow_export = Some(run.clone());
let started = json!({
"taskId": task.id(),
"background": !wait,
"format": settings.format.id(),
"path": prepared.request.path,
"slides": prepared.len(),
"seconds": prepared.seconds(),
"total": total,
"width": prepared.width,
"height": prepared.height,
"warnings": prepared.warnings,
});
if wait {
crate::slideshow_export::run_to_end(prepared, &run, &task);
drop(task);
return match run.result() {
Some(Ok(v)) => Ok(v),
Some(Err(e)) => Err(bad(C, e)),
None => Err(bad(C, "the export did not finish")),
};
}
#[cfg(not(target_arch = "wasm32"))]
{
// the row goes when the worker ends, however it ends
let r = run.clone();
std::thread::Builder::new()
.name("lc-slideshow-export".into())
.spawn(move || crate::slideshow_export::run_to_end(prepared, &r, &task))
.map_err(|e| bad(C, format!("could not start: {e}")))?;
}
Ok(started)
}
fn export_status(s: &mut Session, _: &Value) -> Result<Value> {
Ok(s.slideshow_export.as_ref().map_or_else(|| json!({"running": false}), |r| r.json()))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"slideshow.playback",
"Slideshow Playback Settings",
[],
None,
"{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews}",
always,
playback
),
cmd!(
"slideshow.layout",
"Slideshow Layout Settings",
[],
None,
"{margins?: all four, marginLeft?, marginRight?, marginTop?, marginBottom?: fractions of the slide's shorter side (0–0.45; with linkMargins one sets all four), linkMargins?: bool, showGuides?: bool (margin guides in the slide editor), aspect?: \"screen\"|\"16:9\"|\"4:3\" (aspect preview), zoomToFill?: bool (crop the photo to fill its cell), strokeBorder?: bool, strokeColor?: \"#rrggbb\"|[r,g,b], strokeWidth?: fraction of the shorter side (0–0.05), castShadow?: bool, shadowOpacity?: 0–1, shadowOffset?: 0–0.1, shadowRadius?: 0–0.1 (fractions of the shorter side), shadowAngle?: degrees counter-clockwise from the right (−45 = down right), backdropColor?: colour, colorWash?: bool, washColor?: colour, washOpacity?: 0–1, washAngle?: degrees (the side the wash colour is full at), backgroundImage?: photo id|null, backgroundOpacity?: 0–1} — omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library → the settings",
always,
layout
),
cmd!(
"slideshow.overlays",
"Slideshow Overlay Settings",
[],
None,
"{identityPlate?: {enabled?, text?, image?: PNG/JPEG file (drawn instead of the text; \"\" = the text), size?: height as a fraction of the slide's shorter side (0.005–0.5), color?: \"#rrggbb\"|[r,g,b], opacity?: 0–1, …placement, behind?: bool (render behind the photo)}, ratingStars?: {enabled?, color?, size?: a star's height (0.005–0.15), …placement}, texts?: [text overlay] (replaces the list, at most 16; each entry merged onto the one at its index), addText?: text overlay ({} = the default \"{file}\"), removeText?: index, showWatermark?: bool, watermark?: text | the export watermark object {text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth} (merged; sizes relative to the photo)} where a text overlay is {text?: template with tokens ({file}, {name}, {title}, {caption}, {date} (YYYY-MM-DD) / {date:%d.%m.%Y}, {exposure}, {shutter}, {aperture}, {focal}, {iso}, {camera}, {lens}, {rating}, {creator}, {copyright}, {folder}, {ext}; unknown tags show as typed), size?: line height (0.005–0.3), color?, opacity?, …placement, shadow?: bool, shadowOpacity?: 0–1, shadowOffset?, shadowRadius?: 0–0.05 (fractions of the shorter side), shadowAngle?: degrees counter-clockwise from the right} and the placement keys are anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, offsetX?, offsetY?: fractions of the slide's shorter side (x right, y down; −1–1), relativeTo?: \"slide\"|\"photo\" (the overlay is kept on the slide) — omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library; slideshow playback and slideshow.preview draw them → the settings and warnings (an unreadable identity plate image, unknown tokens)",
always,
overlays
),
cmd!(
"slideshow.titles",
"Slideshow Title Settings",
[],
None,
"{intro?: screen, ending?: screen} where a screen is {enabled?: bool, color?: background \"#rrggbb\"|[r,g,b], content?: \"none\"|\"identityPlate\" (the Overlays identity plate, its text or image)|\"text\", text?: the text shown with content \"text\", overrideColor?: bool (draw the plate or text in textColor instead of the identity plate's colour; an image plate keeps its colours), textColor?: colour, scale?: height of the plate or text line as a fraction of the slide's shorter side (0.005–0.5; text wider than the slide shrinks to fit), duration?: seconds (0.5–120) | null (the slide duration)} — the intro plays once before the first slide, the ending after the last one when repeat is off; both fade with the playback fade; random order never moves them. Omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library → the settings and warnings (an unreadable identity plate image, a screen with nothing to show)",
always,
titles
),
cmd!(
query "slideshow.templates.list",
"Slideshow Templates",
[],
None,
"{} → {templates: [{id: \"builtin:…\"|\"user:…\", name, builtin, group: \"builtin\"|\"user\", description?, path? (a user template's file)}], previous: bool (the settings before the last apply can be restored with apply {id: \"previous\"}), folder: the user templates folder | null, problems: [\"file: why it was skipped\"]} — built-ins first, then user templates by name (files in <settings>/slideshow-templates, at most 1000)",
always,
tpl_list
),
cmd!(
"slideshow.templates.apply",
"Apply Slideshow Template",
[],
None,
"{id: template id | \"previous\" (the settings before the last apply) | name: template name (case-insensitive)} — replaces the playback (prepare previews kept), layout, overlays and titles settings with the template's and saves them with the library; there is no undo: the replaced settings are kept as \"previous\" for this session → {applied, name, warnings: [a background photo not in this library, an unreadable identity plate / watermark image…], previous, settings: {playback, layout, overlays, titles}}",
always,
tpl_apply
),
cmd!(
"slideshow.templates.save",
"Save Slideshow Template",
[],
None,
"{name: 1–80 characters, replace?: bool (over a user template of that name)} — the current playback, layout, overlays and titles settings as a user template, <settings>/slideshow-templates/<name>.json (path separators and other reserved characters become _); a name taken (case-insensitive) or a built-in's is refused unless replace → {id, name, builtin, group, path}",
always,
tpl_save
),
cmd!(
"slideshow.templates.update",
"Update Slideshow Template",
[],
None,
"{id: user template} — replace its settings with the current ones (name kept); built-ins can't change → {id, name, builtin, group, path}",
always,
tpl_update
),
cmd!(
"slideshow.templates.rename",
"Rename Slideshow Template",
[],
None,
"{id: user template, name} — its file follows the name; a name taken is refused → {id (the new one), name, builtin, group, path}",
always,
tpl_rename
),
cmd!(
"slideshow.templates.delete",
"Delete Slideshow Template",
[],
None,
"{id: user template} — deletes its file → {deleted, name}",
always,
tpl_delete
),
cmd!(
"slideshow.templates.import",
"Import Slideshow Template",
[],
None,
"{path: a template .json file (at most 256 KB)} — checked like a saved one (format, version, every setting) and saved as a user template under its name (else the file's); a name taken gets \" (2)\"… → {id, name, builtin, group, path, renamed, warnings}",
always,
tpl_import
),
cmd!(
"slideshow.templates.export",
"Export Slideshow Template",
[],
None,
"{id: template (built-in or user), path: .json file to write} → {id, name, path}",
always,
tpl_export
),
cmd!(
query "slideshow.preview",
"Slideshow Slide Preview",
[],
None,
"{id?: photo (default: the active one) | title?: \"intro\"|\"ending\", template?: a template id (slideshow.templates.list): compose with its settings instead of the library's, which don't change (the Template Browser's hover preview), width?, height?: pixels (16–4096; one given: the other from the aspect preview; neither: 1280 wide), path?: write the PNG here} — the photo's slide composed with the slideshow layout and overlays → {id, width, height, cell: [x0,y0,x1,y1], photo: [x0,y0,x1,y1] (the visible photo, slide pixels), overlays: [{kind: identityPlate|ratingStars|textN|watermark, box: [x0,y0,x1,y1]}], path | png (base64 PNG)}; with `title`, that title screen (`slideshow.titles`; drawn even while it is off) → {title, enabled, width, height, plate: [x0,y0,x1,y1] | null (the plate or text), path | png}",
always,
preview
),
cmd!(
query "slideshow.export",
"Export Slideshow",
[],
None,
"{format: \"pdf\"|\"jpeg\"|\"video\", path: the .pdf / .mp4 / .webm file, or for jpeg the folder (created if missing), size?: pdf/jpeg the long side in pixels (64–8192, default 1920), video the frame height (64–2160 or \"720p\"/\"1080p\", default 720), quality?: 1–100 (JPEG quality, default 90; video default 75), fps?: 24|25|30|60 (video, default 30), container?: \"mp4\"|\"webm\" (video, default mp4), fullScreen?: bool (pdf: open in the viewer's full screen mode, default true), speed?: 1–10 (video encoder speed, default 10 = fastest), name?: jpeg file name prefix (default the folder's name: <name>-001.jpg…), ids?: photo ids (default the photos in view, in view order), wait?: bool (run here and reply with the result)} — the photos' slides with the slideshow layout, overlays and the intro/ending screens that are on; video is AV1 (no audio), each slide held for its duration and fades rendered as frames, at most 2 hours. Runs in the background with a row in the activity stack (activity.cancel {id: taskId} stops it; nothing is left behind: files are written as .part and renamed when complete); a photo that can't be rendered is skipped with a warning → {taskId, background, format, path, slides, seconds, total, width, height, warnings}; with wait: the result (slideshow.exportStatus)",
always,
export
),
cmd!(
query "slideshow.exportStatus",
"Slideshow Export Status",
[],
None,
"{} → the last slideshow export: {taskId, format, path, running, done, total (slides, or frames for video), cancelled, result?: {format, path, width, height, slides, skipped, warnings, pages? | files? | frames, samples, keyFrames, seconds, fps, container, codec: \"av1\", audio: false; bytes}, error?} | {running: false} when there was none",
always,
export_status
),
]
}
+12
View File
@@ -108,6 +108,18 @@ pub fn specs() -> Vec<CommandSpec> {
always,
prefs
),
cmd!(
"library.retrySidecars",
"Write Waiting XMP Sidecars",
[],
None,
"{} — writes again the automatic XMP sidecars that failed (folder offline, read-only or full) whose originals are reachable now → {written, waiting}",
always,
|s, _| {
let written = s.retry_unsaved_sidecars();
Ok(json!({"written": written, "waiting": s.unsaved_sidecars()}))
}
),
cmd!("library.toggleAutoWriteXmp", "Automatically Write Changes into XMP", ["File"], None, "{} → {autoWrite}", always, |s, _| {
let on = !s.xmp.auto_write;
prefs(s, &json!({"autoWrite": on}))
+6 -66
View File
@@ -1,4 +1,5 @@
//! Paginated contact sheets, rendered with the normal export pipeline and packaged as PDF.
//! Paginated contact sheets, rendered with the normal export pipeline and packaged as PDF
//! ([`crate::pdf`]).
use crate::export::{Anchor, ExportFormat, ExportOptions, Watermark, draw_watermark, encode_image};
use crate::{Session, media::RenderJob};
use lightcraft_pipeline::{OutputDepth, OutputSpace};
@@ -114,7 +115,7 @@ impl Prepared {
let capacity = self.options.columns * self.options.rows;
let photos = self.items.len();
let pages = photos.div_ceil(capacity);
let mut pdf = Pdf::new(pages, l.points);
let mut pdf = crate::pdf::Pdf::new(Vec::new())?;
let mut page = Rgba8::filled(l.width, l.height, [255; 4]);
for (i, (name, job)) in self.items.into_iter().enumerate() {
if !progress(i, &name) {
@@ -149,17 +150,17 @@ impl Prepared {
}
if slot + 1 == capacity || i + 1 == photos {
let jpg = encode_image(&page, &ExportOptions { format: ExportFormat::Jpeg, quality: 92, ..Default::default() })?;
if pdf.bytes.len().saturating_add(jpg.len()) > 256 * 1024 * 1024 {
if pdf.len().saturating_add(jpg.len()) > 256 * 1024 * 1024 {
return Err("contact sheet exceeds 256 MiB; select fewer photos".into());
}
pdf.page(i / capacity, &jpg, l.width, l.height);
pdf.image_page(&jpg, l.width, l.height, l.points, "")?;
page.data.fill([255; 4]);
}
}
if !progress(photos, "PDF") {
return Err("Contact sheet cancelled".into());
}
Ok(Document { bytes: pdf.finish(), pages, photos })
Ok(Document { bytes: pdf.finish("")?, pages, photos })
}
}
@@ -196,67 +197,6 @@ fn blit(dst: &mut Rgba8, src: &Rgba8, x: usize, y: usize) -> Result<(), String>
Ok(())
}
// Minimal PDF 1.4: one JPEG XObject per page, explicit byte lengths and classic xref offsets.
struct Pdf {
bytes: Vec<u8>,
offsets: Vec<usize>,
points: (f64, f64),
}
impl Pdf {
fn new(pages: usize, points: (f64, f64)) -> Self {
let mut p = Self { bytes: b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(), offsets: vec![0], points };
p.object(1, b"<< /Type /Catalog /Pages 2 0 R >>");
let kids = (0..pages).map(|i| format!("{} 0 R", 3 + i * 3)).collect::<Vec<_>>().join(" ");
p.object(2, format!("<< /Type /Pages /Count {pages} /Kids [{kids}] >>").as_bytes());
p
}
fn object(&mut self, id: usize, body: &[u8]) {
self.offsets.push(self.bytes.len());
self.bytes.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
self.bytes.extend_from_slice(body);
self.bytes.extend_from_slice(b"\nendobj\n");
}
fn stream(&mut self, id: usize, dictionary: &str, bytes: &[u8]) {
let mut body = format!("<< {dictionary} /Length {} >>\nstream\n", bytes.len()).into_bytes();
body.extend_from_slice(bytes);
body.extend_from_slice(b"\nendstream");
self.object(id, &body);
}
fn page(&mut self, index: usize, jpg: &[u8], width: usize, height: usize) {
let id = 3 + index * 3;
let (w, h) = self.points;
self.object(
id,
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w:.2} {h:.2}] /Resources << /XObject << /Im {} 0 R >> >> /Contents {} 0 R >>",
id + 2,
id + 1
)
.as_bytes(),
);
self.stream(id + 1, "", format!("q {w:.2} 0 0 {h:.2} 0 0 cm /Im Do Q").as_bytes());
self.stream(
id + 2,
&format!("/Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode"),
jpg,
);
}
fn finish(mut self) -> Vec<u8> {
let start = self.bytes.len();
self.bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", self.offsets.len()).as_bytes());
for offset in self.offsets.iter().skip(1) {
self.bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
self.bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", self.offsets.len()).as_bytes());
self.bytes
}
}
#[cfg(test)]
mod tests {
use super::*;
+169 -9
View File
@@ -64,13 +64,20 @@ const LOCAL: &[(&str, &str, f64)] = &[
("LocalClarity", "clarity", 100.0),
];
fn adjust(c: &XmpValue) -> Value {
/// Is `crs` (without the `crs:` prefix) a local slider field we map?
pub(crate) fn is_local_field(crs: &str) -> bool {
LOCAL.iter().any(|(k, _, _)| *k == crs)
}
/// Our local adjustment values from the `Local*` slider fields that `get` finds (the name without
/// the `crs:` prefix → the stored fraction); process-version 2012 fields win over the older ones.
pub(crate) fn local_values(get: impl Fn(&str) -> Option<f64>) -> serde_json::Map<String, Value> {
let mut a = serde_json::Map::new();
for (crs, ours, k) in LOCAL {
if a.contains_key(*ours) {
continue;
}
if let Some(v) = num(c, &format!("crs:{crs}")) {
if let Some(v) = get(crs).filter(|v| v.is_finite()) {
let lim = if *ours == "exposure" {
4.0
} else if *ours == "color_hue" {
@@ -82,6 +89,11 @@ fn adjust(c: &XmpValue) -> Value {
a.insert((*ours).into(), json!(v));
}
}
a
}
fn adjust(c: &XmpValue) -> Value {
let mut a = local_values(|crs| num(c, &format!("crs:{crs}")));
let amount = num(c, "crs:CorrectionAmount").unwrap_or(1.0);
a.insert("amount".into(), json!((amount * 100.0).clamp(0.0, 200.0)));
Value::Object(a)
@@ -98,8 +110,46 @@ fn pt(x: f64, y: f64) -> Value {
json!({"x": x, "y": y})
}
/// One mask component → our component JSON; `Err(kind)` names an unsupported one.
fn component(m: &XmpValue, aspect: f64) -> Result<Value, String> {
/// Whether a `Mask/CircularGradient` component puts its effect inside the ellipse, given the
/// container it came from (`crs:Flipped` means different things in different formats).
///
/// What Lightroom Classic does, from its exports of synthetic DNGs whose masks we wrote (issue #1815):
/// - **`MaskGroupBasedCorrections`, `crs:Version` ≥ 2** (what Lightroom writes today): an ordinary
/// radial — effect inside — is written `Flipped="true"`, so `Flipped` is the "inside" flag.
/// Inverting the mask is a separate field, `crs:MaskInverted` (see [`component`]). `Flipped="false"`
/// is read as outside, the same sense as the legacy filter's; Lightroom never wrote one for us.
/// - **`MaskGroupBasedCorrections` without `crs:Version`**: Lightroom renders the effect inside
/// whatever `Flipped` says, and rewrites both `Flipped="false"` and `"true"` as `Flipped="true"`
/// with `Version="2"`. So `Flipped` is ignored here.
/// - **`CircularGradientBasedCorrections`** (the process-2012 Radial Filter): its default effect was
/// outside, and "Invert Mask" — effect inside — was recorded as `Flipped="true"`. The same "inside"
/// flag as version 2, with outside as the default when it is missing.
fn radial_inside(m: &XmpValue, container: &str) -> bool {
let flipped = flag(m, "crs:Flipped");
if container == "crs:MaskGroupBasedCorrections" {
if num(m, "crs:Version").is_some_and(|v| v >= 2.0) { flipped.unwrap_or(true) } else { true }
} else {
flipped.unwrap_or(false)
}
}
/// A `Mask/CircularGradient` component's feather on our 0..100 scale.
///
/// Lightroom halves `crs:Feather` when it upgrades a `MaskGroupBasedCorrections` radial without
/// `crs:Version` to version 2 (50 → 25, 100 → 50) and renders both the same, so the version-less
/// value is on twice the scale. Version 2's value is used as it is: its 25 and 50 put the middle of
/// Lightroom's fall-off at about 0.87 and 0.76 of the radius, ours at about 0.86 and 0.73. (The
/// shape differs: Lightroom's fall-off straddles the ellipse edge, ours ends at it.) The legacy
/// Radial Filter's value is used as it is: no observation says otherwise.
fn radial_feather(m: &XmpValue, container: &str) -> f64 {
let f = num(m, "crs:Feather").unwrap_or(50.0);
let versionless_group = container == "crs:MaskGroupBasedCorrections" && !num(m, "crs:Version").is_some_and(|v| v >= 2.0);
(if versionless_group { f / 2.0 } else { f }).clamp(0.0, 100.0)
}
/// One mask component of a correction in `container` → our component JSON; `Err(kind)` names an
/// unsupported one.
fn component(m: &XmpValue, container: &str, aspect: f64) -> Result<Value, String> {
let what = text(m, "crs:What").unwrap_or("");
let op = match num(m, "crs:MaskBlendMode").unwrap_or(0.0) as i64 {
1 => "subtract",
@@ -123,8 +173,9 @@ fn component(m: &XmpValue, aspect: f64) -> Result<Value, String> {
"rx": ((r - l).abs() / 2.0 * sx).max(1e-3),
"ry": ((b - t).abs() / 2.0 * sy).max(1e-3),
"angle": g("Angle", 0.0),
"feather": g("Feather", 50.0).clamp(0.0, 100.0),
"invert": flag(m, "crs:Flipped").unwrap_or(false),
"feather": radial_feather(m, container),
// our radial's `invert` = effect outside; `crs:MaskInverted` stays the component's
"invert": !radial_inside(m, container),
})
}
"Mask/Paint" => {
@@ -193,7 +244,7 @@ pub fn masks(values: &Values, aspect: f64) -> (Vec<Value>, Vec<String>) {
let comps = c.field("crs:CorrectionMasks").map(XmpValue::items).unwrap_or_default();
let mut parts = Vec::new();
for m in comps {
match component(m, aspect) {
match component(m, container, aspect) {
Ok(v) => parts.push(v),
Err(kind) => skipped.push(kind),
}
@@ -392,7 +443,7 @@ mod tests {
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="0.5" crs:LocalContrast2012="0.2">
<crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/CircularGradient" crs:Top="0.3" crs:Left="0.2" crs:Bottom="0.7" crs:Right="0.8" crs:Angle="10" crs:Feather="40" crs:Flipped="true"/>
<rdf:li crs:What="Mask/CircularGradient" crs:Top="0.3" crs:Left="0.2" crs:Bottom="0.7" crs:Right="0.8" crs:Angle="10" crs:Feather="40" crs:Flipped="true" crs:Version="2"/>
<rdf:li crs:What="Mask/RangeMask" crs:MaskBlendMode="2"><crs:CorrectionRangeMask crs:Type="2" crs:LumRange="0.4 0.5 0.8 0.9"/></rdf:li>
<rdf:li crs:What="Mask/Image" crs:MaskSubType="9"/>
</rdf:Seq></crs:CorrectionMasks>
@@ -433,7 +484,8 @@ mod tests {
let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &rad.components[0].shape else { panic!() };
assert!((center.x - 0.5).abs() < 1e-9 && (center.y - 0.5).abs() < 1e-9);
assert!((rx - 0.3).abs() < 1e-9 && (ry - 0.2 / 1.5).abs() < 1e-9, "radii in long-edge units: {rx} {ry}");
assert_eq!((*angle, *feather, *invert), (10.0, 40.0, true));
assert_eq!((*angle, *feather, *invert), (10.0, 40.0, false), "Flipped=\"true\" is Lightroom's ordinary radial: effect inside");
assert!(!rad.components[0].invert);
let MaskShape::LuminanceRange { lo, hi, lo_feather, .. } = &rad.components[1].shape else { panic!() };
assert!(lo < hi && *lo_feather > 0.0);
let brush = &s.masks[2];
@@ -491,6 +543,114 @@ mod tests {
assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height");
}
/// One radial correction in `container`, with `attrs` on its mask component (values written
/// for this test; the component attributes follow what Lightroom Classic writes).
fn radial_packet(container: &str, attrs: &str) -> String {
format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="0">
<crs:{container}><rdf:Seq>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="1" crs:LocalExposure2012="0.25">
<crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/CircularGradient" crs:MaskValue="1" crs:Top="0.25" crs:Left="0.25" crs:Bottom="0.75" crs:Right="0.75" crs:Angle="0" crs:Midpoint="50" crs:Roundness="0" {attrs}/>
</rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
</rdf:Seq></crs:{container}>
</rdf:Description></rdf:RDF></x:xmpmeta>"#
)
}
fn radial_settings(container: &str, attrs: &str) -> DevelopSettings {
let d = lightcraft_meta::parse_xmp(&radial_packet(container, attrs)).unwrap();
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
assert!(unmapped.is_empty(), "{unmapped:?}");
let s = DevelopSettings::default().merged(&partial).unwrap();
assert_eq!(s.masks.len(), 1, "{:?}", s.masks);
s
}
/// (the radial's `invert`, its feather, the component's `invert`).
fn radial_of(s: &DevelopSettings) -> (bool, f64, bool) {
let c = &s.masks[0].components[0];
let MaskShape::Radial { invert, feather, .. } = &c.shape else { panic!("{:?}", c.shape) };
(*invert, *feather, c.invert)
}
/// How much brighter the mask makes the centre and a corner of a flat grey frame (8-bit green).
fn lift_inside_outside(s: &DevelopSettings) -> (i32, i32) {
let img = lightcraft_raster::Image { width: 90, height: 60, data: vec![[0.18f32; 3]; 90 * 60] };
let render =
|d: &DevelopSettings| lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(90, 60)).image;
let (with, without) = (render(s), render(&DevelopSettings { light: s.light, ..Default::default() }));
let g = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| i32::from(im.data[y * im.width + x][1]);
(g(&with, 45, 30) - g(&without, 45, 30), g(&with, 3, 3) - g(&without, 3, 3))
}
#[test]
fn lightroom_radial_flipped_is_inside() {
// as Lightroom Classic writes an ordinary radial mask today (#1815)
let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="true" crs:Version="2""#);
assert_eq!(radial_of(&s), (false, 25.0, false));
let (inside, outside) = lift_inside_outside(&s);
assert!(inside > 20 && outside == 0, "the effect is inside the circle: {inside} {outside}");
// no MaskInverted at all reads the same
let s = radial_settings("MaskGroupBasedCorrections", r#"crs:Feather="25" crs:Flipped="true" crs:Version="2""#);
assert_eq!(radial_of(&s), (false, 25.0, false));
}
#[test]
fn lightroom_radial_mask_inverted_is_outside() {
let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="true" crs:Feather="25" crs:Flipped="true" crs:Version="2""#);
assert_eq!(radial_of(&s), (false, 25.0, true), "the inversion is the component's");
let (inside, outside) = lift_inside_outside(&s);
assert!(inside == 0 && outside > 20, "the effect is outside the circle: {inside} {outside}");
// version 2 with Flipped="false": outside, the legacy sense of the flag
let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="false" crs:Version="2""#);
assert_eq!(radial_of(&s), (true, 25.0, false));
}
#[test]
fn versionless_group_radial_is_inside_with_half_the_feather() {
// Lightroom renders both inside and rewrites them Flipped="true", Feather halved, Version="2"
for flipped in ["true", "false"] {
let s = radial_settings("MaskGroupBasedCorrections", &format!(r#"crs:MaskInverted="false" crs:Feather="50" crs:Flipped="{flipped}""#));
assert_eq!(radial_of(&s), (false, 25.0, false), "Flipped={flipped}");
let (inside, outside) = lift_inside_outside(&s);
assert!(inside > 20 && outside == 0, "Flipped={flipped}: {inside} {outside}");
}
}
#[test]
fn legacy_radial_filter_is_outside_unless_flipped() {
// the process-2012 Radial Filter: effect outside by default, "Invert Mask" = Flipped="true"
let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="false""#);
assert_eq!(radial_of(&s), (true, 50.0, false));
let (inside, outside) = lift_inside_outside(&s);
assert!(inside == 0 && outside > 20, "default: outside: {inside} {outside}");
assert_eq!(radial_of(&radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50""#)), (true, 50.0, false));
let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="true""#);
assert_eq!(radial_of(&s), (false, 50.0, false));
let (inside, outside) = lift_inside_outside(&s);
assert!(inside > 20 && outside == 0, "inverted: inside: {inside} {outside}");
}
#[test]
fn lrtemplate_radial_filter_reads_flipped() {
// .lrtemplate presets and catalog develop settings are the same Lua tables
let t = |flipped: &str| {
format!(
r#"s = {{ title = "Rad", value = {{ settings = {{ CircularGradientBasedCorrections = {{
{{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.25, CorrectionMasks = {{
{{ What = "Mask/CircularGradient", Top = 0.25, Left = 0.25, Bottom = 0.75, Right = 0.75, Angle = 0, Feather = 50, Flipped = {flipped}, MaskValue = 1 }} }} }} }} }} }} }}"#
)
};
for (flipped, outside) in [("true", false), ("false", true)] {
let v = crate::preset_import::read_presets("Rad.lrtemplate", t(flipped).as_bytes(), None).unwrap();
let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap();
assert_eq!(radial_of(&s).0, outside, "Flipped = {flipped}");
}
}
/// Spot removal in the shape Lightroom Classic writes it (values written for this test):
/// a healed circle with an explicit source, a cloned circle with an automatic source, a
/// brushed removal, a circular-gradient spot, one without a mask, and the legacy list.
+5 -5
View File
@@ -409,7 +409,7 @@ mod tests {
#[test]
fn a_scene_is_rendered_once_however_many_ask() {
use std::sync::atomic::{AtomicUsize, Ordering};
let (renders, peak) = within(60, || {
let (renders, peak) = within(120, || {
let slot = Arc::new(SceneSlot::default());
let (running, peak, renders) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
let render = {
@@ -454,7 +454,7 @@ mod tests {
/// and that pool's limits; other threads render in parallel on the scene pool.
#[test]
fn a_rayon_worker_renders_alone_on_its_own_thread() {
let ((how, caller, renderer), (other_how, other_name)) = within(30, || {
let ((how, caller, renderer), (other_how, other_name)) = within(120, || {
let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap();
let slot = SceneSlot::default();
let mut seen = None;
@@ -485,7 +485,7 @@ mod tests {
/// minutes of CPU time on a small machine).
#[test]
fn callers_wait_for_the_generation_running() {
let (a, b, renders) = within(30, || {
let (a, b, renders) = within(120, || {
let slot = Arc::new(SceneSlot::default());
let renders = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let (started_tx, started) = std::sync::mpsc::channel::<()>();
@@ -518,7 +518,7 @@ mod tests {
/// generates the scene again.
#[test]
fn a_failed_generation_is_an_error_and_the_next_request_tries_again() {
let (first, waiters, again) = within(30, || {
let (first, waiters, again) = within(120, || {
let slot = Arc::new(SceneSlot::default());
let (started_tx, started) = std::sync::mpsc::channel::<()>();
let (go, go_rx) = std::sync::mpsc::channel::<()>();
@@ -564,7 +564,7 @@ mod tests {
assert!(matches!(st.outcome(n), Some(Err(_))), "even once the retry succeeded");
assert!(matches!(st.outcome(m), Some(Ok(_))));
// and with threads: a retry racing the waiters of a failed generation never hands them its result
let wrong = within(60, || {
let wrong = within(120, || {
let mut wrong = 0;
for _ in 0..20 {
let slot = Arc::new(SceneSlot::default());
+38
View File
@@ -104,6 +104,18 @@ impl DeviceWatch {
s.list.clone()
}
/// Like [`DeviceWatch::get`], but `None` until the first scan has landed, so an empty list
/// means no cards rather than not looked yet.
pub fn get_scanned(self: &Arc<Self>) -> Option<Vec<Device>> {
let _ = self.get();
let s = self.state();
#[cfg(not(target_arch = "wasm32"))]
let scanned = s.scanned.is_some();
#[cfg(target_arch = "wasm32")]
let scanned = false;
scanned.then(|| s.list.clone())
}
/// Scan now on this thread, and serve the result to later [`DeviceWatch::get`] calls.
pub fn scan_now(&self) -> Vec<Device> {
let list = (self.scanner)();
@@ -148,6 +160,12 @@ pub fn devices() -> Vec<Device> {
global().get()
}
/// [`devices`], but `None` until the first scan lands. The import prompt uses it so a card that was
/// already in when LightCraft started isn't taken for a new one.
pub fn devices_scanned() -> Option<Vec<Device>> {
global().get_scanned()
}
/// Scan the mounted volumes now (blocking), for explicit requests.
pub fn devices_now() -> Vec<Device> {
global().scan_now()
@@ -285,4 +303,24 @@ mod tests {
assert!(watch.scan_now().is_empty());
assert_eq!(changes.load(Ordering::SeqCst), 2);
}
#[test]
fn get_scanned_waits_for_the_first_scan() {
let gate = Arc::new(std::sync::atomic::AtomicBool::new(false));
let g = gate.clone();
let watch = DeviceWatch::new(
move || {
while !g.load(Ordering::SeqCst) {
std::thread::sleep(Duration::from_millis(5));
}
vec![card("NIKON Z 8")]
},
Duration::from_secs(60),
);
assert_eq!(watch.get_scanned(), None, "nothing scanned yet");
assert_eq!(watch.get_scanned(), None);
gate.store(true, Ordering::SeqCst);
assert!(until(|| watch.get_scanned().is_some()));
assert_eq!(watch.get_scanned(), Some(vec![card("NIKON Z 8")]));
}
}
+178 -186
View File
@@ -162,6 +162,29 @@ pub enum Conflict {
Skip,
}
/// Where a batch's files go (Lightroom Classic's Export To).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DestinationMode {
/// One folder for the whole batch ([`Destination::dir`]).
#[default]
SpecificFolder,
/// Each photo's own folder: the folder its original is in. A photo without one on disk (a
/// demo photo, a photo in the browser's storage) or whose folder is gone gets an error.
SameAsOriginal,
}
impl DestinationMode {
/// `specificFolder` / `sameAsOriginal` (any case).
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"specificfolder" => Some(Self::SpecificFolder),
"sameasoriginal" => Some(Self::SameAsOriginal),
_ => None,
}
}
}
/// A named set of `app.export` params.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ExportPreset {
@@ -293,58 +316,8 @@ fn turn(turns: u8, x: f32, y: f32, w: f32, h: f32) -> (f32, f32) {
}
}
/// Inter SemiBold (OFL, see assets/ATTRIBUTION.md).
static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
/// The watermark faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's
/// serif look; then any other CJK face — a watermark has no UI language, so every CJK script is
/// offered). Without craft-fonts that is Inter alone, and CJK characters draw as Inter's
/// missing-glyph box.
fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec<ab_glyph::FontRef<'static>> {
let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect();
cjk.sort_by_key(|f| !f.is_mincho());
std::iter::once(WATERMARK_FONT)
.chain(cjk.into_iter().map(|f| f.bytes))
.filter_map(|bytes| ab_glyph::FontRef::try_from_slice(bytes).ok())
.collect()
}
/// Shape a whole grapheme in one cell. CJK uses vertical origins and forms; Latin stays upright.
/// Unlike Unicode presentation-form characters, GSUB works with BIZ UD fonts too.
fn watermark_cell_glyphs(
font: &ab_glyph::FontRef<'_>,
data: &harfrust::ShaperData,
text: &str,
vertical: bool,
px: f32,
left: f32,
top: f32,
) -> Option<Vec<ab_glyph::Glyph>> {
use ab_glyph::{Font, ScaleFont};
let face = harfrust::FontRef::new(font.font_data()).ok()?;
let mut buf = harfrust::UnicodeBuffer::new();
buf.push_str(text);
buf.set_direction(if vertical { harfrust::Direction::TopToBottom } else { harfrust::Direction::LeftToRight });
buf.guess_segment_properties();
let shaped = data.shaper(&face).build().shape(buf, harfrust::ShapeOptions::new());
if shaped.glyph_infos().is_empty() {
return None;
}
let sf = font.as_scaled(px);
let (sx, sy) = (sf.h_scale_factor(), sf.v_scale_factor());
let advance_x = shaped.glyph_positions().iter().map(|p| p.x_advance as f32 * sx).sum::<f32>();
let advance_y = shaped.glyph_positions().iter().map(|p| -(p.y_advance as f32) * sy).sum::<f32>();
let (mut x, mut y) = if vertical { (left + px / 2.0, top + (px - advance_y) / 2.0) } else { (left + (px - advance_x) / 2.0, top + sf.ascent()) };
let mut glyphs = Vec::new();
for (info, pos) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) {
let gid = u16::try_from(info.glyph_id).ok().filter(|g| *g != 0)?;
let pen = ab_glyph::point(x + pos.x_offset as f32 * sx, y - pos.y_offset as f32 * sy);
glyphs.push(ab_glyph::GlyphId(gid).with_scale_and_position(px, pen));
x += pos.x_advance as f32 * sx;
y -= pos.y_advance as f32 * sy;
}
Some(glyphs)
}
#[cfg(test)]
use crate::text::{TEXT_FONT as WATERMARK_FONT, cell_glyphs as watermark_cell_glyphs, faces as watermark_fonts};
/// Draw `wm` onto `img` (straight alpha blending of the encoded values).
pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) {
@@ -369,6 +342,23 @@ fn blend_watermark(img: &mut Rgba8, wm: &Watermark, floor_scale: f32) {
});
}
/// Draw `wm` into the `w × h` area of `img` whose top-left corner is (`x0`, `y0`), sized and
/// anchored as on an image of that size (a slideshow slide's photo). The area is clipped to `img`.
pub fn draw_watermark_in(img: &mut Rgba8, wm: &Watermark, x0: usize, y0: usize, w: usize, h: usize) {
let (width, height) = (img.width, img.height);
watermark_coverage(w, h, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| {
let (px, py) = (x0.saturating_add(x), y0.saturating_add(y));
if px >= width || py >= height {
return;
}
if let Some(p) = img.data.get_mut(py * width + px) {
for c in 0..3 {
p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8;
}
}
});
}
/// Draw `wm` onto a high-bit-depth image (`wm.color` is in the image's space, 8-bit encoded; float
/// images are linear and blend in linear light).
pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) {
@@ -403,7 +393,6 @@ fn watermark_coverage(
floor_scale: f32,
mut blend_px: impl FnMut(usize, usize, f32, [u8; 3]),
) {
use ab_glyph::{Font, PxScale, ScaleFont, point};
if !wm.image.trim().is_empty() {
logo_coverage(width, height, wm, blend_px);
return;
@@ -412,97 +401,10 @@ fn watermark_coverage(
if text.is_empty() || width == 0 || height == 0 {
return;
}
let fonts = watermark_fonts(craft);
let Some(latin) = fonts.first() else { return };
let vertical_data: Vec<_> = if wm.vertical {
fonts.iter().map(|font| harfrust::FontRef::new(font.font_data()).ok().map(|face| harfrust::ShaperData::new(&face))).collect()
} else {
Vec::new()
};
let short = width.min(height) as f32;
let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0 * floor_scale);
let mut glyphs = Vec::new();
let mut x = 0.0f32;
let mut y = 0.0f32;
let mut tw = 0.0f32;
let mut prev = None;
let columns = text.split('\n').count();
let mut column = 0usize;
use unicode_segmentation::UnicodeSegmentation;
// Horizontal watermarks retain scalar layout and kerning; only vertical cells use graphemes.
let cells: Box<dyn Iterator<Item = &str>> =
if wm.vertical { Box::new(text.graphemes(true)) } else { Box::new(text.char_indices().filter_map(|(i, c)| text.get(i..i + c.len_utf8()))) };
for cell in cells {
let Some(original) = cell.chars().next() else { continue };
if cell == "\n" || (wm.vertical && cell == "\r\n") {
if wm.vertical {
column = column.saturating_add(1);
tw = tw.max(y);
y = 0.0;
} else {
tw = tw.max(x);
x = 0.0;
y += px;
}
prev = None;
continue;
}
let cjk = matches!(original as u32, 0x3000..=0x30FF | 0x3400..=0x9FFF | 0xFF01..=0xFF60);
if wm.vertical && (cjk || cell.chars().count() > 1) {
let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px;
let shaped = fonts.iter().zip(&vertical_data).enumerate().find_map(|(face, (font, data))| {
let data = data.as_ref()?;
let cluster = watermark_cell_glyphs(font, data, cell, cjk, px, left, y)?;
let substituted = cluster.len() != 1 || cluster.first().is_some_and(|g| g.id != font.glyph_id(original));
if cjk && !substituted && matches!(original, '、' | '。' | 'ー' | '(' | ')' | '「' | '」' | '『' | '』' | '【' | '】') {
return None;
}
Some((face, cluster))
});
if let Some((face, cluster)) = shaped {
glyphs.extend(cluster.into_iter().map(|g| (face, g, None)));
y += px;
continue;
}
}
let vertical_form = match original {
'、' => '︑',
'。' => '︒',
'(' => '︵',
')' => '︶',
'「' => '﹁',
'」' => '﹂',
'『' => '﹃',
'』' => '﹄',
'【' => '︻',
'】' => '︼',
'…' => '︙',
other => other,
};
let has = |font: &ab_glyph::FontRef<'_>, c: char| font.glyph_id(c).0 != 0;
let ch = if wm.vertical && fonts.iter().skip(1).any(|f| has(f, vertical_form)) { vertical_form } else { original };
// The first face with the glyph; Inter (its missing-glyph box) when none has it.
let face = fonts.iter().position(|f| has(f, ch)).unwrap_or(0);
let sf = fonts.get(face).unwrap_or(latin).as_scaled(PxScale::from(px));
let id = sf.glyph_id(ch);
if wm.vertical {
let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px;
let pen = point(left + (px - sf.h_advance(id)) / 2.0, y + sf.ascent());
let rotation = matches!(original, 'ー' | '—' | '–').then_some((left + px / 2.0, y + px / 2.0));
glyphs.push((face, id.with_scale_and_position(px, pen), rotation));
y += px;
} else {
if let Some((previous_face, previous)) = prev
&& previous_face == face
{
x += sf.kern(previous, id);
}
glyphs.push((face, id.with_scale_and_position(px, point(x, y + sf.ascent())), None));
x += sf.h_advance(id);
prev = Some((face, id));
}
}
let (tw, th) = if wm.vertical { (columns as f32 * px, tw.max(y)) } else { (tw.max(x), y + px) };
let Some(layout) = crate::text::TextLayout::new(text, px, wm.vertical, craft) else { return };
let (tw, th) = (layout.width, layout.height);
// laid out unturned in a tw × th box, placed by the box it becomes
let turns = wm.quarter_turns();
let (bw, bh) = if turns % 2 == 1 { (th, tw) } else { (tw, th) };
@@ -520,35 +422,29 @@ fn watermark_coverage(
BottomLeft | Bottom | BottomRight => h - iy - bh,
};
let alpha = wm.opacity.clamp(0.0, 1.0);
let mut blend = |gx: i32, gy: i32, cov: f32, col: [u8; 3], a: f32| {
if gx < 0 || gy < 0 || gx >= width as i32 || gy >= height as i32 {
return;
}
blend_px(gx as usize, gy as usize, (cov * a).clamp(0.0, 1.0), col);
};
let passes: &[(f32, [u8; 3], f32)] =
if wm.shadow { &[((px * 0.05).max(floor_scale), [0, 0, 0], 0.45), (0.0, wm.color, 1.0)] } else { &[(0.0, wm.color, 1.0)] };
for &(off, col, a) in passes {
for (face, g, rotation) in &glyphs {
if let Some(o) = fonts.get(*face).and_then(|font| font.outline_glyph(g.clone())) {
let b = o.px_bounds();
o.draw(|gx, gy, cov| {
let (sx, sy) = (b.min.x + gx as f32, b.min.y + gy as f32);
let (sx, sy) = match rotation {
Some((cx, cy)) => (cx - (sy - cy), cy + (sx - cx)),
None => (sx, sy),
};
// turned with the box; the shadow still falls down and to the right
let (sx, sy) = turn(turns, sx, sy, tw, th);
blend((ox + off + sx) as i32, (oy + off + sy) as i32, cov, col, a * alpha);
});
let mut put = |gx: i32, gy: i32, cov: f32| {
if gx < 0 || gy < 0 || gx >= width as i32 || gy >= height as i32 {
return;
}
blend_px(gx as usize, gy as usize, (cov * a * alpha).clamp(0.0, 1.0), col);
};
if turns == 0 {
layout.draw_in(ox + off, oy + off, w, h, &mut put);
} else {
// turned with the box; the shadow still falls down and to the right
layout.draw_points(|x, y, cov| {
let (x, y) = turn(turns, x, y, tw, th);
put((ox + off + x) as i32, (oy + off + y) as i32, cov);
});
}
}
}
/// A watermark graphic, decoded once per file (and again when the file changes).
fn watermark_logo(path: &str) -> Option<std::sync::Arc<Rgba8>> {
pub(crate) fn watermark_logo(path: &str) -> Option<std::sync::Arc<Rgba8>> {
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
static CACHE: Mutex<Option<HashMap<String, (std::time::SystemTime, Arc<Rgba8>)>>> = Mutex::new(None);
@@ -560,12 +456,19 @@ fn watermark_logo(path: &str) -> Option<std::sync::Arc<Rgba8>> {
{
return Some(img.clone());
}
let bytes = std::fs::read(path).ok()?;
let img = Arc::new(lightcraft_codecs::decode(&bytes, Default::default()).ok()?.to_srgb8());
let img = Arc::new(read_graphic(path).ok()?);
map.insert(path.to_string(), (modified, img.clone()));
Some(img)
}
/// A graphic file (a PNG with transparency, typically) as 8-bit sRGB with its alpha: the
/// watermark logo, the loupe's layout image. The error says why it couldn't be read or decoded.
pub fn read_graphic(path: &str) -> Result<Rgba8, String> {
let bytes = std::fs::read(path).map_err(|e| format!("can't read {path}: {e}"))?;
let decoded = lightcraft_codecs::decode(&bytes, Default::default()).map_err(|e| format!("can't decode {path}: {e}"))?;
Ok(decoded.to_srgb8())
}
/// The graphic watermark: scaled (premultiplied, so transparent edges stay clean) to
/// `image_width` of the photo, placed like the text, blended with its alpha × opacity.
fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) {
@@ -682,6 +585,8 @@ pub struct ExportOptions {
pub naming: String,
/// First `{seq}` value.
pub start_number: u32,
/// A specific folder for the whole batch, or each photo's own folder.
pub destination_mode: DestinationMode,
/// Created inside the destination folder (empty = none).
pub subfolder: String,
/// When a file of the same name exists in the destination folder.
@@ -734,6 +639,7 @@ impl Default for ExportOptions {
sharpen_amount: SharpenAmount::Standard,
naming: "{name}".into(),
start_number: 1,
destination_mode: DestinationMode::SpecificFolder,
subfolder: String::new(),
conflict: Conflict::Unique,
tiff_compression: TiffCompression::Deflate,
@@ -776,6 +682,7 @@ pub const OPTION_PARAMS: &[&str] = &[
"sharpenAmount",
"naming",
"startNumber",
"destinationMode",
"subfolder",
"conflict",
"tiffCompression",
@@ -966,11 +873,12 @@ impl ExportOptions {
"sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?,
"hdrTransfer" => one_of(k, v, &["pq", "hlg"])?,
"conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?,
"destinationMode" => one_of(k, v, &["specificFolder", "sameAsOriginal"])?,
"tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?,
"jxlEffort" => one_of(k, v, &["fast", "normal"])?,
"dngCompression" => {
serde_json::from_value::<DngCompression>(v.clone())
.map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed")))?;
.map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed|lossy")))?;
}
"metadata" => one_of(k, v, &["all", "allExceptCamera", "copyright", "none"])?,
"colorSpace" => {
@@ -995,7 +903,7 @@ impl ExportOptions {
}
/// A `watermark` value: text, or an object of [`WATERMARK_PARAMS`] (issue #183).
fn validate_watermark(cmd: &str, v: &serde_json::Value) -> crate::Result<()> {
pub(crate) fn validate_watermark(cmd: &str, v: &serde_json::Value) -> crate::Result<()> {
use serde_json::Value;
let bad = |msg: String| crate::cmd::bad(cmd, msg);
let obj = match v {
@@ -1078,6 +986,7 @@ impl ExportOptions {
sharpen_amount: enm(p, "sharpenAmount").unwrap_or(d.sharpen_amount),
naming: s("naming").map_or(d.naming, str::to_string),
start_number: u("startNumber").map_or(d.start_number, |v| v.min(999_999) as u32),
destination_mode: s("destinationMode").and_then(DestinationMode::parse).unwrap_or(d.destination_mode),
subfolder: s("subfolder").map_or(d.subfolder, |v| v.trim().replace(['\\', ':', '\0'], "_")),
conflict: enm(p, "conflict").unwrap_or(d.conflict),
tiff_compression: match s("tiffCompression").map(str::to_ascii_lowercase).as_deref() {
@@ -1608,6 +1517,9 @@ pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoI
pub struct PreparedExport {
pub photo: lightcraft_catalog::PhotoId,
pub file_name: String,
/// The folder of the photo's original on disk ([`DestinationMode::SameAsOriginal`]); `None`
/// for a demo photo or one in the browser's storage.
pub source_dir: Option<String>,
work: Work,
/// The library's originals, which [`run_batch`] never writes over (shared by a batch).
guard: std::sync::Arc<crate::originals::OriginalGuard>,
@@ -1633,6 +1545,9 @@ enum Work {
read: Option<crate::merge::ByteReader>,
packet: String,
dng: Option<DngCompression>,
/// Lossy DNG: JPEG quality and the long edge to scale to (`None`: full size).
quality: u8,
lossy_edge: Option<u32>,
/// The original must not carry a location ([`removes_location`]).
remove_location: bool,
label: String,
@@ -1667,6 +1582,7 @@ fn prepare_guarded(
) -> Result<PreparedExport, String> {
let p = session.catalog.photo(id).ok_or("no such photo")?;
let file_name = o.file_name_for(p, seq);
let source_dir = original_folder(&p.source);
// HDR output only for photos edited in HDR; the rest of the batch exports as usual
let sdr_opts;
let o = if o.hdr && !p.develop.hdr.enabled {
@@ -1702,13 +1618,29 @@ fn prepare_guarded(
read: session.media.file_bytes.clone(),
packet: crate::sidecar::sidecar_packet_with(p, &session.catalog, removes_location(p, &session.catalog, o)),
dng,
quality: o.quality,
lossy_edge: o.resize.is_some().then(|| {
let (w, h) = output_size(p, o);
u32::try_from(w.max(h)).unwrap_or(u32::MAX)
}),
remove_location: removes_location(p, &session.catalog, o),
label: p.file_name.clone(),
size: (p.width as usize, p.height as usize),
};
(work, weight)
};
Ok(PreparedExport { photo: id, file_name, work, guard, weight })
Ok(PreparedExport { photo: id, file_name, source_dir, work, guard, weight })
}
/// The folder a photo's original is in, for [`DestinationMode::SameAsOriginal`]. None for a demo
/// photo, and on the web, where an original lives in the browser's storage, not in a folder.
fn original_folder(source: &lightcraft_catalog::Source) -> Option<String> {
let lightcraft_catalog::Source::File { path } = source else { return None };
if cfg!(target_arch = "wasm32") {
return None;
}
let dir = std::path::Path::new(path).parent()?.to_string_lossy().to_string();
(!dir.is_empty()).then_some(dir)
}
/// Why an export of `out` pixels (`w`, `h`), from a photo of `native_px` pixels at its own size,
@@ -1761,7 +1693,7 @@ impl PreparedExport {
};
Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new(), gpu_fallback })
}
Work::File { path, read, packet, dng, label, size, remove_location } => {
Work::File { path, read, packet, dng, quality, lossy_edge, label, size, remove_location } => {
let bytes = match &read {
Some(r) => r(&path)?,
None => std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?,
@@ -1785,12 +1717,16 @@ impl PreparedExport {
if lightcraft_raw::probe(&bytes).is_none() {
return Err(format!("{label}: DNG export needs a raw photo"));
}
let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{label}: {e}"))?;
let mut raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{label}: {e}"))?;
crate::camera_preview::with_starting_colour(&mut raw, &bytes);
drop(bytes);
let compression = match dng {
DngCompression::Lossless => lightcraft_raw::DngCompression::Lj92 { tile: 256 },
DngCompression::Deflate => lightcraft_raw::DngCompression::Deflate { tile: 256, half: false },
DngCompression::Uncompressed => lightcraft_raw::DngCompression::Uncompressed,
DngCompression::Lossy => {
lightcraft_raw::DngCompression::LossyJpeg { tile: 256, quality: quality.clamp(1, 100), long_edge: lossy_edge }
}
};
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), compression, ..Default::default() })
.map_err(|e| e.to_string())?;
@@ -1810,11 +1746,15 @@ pub enum DngCompression {
/// Deflate (zip) tiles with prediction.
Deflate,
Uncompressed,
/// Lossy DNG: demosaiced linear data as JPEG tiles at the export quality, scaled to the resize
/// setting when there is one (Lightroom's "Use Lossy Compression").
Lossy,
}
/// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy
/// applied to each file together with its sidecars), or one exact file path (single photo; an
/// ordinary file already there is replaced). Either way a catalogued original (or its sidecar)
/// applied to each file together with its sidecars; with [`DestinationMode::SameAsOriginal`] each
/// photo's own folder instead of `dir`), or one exact file path (single photo; an ordinary file
/// already there is replaced). Either way a catalogued original (or its sidecar)
/// is never written over: see [`crate::originals`].
#[derive(Clone, Debug, Default)]
pub struct Destination {
@@ -2011,12 +1951,12 @@ fn run_batch_with(
if !progress(i, &item.file_name) {
break;
}
let (photo, name, guard) = (item.photo, item.file_name.clone(), item.guard.clone());
placer.place(photo, name, &guard, run_gated(item))?;
let (photo, name, guard, source_dir) = (item.photo, item.file_name.clone(), item.guard.clone(), item.source_dir.clone());
placer.place(photo, name, source_dir.as_deref(), &guard, run_gated(item))?;
}
return Ok(std::mem::take(&mut placer.out));
}
let heads: Vec<_> = items.iter().map(|i| (i.photo, i.file_name.clone(), i.guard.clone())).collect();
let heads: Vec<_> = items.iter().map(|i| (i.photo, i.file_name.clone(), i.guard.clone(), i.source_dir.clone())).collect();
let queue = Queue {
lane: std::sync::Mutex::new(Lane { items: items.into_iter().map(Some).collect(), next: 0, placed: 0, stop: false, done: Default::default() }),
cv: std::sync::Condvar::new(),
@@ -2031,12 +1971,12 @@ fn run_batch_with(
}
}
let placed = || {
for (i, (photo, name, guard)) in heads.into_iter().enumerate() {
for (i, (photo, name, guard, source_dir)) in heads.into_iter().enumerate() {
if !progress(i, &name) {
break;
}
let e = queue.result(i);
placer.place(photo, name, &guard, e)?;
placer.place(photo, name, source_dir.as_deref(), &guard, e)?;
}
Ok::<_, String>(())
};
@@ -2147,12 +2087,11 @@ impl Queue {
}
}
/// Where a batch's files go: each photo's path (subfolder, conflict policy, sidecars, never an
/// original) and its write, in batch order.
/// Where a batch's files go: each photo's path (destination folder, subfolder, conflict policy,
/// sidecars, never an original) and its write, in batch order.
struct Placer<'a> {
o: &'a ExportOptions,
to: &'a Destination,
dir: String,
single: bool,
taken: std::collections::HashSet<String>,
out: Vec<serde_json::Value>,
@@ -2173,29 +2112,53 @@ impl<'a> Placer<'a> {
exists: &'a dyn Fn(&str) -> bool,
stop_on_error: bool,
) -> Placer<'a> {
let dir = if o.subfolder.is_empty() { to.dir.clone() } else { join_path(&to.dir, &o.subfolder) };
Placer { o, to, dir, single: false, taken: Default::default(), out: Vec::new(), write, exists, stop_on_error }
Placer { o, to, single: false, taken: Default::default(), out: Vec::new(), write, exists, stop_on_error }
}
/// Place photo `photo`'s export result (file `name`): write it and record the outcome. `Err`
/// only when it failed and `stop_on_error` is set.
/// The folder photo `name` (its original in `source_dir`) is written to: the batch's folder or
/// the photo's own ([`DestinationMode`]), plus the subfolder. An error for a photo with no
/// folder of its own, or whose folder is gone: never a file written somewhere else instead.
fn folder_for(&self, name: &str, source_dir: Option<&str>) -> Result<String, String> {
let base = match self.o.destination_mode {
DestinationMode::SpecificFolder => self.to.dir.clone(),
DestinationMode::SameAsOriginal => {
let Some(d) = source_dir else {
return Err(format!(
"{name}: the photo has no folder on disk (a demo photo or one in the browser's storage), so it can't be exported to the same folder as its original; choose a specific folder"
));
};
if !(self.exists)(d) {
return Err(format!("{name}: the original's folder is missing ({d}); reconnect it or choose a specific folder"));
}
d.to_string()
}
};
Ok(if self.o.subfolder.is_empty() { base } else { join_path(&base, &self.o.subfolder) })
}
/// Place photo `photo`'s export result (file `name`, its original in `source_dir`): write it
/// and record the outcome. `Err` only when it failed and `stop_on_error` is set.
fn place(
&mut self,
photo: lightcraft_catalog::PhotoId,
name: String,
source_dir: Option<&str>,
guard: &crate::originals::OriginalGuard,
e: Result<Exported, String>,
) -> Result<(), String> {
use serde_json::json;
let (o, dir, exists) = (self.o, &self.dir, self.exists);
let e = match e {
Ok(e) => e,
// an exact file path needs no folder
let dir = if self.to.exact.is_some() && self.single { Ok(String::new()) } else { self.folder_for(&name, source_dir) };
let (o, exists) = (self.o, self.exists);
let (dir, e) = match dir.and_then(|dir| e.map(|e| (dir, e))) {
Ok(v) => v,
Err(err) if self.stop_on_error => return Err(err),
Err(err) => {
self.out.push(json!({"photo": photo.0, "file": name, "error": err}));
return Ok(());
}
};
let dir = dir.as_str();
// the exported file and its sidecars
let group = |main: &str| std::iter::once(main.to_string()).chain(e.sidecars.iter().map(|(x, _)| sidecar_path(main, x))).collect::<Vec<_>>();
let path = match self.to.exact.as_deref().filter(|_| self.single) {
@@ -3190,6 +3153,35 @@ mod tests {
ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap();
}
/// Issue #956: `destinationMode` picks a specific folder (the default) or each photo's own
/// folder; it is checked like the other params and kept in presets and Export with Previous.
#[test]
fn destination_mode_param() {
use serde_json::json;
assert_eq!(ExportOptions::default().destination_mode, DestinationMode::SpecificFolder);
for (v, want) in [
("sameAsOriginal", DestinationMode::SameAsOriginal),
("SameAsOriginal", DestinationMode::SameAsOriginal),
("specificFolder", DestinationMode::SpecificFolder),
] {
assert_eq!(ExportOptions::from_params(&json!({"destinationMode": v})).unwrap().destination_mode, want, "{v}");
}
let e = ExportOptions::from_params(&json!({"destinationMode": "nextToIt"})).unwrap_err().to_string();
assert!(e.contains("`destinationMode` must be one of specificFolder|sameAsOriginal"), "{e}");
assert!(ExportOptions::from_params(&json!({"destinationMode": 1})).is_err());
assert!(
ExportOptions::from_params(&json!({"destinationMod": "sameAsOriginal"}))
.unwrap_err()
.to_string()
.contains("did you mean `destinationMode`")
);
// saved (a preset, `lastExport`) and read back
let o = ExportOptions { destination_mode: DestinationMode::SameAsOriginal, subfolder: "Exports".into(), ..Default::default() };
let j = o.to_json();
assert_eq!(j["destinationMode"], "sameAsOriginal");
assert_eq!(ExportOptions::from_params(&ExportOptions::known_keys_only(&j)).unwrap(), o);
}
/// Issue #183: the `watermark` object's keys, kinds and ranges are checked (and documented in
/// the `app.export` param string), instead of unknown keys passing and sizes clamping silently.
#[test]
+82 -8
View File
@@ -55,6 +55,8 @@ pub(crate) struct Index {
skipped: std::collections::HashSet<Key>,
/// The cache file on disk belongs to this model, so new records can be appended to it.
file_matches: bool,
/// Records were dropped ([`Index::forget_photo`]): the cache file is written fresh at the next save.
rewrite: bool,
pub path: Option<PathBuf>,
}
@@ -87,6 +89,16 @@ impl Index {
self.skipped.insert((photo, region_key(rect)));
}
/// Drop every face of `photo` (its file was replaced, so they were made from other pixels): they are embedded
/// again, and the cache file is rewritten without them at the next save.
pub fn forget_photo(&mut self, photo: u64) {
let before = self.entries.len();
self.entries.retain(|k, _| k.0 != photo);
self.unsaved.retain(|(k, _)| k.0 != photo);
self.skipped.retain(|k| k.0 != photo);
self.rewrite |= self.entries.len() != before;
}
#[cfg(test)]
pub fn insert(&mut self, photo: u64, rect: &Rect, embedding: Vec<f32>) {
self.insert_with_view(photo, rect, embedding, rect);
@@ -102,7 +114,7 @@ impl Index {
/// Whether there are embeddings not yet written to the cache file.
pub fn has_unsaved(&self) -> bool {
!self.unsaved.is_empty()
!self.unsaved.is_empty() || self.rewrite
}
/// Start over for `tag` (`dim` numbers per face), loading what the cache file at `path` holds for it. What the
@@ -116,6 +128,7 @@ impl Index {
self.unsaved.clear();
self.skipped.clear();
self.file_matches = false;
self.rewrite = false;
if let Some(p) = self.path.clone() {
self.load(&p);
}
@@ -175,17 +188,19 @@ impl Index {
r
}
/// Write what was added since the last save: appended when the file already belongs to this model, else the
/// whole index is written fresh (atomically). A failure is returned, the records stay pending.
/// Write what was added since the last save: appended when the file already belongs to this model, else (or when
/// records were dropped) the whole index is written fresh (atomically). A failure is returned, the records stay
/// pending.
pub fn save(&mut self) -> std::io::Result<()> {
let Some(path) = self.path.clone() else {
self.unsaved.clear();
self.rewrite = false;
return Ok(());
};
if self.unsaved.is_empty() {
if self.unsaved.is_empty() && !self.rewrite {
return Ok(());
}
if self.file_matches {
if self.file_matches && !self.rewrite {
let mut f = std::fs::OpenOptions::new().append(true).open(&path)?;
let mut buf = Vec::new();
for (k, e) in &self.unsaved {
@@ -201,6 +216,7 @@ impl Index {
std::fs::write(&part, &buf)?;
std::fs::rename(&part, &path)?;
self.file_matches = true;
self.rewrite = false;
}
self.unsaved.clear();
Ok(())
@@ -228,6 +244,8 @@ pub(crate) struct Scanned {
unsaved: Vec<u64>,
path: Option<PathBuf>,
file_matches: bool,
/// A photo was taken out ([`Scanned::remove`]): the record is written fresh at the next save.
rewrite: bool,
}
impl Scanned {
@@ -249,8 +267,16 @@ impl Scanned {
}
}
/// Search `photo` again (its file was replaced).
pub fn remove(&mut self, photo: u64) {
if self.set.remove(&photo) {
self.unsaved.retain(|p| *p != photo);
self.rewrite = true;
}
}
pub fn has_unsaved(&self) -> bool {
!self.unsaved.is_empty()
!self.unsaved.is_empty() || self.rewrite
}
/// Start over with the record at `path` (written first: what was not yet saved is not lost).
@@ -259,6 +285,7 @@ impl Scanned {
self.set.clear();
self.unsaved.clear();
self.file_matches = false;
self.rewrite = false;
self.path = path;
if let Some(p) = self.path.clone() {
self.load(&p);
@@ -292,12 +319,13 @@ impl Scanned {
pub fn save(&mut self) -> std::io::Result<()> {
let Some(path) = self.path.clone() else {
self.unsaved.clear();
self.rewrite = false;
return Ok(());
};
if self.unsaved.is_empty() {
if self.unsaved.is_empty() && !self.rewrite {
return Ok(());
}
if self.file_matches {
if self.file_matches && !self.rewrite {
let mut f = std::fs::OpenOptions::new().append(true).open(&path)?;
let buf: Vec<u8> = self.unsaved.iter().flat_map(|id| id.to_le_bytes()).collect();
f.write_all(&buf)?;
@@ -308,6 +336,7 @@ impl Scanned {
std::fs::write(&part, &buf)?;
std::fs::rename(&part, &path)?;
self.file_matches = true;
self.rewrite = false;
}
self.unsaved.clear();
Ok(())
@@ -406,6 +435,43 @@ mod tests {
assert_eq!(b.len(), 0, "a non-finite vector is not loaded back");
let _ = std::fs::remove_file(path);
}
/// The faces and the search of a photo whose file was replaced are dropped from the files too, not only from memory:
/// they don't come back at the next launch (issue #1524).
#[test]
fn a_forgotten_photo_stays_forgotten_after_reloading() {
let dir = std::env::temp_dir().join(format!("lc-faces-forget-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let (emb, scan) = (dir.join("face-embeddings.bin"), dir.join("face-scanned.bin"));
let mut a = Index::default();
a.reset("m@abc", 2, Some(emb.clone()));
a.insert(1, &rect(0.1), vec![1.0, 0.0]);
a.insert(2, &rect(0.1), vec![0.0, 1.0]);
a.save().unwrap();
a.forget_photo(1);
assert!(a.has_unsaved());
a.save().unwrap();
a.insert(3, &rect(0.1), vec![1.0, 1.0]);
a.save().unwrap();
let mut b = Index::default();
b.reset("m@abc", 2, Some(emb));
assert!(!b.contains(1, &rect(0.1)) && b.contains(2, &rect(0.1)) && b.contains(3, &rect(0.1)));
let mut s = Scanned::default();
s.reset(Some(scan.clone()));
s.insert(1);
s.insert(2);
s.save().unwrap();
s.remove(1);
assert!(s.has_unsaved());
s.save().unwrap();
s.insert(3);
s.save().unwrap();
let mut t = Scanned::default();
t.reset(Some(scan));
assert!(!t.contains(1) && t.contains(2) && t.contains(3));
let _ = std::fs::remove_dir_all(&dir);
}
}
/// What the recognition commands keep between calls: the loaded model and the embeddings made with it.
@@ -456,4 +522,12 @@ impl FacesState {
self.saved_at = None;
self.epoch = self.epoch.wrapping_add(1);
}
/// Photo `id`'s file was replaced by other content: the faces made from the old pixels are dropped and the photo is
/// searched again (issue #1524). What a worker is still making from the old file is dropped when it comes back.
pub fn content_replaced(&mut self, id: lightcraft_catalog::PhotoId) {
self.index.forget_photo(id.0);
self.scanned.remove(id.0);
self.scan_failed.remove(&id.0);
}
}
+6 -2
View File
@@ -134,6 +134,9 @@ pub(crate) struct Prepared {
/// The session's face epoch when this was prepared: another library has been opened since if it differs.
pub epoch: u64,
pub id: PhotoId,
/// The photo's content key ([`crate::media::content_key`]) when this was prepared: its file was replaced (Reload
/// after an outside edit) since if it differs.
pub content: String,
pub job: RenderJob,
/// A raw's embedded preview, tried before the render job (much faster than decoding the raw).
pub preview: Option<(String, PreviewLoader)>,
@@ -150,6 +153,7 @@ pub(crate) struct Done {
pub tag: String,
pub epoch: u64,
pub id: PhotoId,
pub content: String,
/// Each face's region, its embedding (`None` when it could not be made) and the box to show it by.
pub results: Vec<(Rect, Option<Vec<f32>>, Rect)>,
/// Faces were to be looked for in this photo.
@@ -177,7 +181,7 @@ fn face_rect(f: &Face) -> Option<Rect> {
/// Do the work for one photo. Never panics: whatever goes wrong, its faces come back as `None`.
pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done {
let Prepared { tag, epoch, id, job, preview, todo, detect, detector } = p;
let Prepared { tag, epoch, id, content, job, preview, todo, detect, detector } = p;
let fallback = |todo: Vec<Rect>| todo.into_iter().map(|r| (r, None, r)).collect::<Vec<_>>();
let rects = todo.clone();
let run = catch_unwind(AssertUnwindSafe(|| {
@@ -238,7 +242,7 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done {
(results, new_faces)
}));
let (results, found) = run.unwrap_or_else(|_| (fallback(rects), None));
Done { tag, epoch, id, results, detect_wanted: detect, found }
Done { tag, epoch, id, content, results, detect_wanted: detect, found }
}
type Job = (Arc<Embedder>, Prepared);
+199 -7
View File
@@ -18,7 +18,8 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
let camera = [m.make.clone().unwrap_or_default(), m.model.clone().unwrap_or_default()].join(" ").trim().to_string();
let meta = Meta {
camera,
lens: m.lens_model.clone().unwrap_or_default(),
// (a camera that writes no lens name, like Nikon's DSLRs, still says what range the lens covers)
lens: m.lens_model.clone().or_else(|| m.lens_spec.and_then(lightcraft_meta::lens_spec_name)).unwrap_or_default(),
focal_mm: m.focal_length.map(|f| f as f32),
aperture: m.f_number.map(|f| f as f32),
shutter,
@@ -499,6 +500,8 @@ fn load_bytes_now(
camera_tone,
mattes,
local_tone,
// (the catalog knows the original's size: `media::source_info`)
native_long: None,
},
));
}
@@ -517,17 +520,28 @@ fn load_bytes_now(
/// (`lightcraft_raw::spectral`), else when its camera's white-balance presets give one
/// (`lightcraft_raw::kelvin_presets`): either turns its as-shot multipliers into Kelvin and tint, nothing else — the
/// look stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot).
struct WbScaleOf {
pub(crate) struct WbScaleOf {
own_matrix: bool,
relative: bool,
/// Kelvin through the camera's measured matrices or presets: edited on the relative scale before those were used.
legacy_relative: bool,
/// The as-shot white through the measured matrices or the presets, when that is where the scale comes from.
scale_white: Option<lightcraft_color::Xy>,
/// The camera's measured matrices, when they give the scale.
camera: Option<&'static lightcraft_raw::spectral::Camera>,
}
/// The white balance a DNG of a raw's camera RGB (Photo Merge) declares so that it shows as its source does
/// ([`WbScaleOf::dng_white`]).
pub(crate) enum DngWhite {
/// Colour tags with the camera's measured matrices: the DNG reads every white through them, as the source does.
Matrices(Box<lightcraft_raw::ColorData>),
/// The white the source's scale shows at As Shot.
At(lightcraft_color::Xy),
}
impl WbScaleOf {
fn new(
pub(crate) fn new(
format: lightcraft_raw::RawFormat,
color: &lightcraft_raw::ColorData,
wb_multipliers: Option<[f32; 3]>,
@@ -544,7 +558,23 @@ impl WbScaleOf {
None => None,
};
let covered = camera.is_some() || presets;
WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white }
WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white, camera }
}
/// What a DNG of this raw's camera RGB declares so that it shows the white balance as this scale does: the
/// camera's measured matrices when they give the scale, else the white the scale shows at As Shot (the presets'
/// reading; 6500 K / 0 on the relative scale; else `own`, the white the file's tags give).
pub(crate) fn dng_white(&self, color: &lightcraft_raw::ColorData, own: lightcraft_color::Xy) -> DngWhite {
if let Some(camera) = self.camera {
let mut c = lightcraft_raw::ColorData { profile: Default::default(), kelvin_presets: None, ..color.clone() };
if camera.fill(&mut c) {
return DngWhite::Matrices(Box::new(c));
}
}
if self.relative {
return DngWhite::At(lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0));
}
DngWhite::At(self.scale_white.unwrap_or(own))
}
/// The as-shot temperature and tint on the file's scale: the relative scale's reference, the white through
/// the measured matrices or the presets, else `own` (the white the file's own tags give). Whole numbers, as the
@@ -1051,10 +1081,24 @@ impl crate::Session {
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
/// Keywords from a file keep their hierarchy: the paths of `lr:hierarchicalSubject`, not the
/// flat names of `dc:subject` that LightCraft's exports (and Lightroom Classic) write besides
/// them; an exported `travel|Italy|Rome` comes back as itself.
#[test]
fn a_lens_without_a_name_is_shown_by_its_range() {
// a Nikon DSLR raw: no LensModel, the range from the maker note
let m = lightcraft_meta::Metadata { lens_spec: Some([18.0, 135.0, 3.5, 5.6]), ..Default::default() };
assert_eq!(super::meta_of(&m).0.lens, "18.0-135.0 mm f/3.5-5.6");
let named = lightcraft_meta::Metadata {
lens_model: Some("NIKKOR Z 24-70mm f/4 S".into()),
lens_spec: Some([24.0, 70.0, 4.0, 4.0]),
..Default::default()
};
assert_eq!(super::meta_of(&named).0.lens, "NIKKOR Z 24-70mm f/4 S");
assert_eq!(super::meta_of(&lightcraft_meta::Metadata::default()).0.lens, "");
}
#[test]
fn imported_keywords_keep_their_hierarchy() {
let m = lightcraft_meta::Metadata {
@@ -1128,6 +1172,40 @@ mod tests {
assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3"));
}
/// A merged DNG (Photo Merge) of a raw without colour matrices shows the As Shot temperature and tint its source
/// shows, on every scale: through the measured matrices (ILCE-7M3), through the presets (ILCA-99M2), the relative
/// scale's 6500 K / 0 (ILCE-7M2) and a file's own white (an unfitted format). Merges of real ILCE-7M3 brackets
/// opened at 6113–7307 K / +115 where the ARWs show 3932–5268 K / +7.
#[test]
fn a_merged_dng_shows_the_white_balance_its_source_shows() {
use lightcraft_raw::{ColorData, RawFormat};
let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() };
let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0];
let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([
(2500.0, g(1336.0, 3672.0)),
(3200.0, g(1720.0, 2620.0)),
(4500.0, g(2256.0, 1880.0)),
(6000.0, g(2680.0, 1544.0)),
(8500.0, g(3136.0, 1300.0)),
]);
let with_presets = ColorData { kelvin_presets: presets, ..Default::default() };
let cases = [
(RawFormat::Arw, ColorData::default(), [2.5039f32, 1.0, 1.5078], meta("SONY", "ILCE-7M3")),
(RawFormat::Arw, with_presets, g(2500.0, 1668.0), meta("SONY", "ILCA-99M2")),
(RawFormat::Arw, ColorData::default(), [2.2, 1.0, 1.6], meta("SONY", "ILCE-7M2")),
(RawFormat::Rw2, ColorData::default(), [2.0, 1.0, 1.4], meta("Panasonic", "DC-G9")),
];
for (format, color, mul, meta) in cases {
let own = lightcraft_raw::color::as_shot_white_of(&color, Some(mul));
let source = WbScaleOf::new(format, &color, Some(mul), &meta);
let t = lightcraft_raw::color::camera_transform_of(&color, own);
let dng = crate::camera_preview::dng_colour(&color, &source.dng_white(&color, own), &t, None).unwrap();
let merged = WbScaleOf::new(RawFormat::Dng, &dng, None, &meta);
let shown = source.as_shot(own);
assert_eq!(merged.as_shot(lightcraft_raw::color::as_shot_white_of(&dng, None)), shown, "{:?}", meta.model);
}
}
/// A RAF whose preview pointer is in its header, then a large raw tail the reader must not need.
fn raf_with_embedded_jpeg(jpeg: &[u8], tail: usize) -> Vec<u8> {
let head = 160;
@@ -1636,7 +1714,7 @@ mod tests {
}
/// A CR3-shaped file (not decodable yet) whose only content is a `PRVW` preview box.
fn cr3_with_preview(w: u32, h: u32) -> Vec<u8> {
pub(crate) fn cr3_with_preview(w: u32, h: u32) -> Vec<u8> {
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
let jpeg = encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
let mut b = b"\0\0\0\x18ftypcrx \0\0\0\x01crx isom".to_vec();
@@ -2160,10 +2238,13 @@ mod tests {
let p = s.catalog.photo(id).unwrap().clone();
let why = p.preview_only.clone().expect("marked preview only");
assert!(why.to_uppercase().contains("CR3"), "the decoder's reason: {why}");
// stamped with the version that decided it, so a later version rechecks it (#662)
assert_eq!(p.preview_checked, lightcraft_catalog::APP_VERSION);
assert!(!p.preview_only_stale());
assert_eq!(p.kind, MediaKind::Raw, "still a raw file (filters, Convert to DNG…)");
assert!(!p.develops_raw());
// rendered like the JPEG it is: relative white balance, display tone curve, no raw defaults
assert_eq!(crate::media::source_info(&p), SourceInfo::default());
assert_eq!(SourceInfo { native_long: None, ..crate::media::source_info(&p) }, SourceInfo::default());
assert_eq!(*p.develop, lightcraft_develop::DevelopSettings::default());
assert!(s.render_job(id, 48, 48, false, true).unwrap().run().rendered.is_ok());
// agents see it
@@ -2177,14 +2258,17 @@ mod tests {
let mut s = crate::Session::new().with_fs();
s.open_library(&lib, false).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str()));
assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION);
// the file decodes now (here: replaced by a DNG): Reload clears it, undo brings it back
std::fs::write(&path, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap();
let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap();
assert_eq!(r["reloaded"], json!([id.0]), "{r}");
assert!(s.catalog.photo(id).unwrap().develops_raw());
assert_eq!(s.catalog.photo(id).unwrap().preview_checked, "", "the stamp goes with the flag");
assert!(crate::media::source_info(s.catalog.photo(id).unwrap()).raw);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str()));
assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION);
// relinking to a file that decodes clears it too
let dng = files.join("DSC_0001.dng");
std::fs::write(&dng, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap();
@@ -2403,4 +2487,112 @@ mod tests {
assert!(q.rendered.is_ok(), "{:?}", q.rendered.err());
let _ = std::fs::remove_dir_all(&dir);
}
/// A Sony ARW of `model`: a 64 × 48 uncompressed 14-bit CFA strip in a SubIFD (as `crates/raw/tests/vendor_robust.rs`
/// lays it out) whose IFD carries the full-frame 16–35 mm distortion table (tag 0x7037).
fn sony_arw(model: &str) -> Vec<u8> {
use lightcraft_tiff::tags::{self as t, photometric};
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value};
// 3:2, the aspect Sony's lens opcodes are limited to
let (w, h) = (72u32, 48u32);
let mut raw = IfdBuilder::new();
raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
raw.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
raw.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![14]));
raw.set(t::COMPRESSION, Value::Short(vec![1]));
raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA]));
raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2]));
raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2]));
raw.set(0x7037, Value::SShort(vec![16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479]));
raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![2000u16.to_le_bytes().repeat((w * h) as usize)] });
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::MAKE, Value::Ascii("SONY".into()));
ifd0.set(t::MODEL, Value::Ascii(model.into()));
ifd0.add_sub_ifd(raw);
TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap()
}
/// Sony's embedded distortion correction is on by default: an ARW of a verified body imports with "Enable Lens
/// Corrections" on and still counts as unedited; another body's tables aren't read, so its correction is off.
#[test]
fn arw_with_lens_data_imports_with_profile_corrections_on() {
let dir = std::env::temp_dir().join(format!("lc-arw-lens-default-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut s = crate::Session::new().with_fs();
for (model, on) in [("ILCE-7RM6", true), ("ILCE-7M4", false)] {
let path = dir.join(format!("{model}.arw"));
std::fs::write(&path, sony_arw(model)).unwrap();
let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap();
let p = s.catalog.photo(lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap())).unwrap();
assert_eq!((p.format.as_str(), p.embedded_lens.is_some(), p.develop.optics.lens_profile), ("ARW", on, on), "{model}");
assert!(!p.is_edited(), "{model}: as imported");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// RW2 and DNG keep their default: the correction is on when the file carries one (RW2 distortion, a DNG's
/// opcodes), off when it doesn't.
#[test]
fn rw2_and_dng_defaults_unchanged() {
use lightcraft_catalog::{Photo, PhotoId, Source};
let s = crate::Session::new();
let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), ..Default::default() };
for format in ["RW2", "DNG"] {
for embedded in [None, Some(lens)] {
let name = format!("x.{format}");
let mut p = Photo::new(PhotoId(1), Source::File { path: format!("/{name}") }, &name, format, 64, 48, "");
p.kind = MediaKind::Raw;
p.embedded_lens = embedded;
crate::import::apply_import_defaults(&s, &mut p);
assert_eq!(p.develop.optics.lens_profile, embedded.is_some(), "{format} {embedded:?}");
}
}
}
/// Stored settings stay as they are when the library opens again: an ARW catalogued before its lens data was read
/// (none, correction off) keeps the correction off and counts as unedited, and one whose user switched the
/// correction off keeps it off.
#[test]
fn existing_photo_settings_are_not_rewritten() {
use lightcraft_catalog::{Op, PhotoId};
let dir = std::env::temp_dir().join(format!("lc-arw-lens-existing-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (lib, files) = (dir.join("lib"), dir.join("files"));
std::fs::create_dir_all(&files).unwrap();
let mut s = crate::Session::new().with_fs();
s.open_library(&lib, false).unwrap();
let import = |s: &mut crate::Session, name: &str| {
let path = files.join(name);
// trailing bytes of its own: not a duplicate of the other file
std::fs::write(&path, [sony_arw("ILCE-7RM6"), name.as_bytes().to_vec()].concat()).unwrap();
let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap();
PhotoId(r["imported"][0].as_u64().unwrap())
};
let old = import(&mut s, "old.arw");
// as a release that didn't read the Sony tables catalogued it
let mut d = (*s.catalog.photo(old).unwrap().develop).clone();
d.optics.lens_profile = false;
let ops = vec![
Op::SetEmbeddedLens { id: old, lens: None },
Op::SetDevelop { id: old, settings: std::sync::Arc::new(d), label: "x".into(), edited: None },
];
s.commit("x", Op::Batch { ops }).unwrap();
let off = import(&mut s, "off.arw");
s.execute("develop.merge", &serde_json::json!({"settings": {"optics": {"lens_profile": false}}})).unwrap();
let before: Vec<_> = [old, off].iter().map(|id| s.catalog.photo(*id).unwrap().clone()).collect();
assert!(before.iter().all(|p| !p.develop.optics.lens_profile));
s.persist().unwrap();
drop(s);
let mut s = crate::Session::new().with_fs();
s.open_library(&lib, false).unwrap();
for b in &before {
let p = s.catalog.photo(b.id).unwrap();
assert_eq!((&p.develop, &p.embedded_lens), (&b.develop, &b.embedded_lens), "{}", p.file_name);
}
assert!(!s.catalog.photo(old).unwrap().is_edited(), "still as imported");
drop(s);
let _ = std::fs::remove_dir_all(&dir);
}
}
+11 -2
View File
@@ -39,7 +39,7 @@ pub const EXTENSIONS: &[&str] = &[
// Decoded when built with the codecs' `heif` feature; otherwise the import reports them as
// failed with the reason ("HEIC/HEIF support isn't included in this build").
"heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff",
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", "mef",
];
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -310,6 +310,8 @@ pub struct ImportDefaults {
pub auto_folder: Option<String>,
pub auto_copy: bool,
pub auto_album: Option<String>,
/// Open the import review when a camera or memory card is connected.
pub show_on_card: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -470,6 +472,12 @@ pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo
probe_all(s.media.file_probe.as_ref(), paths, &progress.done, &progress.cancel)
}
/// Probe files with `probe` (a clone of the session's file probe), off the session: on a worker thread.
pub(crate) fn probe_with(probe: &crate::media::FileProbe, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
let progress = ScanProgress::default();
probe_all(Some(probe), paths, &progress.done, &progress.cancel)
}
/// Probe `paths` (several at once), counting each in `done`. Stops before the next file once
/// `cancel` is set: the files not reached come back as errors.
fn probe_all(
@@ -1386,7 +1394,7 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare
p.as_shot_wb = info.as_shot_wb;
p.content_hash = info.content_hash;
p.embedded_lens = info.embedded_lens;
p.preview_only = info.preview_only.clone();
p.set_preview_only(info.preview_only.clone());
apply_import_defaults(s, &mut p);
if let Some(sc) = &sidecar {
crate::sidecar::merge_into(&mut p, sc, &now);
@@ -1532,6 +1540,7 @@ impl Session {
stages: None,
view_cache: None,
display: None,
slide: None,
preview_cut: None,
};
let embedded = match (&self.media.preview_loader, c.kind) {
+4 -1
View File
@@ -128,7 +128,10 @@ pub(crate) fn sidecars_of(src: &Path) -> Vec<(PathBuf, bool)> {
if !ext.eq_ignore_ascii_case("xmp") || !e.path().is_file() {
return None;
}
if base == name {
// `IMG_1.cr3.xmp` names `IMG_1.CR3` too: some apps lower-case the photo's extension
let full = base == name
|| base.rsplit_once('.').is_some_and(|(s, x)| s == stem && name.rsplit_once('.').is_some_and(|(_, y)| x.eq_ignore_ascii_case(y)));
if full {
Some((e.path(), false))
} else if base == stem {
Some((e.path(), true))
+227 -15
View File
@@ -55,6 +55,7 @@ pub mod memory;
pub mod merge;
mod model_download;
pub mod originals;
mod pdf;
pub mod preset_import;
pub mod preset_luminar;
pub mod presets;
@@ -62,8 +63,15 @@ pub mod proof;
pub mod rename;
pub mod segment;
pub mod sidecar;
pub mod slide;
pub mod slide_overlay;
pub mod slide_template;
pub mod slide_title;
pub mod slideshow;
pub mod slideshow_export;
pub mod smart;
pub mod sync;
pub mod text;
mod view;
pub mod walk;
@@ -172,6 +180,9 @@ pub struct Interaction {
pub original: Arc<DevelopSettings>,
}
/// The most photos an edit may touch for the view to follow it photo by photo; a larger edit is worked out whole.
const MAX_FOLLOWED: usize = 256;
/// Source of [`Session::visible_shared`] generations (process-wide, so two sessions never share one).
static VISIBLE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
static LIBRARY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -190,6 +201,21 @@ pub struct Session {
/// Shared, so frontends can hold it across frames without copying ([`Session::visible_shared`]).
visible: Arc<[PhotoId]>,
visible_key: Option<(u64, String)>,
/// A view holds the photos it shows until it is refreshed (`library.holdView`): an edit that makes a photo stop
/// matching a smart album's rules, Picks or the filter bar leaves it where it is. Off: the photo goes at once.
pub hold_view: bool,
/// The view last worked out, the catalog's revision then, and the photos in it before stacks are arranged.
/// A small edit is followed from it: only the photos the edit touched are asked about again and put at their
/// place in the order ([`Session::visible`]). It is also what a held view holds ([`Session::hold_view`]).
/// Another source, filter or sort is another view, and so is a smart album whose rules were changed.
base: Option<(String, u64, Vec<PhotoId>)>,
/// How many times the view followed an edit from the list it had, and how many times it was worked out from
/// every photo (`ui.inspect` → `perf`): what tells a slow library from a slow edit.
pub view_followed: u64,
pub view_worked_out: u64,
/// Tests: work the view out whole every time (what following has to equal).
#[cfg(test)]
never_follow: bool,
/// Identifies the current `visible` list: a new value (unique in the process) every time it
/// is recomputed, so frontends can key their per-view caches on it instead of hashing the ids.
visible_gen: u64,
@@ -206,6 +232,9 @@ pub struct Session {
/// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on
/// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends.
pub(crate) skip_auto_write: bool,
/// Photos whose automatic XMP sidecar write failed (their folder offline, read-only or full), written again by
/// [`Session::retry_unsaved_sidecars`] once their file is reachable.
pub(crate) unsaved_sidecars: std::collections::BTreeSet<PhotoId>,
/// AI denoise: the model in use, the photos that have their picture and the work in progress.
pub(crate) denoise: denoise::State,
/// Where the host keeps face models (one folder each); `None` where there is no file system (the web).
@@ -269,6 +298,11 @@ pub struct Session {
/// The user's point-curve presets (`curve.*`; built-ins: [`cmd::curves::builtin_presets`]),
/// persisted in prefs.json.
pub curve_presets: Vec<cmd::curves::CurvePreset>,
/// Mask effect presets (the Masking panel's Effect menu).
pub mask_effects: Vec<cmd::mask_effects::MaskEffect>,
/// The effect a new mask of each kind (`brush`, `linear`…) starts with: the one last applied
/// to a mask of that kind.
pub mask_tool_effects: std::collections::BTreeMap<String, String>,
/// Saved colour-label name sets.
pub label_sets: Vec<cmd::manage::LabelSet>,
/// Look-alike signatures by content key (Find Similar).
@@ -296,12 +330,33 @@ pub struct Session {
/// Background tasks in flight (imports, exports, preview builds…), for the activity stack and
/// `activity.list` / `activity.cancel`.
pub activity: activity::Activity,
/// The last (or running) slideshow export (`slideshow.export`).
pub slideshow_export: Option<std::sync::Arc<slideshow_export::ExportRun>>,
/// The last (or running) Build Previews.
pub preview_build: Option<std::sync::Arc<cmd::previews::PreviewBuild>>,
/// Develop defaults applied on import (persisted in prefs.json).
pub import_defaults: import::ImportDefaults,
/// Disk budget of the library's thumbnail cache in MB (0 = default; persisted in prefs.json).
pub cache_mb: u32,
/// Slideshow playback settings (`slideshow.playback`; persisted in prefs.json).
pub slideshow: slideshow::PlaybackSettings,
/// Slideshow slide layout: Layout, Options and Backdrop (`slideshow.layout`; persisted in
/// prefs.json).
pub slide_layout: slide::SlideSettings,
/// Slideshow overlays (identity plate, rating stars, text overlays, watermark).
pub slide_overlays: slide_overlay::OverlaySettings,
/// Slideshow intro and ending screens (`slideshow.titles`; persisted in prefs.json).
pub slide_titles: slide_title::TitleSettings,
/// Where user slideshow templates are kept (`<config>/slideshow-templates`;
/// [`Session::with_default_slide_templates`]). `None`: the built-in templates only.
pub slide_templates_dir: Option<std::path::PathBuf>,
/// The slideshow settings before the last template was applied ("Previous settings"; this
/// session only: library preferences have no undo).
pub slide_templates_previous: Option<slide_template::SlideLook>,
/// The templates as last listed.
pub(crate) slide_templates_cache: slide_template::Cache,
/// The background image the slides last rendered (shared by slide jobs: rendered once).
pub slide_backdrop: std::sync::Arc<slide::BackdropCache>,
/// Where smart previews are kept when not in the library folder (persisted in prefs.json).
pub smart_previews_dir: Option<std::path::PathBuf>,
/// Untouched Local records of folders not browsed for this many days are forgotten when the
@@ -345,6 +400,12 @@ impl Session {
selection: Selection::default(),
visible: Vec::new().into(),
visible_key: None,
hold_view: false,
base: None,
view_followed: 0,
view_worked_out: 0,
#[cfg(test)]
never_follow: false,
visible_gen: 0,
total: None,
undo: Vec::new(),
@@ -353,6 +414,7 @@ impl Session {
pending_sidecar_repairs: Vec::new(),
interaction: None,
skip_auto_write: false,
unsaved_sidecars: Default::default(),
denoise: Default::default(),
face_models_dir: None,
face_downloads: Default::default(),
@@ -385,6 +447,8 @@ impl Session {
metadata_presets: Vec::new(),
filter_presets: Vec::new(),
curve_presets: Vec::new(),
mask_effects: Vec::new(),
mask_tool_effects: Default::default(),
label_sets: Vec::new(),
signatures: Default::default(),
lut_profiles: Vec::new(),
@@ -398,8 +462,17 @@ impl Session {
folder_changes: None,
activity: Default::default(),
preview_build: None,
slideshow_export: None,
import_defaults: import::ImportDefaults::default(),
cache_mb: 0,
slideshow: slideshow::PlaybackSettings::default(),
slide_layout: slide::SlideSettings::default(),
slide_overlays: slide_overlay::OverlaySettings::default(),
slide_titles: slide_title::TitleSettings::default(),
slide_templates_dir: None,
slide_templates_previous: None,
slide_templates_cache: None,
slide_backdrop: Default::default(),
smart_previews_dir: None,
forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS,
#[cfg(not(target_arch = "wasm32"))]
@@ -509,10 +582,16 @@ impl Session {
// Retain only cache IDs while the catalog consumes the operation. Cloning an import
// batch here also cloned every photo, although commit already keeps the journal copy.
let mut invalidated = Vec::new();
// photos whose file was replaced: the faces made from the old pixels go too (issue #1524)
let mut replaced = Vec::new();
let mut pending = vec![&op];
while let Some(op) = pending.pop() {
match op {
Op::SetFile { id, .. } | Op::Relink { id, .. } | Op::SetContent { id, .. } | Op::RemovePhoto { id } => invalidated.push(*id),
Op::SetContent { id, .. } => {
invalidated.push(*id);
replaced.push(*id);
}
Op::SetFile { id, .. } | Op::Relink { id, .. } | Op::RemovePhoto { id } => invalidated.push(*id),
Op::AddPhoto { photo } => invalidated.push(photo.id),
Op::Batch { ops } => pending.extend(ops),
_ => {}
@@ -522,6 +601,12 @@ impl Session {
for id in invalidated {
self.media.forget(id);
}
#[cfg(not(target_arch = "wasm32"))]
for id in replaced {
self.faces.content_replaced(id);
}
#[cfg(target_arch = "wasm32")]
let _ = replaced;
Ok(inv)
}
@@ -547,8 +632,8 @@ impl Session {
}
/// Apply an op that is LightCraft's own bookkeeping rather than something the user did (faces found by the background
/// scan, a raw's as-shot white read by a decode): journaled like any op, but not an undo step, and it leaves the
/// redo stack alone.
/// scan, a raw's as-shot white read by a decode, raws rechecked after an update): journaled like any op, but not
/// an undo step, and it leaves the redo stack alone.
pub(crate) fn apply_system(&mut self, op: Op) -> Result<()> {
let fwd = op.clone();
self.catalog.apply(op)?;
@@ -859,6 +944,16 @@ impl Session {
pub fn visible(&mut self) -> &[PhotoId] {
let mut key =
(self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse, self.library_folder));
// What a held view is told apart by: what is shown and how, and for a smart album its rules (rules that were
// changed ask something else). Not the checks of Missing Photos below: they fill the view in, they are
// no other view.
let asks = matches!(self.source, LibrarySource::Picks)
|| matches!(self.source, LibrarySource::Album(a) if self.catalog.album(a).is_some_and(|al| al.is_smart()));
let rules = match self.source {
LibrarySource::Album(a) if asks => self.catalog.album(a).map(|al| format!("{:?}", al.smart)).unwrap_or_default(),
_ => String::new(),
};
let view = format!("{}|{rules}", key.1);
if self.source == LibrarySource::Missing && self.media.availability.is_background() {
// the view fills in as the background checks find files gone
key.1.push_str(&format!("|{}", self.media.availability.generation()));
@@ -866,18 +961,58 @@ impl Session {
if self.visible_key.as_ref() != Some(&key) {
// "in the last N days" rules count back from the session's clock
lightcraft_catalog::rules::set_now(Some((self.clock)()));
let mut f = self.source.to_filter(&self.filter, &self.catalog);
if self.source == LibrarySource::Folder {
// no folder chosen: nothing (an empty path matches nothing)
let b = self.browse.clone().unwrap_or_default();
f.folder = Some(b.path);
f.subfolders = b.subfolders;
let f = self.view_filter(self.source, &self.filter);
// A held view keeps the photos it shows that stopped matching what is asked of them (a smart album's
// rules, a flag, the filter bar), as long as they are still where the view looks: in the library for
// a view that is only a question, else in the album or folder shown. Taking a photo out of an album
// or the library takes it out of the view at once, held or not.
// (only a view that asks something can lose a photo to an edit: the others are queried as ever, at no
// cost for the hold)
let holds = self.hold_view && (asks || self.filter != Filter::default());
let within = holds.then(|| {
if asks {
self.view_filter(LibrarySource::All, &Filter::default())
} else if self.source == LibrarySource::RecentlyAdded {
// (how far back it reaches was worked out for `f`: not a second pass over the library)
Filter { imported_from: f.imported_from.clone(), ..Default::default() }
} else {
self.view_filter(self.source, &Filter::default())
}
});
// Views whose order or photos are not the query's alone are worked out whole each time: an album in
// its own order, Recently Added (how far back it reaches moves with every import), Missing Photos.
let own_order = matches!(self.source, LibrarySource::Album(a) if self.sort.key == lightcraft_catalog::SortKey::CaptureDate
&& self.filter == Filter::default()
&& self.catalog.album(a).is_some_and(|al| !al.is_smart()));
let whole = own_order || matches!(self.source, LibrarySource::RecentlyAdded | LibrarySource::Missing);
// A view that asks about "the last N days" (itself, or through a smart album it looks into) changes
// with the clock as well as with edits: a photo nobody touched can stop matching. It is asked whole.
let clocked = f.depends_on_now()
|| (!f.albums_tested().is_empty() && self.catalog.albums().any(|al| al.smart.as_deref().is_some_and(Filter::depends_on_now)));
let base = self.base.take().filter(|(of, _, _)| *of == view);
// A small edit to the same view: the photos it touched are taken out, asked about again, and put back
// at their place in the order. The others neither stopped matching nor moved.
#[cfg(test)]
let clocked = clocked || self.never_follow;
let followed = base.as_ref().filter(|_| !whole && !clocked).and_then(|(_, revision, ids)| {
let touched = self.catalog.touched_since(*revision).filter(|touched| touched.len() <= MAX_FOLLOWED)?;
Some(self.catalog.follow(ids, &touched, &f, &self.sort, within.as_ref()))
});
match followed {
Some(_) => self.view_followed += 1,
None => self.view_worked_out += 1,
}
if self.source == LibrarySource::LibraryFolder {
// no folder chosen: nothing (`.` names no folder)
f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into()));
}
let mut visible = self.catalog.query(&f, &self.sort);
let mut visible = match (followed, &base, &within) {
(Some(ids), _, _) => ids,
(None, Some((_, _, held)), Some(within)) => {
let keep: std::collections::HashSet<PhotoId> = held.iter().copied().collect();
self.catalog.query_keeping(&f, &self.sort, &keep, within)
}
_ => self.catalog.query(&f, &self.sort),
};
// what the view shows now is what the next edit is followed from, and what it holds until it is
// refreshed (taken before stacks are arranged, so a photo folded into a stack is held with it)
self.base = (!whole || holds).then(|| (view, self.catalog.revision, visible.clone()));
if visible.is_empty() && self.source == LibrarySource::LibraryFolder && !self.folder_holds_photos() {
// the shown folder lost its last photo (deleted, moved, removed): everything, not
// an empty grid under the name of a folder that is gone from the sidebar
@@ -927,6 +1062,43 @@ impl Session {
&self.visible
}
/// `source` as a filter over `base` (the filter bar's, or none), with the folder it shows.
fn view_filter(&self, source: LibrarySource, base: &Filter) -> Filter {
let mut f = source.to_filter(base, &self.catalog);
if source == LibrarySource::Folder {
// no folder chosen: nothing (an empty path matches nothing)
let b = self.browse.clone().unwrap_or_default();
f.folder = Some(b.path);
f.subfolders = b.subfolders;
}
if source == LibrarySource::LibraryFolder {
// no folder chosen: nothing (`.` names no folder)
f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into()));
}
f
}
/// Let go of what a held view kept: the view shows what matches now ([`Session::hold_view`]).
pub fn refresh_view(&mut self) {
self.base = None;
self.visible_key = None;
}
/// [`Session::visible`] worked out from every photo, whatever was followed before: what following an edit
/// has to equal.
/// Nothing the session keeps is changed by asking: the next edit is followed from where the last one left the
/// view, so a list followed many times over is compared, not only one followed once.
#[cfg(test)]
pub(crate) fn visible_in_full(&mut self) -> Vec<PhotoId> {
let kept = (self.base.clone(), self.visible_key.clone(), self.visible.clone(), self.visible_gen, self.view_followed, self.view_worked_out);
self.never_follow = true;
self.visible_key = None;
let whole = self.visible().to_vec();
self.never_follow = false;
(self.base, self.visible_key, self.visible, self.visible_gen, self.view_followed, self.view_worked_out) = kept;
whole
}
/// Whether the library still holds a photo imported from the folder a `LibraryFolder` source
/// shows.
fn folder_holds_photos(&self) -> bool {
@@ -940,6 +1112,16 @@ impl Session {
if self.source == LibrarySource::Missing {
return None;
}
if self.filter == Filter::default() && !self.hold_view {
// nothing narrows the source: what the view lists is all of it, and is counted already (not a view
// that holds photos that stopped matching: those are not of the source any more)
let _ = self.visible();
if let Some((_, revision, ids)) = &self.base
&& *revision == self.catalog.revision
{
return Some(ids.len());
}
}
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}", self.source, self.browse, self.library_folder));
if self.total.as_ref().map(|t| &t.0) != Some(&key) {
let mut f = self.source.to_filter(&Filter::default(), &self.catalog);
@@ -951,7 +1133,8 @@ impl Session {
if self.source == LibrarySource::LibraryFolder {
f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into()));
}
let n = self.catalog.query(&f, &self.sort).len();
// (how many, not which: nothing to put in order)
let n = self.catalog.count(&f);
self.total = Some((key, n));
}
self.total.as_ref().map(|t| t.1)
@@ -961,6 +1144,21 @@ impl Session {
self.visible().to_vec()
}
/// After the source changes, the active photo follows the rule every source shares: it stays
/// when the view shows it, else the view's first photo becomes the active (and only selected)
/// one, or nothing when the view is empty. A view never keeps an active photo it doesn't show
/// (issue #1447: a Local folder kept a library photo active, so Detail and the info showed a
/// photo the grid didn't).
pub fn keep_active_in_view(&mut self) {
let active = self.selection.active;
let vis = self.visible();
if active.is_some_and(|a| vis.contains(&a)) {
return;
}
let first = vis.first().copied();
self.selection = first.map(Selection::single).unwrap_or_default();
}
/// The visible photos without copying them, with their generation: equal generations mean
/// the very same list (same catalog revision, source, filter, sort), so a frontend can cache
/// whatever it derives from the list under that number.
@@ -1002,6 +1200,8 @@ mod tests;
#[cfg(test)]
mod tests_album_order;
#[cfg(test)]
mod tests_album_remove;
#[cfg(test)]
mod tests_albums_local;
#[cfg(test)]
mod tests_color;
@@ -1028,6 +1228,8 @@ mod tests_forget_local;
#[cfg(test)]
mod tests_history_cursor;
#[cfg(test)]
mod tests_hold_view;
#[cfg(test)]
mod tests_import;
#[cfg(test)]
mod tests_import_move;
@@ -1040,6 +1242,8 @@ mod tests_library;
#[cfg(test)]
mod tests_lightroom_folder;
#[cfg(test)]
mod tests_mask_effects;
#[cfg(test)]
mod tests_merge;
#[cfg(test)]
mod tests_organize;
@@ -1052,12 +1256,20 @@ mod tests_presets_scale;
#[cfg(test)]
mod tests_process;
#[cfg(test)]
mod tests_recheck;
#[cfg(test)]
mod tests_segment;
#[cfg(test)]
mod tests_settings_files;
#[cfg(test)]
mod tests_slide;
#[cfg(test)]
mod tests_slideshow_export;
#[cfg(test)]
mod tests_spots;
#[cfg(test)]
mod tests_sync;
#[cfg(test)]
mod tests_view_follow;
#[cfg(test)]
mod tests_xmp;
+64
View File
@@ -215,6 +215,10 @@ struct PrefsFile {
filter_presets: Vec<crate::cmd::filters::FilterPreset>,
/// Point-curve presets.
curve_presets: Vec<crate::cmd::curves::CurvePreset>,
/// Mask effect presets.
mask_effects: Vec<crate::cmd::mask_effects::MaskEffect>,
/// The effect new masks of each kind start with.
mask_tool_effects: std::collections::BTreeMap<String, String>,
/// Colour-label name sets.
label_sets: Vec<crate::cmd::manage::LabelSet>,
/// Imported LUT profiles.
@@ -232,6 +236,16 @@ struct PrefsFile {
/// Days after which untouched Local records of unbrowsed folders are forgotten (missing =
/// the default, 0 = never).
forget_local_days: Option<u32>,
/// Slideshow playback settings.
slideshow: crate::slideshow::PlaybackSettings,
/// Slideshow slide layout (margins, options, backdrop).
slide_layout: crate::slide::SlideSettings,
/// Slideshow overlays.
slide_overlays: crate::slide_overlay::OverlaySettings,
/// Slideshow intro and ending screens.
slide_titles: crate::slide_title::TitleSettings,
/// A view holds the photos it shows until it is refreshed (`library.holdView`).
hold_view: bool,
}
fn presets_json(s: &Session) -> String {
@@ -322,6 +336,38 @@ impl SettingsLoad {
}
impl Session {
/// Take over what the thumbnail cache wrote under its name of before (`.jpg` files, which
/// photo galleries took for the person's photos: issues #610, #1066), once for a cache
/// folder that was not made or tidied by this build yet. On a thread of its own, with a row
/// in the activity stack that can stop it: a large cache is some 100 000 files to rename.
fn tidy_thumbnail_cache(&mut self) {
#[cfg(not(target_arch = "wasm32"))]
{
use std::sync::atomic::{AtomicBool, Ordering};
let cache = self.media.rendered.clone();
if !cache.disk().is_some_and(|d| d.needs_tidying()) {
return;
}
let stop = std::sync::Arc::new(AtomicBool::new(false));
let task = self.activity.start("cache", "Tidying the thumbnail cache", crate::activity::Cancel::Flag(stop.clone()));
let spawned = std::thread::Builder::new().name("lc-thumbs-tidy".into()).spawn(move || {
// the row stays while this runs (it is dropped with the closure, however that ends)
let _row = task;
if let Some(disk) = cache.disk() {
// (also given up when the library is closed or another is opened: the cache
// is retired then, and it is gone on with when this library opens again)
let stopped = || stop.load(Ordering::Relaxed) || cache.generation() == u64::MAX;
let taken = disk.take_over_old(&stopped, &|done, of| _row.progress(done as u64, of as u64));
log::info!("thumbnail cache: {taken} thumbnails taken over under their new name in {}", disk.dir().display());
}
});
// (not started: the thumbnails are still found one by one, and it is tried at the next open)
if let Err(e) = spawned {
log::warn!("thumbnail cache: could not be tidied: {e}");
}
}
}
/// Check, without closing the library open now, that [`Session::open_library`] can be expected
/// to open `dir`: not a Lightroom catalog, not some other file, a readable folder (or one not
/// made yet), not locked by another program. Returns the library folder ([`resolve_dir`]).
@@ -422,6 +468,8 @@ impl Session {
static LOADS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
catalog.revision = LOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1) << 32;
self.catalog = catalog;
// (nothing of the list the last library showed is this one's)
self.refresh_view();
self.library_generation = crate::LIBRARY_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) {
self.pending_sidecar_repairs.append(&mut entry.retry_sidecars);
@@ -446,6 +494,8 @@ impl Session {
self.metadata_presets = prefs.metadata_presets;
self.filter_presets = prefs.filter_presets;
self.curve_presets = prefs.curve_presets;
self.mask_effects = prefs.mask_effects;
self.mask_tool_effects = prefs.mask_tool_effects;
self.label_sets = prefs.label_sets;
self.lut_profiles = prefs.lut_profiles;
crate::cmd::lut_profiles::register_all(self);
@@ -454,7 +504,13 @@ impl Session {
self.recent_keywords = prefs.recent_keywords;
self.import_defaults = prefs.import;
self.cache_mb = prefs.cache_mb;
self.slideshow = prefs.slideshow.sanitized();
self.slide_layout = prefs.slide_layout.sanitized();
self.slide_overlays = prefs.slide_overlays.sanitized();
self.slide_titles = prefs.slide_titles.sanitized();
self.forget_local_days = prefs.forget_local_days.unwrap_or(lightcraft_catalog::DEFAULT_FORGET_DAYS);
self.hold_view = prefs.hold_view;
self.refresh_view();
self.smart_previews_dir = prefs.smart_previews_dir.filter(|_| on_disk).map(PathBuf::from);
if let Some(d) = &self.smart_previews_dir {
self.media.smart_dir = Some(d.clone());
@@ -504,6 +560,7 @@ impl Session {
self.media.clear_sources();
if on_disk {
self.media.attach_disk_cache(&dir.join("thumbs"), self.cache_bytes());
self.tidy_thumbnail_cache();
}
let view_written = self.view_json();
self.library = Some(Library {
@@ -690,6 +747,8 @@ impl Session {
metadata_presets: self.metadata_presets.clone(),
filter_presets: self.filter_presets.clone(),
curve_presets: self.curve_presets.clone(),
mask_effects: self.mask_effects.clone(),
mask_tool_effects: self.mask_tool_effects.clone(),
label_sets: self.label_sets.clone(),
lut_profiles: self.lut_profiles.clone(),
keyword_sets: self.keyword_sets.clone(),
@@ -699,6 +758,11 @@ impl Session {
cache_mb: self.cache_mb,
smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()),
forget_local_days: Some(self.forget_local_days),
slideshow: self.slideshow.clone(),
slide_layout: self.slide_layout.clone(),
slide_overlays: self.slide_overlays.clone(),
slide_titles: self.slide_titles.clone(),
hold_view: self.hold_view,
})
.unwrap_or_default();
let Some(lib) = self.library.as_mut() else { return Ok(()) };
+8 -3
View File
@@ -16,7 +16,8 @@ use serde_json::{Value, json};
type Row = BTreeMap<String, Value>;
const COMMAND: &str = "library.importLightroom";
const MAX_FILE: u64 = 1 << 30;
/// Largest catalog database or WAL read into memory (4 GiB). The whole file is held in RAM, so the cap stays finite.
const MAX_FILE: u64 = 1 << 32;
fn error(why: impl Into<String>) -> crate::EngineError {
crate::EngineError::BadParams { cmd: COMMAND.into(), msg: why.into() }
@@ -234,10 +235,14 @@ pub(crate) fn validate_for_job(data: &CatalogImport) -> Result<(), String> {
fn bytes(path: &Path) -> Result<Vec<u8>, String> {
use std::io::Read;
let f = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
if f.metadata().map_err(|e| e.to_string())?.len() > MAX_FILE {
return Err("catalog or WAL exceeds 1 GiB import limit".into());
let len = f.metadata().map_err(|e| e.to_string())?.len();
if len > MAX_FILE {
return Err("catalog or WAL exceeds 4 GiB import limit".into());
}
let mut out = Vec::new();
// Reserve the exact size up front: growing by doubling would briefly need about twice the catalog's size.
let wanted = usize::try_from(len).map_err(|_| "catalog is too large for this platform".to_string())?;
out.try_reserve_exact(wanted).map_err(|_| "not enough memory to read the catalog".to_string())?;
f.take(MAX_FILE + 1).read_to_end(&mut out).map_err(|e| e.to_string())?;
if out.len() as u64 > MAX_FILE {
return Err("catalog grew beyond import limit".into());
+61 -5
View File
@@ -62,7 +62,7 @@ impl SettingsHashes {
}
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 62;
pub const RENDER_CACHE_VERSION: u64 = 63;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
@@ -225,9 +225,13 @@ impl DecodedSource {
}
/// What to render these pixels against: the decoder's facts, else `header` (the catalog's)
/// with any stored camera tone curve.
/// with any stored camera tone curve. The original's size comes from `header` either way: the
/// decoder only sees the level it decoded.
pub fn info_or(&self, header: SourceInfo) -> SourceInfo {
self.info.clone().unwrap_or_else(|| SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header })
match &self.info {
Some(info) => SourceInfo { native_long: info.native_long.or(header.native_long), ..info.clone() },
None => SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header },
}
}
}
@@ -752,6 +756,8 @@ pub struct RenderJob {
pub view_cache: Option<(Arc<PreviewCache>, Hash128)>,
/// Render for this monitor profile ([`Self::with_display`]).
pub display: Option<Arc<crate::display::Display>>,
/// Compose the render into a slideshow slide ([`Self::with_slide`]).
pub slide: Option<Arc<crate::slide::SlideCompose>>,
/// A face tile that can be cut from the raw's embedded preview, tried before `source`
/// ([`crate::Session::face_job`]; `None` for every other job).
pub preview_cut: Option<crate::face_tiles::PreviewCut>,
@@ -849,7 +855,8 @@ impl RenderJob {
/// Gets its own result key. Thumbnail jobs (rendered-thumbnail cache) are left as they are:
/// [`crate::display::present`] converts their sRGB results.
pub fn with_display(mut self, display: Option<Arc<crate::display::Display>>) -> Self {
if self.cache.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 {
// (a slide is composed in sRGB, backdrop colours included; the frontend presents it)
if self.cache.is_some() || self.slide.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 {
return self;
}
let k = |d: &Option<Arc<crate::display::Display>>| d.as_ref().map_or(0, |d| d.id()).wrapping_mul(0xd6e8_feb8_6659_fd93);
@@ -883,7 +890,30 @@ impl RenderJob {
}
}
/// Compose the render into a slideshow slide (layout, options, backdrop): the result is the
/// slide, not the photo. Gets its own result key; the photo's cached view preview stays the
/// photo. The slide stays sRGB ([`Self::with_display`] leaves it alone).
pub fn with_slide(mut self, slide: Arc<crate::slide::SlideCompose>) -> Self {
let k = |s: &Option<Arc<crate::slide::SlideCompose>>| s.as_ref().map_or(0, |s| s.key()).wrapping_mul(0xa24b_aed4_963e_e407);
self.key ^= k(&self.slide);
self.key ^= k(&Some(slide.clone()));
self.slide = Some(slide);
self
}
/// [`Self::run_photo`], composed into its slide when this is a slide job (inside [`Self::run`]'s guard).
fn run_unguarded(self) -> RenderResult {
let slide = self.slide.clone();
let mut r = self.run_photo();
if let (Some(slide), Ok(rendered)) = (slide, r.rendered.as_mut()) {
rendered.image = slide.compose(&rendered.image);
rendered.deep = None;
}
r
}
/// The photo's render (without slide composition).
fn run_photo(self) -> RenderResult {
if let Some((cache, key)) = &self.cache
&& let Some(img) = cache.get_at(self.cache_generation, *key)
{
@@ -1131,9 +1161,16 @@ pub fn content_key(p: &Photo) -> String {
pub fn source_info(p: &Photo) -> SourceInfo {
// Procedural demo scenes are scene-referred HDR (like raw files): use the filmic tone map.
// (They are drawn afresh at every size, so they have no original size.)
if matches!(p.source, Source::Demo { .. }) {
return SourceInfo { raw: true, ..Default::default() };
}
// the thumbnail and preview levels are smaller copies: noise reduction works in the original's pixels (#960)
let native_long = Some(p.width.max(p.height) as usize).filter(|&n| n > 0);
SourceInfo { native_long, ..header_info(p) }
}
fn header_info(p: &Photo) -> SourceInfo {
// A raw shown from its embedded preview is a rendered (display-referred) JPEG: relative white
// balance and the display tone curve, like any other rendered file.
if p.develops_raw() {
@@ -1227,6 +1264,7 @@ impl crate::Session {
stages: None,
view_cache: None,
display: None,
slide: None,
preview_cut: None,
})
}
@@ -1279,6 +1317,7 @@ impl crate::Session {
stages: None,
view_cache: None,
display: None,
slide: None,
preview_cut: None,
})
}
@@ -1332,6 +1371,7 @@ impl crate::Session {
stages: None,
view_cache: None,
display: None,
slide: None,
preview_cut,
})
}
@@ -1525,7 +1565,7 @@ impl crate::Session {
/// says and what a smart preview renders with. Only for raws edited in Kelvin.
fn learn_as_shot_wb(&mut self, id: PhotoId, info: &SourceInfo) {
let Some(p) = self.catalog.photo(id) else { return };
if !(info.raw && !info.relative_wb && p.develops_raw() && !p.relative_wb()) {
if !info.raw || info.relative_wb || !p.develops_raw() || p.relative_wb() {
return;
}
let read = (info.as_shot_temp, info.as_shot_tint);
@@ -1720,6 +1760,7 @@ mod thumbnail_hash_tests {
file_size: 99,
content_hash: Some("replacement".into()),
preview_only: None,
preview_checked: String::new(),
})
.unwrap();
s.media.forget(PhotoId(1));
@@ -1757,6 +1798,21 @@ mod thumbnail_hash_tests {
mod tests {
use super::*;
#[test]
fn renders_know_the_original_size_whichever_level_they_come_from() {
// issue #960: noise reduction works in the original's pixels, so a grid cell drawn from the
// 512-px thumbnail level denoises like a scaled-down export
let p = Photo::new(PhotoId(1), Source::File { path: "/photos/a.ARW".into() }, "a.ARW", "ARW", 6000, 4000, "");
assert_eq!(source_info(&p).native_long, Some(6000));
let decoded = SourceInfo { raw: true, as_shot_temp: 4800.0, ..Default::default() };
let thumb = DecodedSource::new(Arc::new(Rgb32f::new(8, 8)), Some(decoded.clone()));
let info = thumb.info_or(source_info(&p));
assert_eq!(info, SourceInfo { native_long: Some(6000), ..decoded }, "the decoder's facts, the catalog's size");
let unknown = Photo::new(PhotoId(2), Source::File { path: "/photos/b.jpg".into() }, "b.jpg", "JPEG", 0, 0, "");
assert_eq!(source_info(&unknown).native_long, None);
assert_eq!(source_info(&Photo::new(PhotoId(3), Source::Demo { scene: 0 }, "d", "ARW", 600, 400, "")).native_long, None);
}
#[test]
fn denoise_mix_is_reserved_before_the_source_enters_the_cache() {
let mut plain = Rgb32f::new(8, 8);
+76 -20
View File
@@ -12,6 +12,7 @@ use std::sync::Arc;
use lightcraft_catalog::{PhotoId, Source};
use lightcraft_develop::DevelopSettings;
use lightcraft_merge::pano::MAX_MEGAPIXELS;
use lightcraft_merge::{Deghost, Frame, HdrOptions, PanoOptions, Projection};
use lightcraft_pipeline::{Quality, RenderRequest, SourceInfo};
use lightcraft_raster::{Rgb32f, Rgba8};
@@ -20,6 +21,10 @@ use serde_json::{Value, json};
use crate::{EngineError, Result, Session};
/// Why `merge.hdrBatch` found nothing to merge.
pub const NO_BRACKET_SETS: &str =
"no bracket sets among these photos: select stacks, or photos taken less than a second apart at different exposures";
/// Long edge of the frames of a preview merge.
pub const PREVIEW_EDGE: usize = 1024;
@@ -105,20 +110,18 @@ impl MergeJob {
use rayon::prelude::*;
let done = std::sync::atomic::AtomicUsize::new(0);
let n = self.sources.len();
let frames: std::result::Result<Vec<Frame>, String> = self
.sources
.par_iter()
.map(|(_, path)| {
let bytes = (self.read)(path)?;
// a raw without colour matrices of its own starts from the colour it has when opened alone (#965)
let f = lightcraft_merge::load_frame_with(&bytes, self.preview, orient, &crate::camera_preview::starting_colour_tags)
.map_err(|e| format!("{path}: {e}"));
let k = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
progress(span.0 + (span.1 - span.0) * k as f32 / n as f32, "Reading photos");
f
})
.collect();
frames
let one = |(_, path): &(PhotoId, String)| {
let bytes = (self.read)(path)?;
// a raw without colour matrices of its own starts from the colour it has when opened alone (#965)
let f = lightcraft_merge::load_frame_with(&bytes, self.preview, orient, &crate::camera_preview::starting_colour_tags)
.map_err(|e| format!("{path}: {e}"));
let k = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
progress(span.0 + (span.1 - span.0) * k as f32 / n as f32, "Reading photos");
f
};
// Full-size photos are decoded one at a time (the demosaic is parallel inside): decoding several 60 MP raws at
// once multiplied the decoders' scratch memory, and a 7-frame bracket ran out of memory.
if self.preview.is_some() { self.sources.par_iter().map(one).collect() } else { self.sources.iter().map(one).collect() }
}
/// Run the merge. `progress(fraction, stage)` returns false to cancel.
@@ -149,8 +152,9 @@ impl MergeJob {
MergeKind::Panorama { pano } => {
let frames = self.load(true, progress, (0.0, 0.25))?;
let p = sub(0.25, 0.9);
let r = lightcraft_merge::stitch(frames, pano, &p).map_err(err)?;
let info = pano_info(&r, &self.sources);
let fit = fit_in_memory(pano, pixels(&frames), crate::memory::total_ram());
let r = lightcraft_merge::stitch(frames, &fit, &p).map_err(err)?;
let info = pano_info(&r, &self.sources, pano, &fit);
(
r.image,
r.color,
@@ -192,8 +196,9 @@ impl MergeJob {
merged.push(f);
}
let p = sub(0.6, 0.9);
let r = lightcraft_merge::stitch(merged, pano, &p).map_err(err)?;
let mut info = pano_info(&r, &self.sources);
let fit = fit_in_memory(pano, pixels(&merged), crate::memory::total_ram());
let r = lightcraft_merge::stitch(merged, &fit, &p).map_err(err)?;
let mut info = pano_info(&r, &self.sources, pano, &fit);
info["bracket"] = json!(b);
(
r.image,
@@ -230,7 +235,20 @@ impl MergeJob {
}
}
fn pano_info(r: &lightcraft_merge::PanoResult, sources: &[(PhotoId, String)]) -> Value {
/// `asked` is what the command asked for, `fit` what ran (held to memory): when the panorama came
/// out smaller than the photos' own resolution, `limit` says why (`memory`, `size`: Lightroom's
/// 512 MP / 65,000 px, or `asked`).
fn pano_info(r: &lightcraft_merge::PanoResult, sources: &[(PhotoId, String)], asked: &PanoOptions, fit: &PanoOptions) -> Value {
let megapixels = (r.image.width * r.image.height) as f64 / 1e6;
let native = r.native_megapixels;
let why = if fit.max_megapixels < asked.max_megapixels && fit.max_megapixels < native {
"memory"
} else if asked.max_megapixels < MAX_MEGAPIXELS && asked.max_megapixels < native {
"asked"
} else {
"size"
};
let limit = (megapixels < native * 0.98).then_some(why);
json!({
"projection": r.projection,
"used": r.used.iter().filter_map(|&i| sources.get(i).map(|s| s.0.0)).collect::<Vec<_>>(),
@@ -238,9 +256,46 @@ fn pano_info(r: &lightcraft_merge::PanoResult, sources: &[(PhotoId, String)]) ->
"rmsPx": r.rms,
"gains": r.gains,
"crop": r.crop,
"megapixels": megapixels,
"nativeMegapixels": native,
"limit": limit,
})
}
/// Peak memory of a panorama per pixel of its output (bytes), measured as the peak resident size
/// of synthetic stitches of 20–81 MP from 38–154 MP of photos, rounded up from the smaller ones
/// (the larger need less): while the photos are projected they are still held, beside the tiles
/// ([`PANO_TILE_BYTES`] on top of the photos' own 12 bytes a pixel); the blend that follows, the
/// photos freed, peaks at [`PANO_BLEND_BYTES`]. Writing the DNG takes less than either.
const PANO_TILE_BYTES: f64 = 36.0;
const PANO_BLEND_BYTES: f64 = 68.0;
/// What a merge may use of the computer's memory: three quarters of it (the rest is the system's
/// and the other apps'), less the app's own memory budget (its caches).
fn merge_memory(ram: Option<usize>) -> f64 {
// a computer whose memory we can't read (Windows) is taken to have 8 GB
let ram = ram.unwrap_or(8 << 30) as f64;
(ram * 0.75 - crate::memory::budget() as f64).max(0.0)
}
/// `pano` with its size held to what fits in memory beside its photos (`frame_px` pixels, 12
/// bytes each, loaded already): a panorama of five 51 MP photos may have up to 166 MP on a 16 GB
/// computer, 355 MP on 32 GB. Never below 40 megapixels, the size every panorama had before it
/// followed the photos' resolution, and never above what was asked.
pub(crate) fn fit_in_memory(pano: &PanoOptions, frame_px: f64, ram: Option<usize>) -> PanoOptions {
let room = merge_memory(ram);
let fits = ((room - frame_px * 12.0) / PANO_TILE_BYTES).min(room / PANO_BLEND_BYTES) / 1e6;
PanoOptions { max_megapixels: pano.max_megapixels.min(fits.max(MIN_FITTED_MEGAPIXELS)), ..pano.clone() }
}
/// The pixels of `frames`.
fn pixels(frames: &[Frame]) -> f64 {
frames.iter().map(|f| (f.width() * f.height()) as f64).sum()
}
/// The smallest size [`fit_in_memory`] holds a panorama to (megapixels).
pub(crate) const MIN_FITTED_MEGAPIXELS: f64 = 40.0;
/// Bracket size from EXIF: the smallest period of the exposure sequence (≥ 2).
pub fn detect_bracket(frames: &[Frame]) -> Option<usize> {
let ev: Vec<f64> = frames.iter().map(|f| f.exposure).collect::<Option<_>>()?;
@@ -331,7 +386,8 @@ pub fn parse(id: &str, p: &Value) -> Result<(MergeKind, MergeFinish)> {
boundary_warp: p.get("boundaryWarp").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, 100.0),
auto_crop: p.get("autoCrop").and_then(Value::as_bool).unwrap_or(false),
fill_edges: p.get("fillEdges").and_then(Value::as_bool).unwrap_or(false),
max_megapixels: p.get("maxMegapixels").and_then(Value::as_f64).unwrap_or(40.0),
// the photos' own resolution unless asked (held to what fits in memory when it runs)
max_megapixels: p.get("maxMegapixels").and_then(Value::as_f64).filter(|v| *v > 0.0).unwrap_or(MAX_MEGAPIXELS).min(MAX_MEGAPIXELS),
};
let kind = match id {
"merge.hdr" => MergeKind::Hdr { hdr },
+110
View File
@@ -0,0 +1,110 @@
//! A minimal PDF 1.4 writer for image pages: one JPEG XObject per page, explicit byte lengths and
//! a classic cross-reference table. Used by the contact sheet ([`crate::contact_sheet`]) and the
//! slideshow PDF export ([`crate::slideshow_export`]). It streams into any writer, so a long
//! slideshow is never held in memory; the page tree and catalog are written last, once the
//! number of pages is known.
use std::io::Write;
/// Objects 1 and 2 are the catalog and the page tree; pages take three objects each from 3.
const FIRST_PAGE: usize = 3;
pub(crate) struct Pdf<W: Write> {
w: W,
/// Bytes written so far.
pos: usize,
/// Each object's offset, by object number (index 0 unused).
offsets: Vec<usize>,
pages: Vec<usize>,
}
fn io(e: std::io::Error) -> String {
format!("PDF: {e}")
}
impl<W: Write> Pdf<W> {
pub(crate) fn new(w: W) -> Result<Self, String> {
let mut p = Pdf { w, pos: 0, offsets: vec![0; FIRST_PAGE], pages: Vec::new() };
p.put(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")?;
Ok(p)
}
/// Bytes written so far.
pub(crate) fn len(&self) -> usize {
self.pos
}
/// Pages added so far.
pub(crate) fn pages(&self) -> usize {
self.pages.len()
}
fn put(&mut self, b: &[u8]) -> Result<(), String> {
self.w.write_all(b).map_err(io)?;
self.pos = self.pos.saturating_add(b.len());
Ok(())
}
fn object(&mut self, id: usize, body: &[u8]) -> Result<(), String> {
if self.offsets.len() <= id {
self.offsets.resize(id + 1, 0);
}
if let Some(o) = self.offsets.get_mut(id) {
*o = self.pos;
}
self.put(format!("{id} 0 obj\n").as_bytes())?;
self.put(body)?;
self.put(b"\nendobj\n")
}
fn stream(&mut self, id: usize, dictionary: &str, bytes: &[u8]) -> Result<(), String> {
let mut body = format!("<< {dictionary} /Length {} >>\nstream\n", bytes.len()).into_bytes();
body.extend_from_slice(bytes);
body.extend_from_slice(b"\nendstream");
self.object(id, &body)
}
/// A page of `points` (width, height) filled by `jpg` (`width × height` pixels, RGB).
/// `extra` goes into the page dictionary (e.g. a display duration and transition).
pub(crate) fn image_page(&mut self, jpg: &[u8], width: usize, height: usize, points: (f64, f64), extra: &str) -> Result<(), String> {
let id = FIRST_PAGE + self.pages.len() * 3;
let (w, h) = points;
self.object(
id,
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w:.2} {h:.2}] /Resources << /XObject << /Im {} 0 R >> >> /Contents {} 0 R{extra} >>",
id + 2,
id + 1
)
.as_bytes(),
)?;
self.stream(id + 1, "", format!("q {w:.2} 0 0 {h:.2} 0 0 cm /Im Do Q").as_bytes())?;
self.stream(
id + 2,
&format!("/Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode"),
jpg,
)?;
self.pages.push(id);
Ok(())
}
/// Write the page tree, the catalog (with `catalog_extra` in its dictionary, e.g. a page
/// mode), the cross-reference table and the trailer.
pub(crate) fn finish(mut self, catalog_extra: &str) -> Result<W, String> {
if self.pages.is_empty() {
return Err("PDF: no pages".into());
}
let kids = self.pages.iter().map(|i| format!("{i} 0 R")).collect::<Vec<_>>().join(" ");
self.object(2, format!("<< /Type /Pages /Count {} /Kids [{kids}] >>", self.pages.len()).as_bytes())?;
self.object(1, format!("<< /Type /Catalog /Pages 2 0 R{catalog_extra} >>").as_bytes())?;
let start = self.pos;
let mut xref = format!("xref\n0 {}\n0000000000 65535 f \n", self.offsets.len()).into_bytes();
for offset in self.offsets.iter().skip(1) {
xref.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
xref.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", self.offsets.len()).as_bytes());
self.put(&xref)?;
self.w.flush().map_err(io)?;
Ok(self.w)
}
}
+1 -1
View File
@@ -155,7 +155,7 @@ pub fn parse_preset_file(name: &str, bytes: &[u8]) -> Result<Vec<Preset>, String
pub fn expand_preset_paths(paths: &[String]) -> Vec<String> {
let preset_file = |p: &Path| {
let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack"].contains(&ext.as_str())
ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack", crate::cmd::mask_effect_files::EFFECT_EXT].contains(&ext.as_str())
};
let mut out = Vec::new();
for p in paths {
+40 -23
View File
@@ -245,6 +245,19 @@ pub fn expand(template: &str, p: &Photo, seq: usize) -> String {
/// The template's tokens replaced for `p` (no extension added, nothing sanitized). A bare `{seq}`
/// is zero-padded to `seq_width` digits.
pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) -> String {
expand_tokens_with(template, p, seq, seq_width, &|_, _, _| None)
}
/// [`expand_tokens`] with more tokens: `extra(name, arg, tag)` (the name lower-cased, the text
/// after `:`, the whole tag without braces) answers a token first; `None` leaves it to the rename
/// tokens (and an unknown one stays as typed). Slideshow text overlays add their tokens this way.
pub fn expand_tokens_with(
template: &str,
p: &Photo,
seq: usize,
seq_width: usize,
extra: &dyn Fn(&str, Option<&str>, &str) -> Option<String>,
) -> String {
let (stem, ext) = split_ext(&p.file_name);
let mut out = String::new();
let mut rest = template;
@@ -257,31 +270,35 @@ pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) ->
};
let tok = &rest[i + 1..i + j];
let (name, arg) = tok.split_once(':').map(|(a, b)| (a, Some(b))).unwrap_or((tok, None));
let v = match name.trim().to_ascii_lowercase().as_str() {
"name" | "filename" => stem.to_string(),
"num" => {
let digits = stem.chars().rev().take_while(char::is_ascii_digit).count();
stem[stem.len() - digits..].to_string()
}
"seq" | "n" => {
let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9);
format!("{seq:0w$}")
}
"date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")),
"folder" => match &p.source {
Source::File { path } => {
Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default()
let lower = name.trim().to_ascii_lowercase();
let v = match extra(&lower, arg, tok) {
Some(v) => v,
None => match lower.as_str() {
"name" | "filename" => stem.to_string(),
"num" => {
let digits = stem.chars().rev().take_while(char::is_ascii_digit).count();
stem[stem.len() - digits..].to_string()
}
Source::Demo { .. } => String::new(),
"seq" | "n" => {
let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9);
format!("{seq:0w$}")
}
"date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")),
"folder" => match &p.source {
Source::File { path } => {
Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default()
}
Source::Demo { .. } => String::new(),
},
"camera" => p.meta.camera.clone(),
"lens" => p.meta.lens.clone(),
"iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(),
"rating" => p.rating.to_string(),
"title" => p.meta.title.clone(),
"creator" => p.meta.creator.clone(),
"ext" => ext.to_string(),
_ => format!("{{{tok}}}"),
},
"camera" => p.meta.camera.clone(),
"lens" => p.meta.lens.clone(),
"iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(),
"rating" => p.rating.to_string(),
"title" => p.meta.title.clone(),
"creator" => p.meta.creator.clone(),
"ext" => ext.to_string(),
_ => format!("{{{tok}}}"),
};
out.push_str(&v);
rest = &rest[i + j + 1..];
+68 -12
View File
@@ -63,16 +63,24 @@ pub fn sidecar_path(original: &str, naming: SidecarNaming) -> PathBuf {
}
/// An existing sidecar for `original`: the preferred naming first, then the other one (also
/// accepting an upper-case `.XMP`).
/// accepting `.XMP` and `.Xmp`, and a full name whose photo extension differs in case, such as
/// `IMG_1.cr3.xmp` beside `IMG_1.CR3`, which a case-sensitive file system would otherwise miss).
pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option<PathBuf> {
let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem };
let ext = Path::new(original).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
let base = Path::new(original).with_extension("");
let recased = |e: String| (e != ext).then(|| format!("{}.{e}", base.to_string_lossy()));
let full_variants: Vec<String> = [recased(ext.to_lowercase()), recased(ext.to_uppercase())].into_iter().flatten().collect();
[naming, other]
.into_iter()
.flat_map(|n| {
let p = sidecar_path(original, n);
let upper = p.with_extension("XMP");
[p, upper]
let mut v = vec![sidecar_path(original, n)];
if n == SidecarNaming::Full {
v.extend(full_variants.iter().map(|o| sidecar_path(o, n)));
}
v
})
.flat_map(|p| [p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")])
.find(|p| p.is_file())
}
@@ -502,10 +510,10 @@ pub fn read_packet(original: &str, kind: MediaKind, naming: SidecarNaming) -> Op
/// Photo ids touched by an op (for auto-write).
pub(crate) fn op_photos(op: &Op, out: &mut Vec<PhotoId>) {
match op {
Op::SetRating { id, .. } | Op::SetFlag { id, .. } | Op::SetLabel { id, .. } | Op::SetDevelop { id, .. } | Op::SetMeta { id, .. } => {
if !out.contains(id) {
out.push(*id);
}
Op::SetRating { id, .. } | Op::SetFlag { id, .. } | Op::SetLabel { id, .. } | Op::SetDevelop { id, .. } | Op::SetMeta { id, .. }
if !out.contains(id) =>
{
out.push(*id);
}
Op::Batch { ops } => ops.iter().for_each(|o| op_photos(o, out)),
_ => {}
@@ -713,13 +721,61 @@ impl Session {
}
let owners = StemOwners::among(&self.catalog, &ids);
for id in ids {
if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none())
&& let Err(e) = self.save_sidecar_with(id, &owners)
{
log::warn!("auto-write XMP: {e}");
if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none()) {
match self.save_sidecar_with(id, &owners) {
Ok(_) => {
self.unsaved_sidecars.remove(&id);
}
Err(e) => {
// kept for a retry once the file is reachable: a network share that dropped out mustn't leave
// the sidecar silently behind the edit
log::warn!("auto-write XMP: {e}");
self.unsaved_sidecars.insert(id);
}
}
}
}
}
/// How many photos have an automatic sidecar write waiting (it failed: the folder offline, read-only or full).
pub fn unsaved_sidecars(&self) -> usize {
self.unsaved_sidecars.len()
}
/// Write the waiting sidecars whose originals are reachable again (the availability cache answers without
/// touching the disk for the others); photos that are gone, or with auto-write off, are dropped from the wait.
/// Returns how many were written.
pub fn retry_unsaved_sidecars(&mut self) -> usize {
if !self.xmp.auto_write {
self.unsaved_sidecars.clear();
return 0;
}
let mut ready = Vec::new();
for id in self.unsaved_sidecars.clone() {
match self.catalog.photo(id).filter(|p| p.copy_of.is_none()).and_then(|p| file_path(p).map(str::to_owned)) {
None => {
self.unsaved_sidecars.remove(&id);
}
Some(path) if self.media.availability.exists(&path) == Some(true) => ready.push(id),
Some(_) => {}
}
}
if ready.is_empty() {
return 0;
}
let owners = StemOwners::among(&self.catalog, &ready);
let mut written = 0;
for id in ready {
match self.save_sidecar_with(id, &owners) {
Ok(_) => {
self.unsaved_sidecars.remove(&id);
written += 1;
}
Err(e) => log::debug!("retry XMP: {e}"),
}
}
written
}
}
#[cfg(test)]
+203
View File
@@ -0,0 +1,203 @@
//! Slideshow slides in the engine: the library's slide layout ([`SlideSettings`], Lightroom
//! Classic's Layout, Options and Backdrop panels; the `slideshow.layout` command), its overlays
//! ([`crate::slide_overlay`], `slideshow.overlays`) and render jobs that compose a photo into its
//! slide ([`SlideCompose`], [`crate::media::RenderJob::with_slide`]).
//!
//! The composer itself is `lightcraft-slideshow` (no UI, no catalog): playback, the Settings
//! preview and `slideshow.preview` all compose through it, so they draw the same pixels.
use std::sync::{Arc, Mutex, PoisonError};
use lightcraft_catalog::PhotoId;
use lightcraft_raster::Rgba8;
pub use lightcraft_slideshow::{Aspect, Layout, SlideSettings};
use crate::media::RenderJob;
/// The last rendered background image: (its render key, the image). Shared by the slide jobs of
/// a session, so the backdrop photo is rendered once, not once per slide.
pub type BackdropCache = Mutex<Option<(u64, Arc<Rgba8>)>>;
/// The background image of a slide: a library photo's render job and the shared cache it goes to.
#[derive(Clone)]
pub struct Backdrop {
pub photo: PhotoId,
job: Box<RenderJob>,
cache: Arc<BackdropCache>,
}
impl Backdrop {
/// The rendered background image (from the cache when it holds this render; otherwise
/// rendered now, on the calling thread). `None` when it can't be rendered (the slide then
/// shows the backdrop colour alone).
pub fn image(&self) -> Option<Arc<Rgba8>> {
let key = self.job.key;
if let Some((k, img)) = self.cache.lock().unwrap_or_else(PoisonError::into_inner).as_ref()
&& *k == key
{
return Some(img.clone());
}
let img = Arc::new((*self.job).clone().run().rendered.ok()?.image);
*self.cache.lock().unwrap_or_else(PoisonError::into_inner) = Some((key, img.clone()));
Some(img)
}
}
/// How to turn a photo's render into its slide: the layout, the slide's size in pixels and the
/// background image, if any.
#[derive(Clone)]
pub struct SlideCompose {
pub settings: SlideSettings,
pub width: usize,
pub height: usize,
pub backdrop: Option<Backdrop>,
/// The overlays resolved for the slide's photo (`None`: none draw).
pub overlays: Option<Arc<crate::slide_overlay::SlideOverlays>>,
}
impl SlideCompose {
/// What makes the slide's pixels differ (for render keys).
pub fn key(&self) -> u64 {
self.settings.pixel_key()
^ (self.width as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15)
^ (self.height as u64).wrapping_mul(0xc2b2_ae3d_27d4_eb4f)
^ self.backdrop.as_ref().map_or(0, |b| b.job.key.rotate_left(29))
^ self.overlays.as_ref().map_or(0, |o| o.key().rotate_left(43))
}
/// The slide showing `photo` (rendered at [`lightcraft_slideshow::photo_size`], or any size).
pub fn compose(&self, photo: &Rgba8) -> Rgba8 {
let bg = self.backdrop.as_ref().and_then(Backdrop::image);
let Some(o) = &self.overlays else {
return lightcraft_slideshow::compose(photo, bg.as_deref(), &self.settings, self.width, self.height);
};
let (mut img, l) =
lightcraft_slideshow::compose_with(photo, bg.as_deref(), &self.settings, self.width, self.height, &mut |img, l| o.draw_behind(img, l));
o.draw_front(&mut img, &l);
img
}
}
impl crate::Session {
/// The aspect (width / height) photo `id` shows at: cropped, straightened, lens-corrected.
pub fn slide_photo_aspect(&self, id: PhotoId) -> Option<f64> {
let p = self.catalog.photo(id)?;
let frame = lightcraft_pipeline::geometry::Frame::with_lens(
p.width.max(1) as usize,
p.height.max(1) as usize,
&p.develop,
true,
p.embedded_lens.as_ref(),
);
let a = frame.aspect();
Some(if a.is_finite() && a > 0.0 { a } else { 1.5 })
}
/// Does a slide show nothing but the photo fitted on black (the plain layout and no
/// overlays)? Playback then shows the photo's own render.
pub fn slides_plain(&self) -> bool {
self.slide_layout.is_plain() && !self.slide_overlays.any()
}
/// The composition of `settings` at `width × height`, with its background image's render job
/// (the photo `settings.background_image` names, when it is in the catalog) and the library's
/// overlays resolved for `photo`.
pub fn slide_compose(&mut self, settings: &SlideSettings, width: usize, height: usize, photo: Option<PhotoId>) -> Arc<SlideCompose> {
let overlays = self.slide_overlays.clone();
self.slide_compose_with(settings, &overlays, width, height, photo)
}
/// [`Self::slide_compose`] with `overlays` in place of the library's (a template's preview).
pub fn slide_compose_with(
&mut self,
settings: &SlideSettings,
overlays: &crate::slide_overlay::OverlaySettings,
width: usize,
height: usize,
photo: Option<PhotoId>,
) -> Arc<SlideCompose> {
let settings = settings.clone().sanitized();
let backdrop = settings.background_image.filter(|_| settings.background_opacity > 0.0).and_then(|bid| {
let id = PhotoId(bid);
let aspect = self.slide_photo_aspect(id)?;
let fill =
SlideSettings { zoom_to_fill: true, margin_left: 0.0, margin_right: 0.0, margin_top: 0.0, margin_bottom: 0.0, ..settings.clone() };
let (w, h) = lightcraft_slideshow::photo_size(&fill, aspect, width, height);
let job = self.render_job(id, w, h, false, true)?;
Some(Backdrop { photo: id, job: Box::new(job), cache: self.slide_backdrop.clone() })
});
let overlays = overlays.any().then(|| {
let p = photo.and_then(|id| self.catalog.photo(id));
Arc::new(crate::slide_overlay::SlideOverlays::resolve(overlays, p.map(|p| &**p)))
});
Arc::new(SlideCompose { settings, width, height, backdrop, overlays })
}
/// A render job whose result is photo `id`'s slide at `width × height` with the library's
/// slide layout: the photo is rendered at the size its cell needs (its long edge at most
/// `photo_edge`, when given), then composed on the worker. The photo's render is kept as its
/// view preview, like the loupe's.
pub fn slide_job(&mut self, id: PhotoId, width: usize, height: usize, photo_edge: Option<usize>) -> Option<RenderJob> {
let settings = self.slide_layout.clone();
self.slide_job_with(&settings, id, width, height, photo_edge, true)
}
/// [`Self::slide_job`] with `settings` in place of the library's layout; `view` keeps the
/// photo's render as its view preview.
pub fn slide_job_with(
&mut self,
settings: &SlideSettings,
id: PhotoId,
width: usize,
height: usize,
photo_edge: Option<usize>,
view: bool,
) -> Option<RenderJob> {
let overlays = self.slide_overlays.clone();
self.slide_job_look(settings, &overlays, id, width, height, photo_edge, view)
}
/// [`Self::slide_job_with`] with `overlays` in place of the library's (a template's preview).
#[allow(clippy::too_many_arguments)]
pub fn slide_job_look(
&mut self,
settings: &SlideSettings,
overlays: &crate::slide_overlay::OverlaySettings,
id: PhotoId,
width: usize,
height: usize,
photo_edge: Option<usize>,
view: bool,
) -> Option<RenderJob> {
let aspect = self.slide_photo_aspect(id)?;
let (mut w, mut h) = lightcraft_slideshow::photo_size(settings, aspect, width, height);
if let Some(edge) = photo_edge.filter(|e| *e > 0 && w.max(h) > *e) {
let k = edge as f64 / w.max(h) as f64;
(w, h) = (((w as f64 * k).round() as usize).max(1), ((h as f64 * k).round() as usize).max(1));
}
let job = if view { self.loupe_job(id, w.max(8), h.max(8), true)? } else { self.render_job(id, w.max(8), h.max(8), false, true)? };
let compose = self.slide_compose_with(settings, overlays, width, height, Some(id));
Some(job.with_slide(compose))
}
/// Photo `id`'s slide at `width × height` with `settings`, rendered now on this thread.
pub fn slide_now(&mut self, settings: &SlideSettings, id: PhotoId, width: usize, height: usize) -> Result<Rgba8, String> {
let overlays = self.slide_overlays.clone();
self.slide_now_look(settings, &overlays, id, width, height)
}
/// [`Self::slide_now`] with `overlays` in place of the library's.
pub fn slide_now_look(
&mut self,
settings: &SlideSettings,
overlays: &crate::slide_overlay::OverlaySettings,
id: PhotoId,
width: usize,
height: usize,
) -> Result<Rgba8, String> {
let job = self.slide_job_look(settings, overlays, id, width, height, None, false).ok_or("no such photo")?;
let r = job.run();
self.accept(&r);
r.rendered.map(|r| r.image)
}
}
File diff suppressed because it is too large Load Diff
+632
View File
@@ -0,0 +1,632 @@
//! Slideshow templates (Lightroom Classic's Template Browser): a named snapshot of how a
//! slideshow looks and plays — the Playback settings, the slide's Layout / Options / Backdrop,
//! the Overlays and the Titles — as one JSON document with a format version.
//!
//! Built-in templates are our own designs ([`builtins`], JSON files beside this module). User
//! templates are files in the settings folder, `<config>/slideshow-templates/<name>.json`
//! ([`Session::slide_templates_dir`]); they are shared by every library on the machine, like
//! Lightroom's templates. Every section is read through the settings' own `merged()` (so a
//! hand-edited or hostile file can never set a value the panels couldn't), files are capped in
//! size and number, a file that can't be used is reported and skipped, and file names are made
//! from the template's name so they can never leave the templates folder.
//!
//! Applying a template replaces the four settings in one step and saves them with the library.
//! Library preferences have no undo, so the settings it replaced are kept for the session as
//! "Previous settings" ([`PREVIOUS`]): applying that puts them back (and applying it again goes
//! forward). "Prepare previews" is a machine preference, not part of a look: templates neither
//! save nor change it.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde_json::{Map, Value, json};
use crate::Session;
use crate::slide::SlideSettings;
use crate::slide_overlay::OverlaySettings;
use crate::slide_title::TitleSettings;
use crate::slideshow::PlaybackSettings;
/// The `format` of a template document.
pub const FORMAT: &str = "lightcraft.slideshowTemplate";
/// The newest document version this build reads (and the one it writes).
pub const VERSION: u64 = 1;
/// Largest template file read (bytes).
pub const MAX_FILE: u64 = 256 * 1024;
/// Most user templates listed (the rest of a folder that holds more are reported, not read).
pub const MAX_TEMPLATES: usize = 1000;
/// Directory entries looked at before giving up on a huge folder.
const MAX_SCAN: usize = 20_000;
/// Longest template name (characters).
pub const NAME_MAX: usize = 80;
/// Longest description (characters).
pub const DESCRIPTION_MAX: usize = 500;
/// Longest file stem made from a name (characters).
const STEM_MAX: usize = 64;
/// The folder under the settings folder user templates live in.
pub const DIR_NAME: &str = "slideshow-templates";
/// Id prefix of built-in templates.
pub const BUILTIN: &str = "builtin:";
/// Id prefix of user templates (followed by the file stem).
pub const USER: &str = "user:";
/// Id of the settings before the last template was applied.
pub const PREVIOUS: &str = "previous";
/// Everything a template sets.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SlideLook {
pub playback: PlaybackSettings,
pub layout: SlideSettings,
pub overlays: OverlaySettings,
pub titles: TitleSettings,
}
impl SlideLook {
/// The library's current slideshow settings.
pub fn of(s: &Session) -> SlideLook {
SlideLook {
playback: s.slideshow.clone(),
layout: s.slide_layout.clone(),
overlays: s.slide_overlays.clone(),
titles: s.slide_titles.clone(),
}
}
/// The four sections as JSON (playback without `preparePreviews`, which isn't part of a look).
pub fn sections(&self) -> Map<String, Value> {
let mut playback = self.playback.to_json();
if let Some(o) = playback.as_object_mut() {
o.remove("preparePreviews");
}
let mut m = Map::new();
m.insert("playback".into(), playback);
m.insert("layout".into(), self.layout.to_json());
m.insert("overlays".into(), self.overlays.to_json());
m.insert("titles".into(), self.titles.to_json());
m
}
/// Read the sections of a template document: each one merged onto the defaults by the
/// settings' own validation (a missing section is the defaults; a wrong type, an unknown key
/// or a non-finite number is an error).
fn from_sections(doc: &Map<String, Value>) -> Result<SlideLook, String> {
fn section<T>(doc: &Map<String, Value>, key: &str, merge: impl Fn(&Value) -> Result<T, String>, default: T) -> Result<T, String> {
match doc.get(key) {
None | Some(Value::Null) => Ok(default),
Some(v @ Value::Object(_)) => merge(v).map_err(|e| format!("{key}: {e}")),
Some(v) => Err(format!("`{key}` must be an object, not {v}")),
}
}
let mut playback = section(doc, "playback", |v| PlaybackSettings::default().merged(v), PlaybackSettings::default())?;
playback.prepare_previews = PlaybackSettings::default().prepare_previews;
Ok(SlideLook {
playback,
layout: section(doc, "layout", |v| SlideSettings::default().merged(v), SlideSettings::default())?,
overlays: section(doc, "overlays", |v| OverlaySettings::default().merged(v), OverlaySettings::default())?,
titles: section(doc, "titles", |v| TitleSettings::default().merged(v), TitleSettings::default())?,
})
}
/// What doesn't work with this look in `s`'s library: a background photo that isn't in it,
/// an identity plate or watermark image file that can't be read, unknown tokens, a title
/// screen with nothing to show. The look still applies; these draw nothing.
pub fn warnings(&self, s: &Session) -> Vec<String> {
let mut out = Vec::new();
if let Some(id) = self.layout.background_image
&& s.catalog.photo(lightcraft_catalog::PhotoId(id)).is_none()
{
out.push(format!("the background image (photo {id}) isn't in this library; the backdrop colour shows instead"));
}
out.extend(self.overlays.warnings());
let w = &self.overlays.watermark;
if self.overlays.show_watermark && !w.image.trim().is_empty() && crate::export::watermark_logo(w.image.trim()).is_none() {
out.push(format!("watermark image {} can't be read; nothing is drawn", w.image.trim()));
}
for t in self.titles.warnings(&self.overlays) {
if !out.contains(&t) {
out.push(t);
}
}
out
}
}
/// A template: built in or a user's file.
#[derive(Clone, Debug, PartialEq)]
pub struct Template {
/// `builtin:<slug>` or `user:<file stem>`.
pub id: String,
pub name: String,
pub description: String,
pub builtin: bool,
/// The file a user template is read from.
pub path: Option<PathBuf>,
pub look: SlideLook,
}
impl Template {
/// The template as a document (what a user template's file holds, and what export writes).
pub fn to_doc(&self) -> Value {
doc(&self.name, &self.description, &self.look)
}
/// The template for listings (no settings).
pub fn info(&self) -> Value {
let mut v = json!({
"id": self.id,
"name": self.name,
"builtin": self.builtin,
"group": if self.builtin { "builtin" } else { "user" },
});
if !self.description.is_empty() {
v["description"] = json!(self.description);
}
if let Some(p) = &self.path {
v["path"] = json!(p.to_string_lossy());
}
v
}
}
/// A template document for `look`.
pub fn doc(name: &str, description: &str, look: &SlideLook) -> Value {
let mut m = Map::new();
m.insert("format".into(), json!(FORMAT));
m.insert("version".into(), json!(VERSION));
m.insert("name".into(), json!(name));
if !description.is_empty() {
m.insert("description".into(), json!(description));
}
m.extend(look.sections());
Value::Object(m)
}
/// A template document read and checked: its name (`None` when it has none), description and look.
#[derive(Clone, Debug, PartialEq)]
pub struct Parsed {
pub name: Option<String>,
pub description: String,
pub look: SlideLook,
}
/// Read a template document. Refused: more than [`MAX_FILE`] bytes, not JSON, another format, a
/// version from a newer LightCraft, a bad name, unknown keys and any setting its section's
/// validation refuses.
pub fn parse(bytes: &[u8]) -> Result<Parsed, String> {
if bytes.len() as u64 > MAX_FILE {
return Err(format!("{} bytes is too large for a slideshow template (at most {} KB)", bytes.len(), MAX_FILE / 1024));
}
let text = std::str::from_utf8(bytes).map_err(|_| "not a slideshow template (not UTF-8 text)".to_string())?;
let text = text.trim_start_matches('\u{feff}');
let v: Value = serde_json::from_str(text).map_err(|e| format!("not a slideshow template: {e}"))?;
let Value::Object(o) = v else { return Err("not a slideshow template (not a JSON object)".into()) };
match o.get("format").and_then(Value::as_str) {
Some(FORMAT) => {}
Some(other) => return Err(format!("not a slideshow template (format `{other}`, expected `{FORMAT}`)")),
None => return Err(format!("not a slideshow template (no `format`: \"{FORMAT}\")")),
}
match o.get("version") {
Some(v) => match v.as_u64() {
Some(n) if n > VERSION => {
return Err(format!(
"made by a newer LightCraft (template version {n}; this one reads version {VERSION}): update LightCraft to use it"
));
}
Some(n) if n >= 1 => {}
_ => return Err(format!("`version` must be a whole number from 1, not {v}")),
},
None => return Err("no `version`".into()),
}
for k in o.keys() {
if !matches!(k.as_str(), "format" | "version" | "name" | "description" | "playback" | "layout" | "overlays" | "titles") {
return Err(format!("unknown key `{k}` (format, version, name, description, playback, layout, overlays, titles)"));
}
}
let name = match o.get("name") {
None | Some(Value::Null) => None,
Some(Value::String(n)) if n.trim().is_empty() => None,
Some(Value::String(n)) => Some(valid_name(n)?),
Some(v) => return Err(format!("`name` must be text, not {v}")),
};
let description = match o.get("description") {
None | Some(Value::Null) => String::new(),
Some(Value::String(d)) => clean_text(d, DESCRIPTION_MAX),
Some(v) => return Err(format!("`description` must be text, not {v}")),
};
Ok(Parsed { name, description, look: SlideLook::from_sections(&o)? })
}
/// `s` without control characters, trimmed, at most `max` characters.
fn clean_text(s: &str, max: usize) -> String {
s.chars().filter(|c| !c.is_control()).take(max).collect::<String>().trim().to_string()
}
/// A template name as stored: trimmed, 1–[`NAME_MAX`] characters, no control characters.
pub fn valid_name(name: &str) -> Result<String, String> {
let n = name.trim();
if n.is_empty() {
return Err("a template needs a name".into());
}
if n.chars().any(char::is_control) {
return Err("a template name can't contain control characters (line breaks, tabs…)".into());
}
if n.chars().count() > NAME_MAX {
return Err(format!("a template name has at most {NAME_MAX} characters"));
}
Ok(n.to_string())
}
/// The file stem a template called `name` is saved as: letters, digits, spaces, `-`, `_`, `(`
/// and `)` kept, anything else (path separators, dots, `:`, `*`…) `_`; trimmed; at most 64
/// characters; Windows' reserved device names get a `_`. `None` when nothing usable is left.
pub fn file_stem(name: &str) -> Option<String> {
let s: String =
name.trim().chars().map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '(' | ')') { c } else { '_' }).take(STEM_MAX).collect();
let s = s.trim().to_string();
if s.is_empty() || s.chars().all(|c| c == '_') {
return None;
}
let upper = s.to_ascii_uppercase();
let reserved = matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| ((upper.starts_with("COM") || upper.starts_with("LPT")) && upper.len() == 4 && upper.ends_with(|c: char| c.is_ascii_digit()));
Some(if reserved { format!("{s}_") } else { s })
}
/// The stem of a user template id, when it is one this module could have made (so it can't
/// name a file outside the templates folder).
fn user_stem(id: &str) -> Option<&str> {
let stem = id.strip_prefix(USER)?;
(file_stem(stem).as_deref() == Some(stem)).then_some(stem)
}
/// The built-in templates: our own designs (no Lightroom template, name or design copied).
const BUILTINS: &[(&str, &str)] = &[
("plain-black", include_str!("slideshow_templates/plain-black.json")),
("gallery-wall", include_str!("slideshow_templates/gallery-wall.json")),
("edge-to-edge", include_str!("slideshow_templates/edge-to-edge.json")),
("cinema-wide", include_str!("slideshow_templates/cinema-wide.json")),
("shooting-data", include_str!("slideshow_templates/shooting-data.json")),
];
/// The built-in templates (one that doesn't parse — a bug the tests catch — is left out).
pub fn builtins() -> Vec<Template> {
BUILTINS
.iter()
.filter_map(|(slug, json)| {
let p = parse(json.as_bytes()).inspect_err(|e| eprintln!("lightcraft: built-in slideshow template {slug}: {e}")).ok()?;
Some(Template {
id: format!("{BUILTIN}{slug}"),
name: p.name.unwrap_or_else(|| (*slug).to_string()),
description: p.description,
builtin: true,
path: None,
look: p.look,
})
})
.collect()
}
/// Every template, and what couldn't be read.
#[derive(Clone, Debug, Default)]
pub struct Listing {
/// Built-ins first (in their order), then user templates by name.
pub templates: Vec<Arc<Template>>,
/// Files skipped, each with why.
pub problems: Vec<String>,
}
impl Listing {
pub fn get(&self, id: &str) -> Option<&Arc<Template>> {
self.templates.iter().find(|t| t.id == id)
}
/// A template by name (case-insensitive); a user template before a built-in.
pub fn by_name(&self, name: &str) -> Option<&Arc<Template>> {
let n = name.trim().to_lowercase();
let mut hits = self.templates.iter().filter(|t| t.name.to_lowercase() == n);
let first = hits.next()?;
Some(hits.find(|t| !t.builtin).unwrap_or(first))
}
}
/// When the templates folder last changed (the listing is read again when this does).
type Stamp = (Option<PathBuf>, Option<std::time::SystemTime>);
/// The cached listing and the folder stamp it was read at.
pub type Cache = Option<(Stamp, Arc<Listing>)>;
fn stamp(dir: Option<&Path>) -> Stamp {
(dir.map(Path::to_path_buf), dir.and_then(|d| std::fs::metadata(d).ok()).and_then(|m| m.modified().ok()))
}
/// Read the user templates in `dir`: `*.json` regular files, at most [`MAX_TEMPLATES`], each
/// parsed and checked; anything else reported.
fn read_dir(dir: &Path, out: &mut Listing) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
Err(e) => {
out.problems.push(format!("{}: {e}", dir.display()));
return;
}
};
let mut files: Vec<(String, PathBuf)> = Vec::new();
for entry in entries.take(MAX_SCAN).flatten() {
let path = entry.path();
let is_json = path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("json"));
if !is_json {
continue;
}
let file = entry.file_name().to_string_lossy().to_string();
files.push((file, path));
}
files.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()).then_with(|| a.0.cmp(&b.0)));
if files.len() > MAX_TEMPLATES {
out.problems.push(format!("{}: {} template files; only the first {MAX_TEMPLATES} are listed", dir.display(), files.len()));
files.truncate(MAX_TEMPLATES);
}
let mut user = Vec::new();
for (file, path) in files {
match read_user(&file, &path) {
Ok(t) => user.push(Arc::new(t)),
Err(e) => out.problems.push(format!("{file}: {e}")),
}
}
user.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()).then_with(|| a.id.cmp(&b.id)));
out.templates.extend(user);
}
/// One user template file.
fn read_user(file: &str, path: &Path) -> Result<Template, String> {
let stem = file.get(..file.len().saturating_sub(5)).unwrap_or_default();
if file_stem(stem).as_deref() != Some(stem) {
return Err("the file name isn't one LightCraft makes (letters, digits, spaces, - _ ( ) only); rename the file".into());
}
let meta = std::fs::symlink_metadata(path).map_err(|e| e.to_string())?;
if !meta.file_type().is_file() {
return Err("not a regular file (a link or a folder)".into());
}
if meta.len() > MAX_FILE {
return Err(format!("{} bytes is too large for a slideshow template (at most {} KB)", meta.len(), MAX_FILE / 1024));
}
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
let p = parse(&bytes)?;
Ok(Template {
id: format!("{USER}{stem}"),
name: p.name.unwrap_or_else(|| stem.to_string()),
description: p.description,
builtin: false,
path: Some(path.to_path_buf()),
look: p.look,
})
}
/// Read a template file the user picked (import): size-capped, then [`parse`]d. Its name is the
/// document's, else the file's.
pub fn read_file(path: &Path) -> Result<Parsed, String> {
let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
if !meta.is_file() {
return Err(format!("{}: not a file", path.display()));
}
if meta.len() > MAX_FILE {
return Err(format!("{}: {} bytes is too large for a slideshow template (at most {} KB)", path.display(), meta.len(), MAX_FILE / 1024));
}
let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
let mut p = parse(&bytes).map_err(|e| format!("{}: {e}", path.display()))?;
if p.name.is_none() {
let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
p.name = valid_name(&clean_text(&stem, NAME_MAX)).ok();
}
Ok(p)
}
fn write_json(path: &Path, v: &Value) -> Result<(), String> {
let bytes = serde_json::to_vec_pretty(v).map_err(|e| e.to_string())?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
lightcraft_catalog::safe_file::write_atomic(path, &bytes).map_err(|e| format!("{}: {e}", path.display()))
}
impl Session {
/// Keep user slideshow templates in `<config>/slideshow-templates` (hosts with a file system).
pub fn with_default_slide_templates(mut self) -> Self {
self.slide_templates_dir = crate::config::config_dir().map(|d| d.join(DIR_NAME));
self
}
/// Every template (built-in, then the user's), read again when the templates folder changed.
pub fn slide_templates(&mut self) -> Arc<Listing> {
let now = stamp(self.slide_templates_dir.as_deref());
if let Some((st, l)) = &self.slide_templates_cache
&& *st == now
{
return l.clone();
}
let mut l = Listing { templates: builtins().into_iter().map(Arc::new).collect(), problems: Vec::new() };
if let Some(dir) = self.slide_templates_dir.clone() {
read_dir(&dir, &mut l);
}
let l = Arc::new(l);
self.slide_templates_cache = Some((now, l.clone()));
l
}
/// Forget the cached listing (after a write: a folder's time may not change within its
/// resolution).
fn templates_changed(&mut self) {
self.slide_templates_cache = None;
}
/// The template `id` (or [`PREVIOUS`], the settings before the last apply).
pub fn slide_template(&mut self, id: &str) -> Result<Arc<Template>, String> {
if id == PREVIOUS {
let look = self.slide_templates_previous.clone().ok_or("no previous settings (no template has been applied this session)")?;
return Ok(Arc::new(Template {
id: PREVIOUS.into(),
name: "Previous settings".into(),
description: String::new(),
builtin: false,
path: None,
look,
}));
}
if id.starts_with(USER) && user_stem(id).is_none() {
return Err(format!("`{id}` isn't a template id (user templates are `user:` and a file name LightCraft made)"));
}
self.slide_templates().get(id).cloned().ok_or_else(|| format!("no slideshow template `{id}`"))
}
/// The template a command names: `id`, or `name` (case-insensitive).
pub fn slide_template_named(&mut self, id: Option<&str>, name: Option<&str>) -> Result<Arc<Template>, String> {
match (id.map(str::trim).filter(|s| !s.is_empty()), name.map(str::trim).filter(|s| !s.is_empty())) {
(Some(id), _) => self.slide_template(id),
(None, Some(name)) => self.slide_templates().by_name(name).cloned().ok_or_else(|| format!("no slideshow template named \"{name}\"")),
(None, None) => Err("name a template: `id` or `name`".into()),
}
}
/// Replace the slideshow settings with `look` (prepare previews kept) and save them; the
/// settings it replaced become [`PREVIOUS`]. Returns the warnings for this library.
pub fn apply_slide_look(&mut self, look: &SlideLook) -> crate::Result<Vec<String>> {
let before = SlideLook::of(self);
let mut next = look.clone();
next.playback.prepare_previews = self.slideshow.prepare_previews;
let next = SlideLook {
playback: next.playback.sanitized(),
layout: next.layout.sanitized(),
overlays: next.overlays.sanitized(),
titles: next.titles.sanitized(),
};
let warnings = next.warnings(self);
if next != before {
(self.slideshow, self.slide_layout, self.slide_overlays, self.slide_titles) = (next.playback, next.layout, next.overlays, next.titles);
if let Err(e) = self.save_prefs() {
(self.slideshow, self.slide_layout, self.slide_overlays, self.slide_titles) =
(before.playback, before.layout, before.overlays, before.titles);
return Err(e);
}
self.slide_templates_previous = Some(before);
}
Ok(warnings)
}
fn templates_dir(&self) -> Result<PathBuf, String> {
self.slide_templates_dir.clone().ok_or_else(|| {
"user templates need a settings folder, and this LightCraft has none (the web build, or LIGHTCRAFT_CONFIG_DIR is set empty)".into()
})
}
/// Is a user template file called `stem` there, or one whose name differs only in case?
fn stem_taken(&mut self, dir: &Path, stem: &str, except: Option<&str>) -> bool {
let lower = stem.to_lowercase();
let other = |s: &str| except.is_none_or(|e| e.to_lowercase() != s.to_lowercase());
(dir.join(format!("{stem}.json")).exists() && other(stem))
|| self.slide_templates().templates.iter().filter_map(|t| t.id.strip_prefix(USER)).any(|s| s.to_lowercase() == lower && other(s))
}
fn builtin_name(name: &str) -> bool {
builtins().iter().any(|b| b.name.to_lowercase() == name.to_lowercase())
}
/// Save the current settings as user template `name` (`replace`: over one of that name).
pub fn save_slide_template(&mut self, name: &str, replace: bool) -> Result<Arc<Template>, String> {
let name = valid_name(name)?;
if Self::builtin_name(&name) {
return Err(format!("\"{name}\" is the name of a built-in template; pick another name"));
}
let stem = file_stem(&name).ok_or_else(|| format!("\"{name}\" has no letters or digits to make a file name from"))?;
let dir = self.templates_dir()?;
if !replace && self.stem_taken(&dir, &stem, None) {
return Err(format!("a template named \"{name}\" (file {stem}.json) already exists: update it, or save under another name"));
}
let look = SlideLook::of(self);
write_json(&dir.join(format!("{stem}.json")), &doc(&name, "", &look))?;
self.templates_changed();
self.slide_template(&format!("{USER}{stem}"))
}
/// A user template (not a built-in).
fn user_template(&mut self, id: &str) -> Result<(Arc<Template>, PathBuf), String> {
if id.starts_with(BUILTIN) {
return Err(format!("`{id}` is a built-in template and can't be changed (save the settings as a template of your own)"));
}
if id == PREVIOUS {
return Err("the previous settings aren't a saved template".into());
}
let t = self.slide_template(id)?;
let path = t.path.clone().ok_or_else(|| format!("`{id}` isn't a user template"))?;
Ok((t, path))
}
/// Replace user template `id`'s settings with the current ones (its name kept).
pub fn update_slide_template(&mut self, id: &str) -> Result<Arc<Template>, String> {
let (t, path) = self.user_template(id)?;
write_json(&path, &doc(&t.name, &t.description, &SlideLook::of(self)))?;
self.templates_changed();
self.slide_template(id)
}
/// Rename user template `id` (its file follows the name).
pub fn rename_slide_template(&mut self, id: &str, name: &str) -> Result<Arc<Template>, String> {
let (t, path) = self.user_template(id)?;
let name = valid_name(name)?;
if Self::builtin_name(&name) {
return Err(format!("\"{name}\" is the name of a built-in template; pick another name"));
}
let stem = file_stem(&name).ok_or_else(|| format!("\"{name}\" has no letters or digits to make a file name from"))?;
let dir = self.templates_dir()?;
let old = id.strip_prefix(USER).unwrap_or_default().to_string();
if self.stem_taken(&dir, &stem, Some(&old)) {
return Err(format!("a template named \"{name}\" (file {stem}.json) already exists"));
}
// the new name into the file, then the file to its new name (one rename: never two copies
// or none, and a change of case works on case-insensitive file systems too)
write_json(&path, &doc(&name, &t.description, &t.look))?;
let to = dir.join(format!("{stem}.json"));
if to != path {
std::fs::rename(&path, &to).map_err(|e| format!("{}: {e}", to.display()))?;
}
self.templates_changed();
self.slide_template(&format!("{USER}{stem}"))
}
/// Delete user template `id`'s file.
pub fn delete_slide_template(&mut self, id: &str) -> Result<Arc<Template>, String> {
let (t, path) = self.user_template(id)?;
std::fs::remove_file(&path).map_err(|e| format!("{}: {e}", path.display()))?;
self.templates_changed();
Ok(t)
}
/// Import the template file at `path` as a user template. A name already taken gets " (2)",
/// " (3)"…; returns the template and whether it was renamed.
pub fn import_slide_template(&mut self, path: &Path) -> Result<(Arc<Template>, bool), String> {
let p = read_file(path)?;
let dir = self.templates_dir()?;
let base = p.name.clone().ok_or_else(|| format!("{}: the template has no name, nor a file name to take one from", path.display()))?;
let mut name = base.clone();
let mut n = 1;
loop {
let free = !Self::builtin_name(&name) && file_stem(&name).is_some_and(|stem| !self.stem_taken(&dir, &stem, None));
if free {
break;
}
n += 1;
if n > 999 {
return Err(format!("too many templates named \"{base}\""));
}
let suffix = format!(" ({n})");
let keep: String = base.chars().take(NAME_MAX.saturating_sub(suffix.chars().count())).collect();
name = format!("{}{suffix}", keep.trim_end());
}
let stem = file_stem(&name).ok_or_else(|| format!("\"{name}\" has no letters or digits to make a file name from"))?;
write_json(&dir.join(format!("{stem}.json")), &doc(&name, &p.description, &p.look))?;
self.templates_changed();
Ok((self.slide_template(&format!("{USER}{stem}"))?, name != base))
}
}
#[cfg(test)]
#[path = "tests_slide_template.rs"]
mod tests;
+527
View File
@@ -0,0 +1,527 @@
//! Slideshow title screens (Lightroom Classic's Slideshow ▸ Titles panel): an intro shown before
//! the first slide and an ending shown after the last one, each switched on on its own, with a
//! background colour and, centred on it, the identity plate (the one of the Overlays panel: its
//! text or image) or a line of text, at a scale. [`TitleSettings`] is a library preference
//! (`prefs.json`), edited with the `slideshow.titles` command; [`TitleCompose`] draws one screen.
//!
//! **One composer:** a title screen is a slide with no photo: `lightcraft_slideshow::compose`
//! fills it with the title's colour, then the overlay code ([`crate::slide_overlay`]) draws the
//! plate or text with the shared text renderer ([`crate::text`]). Playback, the Settings preview
//! and `slideshow.preview {title}` all go through [`TitleCompose::compose`], so they draw the same
//! pixels.
//!
//! **Timing** is in [`crate::slideshow::Playback`]: each screen stays up for its own duration
//! (default: the slide duration) and fades in and out with the playback fade.
//!
//! **Resolution independence:** the scale is the height of the plate (or of a text line) as a
//! fraction of the slide's shorter side; text wider than the slide is scaled down to fit.
//!
//! **Never crash:** settings are validated by [`TitleSettings::merged`] and clamped by
//! [`TitleSettings::sanitized`]; an unreadable plate image draws the colour alone (and
//! `slideshow.titles` reports it).
use std::sync::Arc;
use lightcraft_raster::Rgba8;
use lightcraft_slideshow::{Layout, Rect, SlideSettings, hex, parse_color};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::export::Anchor;
use crate::slide_overlay::{IdentityPlate, OverlaySettings, PLATE_SIZE_MAX, Placement, RelativeTo, SIZE_MIN, SlideOverlays, TEXT_MAX_CHARS};
pub use crate::slideshow::Title;
/// Widest a title's plate or text may be (fraction of the slide's width): wider is scaled down.
pub const FIT_WIDTH: f64 = 0.9;
/// What a title screen shows on its colour.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TitleContent {
/// The colour alone.
None,
/// The identity plate of the Overlays panel (its text or image).
#[default]
IdentityPlate,
/// This screen's own text.
Text,
}
impl TitleContent {
pub const ALL: [TitleContent; 3] = [TitleContent::None, TitleContent::IdentityPlate, TitleContent::Text];
pub fn id(self) -> &'static str {
match self {
TitleContent::None => "none",
TitleContent::IdentityPlate => "identityPlate",
TitleContent::Text => "text",
}
}
fn parse(s: &str) -> Option<TitleContent> {
TitleContent::ALL.into_iter().find(|c| c.id().eq_ignore_ascii_case(s.trim()))
}
}
/// One title screen.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TitleScreen {
pub enabled: bool,
/// The screen's background colour.
pub color: [u8; 3],
pub content: TitleContent,
/// The text drawn with [`TitleContent::Text`].
pub text: String,
/// Draw the plate or text in [`Self::text_color`] (off: in the identity plate's colour).
/// An image plate keeps its own colours.
pub override_color: bool,
pub text_color: [u8; 3],
/// Height of the plate (or of a text line), fraction of the slide's shorter side.
pub scale: f64,
/// Seconds the screen stays up (`None`: the slide duration).
pub duration: Option<f64>,
}
impl TitleScreen {
fn new(text: &str) -> Self {
TitleScreen {
enabled: false,
color: [0, 0, 0],
content: TitleContent::IdentityPlate,
text: text.into(),
override_color: false,
text_color: [255, 255, 255],
scale: 0.1,
duration: None,
}
}
}
/// The intro and ending screens.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TitleSettings {
pub intro: TitleScreen,
pub ending: TitleScreen,
}
impl Default for TitleSettings {
fn default() -> Self {
TitleSettings { intro: TitleScreen::new("Welcome"), ending: TitleScreen::new("The End") }
}
}
impl Default for TitleScreen {
fn default() -> Self {
TitleScreen::new("")
}
}
fn cut(s: &mut String) {
if let Some((i, _)) = s.char_indices().nth(TEXT_MAX_CHARS) {
s.truncate(i);
}
}
const SCREEN_KEYS: &str = "enabled, color, content, text, overrideColor, textColor, scale, duration";
impl TitleScreen {
fn sanitized(mut self) -> Self {
cut(&mut self.text);
self.scale = if self.scale.is_finite() { self.scale.clamp(SIZE_MIN, PLATE_SIZE_MAX) } else { 0.1 };
self.duration = self.duration.filter(|d| d.is_finite()).map(|d| d.clamp(crate::slideshow::SLIDE_MIN, crate::slideshow::SLIDE_MAX));
self
}
pub fn to_json(&self) -> Value {
let mut o = Map::new();
o.insert("enabled".into(), json!(self.enabled));
o.insert("color".into(), json!(hex(self.color)));
o.insert("content".into(), json!(self.content.id()));
o.insert("text".into(), json!(self.text));
o.insert("overrideColor".into(), json!(self.override_color));
o.insert("textColor".into(), json!(hex(self.text_color)));
o.insert("scale".into(), json!(self.scale));
o.insert("duration".into(), json!(self.duration));
Value::Object(o)
}
fn merge(&mut self, ctx: &str, v: &Value) -> Result<(), String> {
let obj = v.as_object().ok_or_else(|| format!("`{ctx}` must be an object, not {v}"))?;
let num = |k: &str, v: &Value| match v.as_f64() {
Some(x) if x.is_finite() => Ok(x),
_ => Err(format!("`{ctx}.{k}` must be a finite number, not {v}")),
};
let boolean = |k: &str, v: &Value| v.as_bool().ok_or_else(|| format!("`{ctx}.{k}` must be true or false, not {v}"));
let colour = |k: &str, v: &Value| parse_color(v).ok_or_else(|| format!("`{ctx}.{k}` must be \"#rrggbb\" or [r, g, b] (0–255), not {v}"));
for (k, v) in obj {
match k.as_str() {
"enabled" => self.enabled = boolean(k, v)?,
"color" => self.color = colour(k, v)?,
"content" => {
self.content = v
.as_str()
.and_then(TitleContent::parse)
.ok_or_else(|| format!("`{ctx}.content` must be \"none\", \"identityPlate\" or \"text\", not {v}"))?
}
"text" => {
let mut s = v.as_str().ok_or_else(|| format!("`{ctx}.text` must be text, not {v}"))?.to_string();
cut(&mut s);
self.text = s;
}
"overrideColor" => self.override_color = boolean(k, v)?,
"textColor" => self.text_color = colour(k, v)?,
"scale" => self.scale = num(k, v)?,
"duration" => {
self.duration = match v {
Value::Null => None,
_ => Some(num(k, v).map_err(|_| format!("`{ctx}.duration` must be seconds or null (the slide duration), not {v}"))?),
}
}
_ => return Err(format!("unknown key `{ctx}.{k}` ({SCREEN_KEYS})")),
}
}
Ok(())
}
}
impl TitleSettings {
pub fn screen(&self, t: Title) -> &TitleScreen {
match t {
Title::Intro => &self.intro,
Title::Ending => &self.ending,
}
}
/// Every value in range (non-finite numbers take the defaults).
pub fn sanitized(mut self) -> Self {
self.intro = self.intro.sanitized();
self.ending = self.ending.sanitized();
self
}
pub fn to_json(&self) -> Value {
json!({"intro": self.intro.to_json(), "ending": self.ending.to_json()})
}
/// These settings with the keys `p` names changed, validated and clamped; a wrong type or an
/// unknown key is an error and nothing changes.
pub fn merged(&self, p: &Value) -> Result<Self, String> {
let Some(obj) = p.as_object() else { return Err("parameters must be an object".into()) };
let mut s = self.clone();
for (k, v) in obj {
match k.as_str() {
"intro" => s.intro.merge(k, v)?,
"ending" => s.ending.merge(k, v)?,
_ => return Err(format!("unknown setting `{k}` (intro, ending)")),
}
}
Ok(s.sanitized())
}
/// How long each screen that is on stays up, given the slide duration (for
/// [`crate::slideshow::Playback::with_titles`]): (intro, ending).
pub fn durations(&self, slide: f64) -> (Option<f64>, Option<f64>) {
let d = |s: &TitleScreen| s.enabled.then(|| s.duration.unwrap_or(slide));
(d(&self.intro), d(&self.ending))
}
/// Problems worth telling the user about: a screen showing an identity plate image that
/// can't be read, or one with nothing to show.
pub fn warnings(&self, overlays: &OverlaySettings) -> Vec<String> {
let mut out = Vec::new();
let plate = &overlays.identity_plate;
for t in Title::ALL {
let s = self.screen(t);
if !s.enabled {
continue;
}
match s.content {
TitleContent::IdentityPlate if !plate.image.trim().is_empty() && crate::export::watermark_logo(plate.image.trim()).is_none() => {
out.push(format!("{} screen: identity plate image {} can't be read; only the colour shows", t.id(), plate.image.trim()))
}
TitleContent::IdentityPlate if plate.image.trim().is_empty() && plate.text.trim().is_empty() => {
out.push(format!("{} screen: the identity plate has no text or image; only the colour shows", t.id()))
}
TitleContent::Text if s.text.trim().is_empty() => out.push(format!("{} screen: no text; only the colour shows", t.id())),
_ => {}
}
}
out
}
}
/// How to draw one title screen at a size: its colour and its plate or text, resolved from the
/// library's settings (no session needed, so it runs on a render worker).
#[derive(Clone, Debug)]
pub struct TitleCompose {
pub title: Title,
/// The screen as a slide: its colour as the backdrop, nothing else.
pub settings: SlideSettings,
/// The plate or text, as a centred identity plate (`None`: the colour alone).
pub overlays: Option<Arc<SlideOverlays>>,
pub width: usize,
pub height: usize,
}
impl TitleCompose {
/// Screen `title` of `titles` at `width × height` (a bigger one is drawn at the composer's
/// [`lightcraft_slideshow::MAX_SIDE`]),
/// with `overlays`' identity plate when the screen shows it.
pub fn new(titles: &TitleSettings, overlays: &OverlaySettings, title: Title, width: usize, height: usize) -> Self {
let s = titles.screen(title).clone().sanitized();
let settings = SlideSettings { backdrop_color: s.color, ..SlideSettings::default() }.sanitized();
let plate = &overlays.identity_plate;
let centre = Placement { anchor: Anchor::Center, offset_x: 0.0, offset_y: 0.0, relative_to: RelativeTo::Slide };
let colour = |own: [u8; 3]| if s.override_color { s.text_color } else { own };
let shown = match s.content {
TitleContent::None => None,
TitleContent::IdentityPlate => Some(IdentityPlate {
enabled: true,
text: plate.text.clone(),
image: plate.image.clone(),
size: s.scale,
color: colour(plate.color),
opacity: plate.opacity,
place: centre,
behind: false,
}),
TitleContent::Text => Some(IdentityPlate {
enabled: true,
text: s.text.clone(),
image: String::new(),
size: s.scale,
color: colour(plate.color),
opacity: 1.0,
place: centre,
behind: false,
}),
};
let mut compose = TitleCompose { title, settings, overlays: None, width, height };
if let Some(p) = shown.filter(|p| !p.text.trim().is_empty() || !p.image.trim().is_empty()) {
let resolved =
|p: IdentityPlate| Arc::new(SlideOverlays::resolve(&OverlaySettings { identity_plate: p, ..OverlaySettings::default() }, None));
compose.overlays = Some(resolved(p.clone()));
// a long text (or a wide logo) at a large scale: shrink it to fit the slide's width
if let Some(b) = compose.plate_box() {
let max = FIT_WIDTH * compose.layout().slide.width();
if b.width() > max && b.width() > 0.0 {
let size = (p.size * max / b.width()).max(SIZE_MIN);
compose.overlays = Some(resolved(IdentityPlate { size, ..p }));
}
}
}
compose
}
fn layout(&self) -> Layout {
lightcraft_slideshow::layout(&self.settings, 1.0, self.width, self.height)
}
/// Where the plate or text is on the screen (`None`: nothing is drawn but the colour).
pub fn plate_box(&self) -> Option<Rect> {
let o = self.overlays.as_ref()?;
o.boxes(&self.layout()).into_iter().next().map(|(_, r)| r)
}
/// What makes the screen's pixels differ (for render keys).
pub fn key(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
"title".hash(&mut h);
self.title.hash(&mut h);
self.settings.pixel_key().hash(&mut h);
self.overlays.as_ref().map(|o| o.key()).hash(&mut h);
(self.width, self.height).hash(&mut h);
h.finish()
}
/// The screen's pixels.
pub fn compose(&self) -> Rgba8 {
let empty = Rgba8::new(0, 0);
let (mut img, l) = lightcraft_slideshow::compose_with(&empty, None, &self.settings, self.width, self.height, &mut |_, _| {});
if let Some(o) = &self.overlays {
o.draw_front(&mut img, &l);
}
img
}
/// [`Self::compose`] as a render result (for a frontend's render queue): request `request_id`,
/// under this screen's key. Title screens show no photo (`photo` is 0).
pub fn result(&self, request_id: u64) -> crate::media::RenderResult {
let image = self.compose();
let histogram = lightcraft_raster::Histogram::of_srgb8(&image);
crate::media::RenderResult {
request_id,
source_key: None,
photo: lightcraft_catalog::PhotoId(0),
level: crate::media::SourceLevel::Preview,
key: self.key(),
rendered: Ok(lightcraft_pipeline::Rendered { image, histogram, deep: None }),
loaded: None,
quick: None,
display: None,
gpu_fallback: None,
}
}
}
impl crate::Session {
/// Title screen `title` at `width × height` with the library's titles and identity plate.
pub fn title_compose(&self, title: Title, width: usize, height: usize) -> Arc<TitleCompose> {
Arc::new(TitleCompose::new(&self.slide_titles, &self.slide_overlays, title, width, height))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(img: &Rgba8, x: usize, y: usize) -> [u8; 3] {
let p = img.get(x, y);
[p[0], p[1], p[2]]
}
/// The bounding box of the pixels that differ from `bg` (x0, y0, x1, y1).
fn ink(img: &Rgba8, bg: [u8; 3]) -> Option<(usize, usize, usize, usize)> {
let mut b: Option<(usize, usize, usize, usize)> = None;
for y in 0..img.height {
for x in 0..img.width {
let c = at(img, x, y);
if c.iter().zip(bg).any(|(a, b)| a.abs_diff(b) > 40) {
b = Some(match b {
None => (x, y, x + 1, y + 1),
Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1)),
});
}
}
}
b
}
fn text_titles(scale: f64) -> TitleSettings {
TitleSettings::default()
.merged(&json!({"intro": {"enabled": true, "color": "#203040", "content": "text", "text": "Holiday", "overrideColor": true, "textColor": "#ffcc00", "scale": scale}}))
.unwrap()
}
#[test]
fn a_text_title_is_centred_in_its_colours_and_scales_with_the_slide() {
let o = OverlaySettings::default();
let t = text_titles(0.2);
let mut boxes = Vec::new();
for (w, h) in [(400, 250), (800, 500)] {
let c = TitleCompose::new(&t, &o, Title::Intro, w, h);
let img = c.compose();
assert_eq!((img.width, img.height), (w, h));
assert_eq!(at(&img, 2, 2), [0x20, 0x30, 0x40], "the title's colour");
let (x0, y0, x1, y1) = ink(&img, [0x20, 0x30, 0x40]).expect("the text is drawn");
// centred (glyph ink sits inside the line box, so allow some slack vertically)
let (cx, cy) = ((x0 + x1) as f64 / 2.0, (y0 + y1) as f64 / 2.0);
assert!((cx - w as f64 / 2.0).abs() < w as f64 * 0.03, "centred across: {cx} of {w}");
assert!((cy - h as f64 / 2.0).abs() < h as f64 * 0.06, "centred down: {cy} of {h}");
let mid = at(&img, (x0..x1).find(|x| at(&img, *x, (y0 + y1) / 2) != [0x20, 0x30, 0x40]).unwrap_or(x0), (y0 + y1) / 2);
assert!(mid[0] > 150 && mid[2] < 120, "drawn in the text colour: {mid:?}");
boxes.push(((x1 - x0) as f64 / w as f64, (y1 - y0) as f64 / h as f64));
let b = c.plate_box().unwrap();
assert!((b.height() - 0.2 * h as f64).abs() < 2.0, "the scale is the line height: {b:?}");
}
let ((w1, h1), (w2, h2)) = (boxes[0], boxes[1]);
assert!((w1 - w2).abs() < 0.02 && (h1 - h2).abs() < 0.02, "proportional at two sizes: {boxes:?}");
// a bigger scale draws bigger text
let big = ink(&TitleCompose::new(&text_titles(0.3), &o, Title::Intro, 800, 500).compose(), [0x20, 0x30, 0x40]).unwrap();
assert!((big.2 - big.0) as f64 / 800.0 > w2 * 1.3);
}
#[test]
fn the_identity_plate_shows_in_its_own_colour_unless_overridden() {
let o = OverlaySettings::default()
.merged(&json!({"identityPlate": {"text": "Studio", "color": "#00ff00", "opacity": 1, "anchor": "topLeft"}}))
.unwrap();
assert!(!o.identity_plate.enabled, "the overlay plate itself may be off");
let t = TitleSettings::default().merged(&json!({"ending": {"enabled": true, "color": "#000000", "scale": 0.15}})).unwrap();
let img = TitleCompose::new(&t, &o, Title::Ending, 600, 400).compose();
let (x0, y0, x1, y1) = ink(&img, [0, 0, 0]).expect("the plate is drawn");
assert!(x0 > 100 && x1 < 500 && y0 > 120 && y1 < 280, "centred, not at the overlay's anchor: {:?}", (x0, y0, x1, y1));
let greens = (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| {
let c = at(&img, x, y);
c[1] > 200 && c[0] < 60
});
assert!(greens.count() > 100, "in the plate's colour");
let over = t.merged(&json!({"ending": {"overrideColor": true, "textColor": "#ff0000"}})).unwrap();
let img = TitleCompose::new(&over, &o, Title::Ending, 600, 400).compose();
let reds = img.data.iter().filter(|p| p[0] > 200 && p[1] < 60).count();
assert!(reds > 100, "overridden");
// the colour alone
let none = t.merged(&json!({"ending": {"content": "none", "color": "#123456"}})).unwrap();
let c = TitleCompose::new(&none, &o, Title::Ending, 64, 40);
assert!(c.plate_box().is_none());
assert!(c.compose().data.iter().all(|p| p[..3] == [0x12, 0x34, 0x56]));
}
#[test]
fn a_missing_plate_image_draws_the_colour_and_is_reported() {
let o = OverlaySettings::default().merged(&json!({"identityPlate": {"image": "/no/such/logo.png"}})).unwrap();
let t = TitleSettings::default().merged(&json!({"intro": {"enabled": true, "color": "#336699"}})).unwrap();
let c = TitleCompose::new(&t, &o, Title::Intro, 120, 80);
assert!(c.compose().data.iter().all(|p| p[..3] == [0x33, 0x66, 0x99]));
let w = t.warnings(&o);
assert!(w.iter().any(|w| w.contains("intro") && w.contains("can't be read")), "{w:?}");
let t = t.merged(&json!({"intro": {"content": "text", "text": " "}})).unwrap();
assert!(t.warnings(&o).iter().any(|w| w.contains("no text")));
}
#[test]
fn long_text_is_scaled_down_to_fit_the_width() {
let t = TitleSettings::default()
.merged(&json!({"intro": {"enabled": true, "content": "text", "text": "A very long title that would never fit across", "scale": 0.5}}))
.unwrap();
let c = TitleCompose::new(&t, &OverlaySettings::default(), Title::Intro, 800, 450);
let b = c.plate_box().unwrap();
assert!(b.width() <= 800.0 * FIT_WIDTH + 2.0 && b.x0 >= 0.0, "{b:?}");
}
#[test]
fn settings_are_validated_clamped_and_round_trip() {
let d = TitleSettings::default();
assert!(!d.intro.enabled && !d.ending.enabled, "off by default");
assert_eq!(d.durations(4.0), (None, None));
for bad in [
json!({"intro": {"enabled": 1}}),
json!({"intro": {"color": "#12"}}),
json!({"ending": {"content": "video"}}),
json!({"ending": {"scale": "big"}}),
json!({"ending": {"duration": "long"}}),
json!({"intro": {"sparkle": true}}),
json!({"intro": []}),
json!({"credits": {}}),
json!({"INTRO": {}}),
json!([1]),
] {
assert!(d.merged(&bad).is_err(), "{bad}");
}
let s = d
.merged(&json!({"intro": {"enabled": true, "scale": 9, "duration": 1e9}, "ending": {"enabled": true, "scale": -1, "duration": 0}}))
.unwrap();
assert_eq!((s.intro.scale, s.ending.scale), (PLATE_SIZE_MAX, SIZE_MIN));
assert_eq!((s.intro.duration, s.ending.duration), (Some(crate::slideshow::SLIDE_MAX), Some(crate::slideshow::SLIDE_MIN)));
assert_eq!(s.durations(4.0), (Some(crate::slideshow::SLIDE_MAX), Some(crate::slideshow::SLIDE_MIN)));
let s = s.merged(&json!({"intro": {"duration": null}})).unwrap();
assert_eq!(s.durations(4.0).0, Some(4.0), "null: the slide duration");
assert_eq!(d.merged(&s.to_json()).unwrap(), s, "the JSON form reads back");
let back: TitleSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(back, s);
let partial: TitleSettings = serde_json::from_str(r#"{"ending": {"enabled": true}}"#).unwrap();
assert!(partial.ending.enabled && !partial.intro.enabled);
// NaN / ∞ from a hand-edited file
let s = TitleSettings { intro: TitleScreen { scale: f64::NAN, duration: Some(f64::INFINITY), ..d.intro.clone() }, ..d.clone() }.sanitized();
assert_eq!((s.intro.scale, s.intro.duration), (0.1, None));
// any size composes
for (w, h) in [(0, 0), (1, 100_000), (usize::MAX, 3)] {
let c = TitleCompose::new(&text_titles(0.5), &OverlaySettings::default(), Title::Intro, w, h);
let img = c.compose();
assert!(img.width >= 1 && img.height >= 1 && img.width <= lightcraft_slideshow::MAX_SIDE);
}
}
}
+919
View File
@@ -0,0 +1,919 @@
//! Slideshow playback: the settings of Lightroom Classic's Playback panel and the timeline that
//! plays the photos in view (durations, fades, random order, repeat, pause, stepping), between an
//! optional intro and ending screen (the Titles panel, [`crate::slide_title`]).
//!
//! No UI here: [`Playback`] is a pure state machine driven with injected times (seconds on any
//! monotonic clock), so the desktop app, a later Slideshow module and tests run the same timeline.
//! The frontend asks [`Playback::frame`] what to draw (the current slide, and while a transition
//! runs the previous one and its progress) and [`Playback::upcoming`] which slides to prepare; it
//! tells [`Playback::tick`] which slides are ready, so a transition never starts on a slide that
//! has not been rendered yet (the current one is held instead).
//!
//! The settings are a library preference (`prefs.json`, see [`crate::library`]), read and changed
//! with the `slideshow.playback` command.
use lightcraft_catalog::PhotoId;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
/// Shortest and longest time a slide stays up (seconds).
pub const SLIDE_MIN: f64 = 0.5;
pub const SLIDE_MAX: f64 = 120.0;
/// Longest fade (seconds); a fade is never longer than the slide duration.
pub const FADE_MAX: f64 = 10.0;
/// How playback runs (Lightroom Classic's Playback panel, without music and pan & zoom).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct PlaybackSettings {
/// Seconds from the start of one slide's transition to the start of the next one's (the
/// fade in is part of it).
pub slide_duration: f64,
/// Seconds a transition takes (0 = cut).
pub fade_duration: f64,
/// Fade through this colour (out to it, then in from it) instead of crossfading.
pub fade_color: Option<[u8; 3]>,
/// Shuffle the photos (a new order for every pass when repeating).
pub random_order: bool,
/// Start over after the last slide; off: playback ends there.
pub repeat: bool,
/// Render the next slides before they are due.
pub prepare_previews: bool,
}
impl Default for PlaybackSettings {
fn default() -> Self {
PlaybackSettings { slide_duration: 4.0, fade_duration: 1.0, fade_color: None, random_order: false, repeat: true, prepare_previews: true }
}
}
impl PlaybackSettings {
/// The settings with every value in range (a non-finite duration takes the default; the fade
/// is never longer than the slide).
pub fn sanitized(mut self) -> Self {
let d = PlaybackSettings::default();
self.slide_duration = if self.slide_duration.is_finite() { self.slide_duration.clamp(SLIDE_MIN, SLIDE_MAX) } else { d.slide_duration };
let fade = if self.fade_duration.is_finite() { self.fade_duration } else { d.fade_duration };
self.fade_duration = fade.clamp(0.0, FADE_MAX).min(self.slide_duration);
self
}
pub fn to_json(&self) -> Value {
json!({
"slideDuration": self.slide_duration,
"fadeDuration": self.fade_duration,
"fadeColor": self.fade_color.map(hex),
"randomOrder": self.random_order,
"repeat": self.repeat,
"preparePreviews": self.prepare_previews,
})
}
/// These settings with the keys `p` names changed (the others kept), validated and clamped.
/// A wrong type, an unknown key or a non-finite number is an error; nothing changes then.
pub fn merged(&self, p: &Value) -> std::result::Result<Self, String> {
let Some(obj) = p.as_object() else { return Err("parameters must be an object".into()) };
let mut s = self.clone();
for (k, v) in obj {
match k.as_str() {
"slideDuration" => s.slide_duration = finite(k, v)?,
"fadeDuration" => s.fade_duration = finite(k, v)?,
"fadeColor" => s.fade_color = color(v)?,
"randomOrder" => s.random_order = boolean(k, v)?,
"repeat" => s.repeat = boolean(k, v)?,
"preparePreviews" => s.prepare_previews = boolean(k, v)?,
other => {
return Err(format!("unknown setting `{other}` (slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews)"));
}
}
}
// a fade longer than a slide asked for together with a shorter slide: the slide wins
Ok(s.sanitized())
}
}
fn finite(k: &str, v: &Value) -> std::result::Result<f64, String> {
match v.as_f64() {
Some(x) if x.is_finite() => Ok(x),
_ => Err(format!("`{k}` must be a finite number of seconds, not {v}")),
}
}
fn boolean(k: &str, v: &Value) -> std::result::Result<bool, String> {
v.as_bool().ok_or_else(|| format!("`{k}` must be true or false, not {v}"))
}
fn hex(c: [u8; 3]) -> String {
format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])
}
/// `null` / `""` / `"none"` = crossfade; `"#rrggbb"` or `[r, g, b]` (0–255) = fade through it.
fn color(v: &Value) -> std::result::Result<Option<[u8; 3]>, String> {
let bad = || format!("`fadeColor` must be \"#rrggbb\", [r, g, b] or null, not {v}");
match v {
Value::Null => Ok(None),
Value::String(s) if s.is_empty() || s.eq_ignore_ascii_case("none") => Ok(None),
Value::String(s) => {
let h = s.strip_prefix('#').unwrap_or(s);
if h.len() != 6 || !h.is_ascii() {
return Err(bad());
}
let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).ok_or_else(bad);
Ok(Some([byte(0)?, byte(2)?, byte(4)?]))
}
Value::Array(a) if a.len() == 3 => {
let mut c = [0u8; 3];
for (o, x) in c.iter_mut().zip(a) {
*o = x.as_u64().filter(|n| *n <= 255).ok_or_else(bad)? as u8;
}
Ok(Some(c))
}
_ => Err(bad()),
}
}
/// A title screen (Lightroom Classic's Titles panel): the intro before the first slide, the
/// ending after the last one.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Title {
Intro,
Ending,
}
impl Title {
pub const ALL: [Title; 2] = [Title::Intro, Title::Ending];
pub fn id(self) -> &'static str {
match self {
Title::Intro => "intro",
Title::Ending => "ending",
}
}
pub fn parse(s: &str) -> Option<Title> {
Title::ALL.into_iter().find(|t| t.id().eq_ignore_ascii_case(s.trim()))
}
}
/// One slide of the timeline: a photo, or a title screen (not a photo: it shows no photo, never
/// moves the selection and isn't counted in "n of N").
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Item {
Title(Title),
Photo(PhotoId),
}
impl From<PhotoId> for Item {
fn from(p: PhotoId) -> Item {
Item::Photo(p)
}
}
impl Item {
pub const INTRO: Item = Item::Title(Title::Intro);
pub const ENDING: Item = Item::Title(Title::Ending);
/// The photo this slide shows (`None` for a title screen).
pub fn photo(self) -> Option<PhotoId> {
match self {
Item::Photo(p) => Some(p),
Item::Title(_) => None,
}
}
pub fn title(self) -> Option<Title> {
match self {
Item::Title(t) => Some(t),
Item::Photo(_) => None,
}
}
}
/// What a tick did.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tick {
/// Nothing changed (the slide is up, paused, or a transition is running).
Idle,
/// The next slide is due but not ready: the current one is held.
Waiting(Item),
/// A new slide came up (its transition starts now).
Advanced(Item),
/// The last slide was shown and repeat is off.
Ended,
}
/// What to draw at a moment of playback.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Frame {
pub current: Item,
/// The slide being faded out, while a transition runs.
pub from: Option<Item>,
/// How far the transition is (0–1; 1 when there is none).
pub progress: f32,
pub fade_color: Option<[u8; 3]>,
}
/// Where the timeline is: the intro, the photos, or the ending.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Phase {
Intro,
Photos,
Ending,
}
/// One run of a slideshow over a fixed list of photos, with an optional intro and ending.
///
/// **Titles:** the intro plays once, before the first photo; the ending once, after the last photo
/// when repeat is off (with repeat on the show only ends when it is stopped, so the ending never
/// comes up; passes follow each other without titles between them). Random order shuffles the
/// photos only: titles keep their places.
#[derive(Clone, Debug)]
pub struct Playback {
settings: PlaybackSettings,
photos: Vec<PhotoId>,
/// This pass's order (indices into `photos`).
order: Vec<usize>,
/// Index into `order` of the photo up now (the last one while the ending is up).
pos: usize,
phase: Phase,
pass: u64,
seed: u64,
/// How long the intro and the ending stay up (`None`: no such screen).
intro: Option<f64>,
ending: Option<f64>,
/// When the current slide came up (its transition started).
started: f64,
/// The slide fading out (until the transition ends).
from: Option<Item>,
/// Paused at this time.
paused_at: Option<f64>,
ended: bool,
/// The next slide was due but not ready at the last tick.
held: Option<Item>,
}
/// SplitMix64: a small, well-mixed generator for the shuffle (deterministic per seed).
fn splitmix(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^ (z >> 31)
}
/// Fisher–Yates shuffle of `v` with `seed`.
fn shuffle(v: &mut [usize], seed: u64) {
let mut st = seed;
for i in (1..v.len()).rev() {
let j = (splitmix(&mut st) % (i as u64 + 1)) as usize;
v.swap(i, j);
}
}
impl Playback {
/// Start playing `photos` (the ones in view, in view order) at `start` (the selected photo;
/// the first one when absent or not among them), at time `now`. `seed` fixes the random order.
/// `None` when there is nothing to show.
pub fn new(photos: Vec<PhotoId>, start: Option<PhotoId>, settings: PlaybackSettings, seed: u64, now: f64) -> Option<Self> {
if photos.is_empty() {
return None;
}
let settings = settings.sanitized();
let n = photos.len();
let first = start.and_then(|s| photos.iter().position(|p| *p == s)).unwrap_or(0);
let order: Vec<usize> = if settings.random_order {
// the starting photo first, the others shuffled
let mut rest: Vec<usize> = (0..n).filter(|i| *i != first).collect();
shuffle(&mut rest, seed);
std::iter::once(first).chain(rest).collect()
} else {
// from the starting photo to the end, then the ones before it: every photo once
(first..n).chain(0..first).collect()
};
let now = if now.is_finite() { now } else { 0.0 };
Some(Playback {
settings,
photos,
order,
pos: 0,
phase: Phase::Photos,
pass: 0,
seed,
intro: None,
ending: None,
started: now,
from: None,
paused_at: None,
ended: false,
held: None,
})
}
/// With an intro and / or an ending screen, each up for its duration in seconds (clamped like
/// a slide's; a non-finite one takes the slide duration). Call it right after [`Self::new`]:
/// the show then starts at the intro.
pub fn with_titles(mut self, intro: Option<f64>, ending: Option<f64>) -> Self {
let slide = self.settings.slide_duration;
let dur = |d: f64| if d.is_finite() { d.clamp(SLIDE_MIN, SLIDE_MAX) } else { slide };
self.intro = intro.map(dur);
self.ending = ending.map(dur);
if self.intro.is_some() && self.pass == 0 && self.pos == 0 && self.phase == Phase::Photos {
self.phase = Phase::Intro;
}
self
}
pub fn settings(&self) -> &PlaybackSettings {
&self.settings
}
/// The order of pass `pass` (after the first), given the order of the pass before it.
fn order_after(&self, pass: u64, prev: &[usize]) -> Vec<usize> {
let mut order = prev.to_vec();
if self.settings.random_order {
order.sort_unstable();
shuffle(&mut order, self.seed ^ pass.wrapping_mul(0xa076_1d64_78bd_642f));
// never the same photo twice in a row across passes
if order.len() > 1 && order.first() == prev.last() {
order.swap(0, 1);
}
}
order
}
fn photo_at(&self, order: &[usize], pos: usize) -> Option<PhotoId> {
order.get(pos).and_then(|i| self.photos.get(*i)).copied()
}
fn item_at(&self, phase: Phase, order: &[usize], pos: usize) -> Option<Item> {
match phase {
Phase::Intro => Some(Item::INTRO),
Phase::Ending => Some(Item::ENDING),
Phase::Photos => self.photo_at(order, pos).map(Item::Photo),
}
}
/// The place after (`phase`, `pos`, `pass`); `order` is that pass's order and becomes the next
/// pass's when it wraps. `None`: the show ends there.
fn following(&self, phase: Phase, pos: usize, pass: u64, order: &mut Vec<usize>) -> Option<(Phase, usize, u64)> {
match phase {
Phase::Intro => Some((Phase::Photos, 0, pass)),
Phase::Photos if pos + 1 < order.len() => Some((Phase::Photos, pos + 1, pass)),
Phase::Photos if self.settings.repeat => {
*order = self.order_after(pass + 1, order);
Some((Phase::Photos, 0, pass + 1))
}
Phase::Photos if self.ending.is_some() => Some((Phase::Ending, pos, pass)),
Phase::Photos | Phase::Ending => None,
}
}
/// The slide up now.
pub fn current(&self) -> Item {
self.item_at(self.phase, &self.order, self.pos).or_else(|| self.photos.first().copied().map(Item::Photo)).unwrap_or(Item::Photo(PhotoId(0)))
}
/// The photo up now (`None` while a title screen is up).
pub fn current_photo(&self) -> Option<PhotoId> {
self.current().photo()
}
/// (photo number from 1, photos per pass). Titles aren't photos: (0, N) while the intro is up,
/// (N, N) while the ending is; [`Self::title`] says which.
pub fn position(&self) -> (usize, usize) {
let n = self.order.len();
match self.phase {
Phase::Intro => (0, n),
Phase::Photos => (self.pos + 1, n),
Phase::Ending => (n, n),
}
}
/// The title screen up now, if one is.
pub fn title(&self) -> Option<Title> {
self.current().title()
}
pub fn is_paused(&self) -> bool {
self.paused_at.is_some()
}
pub fn is_ended(&self) -> bool {
self.ended
}
/// The slide that was due but not ready at the last tick (the current one is held for it).
pub fn waiting_for(&self) -> Option<Item> {
self.held
}
/// The clock as playback sees it: stopped while paused.
fn clock(&self, now: f64) -> f64 {
let now = if now.is_finite() { now } else { self.started };
self.paused_at.map_or(now, |p| p.min(now))
}
/// How long `item` stays up (seconds).
pub fn duration(&self, item: Item) -> f64 {
match item {
Item::Title(Title::Intro) => self.intro.unwrap_or(self.settings.slide_duration),
Item::Title(Title::Ending) => self.ending.unwrap_or(self.settings.slide_duration),
Item::Photo(_) => self.settings.slide_duration,
}
}
/// How long the transition to `item` takes: the fade, never longer than the slide.
fn fade_to(&self, item: Item) -> f64 {
self.settings.fade_duration.min(self.duration(item))
}
/// When the next slide is due.
pub fn due(&self) -> f64 {
self.started + self.duration(self.current())
}
/// Seconds until the next slide is due (as of `now`; frozen while paused).
pub fn remaining(&self, now: f64) -> f64 {
(self.due() - self.clock(now)).max(0.0)
}
/// The next `n` slides in playing order (titles included; into the next pass when repeating;
/// never the one up now twice).
pub fn upcoming(&self, n: usize) -> Vec<Item> {
let mut out = Vec::new();
let mut order = self.order.clone();
let (mut phase, mut pos, mut pass) = (self.phase, self.pos, self.pass);
let current = self.current();
while out.len() < n {
let Some(next) = self.following(phase, pos, pass, &mut order) else { break };
(phase, pos, pass) = next;
match self.item_at(phase, &order, pos) {
Some(it) if it != current && !out.contains(&it) => out.push(it),
_ => break,
}
}
out
}
/// Move one slide on (`Advanced`), or end (`Ended`) past the last one without repeat.
fn advance(&mut self) -> Tick {
let mut order = std::mem::take(&mut self.order);
let next = self.following(self.phase, self.pos, self.pass, &mut order);
self.order = order;
match next {
Some((phase, pos, pass)) => {
(self.phase, self.pos, self.pass) = (phase, pos, pass);
Tick::Advanced(self.current())
}
None => {
self.ended = true;
Tick::Ended
}
}
}
/// Advance the timeline to `now`. `ready(slide)` says whether a slide can be shown: the
/// transition to the next slide waits until it is.
pub fn tick(&mut self, now: f64, ready: impl Fn(Item) -> bool) -> Tick {
if self.ended {
return Tick::Ended;
}
let now = self.clock(now);
if self.from.is_some() && now >= self.started + self.fade_to(self.current()) {
self.from = None;
}
if self.paused_at.is_some() {
return Tick::Idle;
}
// a slide's time starts once it can be shown (the first one, one stepped to with ←/→)
if self.from.is_none() && !ready(self.current()) {
self.started = now;
return Tick::Idle;
}
if now < self.due() {
return Tick::Idle;
}
let Some(next) = self.upcoming(1).first().copied() else {
if self.settings.repeat {
// a single photo repeating: nothing to change to
self.started = now;
return Tick::Idle;
}
self.ended = true;
return Tick::Ended;
};
if !ready(next) {
self.held = Some(next);
return Tick::Waiting(next);
}
self.held = None;
let old = self.current();
let t = self.advance();
if let Tick::Advanced(new) = t {
self.started = now;
self.from = (self.fade_to(new) > 0.0).then_some(old);
}
t
}
/// What to draw at `now`.
pub fn frame(&self, now: f64) -> Frame {
let now = self.clock(now);
let current = self.current();
let fade = self.fade_to(current);
let (from, progress) = match self.from {
Some(f) if fade > 0.0 && now < self.started + fade => (Some(f), ((now - self.started) / fade).clamp(0.0, 1.0) as f32),
_ => (None, 1.0),
};
Frame { current, from, progress, fade_color: self.settings.fade_color }
}
/// Pause or resume at `now`; resuming keeps the time the slide had left. Returns whether it
/// is paused now.
pub fn toggle_pause(&mut self, now: f64) -> bool {
let now = if now.is_finite() { now } else { self.started };
match self.paused_at.take() {
Some(p) => {
// the slide (and a transition in progress) carries on where it stopped
self.started += (now - p).max(0.0);
}
None => self.paused_at = Some(now),
}
self.paused_at.is_some()
}
/// Step `delta` slides (←/→) at `now`: a cut, and the new slide gets its full time (paused
/// playback stays paused). Past the last slide (the ending, when there is one) without repeat
/// playback ends; before the first it stays there (the intro, when there is one: in the first
/// pass ← from the first photo goes back to it).
pub fn step(&mut self, delta: i32, now: f64) -> Tick {
if self.ended {
return Tick::Ended;
}
let now = if now.is_finite() { now } else { self.started };
// (more steps than slides would only go round again)
let slides = self.order.len().saturating_add(2).min(u32::MAX as usize) as u32;
let steps = delta.unsigned_abs().min(slides);
let mut t = Tick::Idle;
if delta > 0 {
for _ in 0..steps {
t = self.advance();
if t == Tick::Ended {
return t;
}
}
} else {
for _ in 0..steps {
match self.phase {
// (the ending keeps the last photo's place)
Phase::Ending => self.phase = Phase::Photos,
Phase::Photos if self.pos > 0 => self.pos -= 1,
Phase::Photos if self.intro.is_some() && self.pass == 0 => self.phase = Phase::Intro,
Phase::Photos if self.settings.repeat => self.pos = self.order.len().saturating_sub(1),
Phase::Photos | Phase::Intro => {}
}
}
t = Tick::Advanced(self.current());
}
self.from = None;
self.held = None;
self.started = now;
if self.paused_at.is_some() {
self.paused_at = Some(now);
}
t
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ids(n: u64) -> Vec<PhotoId> {
(1..=n).map(PhotoId).collect()
}
fn settings(slide: f64, fade: f64) -> PlaybackSettings {
PlaybackSettings { slide_duration: slide, fade_duration: fade, ..Default::default() }
}
fn ph(n: u64) -> Item {
Item::Photo(PhotoId(n))
}
fn all_ready(_: Item) -> bool {
true
}
#[test]
fn timeline_durations_and_fade_progress() {
let mut p = Playback::new(ids(3), Some(PhotoId(2)), settings(4.0, 1.0), 1, 10.0).unwrap();
assert_eq!(p.current(), ph(2), "starts at the selected photo");
assert_eq!(p.position(), (1, 3));
assert_eq!(p.tick(13.9, all_ready), Tick::Idle);
assert_eq!(p.frame(13.9).from, None);
assert_eq!(p.tick(14.0, all_ready), Tick::Advanced(ph(3)));
let f = p.frame(14.25);
assert_eq!((f.current, f.from), (ph(3), Some(ph(2))));
assert!((f.progress - 0.25).abs() < 1e-6, "{f:?}");
assert!((p.frame(14.5).progress - 0.5).abs() < 1e-6);
assert_eq!(p.tick(15.0, all_ready), Tick::Idle);
assert_eq!(p.frame(15.0).from, None, "the fade is over after fadeDuration");
// the slide period counts from its transition: next at 18
assert_eq!(p.tick(17.99, all_ready), Tick::Idle);
assert_eq!(p.tick(18.0, all_ready), Tick::Advanced(ph(1)), "wraps to the photos before the start");
assert_eq!(p.position(), (3, 3));
}
#[test]
fn fade_zero_is_a_cut_and_fade_color_is_reported() {
let mut p = Playback::new(ids(2), None, settings(2.0, 0.0), 1, 0.0).unwrap();
assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(2)));
assert_eq!(p.frame(2.0).from, None);
let s = PlaybackSettings { fade_color: Some([255, 0, 0]), ..settings(2.0, 1.0) };
let mut p = Playback::new(ids(2), None, s, 1, 0.0).unwrap();
p.tick(2.0, all_ready);
assert_eq!(p.frame(2.5).fade_color, Some([255, 0, 0]));
}
#[test]
fn arrows_cut_immediately_and_restart_the_slide_time() {
let mut p = Playback::new(ids(4), None, settings(4.0, 2.0), 1, 0.0).unwrap();
p.tick(4.0, all_ready);
assert!(p.frame(4.5).from.is_some(), "fading");
assert_eq!(p.step(1, 4.5), Tick::Advanced(ph(3)));
assert_eq!(p.frame(4.5), Frame { current: ph(3), from: None, progress: 1.0, fade_color: None }, "a cut");
assert_eq!(p.tick(8.4, all_ready), Tick::Idle, "full time from the step");
assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(2)));
assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(1)));
// repeat on: before the first is the last
assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(4)));
assert_eq!(p.remaining(6.0), 4.0);
}
#[test]
fn random_order_is_deterministic_per_seed_and_covers_every_photo_once_per_pass() {
let photos = ids(20);
let run = |seed: u64| {
let s = PlaybackSettings { random_order: true, ..settings(1.0, 0.0) };
let mut p = Playback::new(photos.clone(), Some(PhotoId(5)), s, seed, 0.0).unwrap();
let mut seen = vec![p.current_photo().unwrap()];
for k in 1..60 {
p.tick(k as f64, all_ready);
seen.push(p.current_photo().unwrap());
}
seen
};
let a = run(42);
assert_eq!(a, run(42), "same seed, same order");
assert_ne!(a, run(43), "another seed, another order");
assert_eq!(a[0], PhotoId(5), "starts at the selected photo");
assert_ne!(a[..20].to_vec(), photos, "shuffled");
for pass in a.chunks(20).take(2) {
let mut sorted = pass.to_vec();
sorted.sort();
assert_eq!(sorted, photos, "every photo once per pass");
}
assert_ne!(a[..20], a[20..40], "a new order for the next pass");
assert!(a.windows(2).all(|w| w[0] != w[1]), "never the same photo twice in a row");
}
#[test]
fn repeat_off_stops_after_the_last_slide() {
let s = PlaybackSettings { repeat: false, ..settings(1.0, 0.0) };
let mut p = Playback::new(ids(3), Some(PhotoId(2)), s.clone(), 1, 0.0).unwrap();
assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(3)));
assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(1)));
assert_eq!(p.upcoming(2), vec![], "nothing after the last");
assert_eq!(p.tick(3.0, all_ready), Tick::Ended);
assert!(p.is_ended());
// → past the last one ends it too
let mut p = Playback::new(ids(2), None, s, 1, 0.0).unwrap();
assert_eq!(p.step(1, 0.5), Tick::Advanced(ph(2)));
assert_eq!(p.step(1, 0.6), Tick::Ended);
// with repeat: it goes on
let mut p = Playback::new(ids(2), None, settings(1.0, 0.0), 1, 0.0).unwrap();
for k in 1..10 {
assert!(matches!(p.tick(k as f64, all_ready), Tick::Advanced(_)));
}
}
#[test]
fn pause_and_resume_keep_the_remaining_time() {
let mut p = Playback::new(ids(3), None, settings(4.0, 1.0), 1, 0.0).unwrap();
assert!(p.toggle_pause(3.0));
assert_eq!(p.tick(100.0, all_ready), Tick::Idle, "paused: nothing moves");
assert_eq!(p.remaining(100.0), 1.0);
assert!(!p.toggle_pause(100.0));
assert_eq!(p.tick(100.9, all_ready), Tick::Idle);
assert_eq!(p.tick(101.0, all_ready), Tick::Advanced(ph(2)), "1 s left after resuming");
// pausing mid-fade freezes the fade
p.toggle_pause(101.25);
assert!((p.frame(150.0).progress - 0.25).abs() < 1e-6);
p.toggle_pause(150.0);
assert!((p.frame(150.5).progress - 0.75).abs() < 1e-6);
}
#[test]
fn a_transition_waits_for_the_next_slide_to_be_ready() {
let mut p = Playback::new(ids(3), None, settings(2.0, 1.0), 1, 0.0).unwrap();
assert_eq!(p.upcoming(2), vec![ph(2), ph(3)], "what to prepare");
let not_two = |id: Item| id != ph(2);
assert_eq!(p.tick(2.0, not_two), Tick::Waiting(ph(2)));
assert_eq!(p.frame(3.0), Frame { current: ph(1), from: None, progress: 1.0, fade_color: None }, "held, not flashed");
assert_eq!(p.waiting_for(), Some(ph(2)));
assert_eq!(p.tick(3.5, all_ready), Tick::Advanced(ph(2)));
assert_eq!(p.frame(3.5).from, Some(ph(1)), "the fade starts once it is ready");
assert!((p.frame(4.0).progress - 0.5).abs() < 1e-6, "and runs its full length");
}
#[test]
fn a_slide_s_time_starts_once_it_is_ready() {
let mut p = Playback::new(ids(3), None, settings(2.0, 0.5), 1, 0.0).unwrap();
let none = |_: Item| false;
assert_eq!(p.tick(1.0, none), Tick::Idle);
assert_eq!(p.tick(5.0, none), Tick::Idle, "the first slide isn't rendered yet: its clock waits");
assert_eq!(p.remaining(5.0), 2.0);
assert_eq!(p.tick(6.0, all_ready), Tick::Idle);
assert_eq!(p.tick(7.0, all_ready), Tick::Advanced(ph(2)), "2 s from when it could be shown");
}
#[test]
fn hostile_settings_are_rejected_or_clamped() {
let d = PlaybackSettings::default();
for bad in [
json!({"slideDuration": "fast"}),
json!({"fadeDuration": null}),
json!({"repeat": 1}),
json!({"fadeColor": "#12345"}),
json!({"fadeColor": "#ééé"}),
json!({"fadeColor": [300, 0, 0]}),
json!({"fadeColor": [1, 2]}),
json!({"speed": 2}),
json!([1, 2]),
] {
assert!(d.merged(&bad).is_err(), "{bad}");
}
let s = d.merged(&json!({"slideDuration": 1e300, "fadeDuration": -5})).unwrap();
assert_eq!((s.slide_duration, s.fade_duration), (SLIDE_MAX, 0.0));
let s = d.merged(&json!({"slideDuration": 0.0, "fadeDuration": 9})).unwrap();
assert_eq!((s.slide_duration, s.fade_duration), (SLIDE_MIN, SLIDE_MIN), "the fade fits in the slide");
let s = d.merged(&json!({"fadeColor": "#FF8000"})).unwrap();
assert_eq!(s.fade_color, Some([255, 128, 0]));
assert_eq!(s.to_json()["fadeColor"], "#ff8000");
assert_eq!(d.merged(&json!({"fadeColor": [1, 2, 3]})).unwrap().fade_color, Some([1, 2, 3]));
// NaN / inf (from a hand-edited file or a caller in-process) take the defaults
let s = PlaybackSettings { slide_duration: f64::NAN, fade_duration: f64::INFINITY, ..d.clone() }.sanitized();
assert_eq!((s.slide_duration, s.fade_duration), (d.slide_duration, d.fade_duration));
// and the timeline never panics on hostile times
let mut p = Playback::new(ids(2), None, s, 0, f64::NAN).unwrap();
p.tick(f64::NAN, all_ready);
p.tick(f64::INFINITY, all_ready);
p.toggle_pause(f64::NEG_INFINITY);
p.step(i32::MIN, f64::NAN);
let _ = p.frame(f64::NAN);
assert!(Playback::new(vec![], None, d, 0, 0.0).is_none());
}
#[test]
fn a_single_photo_repeats_without_changing() {
let mut p = Playback::new(ids(1), None, settings(1.0, 0.5), 1, 0.0).unwrap();
assert_eq!(p.upcoming(2), vec![]);
assert_eq!(p.tick(5.0, all_ready), Tick::Idle);
assert_eq!(p.frame(5.0).from, None);
}
fn titled(n: u64, s: PlaybackSettings, intro: Option<f64>, ending: Option<f64>) -> Playback {
Playback::new(ids(n), None, s, 1, 0.0).unwrap().with_titles(intro, ending)
}
#[test]
fn titles_play_once_around_the_photos_for_their_own_durations() {
let s = PlaybackSettings { repeat: false, ..settings(4.0, 1.0) };
let mut p = titled(3, s, Some(2.0), Some(3.0));
assert_eq!(p.current(), Item::INTRO, "the intro comes first");
assert_eq!((p.position(), p.title(), p.current_photo()), ((0, 3), Some(Title::Intro), None), "titles aren't photos");
assert_eq!(p.upcoming(2), vec![ph(1), ph(2)]);
assert_eq!(p.tick(1.99, all_ready), Tick::Idle);
assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(1)), "the intro's own 2 s");
let f = p.frame(2.5);
assert_eq!((f.current, f.from), (ph(1), Some(Item::INTRO)), "the intro fades out like a slide");
assert!((f.progress - 0.5).abs() < 1e-6);
assert_eq!(p.position(), (1, 3));
assert_eq!(p.tick(6.0, all_ready), Tick::Advanced(ph(2)));
assert_eq!(p.tick(10.0, all_ready), Tick::Advanced(ph(3)));
assert_eq!(p.upcoming(2), vec![Item::ENDING], "the ending is prepared like a slide");
assert_eq!(p.tick(14.0, all_ready), Tick::Advanced(Item::ENDING));
assert_eq!((p.position(), p.title()), ((3, 3), Some(Title::Ending)));
assert_eq!(p.frame(14.5).from, Some(ph(3)), "the ending fades in");
assert_eq!(p.tick(16.99, all_ready), Tick::Idle);
assert_eq!(p.tick(17.0, all_ready), Tick::Ended, "the ending's own 3 s, then the show ends");
// without titles nothing changes
let mut p = titled(2, PlaybackSettings { repeat: false, ..settings(1.0, 0.0) }, None, None);
assert_eq!(p.current(), ph(1));
assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(2)));
assert_eq!(p.tick(2.0, all_ready), Tick::Ended);
}
#[test]
fn a_fade_never_outlasts_a_short_title() {
let s = PlaybackSettings { repeat: false, ..settings(4.0, 2.0) };
let mut p = titled(1, s, Some(0.5), Some(0.5));
assert_eq!(p.tick(0.5, all_ready), Tick::Advanced(ph(1)));
assert!((p.frame(1.5).progress - 0.5).abs() < 1e-6, "the photo fades in over the full 2 s");
assert_eq!(p.tick(4.5, all_ready), Tick::Advanced(Item::ENDING));
assert!((p.frame(4.75).progress - 0.5).abs() < 1e-6, "into the 0.5 s ending: a 0.5 s fade");
assert_eq!(p.frame(5.0).from, None);
assert_eq!(p.tick(5.0, all_ready), Tick::Ended);
}
#[test]
fn with_repeat_the_intro_plays_once_and_the_ending_never() {
let mut p = titled(2, settings(1.0, 0.0), Some(1.0), Some(1.0));
let mut seen = vec![p.current()];
for k in 1..12 {
p.tick(k as f64, all_ready);
seen.push(p.current());
assert!(!p.upcoming(3).contains(&Item::ENDING), "no ending while repeating");
}
assert_eq!(seen[0], Item::INTRO);
assert!(seen[1..].iter().all(|i| i.photo().is_some()), "passes follow each other without titles: {seen:?}");
assert_eq!(seen[1..5], [ph(1), ph(2), ph(1), ph(2)]);
}
#[test]
fn random_order_shuffles_the_photos_and_keeps_the_titles_in_place() {
for seed in [1, 2, 3] {
let s = PlaybackSettings { random_order: true, repeat: false, ..settings(1.0, 0.0) };
let mut p = Playback::new(ids(10), None, s, seed, 0.0).unwrap().with_titles(Some(1.0), Some(1.0));
let mut seen = vec![p.current()];
let mut k = 1.0;
while p.tick(k, all_ready) != Tick::Ended {
seen.push(p.current());
k += 1.0;
}
assert_eq!(seen.len(), 12, "{seen:?}");
assert_eq!((seen[0], seen[11]), (Item::INTRO, Item::ENDING), "seed {seed}");
let mut photos: Vec<PhotoId> = seen[1..11].iter().filter_map(|i| i.photo()).collect();
assert_ne!(photos, ids(10), "shuffled");
photos.sort();
assert_eq!(photos, ids(10), "every photo once, between the titles");
}
}
#[test]
fn arrows_step_across_the_titles() {
let s = PlaybackSettings { repeat: false, ..settings(4.0, 1.0) };
let mut p = titled(3, s, Some(2.0), Some(2.0));
assert_eq!(p.step(-1, 0.5), Tick::Advanced(Item::INTRO), "← on the intro stays there");
assert_eq!(p.step(1, 0.5), Tick::Advanced(ph(1)));
assert_eq!(p.step(-1, 0.6), Tick::Advanced(Item::INTRO), "← from the first photo: back to the intro");
assert_eq!(p.remaining(0.6), 2.0, "with its full time");
assert_eq!(p.step(3, 1.0), Tick::Advanced(ph(3)));
assert_eq!(p.step(1, 1.0), Tick::Advanced(Item::ENDING));
assert_eq!(p.position(), (3, 3));
assert_eq!(p.step(-1, 1.0), Tick::Advanced(ph(3)), "← from the ending: the last photo");
assert_eq!(p.step(1, 1.0), Tick::Advanced(Item::ENDING));
assert_eq!(p.step(1, 1.0), Tick::Ended, "→ past the ending ends the show");
assert_eq!(p.step(1, 2.0), Tick::Ended);
// repeating: ← from the first photo of a later pass wraps to the last, not the intro
let mut p = titled(3, settings(4.0, 0.0), Some(2.0), None);
assert_eq!(p.step(4, 0.0), Tick::Advanced(ph(1)), "intro, 1, 2, 3, then 1 again");
assert_eq!(p.step(-1, 0.0), Tick::Advanced(ph(3)));
assert_eq!(p.step(i32::MAX, 0.0), Tick::Advanced(p.current()));
assert_eq!(p.step(i32::MIN, f64::NAN), Tick::Advanced(p.current()));
}
#[test]
fn a_title_pauses_and_waits_like_a_slide() {
let mut p = titled(2, settings(4.0, 1.0), Some(3.0), None);
let not_intro = |i: Item| i != Item::INTRO;
assert_eq!(p.tick(5.0, not_intro), Tick::Idle, "the intro isn't rendered yet: its clock waits");
assert_eq!(p.remaining(5.0), 3.0);
assert!(p.toggle_pause(6.0));
assert_eq!(p.tick(100.0, all_ready), Tick::Idle);
assert_eq!(p.remaining(100.0), 2.0, "paused on the intro");
assert!(!p.toggle_pause(100.0));
assert_eq!(p.tick(101.9, all_ready), Tick::Idle);
let not_one = |i: Item| i != ph(1);
assert_eq!(p.tick(102.0, not_one), Tick::Waiting(ph(1)), "the first photo isn't ready: the intro is held");
assert_eq!(p.frame(102.5).current, Item::INTRO);
assert_eq!(p.tick(103.0, all_ready), Tick::Advanced(ph(1)));
}
#[test]
fn hostile_title_durations_are_clamped() {
let p = titled(2, settings(4.0, 1.0), Some(f64::NAN), Some(1e300));
assert_eq!((p.duration(Item::INTRO), p.duration(Item::ENDING)), (4.0, SLIDE_MAX));
let p = titled(2, settings(4.0, 1.0), Some(-1.0), Some(f64::NEG_INFINITY));
assert_eq!((p.duration(Item::INTRO), p.duration(Item::ENDING)), (SLIDE_MIN, 4.0));
// a single photo between titles, repeating: the photo holds after the intro
let mut p = titled(1, settings(1.0, 0.0), Some(1.0), Some(1.0));
assert_eq!(p.upcoming(3), vec![ph(1)]);
assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(1)));
assert_eq!(p.upcoming(3), vec![]);
assert_eq!(p.tick(9.0, all_ready), Tick::Idle);
assert_eq!(Title::parse(" Ending "), Some(Title::Ending));
assert_eq!(Title::parse("credits"), None);
}
}
+760
View File
@@ -0,0 +1,760 @@
//! Slideshow export (Lightroom Classic's Export PDF / Export JPEG / Export Video): the photos in
//! view as their composed slides (layout, overlays: [`crate::slide`]), between the intro and
//! ending screens that are on ([`crate::slide_title`]), written as
//!
//! - **PDF**: one page per slide, the page the slide's size in points (1 px = 1 pt), its picture a
//! JPEG; each page carries the slide duration and a fade transition for viewers' presentation
//! mode, and "full screen" asks the viewer to open in it (`/PageMode /FullScreen`);
//! - **JPEG sequence**: `<name>-001.jpg`, `<name>-002.jpg`… in a folder;
//! - **video**: AV1 in MP4 or WebM ([`lightcraft_codecs::video`], our own muxers), the timeline
//! sampled at 24/25/30/60 fps: each slide held for its duration, fades rendered as blended
//! frames (crossfade, or through the fade colour). No audio track.
//!
//! The slides play once in view order (random order and repeat are playback choices, not
//! export ones). The slide aspect comes from Layout's aspect preview; "screen" exports 16:9.
//!
//! [`prepare`] reads the session (cheap: render jobs, no pixels) and [`Prepared::run`] does the
//! work on any thread, with progress and a cancel flag. Files are written as `<file>.part` and
//! renamed when everything is written, so a cancelled or failed export leaves nothing behind. A
//! slide whose photo can't be rendered (file gone, unreadable) is skipped with a warning.
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use lightcraft_catalog::PhotoId;
use lightcraft_codecs::video::{Av1Encoder, Container, VideoConfig, VideoWriter};
use lightcraft_pipeline::{OutputDepth, OutputSpace};
use lightcraft_raster::Rgba8;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::media::RenderJob;
use crate::originals::OriginalGuard;
use crate::slide_title::TitleCompose;
use crate::slideshow::{SLIDE_MAX, SLIDE_MIN, Title};
use crate::{Session, export::ExportFormat, export::ExportOptions};
/// The longest video exported (seconds): 2 hours.
pub const MAX_SECONDS: f64 = 2.0 * 3600.0;
/// The most slides an export takes.
pub const MAX_SLIDES: usize = 10_000;
/// Long side of PDF pages and JPEGs, in pixels.
pub const SIZE_MIN: usize = 64;
pub const SIZE_MAX: usize = lightcraft_slideshow::MAX_SIDE;
/// Video frame height, in pixels.
pub const VIDEO_MIN: usize = 64;
pub const VIDEO_MAX: usize = 2160;
/// Frame rates offered.
pub const FPS: [u32; 4] = [24, 25, 30, 60];
/// What an export writes.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Format {
Pdf,
Jpeg,
Video,
}
impl Format {
pub fn id(self) -> &'static str {
match self {
Format::Pdf => "pdf",
Format::Jpeg => "jpeg",
Format::Video => "video",
}
}
}
/// The export's settings (the dialog's state, and `slideshow.export`'s params without `path`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ExportSettings {
pub format: Format,
/// PDF / JPEG: the long side in pixels; video: the frame height (720 = 720p).
pub size: usize,
/// JPEG quality (PDF pages, JPEG files) or video quality, 1–100.
pub quality: u8,
/// Video frames per second.
pub fps: u32,
/// Video container: "mp4" or "webm".
pub container: String,
/// PDF: ask the viewer to open in full screen.
pub full_screen: bool,
/// Video: rav1e speed preset, 1 (slow, smaller) – 10 (fast).
pub speed: u8,
}
impl Default for ExportSettings {
fn default() -> Self {
ExportSettings { format: Format::Pdf, size: 1920, quality: 90, fps: 30, container: "mp4".into(), full_screen: true, speed: 10 }
}
}
impl ExportSettings {
/// The defaults for `format` (sizes: PDF/JPEG 1920 px, video 720p; video quality 75).
pub fn for_format(format: Format) -> Self {
match format {
Format::Video => ExportSettings { format, size: 720, quality: 75, ..Default::default() },
_ => ExportSettings { format, ..Default::default() },
}
}
/// The file extension the export's path takes (none: a JPEG sequence goes in a folder).
pub fn extension(&self) -> Option<&'static str> {
match self.format {
Format::Pdf => Some("pdf"),
Format::Jpeg => None,
Format::Video => Some(if self.container == "webm" { "webm" } else { "mp4" }),
}
}
}
/// `slideshow.export`'s params, checked: settings, path, file name prefix and photos.
#[derive(Clone, Debug, PartialEq)]
pub struct Request {
pub settings: ExportSettings,
pub path: String,
/// JPEG sequence: the files' name before `-001` (default: the folder's name).
pub name: Option<String>,
pub ids: Option<Vec<PhotoId>>,
}
const KEYS: &str = "format, path, size, quality, fps, container, fullScreen, speed, name, ids, wait";
fn uint(k: &str, v: &Value, lo: u64, hi: u64) -> Result<u64, String> {
match v.as_u64() {
Some(n) if (lo..=hi).contains(&n) => Ok(n),
_ => Err(format!("`{k}` must be a whole number from {lo} to {hi}, not {v}")),
}
}
impl Request {
pub fn parse(p: &Value) -> Result<Request, String> {
let o: &Map<String, Value> = p.as_object().ok_or("params must be an object")?;
if let Some(k) = o.keys().find(|k| !KEYS.split(", ").any(|x| x == k.as_str())) {
return Err(format!("unknown key `{k}` (known: {KEYS})"));
}
let format = match o.get("format").and_then(Value::as_str).map(str::trim) {
Some("pdf") => Format::Pdf,
Some("jpeg" | "jpg") => Format::Jpeg,
Some("video") => Format::Video,
_ => return Err(format!("`format` must be \"pdf\", \"jpeg\" or \"video\", not {}", o.get("format").unwrap_or(&Value::Null))),
};
let mut s = ExportSettings::for_format(format);
let path = o.get("path").and_then(Value::as_str).map(str::trim).filter(|x| !x.is_empty()).ok_or("missing `path`")?.to_string();
if let Some(v) = o.get("size").filter(|v| !v.is_null()) {
// "720p" / "1080p" for video
let v = match v.as_str().map(|x| x.trim().trim_end_matches(['p', 'P'])) {
Some(x) => x.parse::<u64>().map(Value::from).map_err(|_| format!("`size` must be a number of pixels, not {v}"))?,
None => v.clone(),
};
s.size = match format {
Format::Video => uint("size", &v, VIDEO_MIN as u64, VIDEO_MAX as u64)? as usize,
_ => uint("size", &v, SIZE_MIN as u64, SIZE_MAX as u64)? as usize,
};
}
if let Some(v) = o.get("quality").filter(|v| !v.is_null()) {
s.quality = uint("quality", v, 1, 100)? as u8;
}
if let Some(v) = o.get("fps").filter(|v| !v.is_null()) {
let f =
v.as_u64().filter(|f| FPS.iter().any(|x| u64::from(*x) == *f)).ok_or_else(|| format!("`fps` must be 24, 25, 30 or 60, not {v}"))?;
s.fps = f as u32;
}
if let Some(v) = o.get("container").filter(|v| !v.is_null()) {
let c = v.as_str().and_then(Container::parse).ok_or_else(|| format!("`container` must be \"mp4\" or \"webm\", not {v}"))?;
s.container = c.id().into();
}
if let Some(v) = o.get("fullScreen").filter(|v| !v.is_null()) {
s.full_screen = v.as_bool().ok_or_else(|| format!("`fullScreen` must be true or false, not {v}"))?;
}
if let Some(v) = o.get("speed").filter(|v| !v.is_null()) {
s.speed = uint("speed", v, 1, 10)? as u8;
}
if let Some(v) = o.get("wait").filter(|v| !v.is_null() && !v.is_boolean()) {
return Err(format!("`wait` must be true or false, not {v}"));
}
let name = match o.get("name") {
None | Some(Value::Null) => None,
Some(Value::String(n)) => {
let n = n.trim();
if n.is_empty()
|| n.chars().count() > 120
|| n.chars().any(|c| c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|'))
{
return Err(format!("`name` must be 1–120 characters without path separators or reserved characters, not {n:?}"));
}
Some(n.to_string())
}
Some(v) => return Err(format!("`name` must be text, not {v}")),
};
if name.is_some() && format != Format::Jpeg {
return Err("`name` is for the JPEG sequence only".into());
}
let ids = match o.get("ids") {
None | Some(Value::Null) => None,
Some(Value::Array(a)) => {
let ids: Option<Vec<PhotoId>> = a.iter().map(|v| v.as_u64().map(PhotoId)).collect();
Some(ids.ok_or("`ids` must be an array of photo ids")?)
}
Some(v) => return Err(format!("`ids` must be an array of photo ids, not {v}")),
};
Ok(Request { settings: s, path, name, ids })
}
}
/// What a slide shows.
enum Source {
Photo { name: String, job: Box<RenderJob> },
Title { title: Title, compose: Arc<TitleCompose> },
}
struct Slide {
source: Source,
/// Seconds it stays up, its fade in included.
duration: f64,
/// Seconds the transition into it takes (none for the first).
fade: f64,
}
impl Slide {
fn label(&self) -> String {
match &self.source {
Source::Photo { name, .. } => name.clone(),
Source::Title { title, .. } => title.id().to_string(),
}
}
/// The slide's pixels, `w × h`.
fn render(self, w: usize, h: usize) -> Result<Rgba8, String> {
let img = match self.source {
Source::Photo { job, .. } => job.run().rendered?.image,
Source::Title { compose, .. } => compose.compose(),
};
Ok(fit(img, w, h))
}
}
/// `img` at exactly `w × h`: as it is when it already is (the composer's output), else centred
/// on black (a slide bigger than the composer draws, or a surprise from a renderer).
fn fit(img: Rgba8, w: usize, h: usize) -> Rgba8 {
if img.width == w && img.height == h && img.data.len() == w * h {
return img;
}
let mut out = Rgba8::filled(w, h, [0, 0, 0, 255]);
let (cw, ch) = (img.width.min(w), img.height.min(h));
let (dx, dy) = ((w - cw) / 2, (h - ch) / 2);
let (sx, sy) = ((img.width - cw) / 2, (img.height - ch) / 2);
for y in 0..ch {
let (Some(src), Some(dst)) =
(img.data.get((sy + y) * img.width + sx..(sy + y) * img.width + sx + cw), out.data.get_mut((dy + y) * w + dx..(dy + y) * w + dx + cw))
else {
continue;
};
dst.copy_from_slice(src);
}
out
}
/// An export ready to run: everything it needs from the session.
pub struct Prepared {
pub request: Request,
slides: Vec<Slide>,
pub width: usize,
pub height: usize,
fade_color: Option<[u8; 3]>,
guard: OriginalGuard,
/// Problems found while preparing (photos not in the library).
pub warnings: Vec<String>,
}
/// The size of a slide whose long side is `long` at `ratio` (width / height).
fn sized(ratio: f64, long: usize) -> (usize, usize) {
let short = |l: usize, r: f64| ((l as f64 / r).round() as usize).max(1);
if ratio >= 1.0 { (long, short(long, ratio)) } else { (short(long, 1.0 / ratio), long) }
}
/// Read the session: the slides, their render jobs and durations, the output size.
pub fn prepare(s: &mut Session, p: &Value) -> Result<Prepared, String> {
let request = Request::parse(p)?;
let settings = &request.settings;
let ratio = s.slide_layout.aspect.ratio(16.0 / 9.0);
let (width, height) = match settings.format {
Format::Video => {
let h = settings.size & !1;
((((h as f64 * ratio).round() as usize) & !1).max(16), h)
}
_ => sized(ratio, settings.size),
};
let ids = match &request.ids {
Some(ids) => ids.clone(),
None => s.visible_cloned(),
};
if ids.is_empty() {
return Err("no photos in view to export".into());
}
if ids.len() > MAX_SLIDES {
return Err(format!("a slideshow export takes at most {MAX_SLIDES} photos; this view has {}", ids.len()));
}
let play = s.slideshow.clone().sanitized();
let (intro, ending) = s.slide_titles.durations(play.slide_duration);
let clamp = |d: f64| if d.is_finite() { d.clamp(SLIDE_MIN, SLIDE_MAX) } else { play.slide_duration };
let layout = s.slide_layout.clone().sanitized();
let mut warnings = Vec::new();
let mut slides = Vec::with_capacity(ids.len() + 2);
let title = |s: &Session, t: Title, d: f64| Slide {
source: Source::Title { title: t, compose: s.title_compose(t, width, height) },
duration: clamp(d),
fade: 0.0,
};
if let Some(d) = intro {
slides.push(title(s, Title::Intro, d));
}
for id in ids {
let Some(photo) = s.catalog.photo(id).cloned() else {
warnings.push(format!("photo {} is not in the library; skipped", id.0));
continue;
};
let aspect = s.slide_photo_aspect(id).unwrap_or(1.5);
let (pw, ph) = lightcraft_slideshow::photo_size(&layout, aspect, width, height);
let job = match s.export_job(id, pw.max(8), ph.max(8), OutputSpace::Srgb, OutputDepth::U8) {
Ok(job) => job,
Err(e) => {
warnings.push(format!("{}: {e}; skipped", photo.file_name));
continue;
}
};
let compose = s.slide_compose(&layout, width, height, Some(id));
slides.push(Slide {
source: Source::Photo { name: photo.file_name.clone(), job: Box::new(job.with_slide(compose)) },
duration: play.slide_duration,
fade: 0.0,
});
}
if !slides.iter().any(|x| matches!(x.source, Source::Photo { .. })) {
return Err("none of the photos can be exported".into());
}
if let Some(d) = ending {
slides.push(title(s, Title::Ending, d));
}
// the transition into each slide after the first: the fade, never longer than the slide
for x in slides.iter_mut().skip(1) {
x.fade = play.fade_duration.min(x.duration).max(0.0);
}
if settings.format == Format::Video {
let total: f64 = slides.iter().map(|x| x.duration).sum();
if total > MAX_SECONDS {
return Err(format!(
"the slideshow lasts {} min; video export is limited to {} min (shorten the slides or export fewer photos)",
(total / 60.0).ceil(),
MAX_SECONDS / 60.0
));
}
if !lightcraft_codecs::video::AV1_AVAILABLE {
return Err("video export (AV1) is not available in this build".into());
}
}
s.check_write_target(&request.path)?;
Ok(Prepared { request, slides, width, height, fade_color: play.fade_color, guard: s.original_guard(), warnings })
}
/// What an export wrote.
#[derive(Clone, Debug, PartialEq)]
pub struct Outcome {
pub json: Value,
}
/// Progress: (done, total, the slide being worked on). `total` is slides (PDF, JPEG) or frames
/// at the nominal rate (video).
pub type Progress<'a> = &'a dyn Fn(u64, u64, &str);
const CANCELLED: &str = "Slideshow export cancelled";
/// A file written as `<path>.part` and renamed over `path` when complete; dropped before that,
/// the part file is removed.
struct PartFile {
part: PathBuf,
target: PathBuf,
done: bool,
}
impl PartFile {
fn new(target: &Path) -> Result<(PartFile, std::fs::File), String> {
if let Some(dir) = target.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
let mut name = target.file_name().ok_or_else(|| format!("{}: not a file name", target.display()))?.to_os_string();
name.push(".part");
let part = target.with_file_name(name);
let f = std::fs::File::create(&part).map_err(|e| format!("{}: {e}", part.display()))?;
Ok((PartFile { part, target: target.to_path_buf(), done: false }, f))
}
fn commit(mut self, guard: &OriginalGuard) -> Result<(), String> {
guard.check(&self.target)?;
std::fs::rename(&self.part, &self.target).map_err(|e| format!("{}: {e}", self.target.display()))?;
self.done = true;
Ok(())
}
}
impl Drop for PartFile {
fn drop(&mut self) {
if !self.done {
let _ = std::fs::remove_file(&self.part);
}
}
}
/// Blend `a` → `b` at `k` (0 = a, 1 = b), or through `colour` (out to it, then in from it).
fn blend(a: &Rgba8, b: &Rgba8, k: f32, colour: Option<[u8; 3]>) -> Vec<[u8; 4]> {
let mix = |x: u8, y: u8, t: f32| (f32::from(x) + (f32::from(y) - f32::from(x)) * t).round().clamp(0.0, 255.0) as u8;
match colour {
Some(c) => {
let (img, veil) = if k < 0.5 { (a, k * 2.0) } else { (b, 2.0 - k * 2.0) };
img.data.iter().map(|p| [mix(p[0], c[0], veil), mix(p[1], c[1], veil), mix(p[2], c[2], veil), 255]).collect()
}
None => a.data.iter().zip(&b.data).map(|(p, q)| [mix(p[0], q[0], k), mix(p[1], q[1], k), mix(p[2], q[2], k), 255]).collect(),
}
}
impl Prepared {
/// Slides to export (titles included).
pub fn len(&self) -> usize {
self.slides.len()
}
pub fn is_empty(&self) -> bool {
self.slides.is_empty()
}
/// Seconds the slideshow lasts.
pub fn seconds(&self) -> f64 {
self.slides.iter().map(|x| x.duration).sum()
}
/// Units of progress: slides, or frames at the nominal rate for video.
pub fn total(&self) -> u64 {
match self.request.settings.format {
Format::Video => (self.seconds() * f64::from(self.request.settings.fps)).round() as u64,
_ => self.slides.len() as u64,
}
}
/// Write the export. `cancel` is checked between slides and frames.
pub fn run(self, progress: Progress, cancel: &AtomicBool) -> Result<Outcome, String> {
match self.request.settings.format {
Format::Pdf => self.run_pdf(progress, cancel),
Format::Jpeg => self.run_jpeg(progress, cancel),
Format::Video => self.run_video(progress, cancel),
}
}
fn jpeg(&self, img: &Rgba8) -> Result<Vec<u8>, String> {
crate::export::encode_image(img, &ExportOptions { format: ExportFormat::Jpeg, quality: self.request.settings.quality, ..Default::default() })
}
fn summary(&self, format: Format, slides: usize, skipped: usize, warnings: Vec<String>) -> Value {
json!({
"format": format.id(),
"path": self.request.path,
"width": self.width,
"height": self.height,
"slides": slides,
"skipped": skipped,
"warnings": warnings,
})
}
fn run_pdf(mut self, progress: Progress, cancel: &AtomicBool) -> Result<Outcome, String> {
let target = PathBuf::from(&self.request.path);
let (part, file) = PartFile::new(&target)?;
let mut pdf = crate::pdf::Pdf::new(BufWriter::new(file))?;
let (w, h) = (self.width, self.height);
let total = self.slides.len() as u64;
let mut warnings = std::mem::take(&mut self.warnings);
let mut skipped = 0;
let slides = std::mem::take(&mut self.slides);
for (i, slide) in slides.into_iter().enumerate() {
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
let label = slide.label();
progress(i as u64, total, &label);
let (duration, fade) = (slide.duration, slide.fade);
let img = match slide.render(w, h) {
Ok(img) => img,
Err(e) => {
warnings.push(format!("{label}: {e}; skipped"));
skipped += 1;
continue;
}
};
let jpg = self.jpeg(&img)?;
// presentation mode: how long the page stays, and the fade into it
let mut extra = format!(" /Dur {duration:.2}");
if fade > 0.0 {
extra += &format!(" /Trans << /S /Fade /D {fade:.2} >>");
}
pdf.image_page(&jpg, w, h, (w as f64, h as f64), &extra)?;
}
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
let pages = pdf.pages();
if pages == 0 {
return Err("no slide could be rendered".into());
}
let bytes = pdf.len();
let catalog = if self.request.settings.full_screen { " /PageMode /FullScreen" } else { "" };
pdf.finish(catalog)?.into_inner().map_err(|e| format!("PDF: {}", e.error()))?.sync_all().map_err(|e| format!("PDF: {e}"))?;
part.commit(&self.guard)?;
progress(total, total, "");
let mut out = self.summary(Format::Pdf, pages, skipped, warnings);
out["pages"] = json!(pages);
out["bytes"] = json!(bytes);
out["fullScreen"] = json!(self.request.settings.full_screen);
Ok(Outcome { json: out })
}
fn run_jpeg(mut self, progress: Progress, cancel: &AtomicBool) -> Result<Outcome, String> {
let dir = PathBuf::from(&self.request.path);
let prefix = self
.request
.name
.clone()
.or_else(|| dir.file_name().map(|n| n.to_string_lossy().trim().to_string()).filter(|n| !n.is_empty()))
.unwrap_or_else(|| "Slideshow".into());
let digits = self.slides.len().to_string().len().max(3);
let (w, h) = (self.width, self.height);
let total = self.slides.len() as u64;
let mut warnings = std::mem::take(&mut self.warnings);
let mut skipped = 0;
let mut parts = Vec::new();
let mut bytes = 0usize;
let slides = std::mem::take(&mut self.slides);
for (i, slide) in slides.into_iter().enumerate() {
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
let label = slide.label();
progress(i as u64, total, &label);
let img = match slide.render(w, h) {
Ok(img) => img,
Err(e) => {
warnings.push(format!("{label}: {e}; skipped"));
skipped += 1;
continue;
}
};
let jpg = self.jpeg(&img)?;
let target = dir.join(format!("{prefix}-{:0digits$}.jpg", parts.len() + 1));
self.guard.check(&target)?;
let (part, mut f) = PartFile::new(&target)?;
f.write_all(&jpg).map_err(|e| format!("{}: {e}", part.part.display()))?;
f.sync_all().map_err(|e| format!("{}: {e}", part.part.display()))?;
bytes = bytes.saturating_add(jpg.len());
parts.push(part);
}
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
if parts.is_empty() {
return Err("no slide could be rendered".into());
}
let files: Vec<String> = parts.iter().map(|p| p.target.to_string_lossy().to_string()).collect();
// all written: now they take their names (a failure here removes the rest)
for part in parts {
part.commit(&self.guard)?;
}
progress(total, total, "");
let mut out = self.summary(Format::Jpeg, files.len(), skipped, warnings);
out["files"] = json!(files);
out["bytes"] = json!(bytes);
Ok(Outcome { json: out })
}
fn run_video(mut self, progress: Progress, cancel: &AtomicBool) -> Result<Outcome, String> {
let st = self.request.settings.clone();
let container = Container::parse(&st.container).unwrap_or(Container::Mp4);
let (w, h) = (self.width, self.height);
let fps = st.fps;
let mut enc = Av1Encoder::new(&VideoConfig { width: w as u32, height: h as u32, fps, quality: st.quality, speed: st.speed })
.map_err(|e| e.to_string())?;
let target = PathBuf::from(&self.request.path);
let (part, file) = PartFile::new(&target)?;
let mut mux = VideoWriter::new(BufWriter::new(file), container, w as u32, h as u32, fps).map_err(|e| e.to_string())?;
let total = self.total();
let mut warnings = std::mem::take(&mut self.warnings);
let mut skipped = 0;
let mut slides_done = 0;
let mut samples = 0u64;
let mut keys = 0u64;
// frames emitted so far, and the slideshow time (seconds) of the slides kept
let mut frames = 0u64;
let mut clock = 0.0f64;
let mut prev: Option<Rgba8> = None;
let mut emit = |mux: &mut VideoWriter<BufWriter<std::fs::File>>,
enc: &mut Av1Encoder,
pixels: &[[u8; 4]],
periods: u64,
key: bool|
-> Result<(), String> {
let out = enc.push(pixels, periods, key).map_err(|e| e.to_string())?;
for s in out {
if let Some(c) = enc.config_record() {
mux.set_config(c);
}
keys += u64::from(s.key);
samples += 1;
mux.add(&s).map_err(|e| e.to_string())?;
}
Ok(())
};
let fade_color = self.fade_color;
let slides = std::mem::take(&mut self.slides);
for slide in slides {
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
let label = slide.label();
progress(frames, total, &label);
let (duration, fade) = (slide.duration, slide.fade);
let img = match slide.render(w, h) {
Ok(img) => img,
Err(e) => {
warnings.push(format!("{label}: {e}; skipped"));
skipped += 1;
continue;
}
};
let start = (clock * f64::from(fps)).round() as u64;
clock += duration;
let end = ((clock * f64::from(fps)).round() as u64).max(start + 1);
let n = end - start;
let fade_frames = match &prev {
Some(_) => ((fade * f64::from(fps)).round() as u64).min(n),
None => 0,
};
if let Some(a) = &prev {
for j in 0..fade_frames {
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
let k = (j + 1) as f32 / (fade_frames + 1) as f32;
emit(&mut mux, &mut enc, &blend(a, &img, k, fade_color), 1, false)?;
frames += 1;
progress(frames, total, &label);
}
}
if n > fade_frames {
// a held slide is a key frame: clean of the slide before it, and a place to seek to
emit(&mut mux, &mut enc, &img.data, n - fade_frames, true)?;
frames += n - fade_frames;
}
slides_done += 1;
prev = Some(img);
}
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
if slides_done == 0 {
return Err("no slide could be rendered".into());
}
progress(frames, total, "AV1");
for s in enc.finish().map_err(|e| e.to_string())? {
if let Some(c) = enc.config_record() {
mux.set_config(c);
}
keys += u64::from(s.key);
samples += 1;
mux.add(&s).map_err(|e| e.to_string())?;
}
let periods = mux.periods();
let file = mux.finish().map_err(|e| e.to_string())?;
let file = file.into_inner().map_err(|e| format!("video: {}", e.error()))?;
file.sync_all().map_err(|e| format!("video: {e}"))?;
let bytes = file.metadata().map(|m| m.len()).unwrap_or(0);
drop(file);
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
part.commit(&self.guard)?;
progress(total, total, "");
let mut out = self.summary(Format::Video, slides_done, skipped, warnings);
out["container"] = json!(container.id());
out["codec"] = json!("av1");
out["fps"] = json!(fps);
out["frames"] = json!(periods);
out["samples"] = json!(samples);
out["keyFrames"] = json!(keys);
out["seconds"] = json!(periods as f64 / f64::from(fps));
out["bytes"] = json!(bytes);
out["audio"] = json!(false);
Ok(Outcome { json: out })
}
}
/// A slideshow export running in the background (`slideshow.export`; `slideshow.exportStatus`).
#[derive(Debug, Default)]
pub struct ExportRun {
/// Its row in the activity stack (`activity.cancel {id}` stops it).
pub task_id: u64,
pub format: String,
pub path: String,
pub total: std::sync::atomic::AtomicU64,
pub done: std::sync::atomic::AtomicU64,
pub cancel: Arc<AtomicBool>,
pub finished: AtomicBool,
/// The front end has told the user how it ended.
pub reported: AtomicBool,
pub result: std::sync::Mutex<Option<Result<Value, String>>>,
}
impl ExportRun {
pub fn running(&self) -> bool {
!self.finished.load(Ordering::Relaxed)
}
pub fn result(&self) -> Option<Result<Value, String>> {
self.result.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
}
pub fn json(&self) -> Value {
let mut out = json!({
"taskId": self.task_id,
"format": self.format,
"path": self.path,
"running": self.running(),
"done": self.done.load(Ordering::Relaxed),
"total": self.total.load(Ordering::Relaxed),
"cancelled": self.cancel.load(Ordering::Relaxed),
});
match self.result() {
Some(Ok(v)) => out["result"] = v,
Some(Err(e)) => out["error"] = json!(e),
None => {}
}
out
}
}
/// Run `prepared` to the end, recording progress and the outcome in `run` (and the row).
pub fn run_to_end(prepared: Prepared, run: &ExportRun, task: &crate::activity::TaskGuard) {
let handle = task.handle();
let progress = |done: u64, total: u64, detail: &str| {
run.done.store(done, Ordering::Relaxed);
run.total.store(total, Ordering::Relaxed);
handle.progress(done, total);
handle.detail(detail);
};
// a panic in a renderer or the encoder is an error, never a crash (the part file goes with it)
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| prepared.run(&progress, &run.cancel)))
.unwrap_or_else(|_| Err("slideshow export failed unexpectedly".into()))
.map(|o| o.json);
*run.result.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(r);
run.finished.store(true, Ordering::Relaxed);
}
@@ -0,0 +1,19 @@
{
"format": "lightcraft.slideshowTemplate",
"version": 1,
"name": "Cinema Wide",
"description": "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.",
"playback": {"slideDuration": 6, "fadeDuration": 1.5, "fadeColor": "#000000", "randomOrder": false, "repeat": false},
"layout": {
"aspect": "16:9", "linkMargins": false, "marginLeft": 0, "marginRight": 0, "marginTop": 0.09, "marginBottom": 0.09,
"zoomToFill": true, "backdropColor": "#000000"
},
"overlays": {
"texts": [
{"text": "{title}", "size": 0.03, "color": "#f0ece4", "opacity": 0.9, "anchor": "bottomLeft", "offsetX": 0.04, "offsetY": -0.025, "relativeTo": "slide", "shadow": false}
]
},
"titles": {
"ending": {"enabled": true, "color": "#000000", "content": "text", "text": "The End", "overrideColor": true, "textColor": "#e8e2d6", "scale": 0.07, "duration": 3}
}
}
@@ -0,0 +1,10 @@
{
"format": "lightcraft.slideshowTemplate",
"version": 1,
"name": "Edge to Edge",
"description": "Every photo cropped to fill the whole screen, with slow crossfades.",
"playback": {"slideDuration": 6, "fadeDuration": 2, "fadeColor": null, "randomOrder": false, "repeat": true},
"layout": {"zoomToFill": true},
"overlays": {},
"titles": {}
}
@@ -0,0 +1,20 @@
{
"format": "lightcraft.slideshowTemplate",
"version": 1,
"name": "Gallery Wall",
"description": "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.",
"playback": {"slideDuration": 5, "fadeDuration": 1.2, "fadeColor": null, "randomOrder": false, "repeat": true},
"layout": {
"linkMargins": false, "marginLeft": 0.1, "marginRight": 0.1, "marginTop": 0.08, "marginBottom": 0.16,
"strokeBorder": true, "strokeColor": "#ffffff", "strokeWidth": 0.012,
"castShadow": true, "shadowOpacity": 0.35, "shadowOffset": 0.012, "shadowRadius": 0.02, "shadowAngle": -60,
"backdropColor": "#dcd9d4"
},
"overlays": {
"ratingStars": {"enabled": true, "color": "#55524d", "size": 0.03, "anchor": "bottomRight", "offsetX": 0, "offsetY": 0.075, "relativeTo": "photo"},
"texts": [
{"text": "{caption}", "size": 0.032, "color": "#3c3a36", "opacity": 1, "anchor": "bottomLeft", "offsetX": 0, "offsetY": 0.08, "relativeTo": "photo", "shadow": false}
]
},
"titles": {}
}
@@ -0,0 +1,10 @@
{
"format": "lightcraft.slideshowTemplate",
"version": 1,
"name": "Plain Black",
"description": "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.",
"playback": {"slideDuration": 4, "fadeDuration": 1, "fadeColor": null, "randomOrder": false, "repeat": true},
"layout": {},
"overlays": {},
"titles": {}
}
@@ -0,0 +1,18 @@
{
"format": "lightcraft.slideshowTemplate",
"version": 1,
"name": "Shooting Data",
"description": "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.",
"playback": {"slideDuration": 5, "fadeDuration": 0.8, "fadeColor": null, "randomOrder": false, "repeat": true},
"layout": {
"linkMargins": false, "marginLeft": 0.05, "marginRight": 0.05, "marginTop": 0.05, "marginBottom": 0.15,
"backdropColor": "#1d2126", "colorWash": true, "washColor": "#3a4550", "washOpacity": 0.7, "washAngle": 90
},
"overlays": {
"texts": [
{"text": "{exposure} ISO {iso} {focal}", "size": 0.034, "color": "#e6e8ea", "opacity": 1, "anchor": "bottom", "offsetX": 0, "offsetY": -0.062, "relativeTo": "slide", "shadow": false},
{"text": "{camera} {lens}", "size": 0.026, "color": "#9aa4ae", "opacity": 1, "anchor": "bottom", "offsetX": 0, "offsetY": -0.024, "relativeTo": "slide", "shadow": false}
]
},
"titles": {}
}
+64
View File
@@ -313,6 +313,23 @@ fn permanently_deleting_explicit_targets_keeps_the_surviving_selection() {
assert_eq!(s.catalog.to_snapshot(), snapshot);
}
#[test]
fn brush_strokes_keep_pen_pressure_and_refuse_bad_lists() {
let mut s = demo();
s.execute("mask.add", &json!({"kind": "brush"})).unwrap();
s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.2], [0.3, 0.3]], "pressure": [0.25, 7.0]})).unwrap();
let lightcraft_develop::MaskShape::Brush { strokes } = &active_dev(&s).masks[0].components[0].shape else { panic!("a brush") };
assert_eq!(strokes[0].pressure, vec![0.25, 1.0], "kept, clamped to 0..1");
for bad in [json!([0.5, 0.5, 0.5]), json!("hard"), json!([0.5, "x"])] {
assert!(s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.2], [0.3, 0.3]], "pressure": bad})).is_err(), "{bad}");
}
s.execute("mask.brushStroke", &json!({"points": [[0.4, 0.4]]})).unwrap();
let lightcraft_develop::MaskShape::Brush { strokes } = &active_dev(&s).masks[0].components[0].shape else { panic!("a brush") };
assert!(strokes[1].pressure.is_empty(), "a mouse stroke has no pressure data");
let json = serde_json::to_string(&strokes[1]).unwrap();
assert!(!json.contains("pressure"), "and stores none: {json}");
}
#[test]
fn masks_crop_and_render() {
let mut s = demo();
@@ -457,6 +474,32 @@ fn degenerate_crops_render_with_spots_and_masks() {
}
}
/// `crop.rotateAspect` on a crop of no size took its aspect from 0/0 = NaN and stored a NaN crop
/// rectangle; it now falls back to the image's own aspect, a finite crop inside the image.
#[test]
fn rotate_aspect_of_degenerate_crop_stays_finite() {
for rect in [[0.5, 0.5, 0.5, 0.5], [0.2, 0.2, 0.2, 0.8], [0.2, 0.2, 0.8, 0.2]] {
let mut s = demo();
s.execute("crop.set", &json!({"rect": rect})).unwrap();
s.execute("crop.rotateAspect", &json!({})).unwrap();
let r = active_dev(&s).crop.geometry.rect;
assert!([r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()), "{rect:?}: {r:?}");
assert!(r.width() > 0.0 && r.height() > 0.0, "{rect:?}: {r:?}");
assert!(r.x0 >= 0.0 && r.y0 >= 0.0 && r.x1 <= 1.0 && r.y1 <= 1.0, "{rect:?}: {r:?}");
}
}
/// `crop.set` with a huge rect shrank it by a scale of 0 (`inf * 0`) and stored a NaN crop.
#[test]
fn crop_set_huge_rect_stays_finite() {
for rect in [[0.0, 0.0, 1e308, 1e308], [-1e308, -1e308, 1e308, 1e308]] {
let mut s = demo();
let _ = s.execute("crop.set", &json!({"rect": rect}));
let r = active_dev(&s).crop.geometry.rect;
assert!([r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && (-1e-6..=1.0 + 1e-6).contains(v)), "{rect:?}: {r:?}");
}
}
/// `mask.move` with a delta near `i64::MAX` overflowed `index + delta` (a panic wherever overflow
/// checks are on); extreme deltas and indices now move the mask to the bottom or the top.
#[test]
@@ -1519,3 +1562,24 @@ fn oversized_spots_render() {
}
}
}
/// Rotate turns the crop with the image: a crop of the left half becomes the top half, and turning back restores it.
#[test]
fn rotate_carries_the_crop_with_the_image() {
use lightcraft_geom::Rect;
let mut s = demo();
let id = s.active().unwrap();
let mut d = active_dev(&s);
let left_half = Rect { x0: 0.0, y0: 0.0, x1: 0.5, y1: 1.0 };
d.crop.geometry.rect = left_half;
d.crop.aspect = Some((2, 3));
s.set_develop(id, d, "Crop").unwrap();
s.execute("photo.rotateRight", &json!({})).unwrap();
let c = active_dev(&s).crop;
assert_eq!(c.geometry.rect, Rect { x0: 0.0, y0: 0.0, x1: 1.0, y1: 0.5 });
assert_eq!(c.aspect, Some((3, 2)), "a locked aspect turns too");
s.execute("photo.rotateLeft", &json!({})).unwrap();
let c = active_dev(&s).crop;
assert_eq!(c.geometry.rect, left_half);
assert_eq!(c.aspect, Some((2, 3)));
}
+105
View File
@@ -0,0 +1,105 @@
//! Removing photos from an album changes the album only (issue #1805): the photos stay in the
//! library, in their library folder and in the folder view of their folder on disk. Deleting a
//! photo (`photo.delete`) still removes it from all of them.
use std::path::{Path, PathBuf};
use lightcraft_catalog::PhotoId;
use serde_json::json;
use crate::{Selection, Session};
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-album-remove-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn png(p: &Path, seed: u8) {
let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]);
let b =
crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() }).unwrap();
std::fs::write(p, b).unwrap();
}
/// A folder of three files imported into a new album "Trip": (session, folder, album id, photos).
fn imported(tag: &str) -> (Session, String, u64, Vec<PhotoId>) {
let dir = temp_dir(tag);
for (i, n) in ["a.png", "b.png", "c.png"].iter().enumerate() {
png(&dir.join(n), i as u8 * 40);
}
let folder = dir.to_string_lossy().to_string();
let mut s = Session::new().with_fs();
let r = s.execute("library.import", &json!({"paths": [folder], "mode": "add", "albumName": "Trip"})).unwrap();
let album = r["album"].as_u64().expect("the import made the album");
let mut ids: Vec<PhotoId> = r["imported"].as_array().unwrap().iter().filter_map(|v| v.as_u64()).map(PhotoId).collect();
ids.sort();
assert_eq!(ids.len(), 3);
(s, folder, album, ids)
}
/// How many photos the library folder view, the folder (disk) view and the album show.
fn counts(s: &mut Session, folder: &str, album: u64) -> (u64, u64, u64) {
let lib = s.execute("library.source", &json!({"kind": "libraryFolder", "path": folder})).unwrap()["count"].as_u64().unwrap();
let disk = s.execute("library.browse", &json!({"path": folder})).unwrap()["photos"].as_u64().unwrap();
let disk_visible = s.visible_cloned().len() as u64;
assert_eq!(disk, disk_visible, "browse reports what it shows");
let al = s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap()["count"].as_u64().unwrap();
(lib, disk, al)
}
/// The reporter's steps: a folder added to an album, a photo removed in the album, then the folder
/// view: it still shows every photo of the folder.
#[test]
fn removing_from_an_album_keeps_the_photo_in_its_folder() {
let (mut s, folder, album, ids) = imported("keep");
assert_eq!(counts(&mut s, &folder, album), (3, 3, 3));
// in the album (its source on screen), remove one photo; no `id`: the album on screen
s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap();
s.selection = Selection::single(ids[1]);
let r = s.execute("album.removePhotos", &json!({})).unwrap();
assert_eq!(r["removed"], 1, "{r}");
assert_eq!(r["album"], "Trip", "{r}");
assert!(!s.catalog.photo(ids[1]).unwrap().deleted, "still in the library");
assert_eq!(counts(&mut s, &folder, album), (3, 3, 2), "(library folder, folder on disk, album)");
// removing what isn't in the album changes nothing (and makes no undo step)
let undo = s.undo.len();
s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap();
let r = s.execute("album.removePhotos", &json!({"ids": [ids[1].0]})).unwrap();
assert_eq!(r["removed"], 0, "{r}");
assert_eq!(s.undo.len(), undo);
let _ = std::fs::remove_dir_all(&folder);
}
/// Delete (to Recently Deleted) is the way out of the library: the photo leaves its folder views
/// and the album with it.
#[test]
fn deleting_a_photo_still_removes_it_everywhere() {
let (mut s, folder, album, ids) = imported("delete");
s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap();
s.selection = Selection::single(ids[0]);
s.execute("photo.delete", &json!({})).unwrap();
assert!(s.catalog.photo(ids[0]).unwrap().deleted);
assert_eq!(counts(&mut s, &folder, album), (2, 2, 2), "(library folder, folder on disk, album)");
let n = s.execute("library.source", &json!({"kind": "recentlyDeleted"})).unwrap()["count"].as_u64().unwrap();
assert_eq!(n, 1);
let _ = std::fs::remove_dir_all(&folder);
}
/// Without an album on screen and without `id` there is nothing to remove from; smart albums
/// follow their rules.
#[test]
fn remove_from_album_refuses_what_it_cannot_do() {
let (mut s, folder, _album, ids) = imported("refuse");
s.execute("library.source", &json!({"kind": "all"})).unwrap();
s.selection = Selection::single(ids[0]);
assert!(s.execute("album.removePhotos", &json!({})).is_err(), "no album on screen");
let smart = s.execute("album.createSmart", &json!({"name": "All", "rules": {}})).unwrap()["id"].as_u64().unwrap();
assert!(s.execute("album.removePhotos", &json!({"id": smart})).is_err(), "smart album");
assert!(s.execute("album.removePhotos", &json!({"id": 9999})).is_err(), "no such album");
assert!(s.execute("album.removePhotos", &json!({"id": "x"})).is_err(), "bad id");
let _ = std::fs::remove_dir_all(&folder);
}
+27
View File
@@ -107,6 +107,33 @@ fn a_straighten_slider_drag_fits_live_and_is_one_undo_step() {
assert!(close(geometry(&s), start));
}
#[test]
fn turning_a_crop_dragged_in_from_a_corner_keeps_that_crop() {
// issue #1794: lock the aspect, drag a corner in, then rotate: the crop went back to the photo's
// size, because the dragged crop still touches two edges and stops fitting at once
let mut s = Session::with_demo();
let (w, h) = dims(&s);
s.execute("crop.aspect", &json!({"aspect": "original"})).unwrap();
s.execute("crop.drag", &json!({"handle": "bottomRight", "from": [1.0, 1.0], "to": [0.6, 0.6]})).unwrap();
let made = geometry(&s).rect_px(w, h);
assert!(made.width() < 0.7 * w, "the drag cropped: {made:?}");
s.execute("develop.beginInteraction", &json!({"label": "Crop"})).unwrap();
for angle in [0.25, 1.0, 2.5, 5.0, -3.0] {
s.execute("crop.straighten", &json!({"angle": angle})).unwrap();
let g = geometry(&s);
let r = g.rect_px(w, h);
assert!(g.is_within_image(w, h), "{angle}°: inside the photo");
assert!(r.width() <= made.width() + 1e-6, "{angle}°: no larger than the crop made, got {r:?} from {made:?}");
assert!((r.aspect() - made.aspect()).abs() < 1e-6, "{angle}°: the locked aspect holds");
}
s.execute("develop.endInteraction", &json!({})).unwrap();
// the first small turn only trims it
s.execute("edit.undo", &json!({})).unwrap();
s.execute("crop.straighten", &json!({"angle": 0.25})).unwrap();
let r = geometry(&s).rect_px(w, h);
assert!(r.width() > 0.97 * made.width(), "0.25° keeps nearly all of it: {r:?} from {made:?}");
}
#[test]
fn a_crop_shrunk_by_straightening_grows_back_towards_0() {
// issue #1388: slide the Straighten slider out and back, in one drag or in several
+1
View File
@@ -705,6 +705,7 @@ fn a_photo_whose_file_changed_has_no_picture_until_it_is_made_again() {
file_size: p.file_size + 1,
content_hash: Some("ffffffffffffffffffffffffffffffff".into()),
preview_only: None,
preview_checked: String::new(),
},
)
.unwrap();
+154
View File
@@ -236,6 +236,69 @@ fn library_with_jpeg(tag: &str) -> (Session, lightcraft_catalog::PhotoId, std::p
(s, id, dir, src, bytes)
}
fn preset_export_preserves_originals(command: &str, selection: serde_json::Value, tag: &str) {
let (mut s, _, dir, src, original) = library_with_jpeg(tag);
let sidecar = src.with_extension("xmp");
let appended_sidecar = dir.join("IMG_1.jpg.xmp");
std::fs::write(&sidecar, b"existing sidecar").unwrap();
std::fs::write(&appended_sidecar, b"appended sidecar").unwrap();
std::fs::create_dir_all(dir.join("sub")).unwrap();
let mut targets = vec![src.clone(), dir.join("sub/../IMG_1.jpg"), sidecar.clone()];
targets.push(appended_sidecar.clone());
#[cfg(unix)]
{
let alias = dir.join("alias.jpg");
std::os::unix::fs::symlink(&src, &alias).unwrap();
targets.push(alias);
let alias = dir.join("alias.xmp");
std::os::unix::fs::symlink(&sidecar, &alias).unwrap();
targets.push(alias);
}
for path in targets {
let mut params = selection.clone();
params["path"] = json!(path.to_string_lossy());
let error = s.execute(command, &params).unwrap_err().to_string();
assert!(error.contains("never writes over an original"), "{error}");
assert_eq!(std::fs::read(&src).unwrap(), original);
assert_eq!(std::fs::read(&sidecar).unwrap(), b"existing sidecar");
assert_eq!(std::fs::read(&appended_sidecar).unwrap(), b"appended sidecar");
}
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn preset_export_never_overwrites_originals_or_sidecars() {
preset_export_preserves_originals("preset.export", json!({"ids": ["lc.warm-glow"]}), "preset-export-guard");
}
#[test]
fn curve_preset_export_never_overwrites_originals_or_sidecars() {
preset_export_preserves_originals("curve.exportPresets", json!({"names": ["Linear"]}), "curve-export-guard");
}
#[test]
fn preset_exports_preserve_previous_files_on_write_failure() {
let dir = temp_dir("preset-export-failure");
let mut s = Session::new();
for (command, selection, file) in
[("preset.export", json!({"ids": ["lc.warm-glow"]}), "look.lcpreset"), ("curve.exportPresets", json!({"names": ["Linear"]}), "curve.lccurve")]
{
let path = dir.join(file);
let mut params = selection;
params["path"] = json!(path.to_string_lossy());
s.execute(command, &params).unwrap();
let previous = std::fs::read(&path).unwrap();
{
let _fault = lightcraft_catalog::safe_file::fail_writes_after(4);
assert!(s.execute(command, &params).is_err(), "{command} must report the failed write");
}
assert_eq!(std::fs::read(&path).unwrap(), previous, "{command} preserves the prior export");
assert_eq!(s.execute(command, &params).unwrap()["count"], 1, "replacement still succeeds");
}
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "no temporary files remain");
let _ = std::fs::remove_dir_all(dir);
}
fn disk_batch(
s: &mut Session,
id: lightcraft_catalog::PhotoId,
@@ -874,3 +937,94 @@ mod apple_photos {
assert_eq!(s.commands().into_iter().find(|c| c.id == "export.addToPhotos").unwrap().enabled, cfg!(target_os = "macos"));
}
}
/// Issue #956: "Same folder as original photo" puts each photo's export next to its own original
/// (plus the subfolder), so one batch from two folders lands in two folders. The conflict policy
/// and the original guard work per folder as they do for a specific folder.
#[test]
fn same_folder_as_original_exports_next_to_each_photo() {
use crate::export::{Conflict, Destination, DestinationMode};
let dir = temp_dir("export-same-folder");
let (a, b) = (dir.join("A"), dir.join("B"));
// two different photos (the same bytes would be imported once)
let jpeg = |blue: u8| {
let img = lightcraft_raster::Rgba8::from_fn(48, 32, |x, y| [(x * 5) as u8, (y * 7) as u8, blue, 255]);
crate::export::encode_image(&img, &ExportOptions::default()).unwrap()
};
let originals = [(&a, jpeg(120)), (&b, jpeg(30))];
for (d, bytes) in &originals {
std::fs::create_dir_all(d).unwrap();
std::fs::write(d.join("IMG_1.jpg"), bytes).unwrap();
}
let mut s = Session::new().with_fs();
let paths = [a.join("IMG_1.jpg").to_string_lossy().to_string(), b.join("IMG_1.jpg").to_string_lossy().to_string()];
s.execute("library.import", &json!({"paths": paths})).unwrap();
let ids: Vec<_> = s.catalog.photos().map(|p| p.id).collect();
assert_eq!(ids.len(), 2);
// the folder of a specific-folder export is not used
let to = Destination { dir: dir.join("unused").to_string_lossy().to_string(), exact: None };
let o = ExportOptions { destination_mode: DestinationMode::SameAsOriginal, subfolder: "Exports".into(), ..Default::default() };
let exists = |p: &str| std::path::Path::new(p).exists();
let files = crate::export::export_batch(&mut s, &ids, &o, &to, &mut crate::export::write_file, &exists).unwrap();
let written: Vec<&str> = files.iter().map(|f| f["path"].as_str().unwrap()).collect();
let mut want = vec![a.join("Exports/IMG_1.jpg").to_string_lossy().to_string(), b.join("Exports/IMG_1.jpg").to_string_lossy().to_string()];
let mut got: Vec<String> = written.iter().map(|p| p.to_string()).collect();
want.sort();
got.sort();
assert_eq!(got, want);
assert!(!dir.join("unused").exists(), "nothing written to the specific folder");
// again: each folder's own collision gets the next free name
let files = crate::export::export_batch(&mut s, &ids, &o, &to, &mut crate::export::write_file, &exists).unwrap();
assert!(files.iter().all(|f| f["path"].as_str().unwrap().ends_with("Exports/IMG_1-2.jpg")), "{files:?}");
// no subfolder: next to the original, which is never written over
let beside = ExportOptions { subfolder: String::new(), conflict: Conflict::Overwrite, ..o.clone() };
let err = crate::export::export_batch(&mut s, &ids, &beside, &to, &mut crate::export::write_file, &exists).unwrap_err();
assert!(err.contains("original of IMG_1.jpg"), "{err}");
let unique = ExportOptions { conflict: Conflict::Unique, ..beside };
let files = crate::export::export_batch(&mut s, &ids, &unique, &to, &mut crate::export::write_file, &exists).unwrap();
assert!(files.iter().all(|f| f["path"].as_str().unwrap().ends_with("IMG_1-2.jpg")), "{files:?}");
for (d, bytes) in &originals {
assert_eq!(&std::fs::read(d.join("IMG_1.jpg")).unwrap(), bytes, "the original is untouched");
assert!(d.join("IMG_1-2.jpg").exists());
}
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #956: a photo with no folder on disk (a demo photo) or whose folder is gone gets its own
/// error in "Same folder as original photo" mode instead of a file written somewhere else.
#[test]
fn same_folder_as_original_refuses_photos_without_a_folder() {
use crate::export::{Destination, DestinationMode};
let o = ExportOptions { destination_mode: DestinationMode::SameAsOriginal, subfolder: "Exports".into(), ..Default::default() };
let to = Destination { dir: "out".into(), exact: None };
let mut s = Session::with_demo();
let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
let mut written = Vec::new();
let mut write = |p: &str, _: &[u8]| -> Result<(), String> {
written.push(p.to_string());
Ok(())
};
let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err();
assert!(err.contains("no folder on disk"), "{err}");
// without stopping: one error per photo, nothing written
let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap();
let files = crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap();
assert_eq!(files.len(), 2);
for (f, id) in files.iter().zip(&ids) {
assert_eq!(f["photo"], id.0, "{f}");
assert!(f["error"].as_str().unwrap().contains("no folder on disk"), "{f}");
}
drop(write);
assert!(written.is_empty(), "{written:?}");
// a photo on disk whose folder is gone (moved, an unplugged drive)
let (mut s, id, dir, _src, _) = library_with_jpeg("export-same-folder-gone");
let items = crate::export::prepare_batch(&mut s, &[id], &o).unwrap();
let gone = dir.to_string_lossy().to_string();
let exists = |p: &str| p != gone && !p.starts_with(&format!("{gone}/"));
let mut write = |p: &str, _: &[u8]| -> Result<(), String> { Err(format!("wrote {p}")) };
let files = crate::export::run_batch(items, &o, &to, &mut write, &exists, false, &mut |_, _| true).unwrap();
let e = files[0]["error"].as_str().unwrap();
assert!(e.contains("folder is missing") && e.contains(&gone), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
+95
View File
@@ -523,3 +523,98 @@ fn face_scan_row_follows_pending_photos() {
assert!(faces_rows(&s).is_empty(), "the row goes when the scan is done");
let _ = std::fs::remove_dir_all(&d);
}
fn face_names(s: &Session, id: PhotoId) -> Vec<Option<String>> {
s.catalog.photo(id).unwrap().meta.regions.iter().map(|r| r.name.clone()).collect()
}
/// Every in-library spelling of a person becomes the exact new name, including a change of case only,
/// and a person filter follows it. One undo step puts the old names back.
#[test]
fn rename_person_rewrites_every_spelling_and_undoes() {
let mut s = Session::with_demo();
let (a, b) = two_photos(&s);
set_regions(&mut s, a, vec![region(0.1, Some("Ann")), region(0.5, Some(" ANN ")), region(0.7, Some("Rex"))]);
let mut pet = region(0.2, Some("Ann"));
pet.kind = RegionKind::Pet;
set_regions(&mut s, b, vec![region(0.3, Some("ann")), pet]);
s.execute("library.filter", &json!({"person": "ANN"})).unwrap();
let undo = s.undo.len();
let r = s.execute("faces.renamePerson", &json!({"from": " ann ", "to": " Ada "})).unwrap();
assert_eq!((r["faces"].clone(), r["photos"].clone(), r["to"].clone(), r["merged"].clone()), (json!(3), json!(2), json!("Ada"), json!(false)));
assert_eq!(face_names(&s, a), vec![Some("Ada".into()), Some("Ada".into()), Some("Rex".into())]);
assert_eq!(face_names(&s, b)[0], Some("Ada".into()));
assert_eq!(face_names(&s, b)[1], Some("Ann".into()), "a pet is not a person");
assert_eq!(s.catalog.people().iter().find(|p| p.name.eq_ignore_ascii_case("ada")).map(|p| p.name.as_str()), Some("Ada"));
assert_eq!(s.filter.person.as_deref(), Some("Ada"));
assert_eq!(s.undo.len(), undo + 1);
assert_eq!(s.undo.last().map(|u| u.label.as_str()), Some("Rename Person"));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(face_names(&s, a), vec![Some("Ann".into()), Some(" ANN ".into()), Some("Rex".into())]);
assert_eq!(face_names(&s, b)[0], Some("ann".into()));
}
/// Renaming onto someone else is refused until merge is asked for; then one undo step restores both people.
#[test]
fn rename_person_refuses_a_merge_until_it_is_asked_for() {
let mut s = Session::with_demo();
let (a, b) = two_photos(&s);
set_regions(&mut s, a, vec![region(0.1, Some("Ann"))]);
set_regions(&mut s, b, vec![region(0.3, Some("Bea"))]);
let before = (face_names(&s, a), face_names(&s, b), s.undo.len());
let err = s.execute("faces.renamePerson", &json!({"from": "Ann", "to": "Bea"})).unwrap_err();
assert!(err.to_string().contains("already another person"), "{err}");
assert_eq!((face_names(&s, a), face_names(&s, b), s.undo.len()), before, "a refusal changes nothing");
let r = s.execute("faces.renamePerson", &json!({"from": "Ann", "to": "Bea", "merge": true})).unwrap();
assert_eq!(r["merged"], true);
assert_eq!(s.undo.last().map(|u| u.label.as_str()), Some("Merge People"));
let names: Vec<String> = s.catalog.people().into_iter().map(|p| p.name).collect();
assert_eq!(names, vec!["Bea".to_string()]);
s.execute("edit.undo", &json!({})).unwrap();
let mut names: Vec<String> = s.catalog.people().into_iter().map(|p| p.name).collect();
names.sort();
assert_eq!(names, vec!["Ann".to_string(), "Bea".to_string()]);
}
/// More faces than `faces.person` lists are still renamed. A bad call, a missing person, or a name that
/// is already exact changes nothing, and deleted or only-browsed photos are not part of the person.
#[test]
fn rename_person_covers_more_than_the_person_query_and_rejects_bad_input() {
let mut s = Session::with_demo();
let (a, b) = two_photos(&s);
set_regions(&mut s, a, vec![region(0.1, Some("Ann"))]);
let undo = s.undo.len();
for p in [
json!({}),
json!({"from": "Ann"}),
json!({"from": " ", "to": "Ada"}),
json!({"from": 1, "to": "Ada"}),
json!({"from": "Ann", "to": ""}),
json!({"from": "Ann", "to": 5}),
json!({"from": "Ann", "to": "a\u{7}b"}),
json!({"from": "Ann", "to": "x".repeat(201)}),
json!({"from": "Nobody", "to": "Ada"}),
json!({"from": "Ann", "to": "Ann"}),
] {
let err = s.execute("faces.renamePerson", &p).unwrap_err();
assert!(s.undo.len() == undo, "{p} changed the catalog: {err}");
}
assert_eq!(face_names(&s, a), vec![Some("Ann".into())]);
let regions: Vec<_> = (0..5001).map(|i| region(0.1, Some(if i == 0 { "ANN" } else { "Ann" }))).collect();
set_regions(&mut s, a, regions);
set_regions(&mut s, b, vec![region(0.2, Some("Ann"))]);
s.commit("delete", Op::SetDeleted { id: b, deleted: true }).unwrap();
let listed = s.execute("faces.person", &json!({"name": "Ann"})).unwrap();
assert_eq!((listed["total"].clone(), listed["confirmed"].as_array().unwrap().len()), (json!(5001), 5000));
let r = s.execute("faces.renamePerson", &json!({"from": "ann", "to": "Ada"})).unwrap();
assert_eq!((r["faces"].clone(), r["photos"].clone()), (json!(5001), json!(1)), "the deleted photo is left alone");
assert!(s.catalog.photo(a).unwrap().meta.regions.iter().all(|r| r.name.as_deref() == Some("Ada")));
assert_eq!(s.catalog.photo(b).unwrap().meta.regions[0].name.as_deref(), Some("Ann"));
let local = s.catalog.photos().map(|p| p.id).find(|id| *id != a && *id != b).unwrap();
set_regions(&mut s, local, vec![region(0.1, Some("Ada"))]);
s.commit("browse", Op::SetLocal { id: local, local: true }).unwrap();
let r = s.execute("faces.renamePerson", &json!({"from": "Ada", "to": "Bea"})).unwrap();
assert_eq!(r["photos"], 1, "the browsed photo is not another person and is not renamed");
assert_eq!(s.catalog.photo(a).unwrap().meta.regions[0].name.as_deref(), Some("Bea"));
assert_eq!(s.catalog.photo(local).unwrap().meta.regions[0].name.as_deref(), Some("Ada"));
}
+1 -1
View File
@@ -222,7 +222,7 @@ fn face_tiles_never_crash() {
assert!(job.run().rendered.is_ok());
let thumbs = s.library.as_ref().unwrap().thumbs_dir();
drop(s);
let entry = thumbs.join(&key[..2]).join(format!("{key}.jpg"));
let entry = thumbs.join(&key[..2]).join(format!("{key}.lcthumb"));
assert!(entry.exists(), "the tile was written to the thumbnail cache");
std::fs::write(&entry, b"not a jpeg").unwrap();
let mut s = open(&dir);

Some files were not shown because too many files have changed in this diff Show More