mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 19:00:31 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -21,6 +21,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 } => {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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<_>>();
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()}))
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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(¤t);
|
||||
assert_eq!((out.exposure, out.shadows, out.amount), (1.0, 0.0, 60.0));
|
||||
assert!(e.matches(&out));
|
||||
assert!(!e.matches(¤t));
|
||||
}
|
||||
|
||||
#[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(¤t);
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)]
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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
|
||||
)]
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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}))
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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(()) };
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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..];
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, ¶ms).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, ¶ms).unwrap();
|
||||
let previous = std::fs::read(&path).unwrap();
|
||||
{
|
||||
let _fault = lightcraft_catalog::safe_file::fail_writes_after(4);
|
||||
assert!(s.execute(command, ¶ms).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, ¶ms).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);
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user