Compare commits
229
Commits
No files matched your search
@@ -0,0 +1,48 @@
|
||||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
name: FreeBSD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: freebsd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
@@ -1,6 +1,7 @@
|
||||
*~
|
||||
plan/
|
||||
/target
|
||||
/target-*/
|
||||
/dist
|
||||
/corpus
|
||||
/log
|
||||
|
||||
@@ -5,8 +5,13 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
|
||||
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots).
|
||||
3. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (Lightroom parity tracker: one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
|
||||
4. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) → *Where we stand* (honest assessment by dimension and
|
||||
by kind of user) and *Where we're going* (ordered priorities). The checklist counts features that *exist*; the real
|
||||
gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render
|
||||
fidelity against Lightroom, AI models, HDR / video / Classic modules. A ✅ row is not proof of parity — if you find a
|
||||
✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note.
|
||||
4. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
|
||||
5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
`plan/` is gitignored (local-only).
|
||||
|
||||
@@ -17,6 +22,34 @@ branches can still break each other (e.g. a new struct field vs. a new construct
|
||||
`git log main..HEAD` + `git status` for unmerged commits or uncommitted work before starting anything new. Commit small
|
||||
and often so a crash loses minutes, not hours.
|
||||
|
||||
## Never crash (outranks feature work)
|
||||
People trust LightCraft with their photo libraries and edits; a crash loses their work. A malformed raw/JPEG/XMP, a bad
|
||||
command, control or MCP argument, a corrupt catalog or settings file, or a full disk must produce an error the user (or
|
||||
agent) can act on, never a panic. Don't ship a feature by adding a panic path, and fix a crash before building on top
|
||||
of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
([storytold/craftrules](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md)).
|
||||
- **Non-test code never panics:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`, and
|
||||
no `unsafe`. Return the crate's error type through `Result` and `?`; use `ok_or(..)?`, `let … else`, `if let`, or a
|
||||
fallback (`unwrap_or…`) only where it can't silently corrupt a document. An unfinished feature returns an
|
||||
"unsupported" error or is disabled. Sole exception: a provably infallible literal, as `#[allow(clippy::expect_used)]`
|
||||
+ `.expect("why it can't fail")`.
|
||||
- **`unsafe` lives only in `crates/sysmem`** (one FFI call, `malloc_zone_pressure_relief`, that returns freed
|
||||
allocator pages to macOS after raw decodes). Every other production crate root has `#![forbid(unsafe_code)]`. A new
|
||||
unsafe need goes in an isolated, well-tested helper crate like it: `// SAFETY:` on every block, a safe API, a safe
|
||||
fallback where possible, and a line here naming it.
|
||||
- **Input-derived numbers are hostile:** `get()` instead of `[i]`/`[a..b]` for offsets from files, users, agents or
|
||||
arithmetic on them; checked/saturating math for lengths, offsets and counts; no division by zero, NaN/inf or negative
|
||||
casts to `usize`; cap allocations sized by input; slice strings only at char boundaries.
|
||||
- **Bound recursion** (depth limits or seen-sets: IFD chains, nested metadata, collections). **Don't cascade:**
|
||||
`lock().unwrap_or_else(PoisonError::into_inner)` or an error; worker-thread joins are `Result`s.
|
||||
- **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped
|
||||
panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native.
|
||||
- **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix.
|
||||
- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic/indexing in tests only, and every production crate
|
||||
root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic,
|
||||
clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Not `[workspace.lints]`: those would also hit
|
||||
integration tests, examples and benches. New crates start with the attribute.
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…).
|
||||
- **Pure Rust** in the product. No C/C++ dependencies.
|
||||
@@ -31,6 +64,7 @@ and often so a crash loses minutes, not hours.
|
||||
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
|
||||
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL).
|
||||
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
|
||||
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
|
||||
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
|
||||
- Icons drawn in code (e.g. `crates/ui-egui/src/icons.rs`) are original work and are recorded in `assets/ATTRIBUTION.md` as such; do not trace them from Adobe icons.
|
||||
- Demo/test images: generated procedurally by `lightcraft-scenes`, or CC0 downloads kept in the gitignored `corpus/` with their source recorded. Screenshots of Adobe apps live only in the gitignored `plan/` and are never committed or published.
|
||||
@@ -57,6 +91,7 @@ and often so a crash loses minutes, not hours.
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
|
||||
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; each agent uses its **own control port** (pick one in 18000–19999, never the default 7980) and its own scratch subfolder (`<scratch>/<agent-name>/`) — never `rm -rf` shared paths; delete your target dir when done (disk is shared); keep every `Cargo.toml` valid at all times (the `crates/*` glob means one broken manifest breaks everyone).
|
||||
- Test corpora: `cargo xtask corpus --download` into `corpus/` (gitignored, CC0 only). Never commit media.
|
||||
- Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||
|
||||
## Testing & performance (do this often)
|
||||
- Unit/property tests next to the code; end-to-end tests drive real binaries (`crates/mcp/tests/e2e.rs`,
|
||||
|
||||
@@ -5,8 +5,13 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
|
||||
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots).
|
||||
3. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (Lightroom parity tracker: one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
|
||||
4. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) → *Where we stand* (honest assessment by dimension and
|
||||
by kind of user) and *Where we're going* (ordered priorities). The checklist counts features that *exist*; the real
|
||||
gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render
|
||||
fidelity against Lightroom, AI models, HDR / video / Classic modules. A ✅ row is not proof of parity — if you find a
|
||||
✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note.
|
||||
4. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
|
||||
5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
`plan/` is gitignored (local-only).
|
||||
|
||||
@@ -17,6 +22,34 @@ branches can still break each other (e.g. a new struct field vs. a new construct
|
||||
`git log main..HEAD` + `git status` for unmerged commits or uncommitted work before starting anything new. Commit small
|
||||
and often so a crash loses minutes, not hours.
|
||||
|
||||
## Never crash (outranks feature work)
|
||||
People trust LightCraft with their photo libraries and edits; a crash loses their work. A malformed raw/JPEG/XMP, a bad
|
||||
command, control or MCP argument, a corrupt catalog or settings file, or a full disk must produce an error the user (or
|
||||
agent) can act on, never a panic. Don't ship a feature by adding a panic path, and fix a crash before building on top
|
||||
of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
([storytold/craftrules](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md)).
|
||||
- **Non-test code never panics:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`, and
|
||||
no `unsafe`. Return the crate's error type through `Result` and `?`; use `ok_or(..)?`, `let … else`, `if let`, or a
|
||||
fallback (`unwrap_or…`) only where it can't silently corrupt a document. An unfinished feature returns an
|
||||
"unsupported" error or is disabled. Sole exception: a provably infallible literal, as `#[allow(clippy::expect_used)]`
|
||||
+ `.expect("why it can't fail")`.
|
||||
- **`unsafe` lives only in `crates/sysmem`** (one FFI call, `malloc_zone_pressure_relief`, that returns freed
|
||||
allocator pages to macOS after raw decodes). Every other production crate root has `#![forbid(unsafe_code)]`. A new
|
||||
unsafe need goes in an isolated, well-tested helper crate like it: `// SAFETY:` on every block, a safe API, a safe
|
||||
fallback where possible, and a line here naming it.
|
||||
- **Input-derived numbers are hostile:** `get()` instead of `[i]`/`[a..b]` for offsets from files, users, agents or
|
||||
arithmetic on them; checked/saturating math for lengths, offsets and counts; no division by zero, NaN/inf or negative
|
||||
casts to `usize`; cap allocations sized by input; slice strings only at char boundaries.
|
||||
- **Bound recursion** (depth limits or seen-sets: IFD chains, nested metadata, collections). **Don't cascade:**
|
||||
`lock().unwrap_or_else(PoisonError::into_inner)` or an error; worker-thread joins are `Result`s.
|
||||
- **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped
|
||||
panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native.
|
||||
- **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix.
|
||||
- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic/indexing in tests only, and every production crate
|
||||
root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic,
|
||||
clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Not `[workspace.lints]`: those would also hit
|
||||
integration tests, examples and benches. New crates start with the attribute.
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…).
|
||||
- **Pure Rust** in the product. No C/C++ dependencies.
|
||||
@@ -31,6 +64,7 @@ and often so a crash loses minutes, not hours.
|
||||
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
|
||||
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL).
|
||||
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
|
||||
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
|
||||
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
|
||||
- Icons drawn in code (e.g. `crates/ui-egui/src/icons.rs`) are original work and are recorded in `assets/ATTRIBUTION.md` as such; do not trace them from Adobe icons.
|
||||
- Demo/test images: generated procedurally by `lightcraft-scenes`, or CC0 downloads kept in the gitignored `corpus/` with their source recorded. Screenshots of Adobe apps live only in the gitignored `plan/` and are never committed or published.
|
||||
@@ -57,6 +91,7 @@ and often so a crash loses minutes, not hours.
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
|
||||
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; each agent uses its **own control port** (pick one in 18000–19999, never the default 7980) and its own scratch subfolder (`<scratch>/<agent-name>/`) — never `rm -rf` shared paths; delete your target dir when done (disk is shared); keep every `Cargo.toml` valid at all times (the `crates/*` glob means one broken manifest breaks everyone).
|
||||
- Test corpora: `cargo xtask corpus --download` into `corpus/` (gitignored, CC0 only). Never commit media.
|
||||
- Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||
|
||||
## Testing & performance (do this often)
|
||||
- Unit/property tests next to the code; end-to-end tests drive real binaries (`crates/mcp/tests/e2e.rs`,
|
||||
|
||||
Generated
+33
-21
@@ -2073,13 +2073,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-engine",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-sysmem",
|
||||
"lightcraft-ui-egui",
|
||||
"log",
|
||||
"muda",
|
||||
@@ -2090,7 +2091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-catalog"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"lightcraft-develop",
|
||||
@@ -2099,11 +2100,12 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"dhat",
|
||||
"lightcraft-codecs",
|
||||
@@ -2111,13 +2113,14 @@ dependencies = [
|
||||
"lightcraft-mcp",
|
||||
"lightcraft-merge",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-sysmem",
|
||||
"lightcraft-ui-egui",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-codecs"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"image",
|
||||
@@ -2142,7 +2145,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-color"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"proptest",
|
||||
@@ -2151,7 +2154,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-develop"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
@@ -2162,7 +2165,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-engine"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"libc",
|
||||
@@ -2180,15 +2183,17 @@ dependencies = [
|
||||
"lightcraft-raw",
|
||||
"lightcraft-scenes",
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-geom"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -2196,7 +2201,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-gpu"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"lightcraft-color",
|
||||
@@ -2212,7 +2217,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-mcp"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-engine",
|
||||
@@ -2223,7 +2228,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-merge"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-color",
|
||||
@@ -2239,7 +2244,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-meta"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"lightcraft-tiff",
|
||||
@@ -2251,7 +2256,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-pipeline"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -2267,7 +2272,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-preview"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"lightcraft-raster",
|
||||
@@ -2279,7 +2284,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raster"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"lightcraft-color",
|
||||
@@ -2291,8 +2296,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raw"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-meta",
|
||||
@@ -2303,11 +2309,13 @@ dependencies = [
|
||||
"rayon",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-scenes"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -2316,9 +2324,13 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-sysmem"
|
||||
version = "0.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-tiff"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -2327,7 +2339,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-ui-egui"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"lightcraft-catalog",
|
||||
@@ -2347,7 +2359,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-web"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5115,7 +5127,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"flate2",
|
||||
|
||||
+2
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -12,6 +12,7 @@ repository = "https://github.com/storytold/lightcraft"
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
lightcraft-geom = { path = "crates/geom" }
|
||||
lightcraft-sysmem = { path = "crates/sysmem" }
|
||||
lightcraft-color = { path = "crates/color" }
|
||||
lightcraft-raster = { path = "crates/raster" }
|
||||
lightcraft-tiff = { path = "crates/tiff" }
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,12 +1,20 @@
|
||||
LightCraft
|
||||
Copyright the LightCraft contributors. Licensed under MIT OR Apache-2.0.
|
||||
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors.
|
||||
Licensed under the MIT License or the Apache License, Version 2.0, at your option
|
||||
(LICENSE-MIT, LICENSE-APACHE).
|
||||
|
||||
This product bundles third-party material under the following licences:
|
||||
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
|
||||
not open source and are not covered by the license above; their terms are in
|
||||
docs/brand/LICENSE-brand.txt.
|
||||
|
||||
- Source Sans 3 (assets/fonts/SourceSans3-*) — SIL Open Font License 1.1. See assets/fonts/OFL-SourceSans3.txt.
|
||||
- Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
||||
Third-party material
|
||||
|
||||
Every bundled asset (fonts, icons, images) and its licence is listed in assets/ATTRIBUTION.md.
|
||||
This product bundles third-party material under its own licenses, listed below. The per-file
|
||||
list (author, source, license) is in assets/ATTRIBUTION.md.
|
||||
|
||||
Algorithms are implemented from public specifications and papers (see crate READMEs). Rust dependencies
|
||||
(egui, eframe, image, rayon, serde, …) are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io.
|
||||
Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
||||
Showcase photographs by Ansel Adams, Dorothea Lange and NASA (docs/images/) — public domain. See assets/ATTRIBUTION.md.
|
||||
|
||||
BIZ UDMincho: Copyright 2022 The BIZ UDMincho Project Authors
|
||||
(https://github.com/googlefonts/morisawa-biz-ud-mincho). SIL Open Font License 1.1;
|
||||
see assets/fonts/OFL-BIZUDMincho.txt.
|
||||
@@ -280,9 +280,18 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
|
||||
## Feature status
|
||||
|
||||
LightCraft is young and moving fast; see the [roadmap](ROADMAP.md) for estimates. The detailed, row-by-row
|
||||
Lightroom parity tracker (every feature, menu item and shortcut with its status, evidence and the top gaps) is
|
||||
[docs/parity.md](docs/parity.md).
|
||||
LightCraft is young and moving fast. **Where we honestly stand** (details in the [roadmap](ROADMAP.md#where-we-stand)):
|
||||
|
||||
- **By feature count we're at ~79%** of Lightroom (core features 98%), tracked row by row in
|
||||
[docs/parity.md](docs/parity.md).
|
||||
- **As a day-to-day Lightroom replacement we're nearer 60–70%.** It's great for JPEG/DNG and most Nikon / Sony /
|
||||
older-Canon raws on one machine.
|
||||
- **The biggest gaps:**
|
||||
- **camera colour calibration:** raws other than DNG develop with a neutral colour matrix today, so colour is muted;
|
||||
- **CR3 and compressed Fujifilm / Olympus raws:** these open as embedded previews only;
|
||||
- **AI masks and denoise:** subject and sky selection are classical heuristics;
|
||||
- **HDR, video and the Classic Print / Book / Map modules.**
|
||||
- **What's next:** see [where we're going](ROADMAP.md#where-were-going).
|
||||
|
||||
| Area | Status |
|
||||
|---|---|
|
||||
@@ -292,17 +301,20 @@ Lightroom parity tracker (every feature, menu item and shortcut with its status,
|
||||
| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) |
|
||||
| Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ |
|
||||
| Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ |
|
||||
| Camera colour: DNG files use their own matrices | ✅ · our own calibration for other raws ⬜ (top priority; neutral fallback today) |
|
||||
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
|
||||
| RAW: DNG, CR2, ARW, NEF (uncompressed), Fujifilm RAF (Bayer + X-Trans), Panasonic RW2, Pentax PEF, Olympus ORF; embedded previews for every format incl. CR3 | ✅ · compressed NEF/RAF/ORF, CR3 decode 🚧 |
|
||||
| RAW: DNG, CR2, ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed, Bayer + X-Trans), Panasonic RW2, Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | ✅ · CR3, compressed RAF/ORF decode ⬜ |
|
||||
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ |
|
||||
| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 |
|
||||
| Export: JPEG / PNG / TIFF / WebP / AVIF, sizing, file-size limit, output sharpening, naming, batch, metadata policy, text watermark | ✅ · DNG export, image watermark ⬜ |
|
||||
| Library persistence (crash-safe op log + snapshots), disk thumbnail cache, import with duplicate detection | ✅ |
|
||||
| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR export ⬜ |
|
||||
| Library persistence (crash-safe op log + snapshots, background compaction, failed saves reported), disk thumbnail cache | ✅ |
|
||||
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ |
|
||||
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
|
||||
| XMP sidecars (read/write, auto-write), reading `crs:` develop settings, preset files (`.lcpreset`, XMP presets) | ✅ |
|
||||
| Optics (distortion, vignetting, auto + manual CA, defringe, DNG-embedded lens corrections), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
|
||||
| Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ |
|
||||
| GPU pipeline (wgpu compute, CPU-exact within 1/255) with CPU fallback | ✅ · WebGPU in the browser 🚧 |
|
||||
| GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 |
|
||||
| AI: segmentation masks, AI denoise, super resolution, faces; HDR editing; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) |
|
||||
| Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 |
|
||||
|
||||
<sub>✅ works today · 🚧 in progress · ⬜ not started</sub>
|
||||
@@ -347,6 +359,9 @@ Humans and agents follow the same rules, so read [AGENTS.md](AGENTS.md) first. T
|
||||
an entry in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) added in the same commit.
|
||||
- **Pure Rust**, enforced crate layering, everything is a command, and `cargo xtask ci` green before every commit
|
||||
(one task id per commit).
|
||||
- **Never crash.** Non-test code returns errors instead of panicking: no `unwrap()`, `expect()`, `panic!` or
|
||||
`unsafe`, checked indexing on anything derived from input, and a regression test with every crash fix. Details in
|
||||
[AGENTS.md](AGENTS.md#never-crash-outranks-feature-work).
|
||||
|
||||
Questions, ideas or a bug you'd like to talk through first? Bring them to [Discord](https://discord.gg/artcraft).
|
||||
|
||||
@@ -396,12 +411,21 @@ And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video stud
|
||||
|
||||
## License and credits
|
||||
|
||||
LightCraft is licensed under **MIT OR Apache-2.0**.
|
||||
LightCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
|
||||
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors. Required notices are in [NOTICE](NOTICE).
|
||||
|
||||
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
|
||||
with its author, source and license in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md).
|
||||
|
||||
Showcase photographs are public-domain works, used via Wikimedia Commons: Ansel Adams, *The Tetons and the Snake River*
|
||||
(1942, U.S. National Archives); Dorothea Lange, *Migrant Mother* (1936, Library of Congress); Bill Anders / NASA,
|
||||
*Earthrise* (1968); NASA, *The Blue Marble* (1972). The demo library is procedurally generated by LightCraft. UI font:
|
||||
Inter (SIL OFL). All icons are original. See [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md).
|
||||
Inter (SIL OFL 1.1). All icons are original.
|
||||
|
||||
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
|
||||
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
|
||||
part of this repository and LightCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. LightCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
|
||||
+114
-31
@@ -4,6 +4,68 @@ Milestones toward full Adobe Lightroom parity (cloud Lightroom first, then every
|
||||
estimates for continuous (24/7) agent-driven development with 4–6 parallel agents. Estimates are calibrated on the sibling
|
||||
projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revised as milestones land.
|
||||
|
||||
## Where we stand
|
||||
|
||||
*Honest assessment, 2026-10-05. Agents: read this before picking work. The checklist in
|
||||
[`docs/parity.md`](docs/parity.md) counts features that **exist**; this section is about whether a photographer can
|
||||
**switch** from Lightroom. Update it when a gap below closes.*
|
||||
|
||||
**In one line:** the checklist says **79%** (P0 98.5%, P1 95.9%, P2 39%), but measured by whether a working
|
||||
photographer could replace Lightroom without noticing, we are at roughly **60–70%**. The remaining gap is mostly
|
||||
**quality of results and camera coverage**, not missing buttons.
|
||||
|
||||
Caveat on the checklist: ✅ is set by whoever lands a feature, and nobody has systematically checked rows against
|
||||
Lightroom's behaviour or output. Bugs keep turning up in ✅ areas (CR2 colour-filter phase on some Canon models #85,
|
||||
duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
||||
|
||||
### By dimension
|
||||
|
||||
| Dimension | Estimate | What's true today | Biggest gaps |
|
||||
|---|---:|---|---|
|
||||
| **Feature checklist** | 79% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
|
||||
| **RAW coverage** (formats people shoot) | ~50% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, packed RW2, PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, RW2 v4, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. Per-model verification is thin (~40 corpus files vs >1,000 models) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No camera colour calibration**: non-DNG raws use a neutral matrix, so colour is muted/inaccurate. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
|
||||
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
|
||||
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
|
||||
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
|
||||
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
|
||||
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; no localisation; accessibility partial; headless UI tests time out under machine load |
|
||||
|
||||
### By kind of user
|
||||
|
||||
| User | Readiness | What blocks them |
|
||||
|---|---:|---|
|
||||
| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
|
||||
| Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour (muted colour on every photo) |
|
||||
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only |
|
||||
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
|
||||
| Relies on AI (masks, denoise) | ~25% | No segmentation or AI denoise models |
|
||||
|
||||
## Where we're going
|
||||
|
||||
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
|
||||
|
||||
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
|
||||
matrices the files carry themselves, then chart shots. Raises colour quality for every non-DNG raw at once.
|
||||
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, RW2 v4, NEF
|
||||
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
|
||||
no LGPL dependency); compressed NEF (#86) is the template.
|
||||
3. **Verified camera coverage** (LR-IMP-CAMERA-COVERAGE, P0): a CC0 sample per model in the corpus, each decoded and
|
||||
checked for plausible colour; fix per-model bugs (#85).
|
||||
4. **Render fidelity suite** (LR-BEHAV-RENDER-FIDELITY, P1): measure our output against Lightroom on the same CC0 raws
|
||||
(references stay in the local `plan/`), then tune against the numbers.
|
||||
5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1).
|
||||
6. **AI model strategy** (maintainer decision): which permissively licensed models (or our own training) for
|
||||
segmentation masks and denoise; then pure-Rust inference. Unblocks M12 and Enhance.
|
||||
7. **Then:** HDR (Q), the Classic output modules (Print first, then Map view, Book, Slideshow), video (R), localisation
|
||||
and accessibility.
|
||||
|
||||
Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29), per-library smart-preview folder,
|
||||
Local roots / cleanup, grid and catalog performance at 85k photos (#35, #37), failed-save reporting, GPU export
|
||||
hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help and folder templates (#31, #32).
|
||||
|
||||
## Milestones
|
||||
|
||||
**Status legend:** ✅ done · 🚧 in progress · ⬜ not started
|
||||
|
||||
| # | Milestone | Scope (summary) | Estimate (h) | Status |
|
||||
@@ -11,7 +73,7 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
|
||||
| M0 | Skeleton + visual shell | workspace, xtask CI + layering, geom/color/raster, develop model, pipeline v0, catalog v0, engine commands, Lightroom-look UI (grid, loupe, filmstrip, Edit panel), control channel, MCP, web build | 3–5 | ✅ |
|
||||
| M1 | Library core | import (JPEG/PNG/TIFF/WebP), EXIF/XMP, persistent catalog (op log + snapshots), albums, ratings/flags/labels, filter/search/sort, thumbnail cache, 100k-photo grid | 6–10 | ✅ (100k-photo grid scale test pending) |
|
||||
| M2 | Pipeline v1 (quality) | WB temp/tint, profiles, local tone mapping (highlights/shadows), curves, HSL, point colour, colour grading, texture/clarity/dehaze, vignette, grain, B&W, auto tone/WB, histogram, before/after | 10–15 | ✅ (look tuning vs our references ongoing) |
|
||||
| M3 | RAW I | TIFF/DNG (LJ92, deflate, tiles, opcodes), demosaic (AHD/PPG/bilinear), highlight recovery, DNG colour model, CR2, NEF, ARW, embedded previews | 10–15 | 🚧 (DNG, CR2, ARW, NEF uncompressed, embedded previews ✅; NEF Huffman ⬜) |
|
||||
| M3 | RAW I | TIFF/DNG (LJ92, deflate, tiles, opcodes), demosaic (AHD/PPG/bilinear), highlight recovery, DNG colour model, CR2, NEF, ARW, embedded previews | 10–15 | 🚧 (DNG, CR2, ARW, NEF uncompressed + Huffman lossless/lossy, embedded previews ✅; NEF lossy-after-split ⬜) |
|
||||
| M4 | Crop, geometry, optics | crop tool + overlays, straighten, Upright (auto/level/vertical/full/guided), manual transforms, CA, defringe, manual lens corrections | 6–10 | ✅ |
|
||||
| M5 | Performance | source pyramids, wgpu compute pipeline (CPU oracle), draft/full renders, prefetch, budgets (16 ms slider updates on 24 MP) | 10–15 | 🚧 (stage cache, source pyramid, wgpu pipeline, prefetch, memory budget ✅; colour NR at half resolution, GPU histogram ⬜) |
|
||||
| M6 | Masking | brush, linear/radial gradients, colour/luminance/depth range, add/subtract/intersect/invert, all local adjustments, masks panel | 8–12 | 🚧 (brush/linear/radial/colour/luminance range, add/subtract/intersect, masks panel ✅; depth range, AI masks ⬜) |
|
||||
@@ -19,60 +81,66 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
|
||||
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
|
||||
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
|
||||
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) |
|
||||
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; CR3, compressed NEF/ORF/RAF ⬜) |
|
||||
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; **camera colour calibration** ⬜ (top priority), CR3, compressed ORF/RAF ⬜) |
|
||||
| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ |
|
||||
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
|
||||
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
|
||||
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
|
||||
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; accessibility, localisation ⬜) |
|
||||
|
||||
## Parity estimate (2026-10-02)
|
||||
## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02)
|
||||
|
||||
**By feature count** — from `docs/parity.md` (one row per Lightroom feature, menu item and shortcut; `cargo xtask
|
||||
parity` prints this line on every run, so it stays current):
|
||||
|
||||
| Scope | Weighted completion | Rows |
|
||||
|---|---:|---:|
|
||||
| P0 (core) | **98.5%** | 198 |
|
||||
| P1 (important parity) | **79.9%** | 144 |
|
||||
| P2 (later / AI / niche, incl. Classic modules) | **11.7%** | 158 |
|
||||
| **All in-scope rows** | **65.7%** | 500 |
|
||||
| Scope | Weighted completion | Rows | 2026-10-02 |
|
||||
|---|---:|---:|---:|
|
||||
| P0 (core) | **98.5%** | 200 | 98.7% |
|
||||
| P1 (important parity) | **95.9%** | 147 | 94.4% |
|
||||
| P2 (later / AI / niche, incl. Classic modules) | **39.2%** | 158 | 29.4% |
|
||||
| **All in-scope rows** | **79.2%** | 505 | 75.6% |
|
||||
|
||||
The 2026-10-05 count includes three new 🟡 rows that make quality gaps visible (camera colour calibration, verified
|
||||
camera coverage, render fidelity), which is why P0 dipped slightly.
|
||||
|
||||
✅ counts 1, 🟡 ½, ⬜ 0; out-of-scope rows (cloud sharing, Adobe accounts…) are left out.
|
||||
|
||||
**By remaining effort** — rows are not equal: a shortcut and the whole Book module are one row each, and what is
|
||||
left is the heavy part (AI, video, Classic output modules, undocumented raw codecs). Remaining work in **Opus agent-hours**
|
||||
(one agent working continuously; calibrated on this project — a single lead agent closed ≈ 45 tracker rows of
|
||||
UI/feature work in ≈ 5 h on 2026-10-01, and four to six parallel agents built M0–M13 in ≈ 25 active hours):
|
||||
UI/feature work in ≈ 5 h on 2026-10-01, and ≈ 60 more (preset import incl. `.lrtemplate` / DNG / zip and masks, smart-album
|
||||
rule editor, auto sync, keyword and label sets, import options and DNG conversion, smart previews, external-editor round
|
||||
trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel agents built M0–M13 in ≈ 25 active hours):
|
||||
|
||||
| Work package | Tracker rows | Agent-hours | Risk |
|
||||
|---|---|---:|---|
|
||||
| Remaining P0/P1 UI and library features (version hover previews, filter presets, quick develop, missing-file relink, metadata presets, keyword sets…) | ≈ 30 | 20–35 | low |
|
||||
| Raw codecs: CR3 (CRX), compressed NEF / ORF / RAF, RW2 v4, HEIC/AVIF decode | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
|
||||
| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low |
|
||||
| Raw codecs: CR3 (CRX), compressed ORF / RAF, NEF lossy-after-split, RW2 v4, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
|
||||
| Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection |
|
||||
| Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium |
|
||||
| AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training |
|
||||
| HDR editing, display, visualisation and export | Q. HDR, LR-EXP-HDR | 15–25 | medium |
|
||||
| Classic modules: Map, Book, Slideshow, Print, publish services, tethering, soft proofing | ≈ 50 | 60–100 | medium (large, well-understood) |
|
||||
| Smaller P2 items: watched folders, Enhance dialog, external-editor round trip, export to Photos, help / what's new, accessibility, localisation | ≈ 25 | 20–35 | low |
|
||||
| Classic modules: Map, Book, Slideshow module, Print, publish services, tethering, soft proofing | ≈ 50 | 60–100 | medium (large, well-understood) |
|
||||
| Smaller P2 items: Enhance dialog, export to Photos, help / what's new, accessibility, localisation, sidecar variants | ≈ 15 | 12–25 | low |
|
||||
| Look tuning, performance budgets (colour NR at half resolution on CPU + GPU, 100k-photo library), packaging, hardening | — | 25–45 | medium |
|
||||
| **Total remaining** | | **≈ 295–540** | |
|
||||
| **Total remaining** | | **≈ 270–505** | |
|
||||
|
||||
Spent so far ≈ 90–130 agent-hours, so **by effort the project is ≈ 20–30% of the way to complete Lightroom + Classic
|
||||
parity**, and ≈ 40–55% of the way for cloud-Lightroom parity without AI and the Classic modules (remaining ≈ 135–235 h).
|
||||
Spent so far ≈ 105–145 agent-hours, so **by effort the project is ≈ 20–35% of the way to complete Lightroom + Classic
|
||||
parity**, and ≈ 45–60% of the way for cloud-Lightroom parity without AI and the Classic modules (remaining ≈ 130–255 h,
|
||||
most of it the raw codecs, lens data, video and HDR).
|
||||
|
||||
**Wall clock:** ≈ 300–540 h for one agent working alone; with 4–6 parallel agents (≈ 70% parallel efficiency, merges and
|
||||
CI under load cost the rest) **≈ 75–130 h of continuous work**. The AI package and the raw codecs carry most of the
|
||||
**Wall clock:** ≈ 270–505 h for one agent working alone; with 4–6 parallel agents (≈ 70% parallel efficiency, merges and
|
||||
CI under load cost the rest) **≈ 65–125 h of continuous work**. The AI package and the raw codecs carry most of the
|
||||
uncertainty: they can finish faster if suitable permissive models / documentation turn up, or stall on licensing.
|
||||
|
||||
## Totals
|
||||
|
||||
Remaining from 2026-10-02 (see *Parity estimate* above for the breakdown):
|
||||
Remaining from 2026-10-02 evening (see *Parity estimate* above for the breakdown):
|
||||
|
||||
| Target | Remaining agent-hours | Wall clock, 4–6 parallel agents |
|
||||
|---|---:|---:|
|
||||
| Cloud-Lightroom parity without AI or Classic modules | ≈ 135–235 h | ≈ 35–60 h |
|
||||
| Full parity incl. AI, video and the Classic modules | ≈ 295–540 h | ≈ 75–130 h |
|
||||
| Cloud-Lightroom parity without AI or Classic modules | ≈ 130–255 h | ≈ 35–65 h |
|
||||
| Full parity incl. AI, video and the Classic modules | ≈ 270–505 h | ≈ 65–125 h |
|
||||
|
||||
The milestone estimates in the table above were made before work started and are kept for calibration (M0–M13 took
|
||||
≈ 25 active hours with parallel agents against an estimate of ≈ 110–170 h for those milestones).
|
||||
@@ -83,24 +151,25 @@ The milestone estimates in the table above were made before work started and are
|
||||
fallbacks first. No permissively licensed sky-segmentation or raw-denoise model was found — we may need to train our own.
|
||||
- **Camera colour and lens data** is a data problem: we never use Adobe's matrices, DCPs or LCPs. DNG-embedded data first,
|
||||
then our own calibration; long-tail camera/lens coverage grows over time.
|
||||
- **Legal decisions pending:** whether GPL-licensed *prose* format descriptions (e.g. the public CR3 write-up) may be read
|
||||
by a designated engineer to produce an internal spec; freedom-to-operate review for local Laplacian filters, PatchMatch and
|
||||
HEVC (HEIC).
|
||||
- **Look parity** with Adobe's default rendering is subjective tuning against our own reference targets.
|
||||
- **Raw-format sources:** decided 2026-10-05: decoders are written from *prose* format descriptions (even ones
|
||||
published alongside GPL code); decoder source is never read. Still open: freedom-to-operate review for local
|
||||
Laplacian filters, PatchMatch and HEVC (HEIC).
|
||||
- **Look parity** with Adobe's default rendering is tuned by eye today; the planned fidelity suite (LR-BEHAV-RENDER-FIDELITY)
|
||||
turns it into measured comparisons against local-only Lightroom references.
|
||||
|
||||
## Raw format coverage and known gaps
|
||||
|
||||
Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
|
||||
LJ92, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92), NEF/NRW uncompressed, RAF uncompressed (Bayer and
|
||||
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed (Bayer and
|
||||
X-Trans), RW2 packed 12/14-bit, PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Every
|
||||
supported container also yields its embedded JPEG preview (CR3 too), and the engine shows that preview for raw variants
|
||||
it can't decode yet.
|
||||
|
||||
Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete):
|
||||
- **Nikon Huffman NEF** (lossless / lossy). Black-box findings so far: maker note `0x0096` holds per-parity predictor
|
||||
seeds (2048 for 14-bit lossless); ≈ 9 bits per pixel; the category-0 code word is a 6-bit rotation of `011111`. The
|
||||
Huffman tables are not stored in the files, and an automatic table search (beam search over prefix codes scored by
|
||||
smoothness and bit rate, validated on Pentax files whose tables *are* stored) did not converge.
|
||||
- **Nikon "lossy after split" NEF** (non-zero split row in maker note `0x0096`, e.g. some D3400/D5000/D5200/D5500/D5600
|
||||
files): rows above the split decode with the regular lossy table; from the split row on a different code is used
|
||||
that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte
|
||||
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
|
||||
- **Panasonic RW2 raw format 4** (quantised): the block layout is known (0x4000-byte chunks rotated by 0x1ff8; 128-bit
|
||||
blocks of two 12-bit seeds plus four groups of a 2-bit scale and three 8-bit codes; scales 0/1 are ×1/×2 differences
|
||||
from the same-colour pixel two to the left), the reconstruction rule for scales 2/3 is not established.
|
||||
@@ -116,3 +185,17 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG/CR2/ARW; README showcase. ≈ 8 h elapsed.
|
||||
- 2026-10-02: parity estimate added (xtask parity prints weighted completion); milestone statuses refreshed.
|
||||
- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman), ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0 samples.
|
||||
- 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues landed: compressed NEF, Move import, folder templates,
|
||||
Local roots and cleanup, 85k-photo grid and catalog performance, background compaction, failed-save errors, GPU
|
||||
export hardening, clippy 1.99. Added the honest *Where we stand* assessment and *Where we're going* priorities; added
|
||||
tracker rows for camera colour, camera coverage and render fidelity.
|
||||
|
||||
## Japanese interface and text watermarks
|
||||
|
||||
English/Japanese interface language is persisted in UI state. Core menus have Japanese
|
||||
translations; untranslated panels and dialogs retain English. BIZ UDMincho is bundled
|
||||
under OFL for Japanese glyph coverage without system fonts. Text watermarks now accept
|
||||
`vertical: true` in export JSON/presets and expose an orientation selector. Japanese
|
||||
characters stay upright in top-to-bottom columns, with newlines starting columns to the
|
||||
left. This is basic lettering, without tate-chu-yoko, ruby, kinsoku, or general vertical
|
||||
OpenType shaping. The same coverage renderer serves 8/16/32-bit exports.
|
||||
@@ -15,6 +15,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
lightcraft-engine = { workspace = true }
|
||||
lightcraft-sysmem = { workspace = true }
|
||||
lightcraft-mcp = { workspace = true }
|
||||
lightcraft-ui-egui = { workspace = true }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
|
||||
@@ -1,32 +1,12 @@
|
||||
//! Give memory the system allocator keeps after frees back to the system
|
||||
//! ([`lightcraft_engine::memory::set_release_hook`]).
|
||||
//!
|
||||
//! macOS's allocator keeps freed large blocks mapped as "reusable": they no longer count in the
|
||||
//! process's memory footprint but stay in its resident size until the system runs short. After a
|
||||
//! raw decode (hundreds of MB of temporaries) that made the resident size climb towards the sum of
|
||||
//! everything ever decoded. `malloc_zone_pressure_relief` (libSystem) returns those pages now.
|
||||
//! Elsewhere (glibc, Windows) blocks this large are unmapped when freed: nothing to do.
|
||||
//! ([`lightcraft_engine::memory::set_release_hook`]). The platform call lives in
|
||||
//! `lightcraft-sysmem`, the workspace's only crate allowed `unsafe`.
|
||||
|
||||
/// Install the hook for this platform.
|
||||
/// Install the hook.
|
||||
pub fn install() {
|
||||
#[cfg(target_os = "macos")]
|
||||
lightcraft_engine::memory::set_release_hook(macos::release);
|
||||
lightcraft_engine::memory::set_release_hook(release);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[allow(unsafe_code)]
|
||||
mod macos {
|
||||
unsafe extern "C" {
|
||||
/// libSystem malloc: release free memory of `zone` (all zones when null) back to the
|
||||
/// system, up to `goal` bytes (0 = as much as possible). Returns the bytes released.
|
||||
fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize;
|
||||
}
|
||||
|
||||
pub fn release() {
|
||||
// SAFETY: a documented libSystem function taking a null zone (= every zone) and a size;
|
||||
// it only returns free pages to the system and touches no memory we own.
|
||||
unsafe {
|
||||
malloc_zone_pressure_relief(std::ptr::null_mut(), 0);
|
||||
}
|
||||
}
|
||||
fn release() {
|
||||
let _ = lightcraft_sysmem::release_free_memory();
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
//! lightcraft-cli commands [--json]
|
||||
//! lightcraft-cli controls [--json]
|
||||
//! ```
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod alloc_release;
|
||||
|
||||
@@ -89,6 +91,17 @@ USAGE:
|
||||
#[global_allocator]
|
||||
static ALLOC: dhat::Alloc = dhat::Alloc;
|
||||
|
||||
/// Why `--library DIR` can't be opened; for a library open in another program, how to work with
|
||||
/// that one instead (issue #99).
|
||||
fn library_error(dir: &str, e: lightcraft_engine::EngineError) -> String {
|
||||
match e {
|
||||
lightcraft_engine::EngineError::LibraryInUse(why) => format!(
|
||||
"{dir}: {why}\nTo work with the library while the app has it open, start the app with `--control PORT` and use `lightcraft-cli mcp --connect 127.0.0.1:PORT`."
|
||||
),
|
||||
e => format!("{dir}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// `--features dhat-heap`: count allocations; the profile is written when `_heap` drops
|
||||
// (LIGHTCRAFT_DHAT_FILE, default dhat-heap.json).
|
||||
@@ -180,7 +193,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let mut h = match &library {
|
||||
Some(dir) => {
|
||||
let mut h = Headless::default();
|
||||
let r = h.session.open_library(dir, demo).map_err(|e| e.to_string())?;
|
||||
let r = h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
|
||||
eprintln!("lightcraft-cli mcp: opened library {dir} ({r:?})");
|
||||
h
|
||||
}
|
||||
@@ -283,14 +296,18 @@ fn synth_merge(args: &[String]) -> Result<(), String> {
|
||||
let mut written = Vec::new();
|
||||
match kind {
|
||||
"hdr" => {
|
||||
for (k, b) in lightcraft_merge::synth::bracket_dngs(1800, 1200, &[-2.0, 0.0, 2.0]).into_iter().enumerate() {
|
||||
for (k, b) in lightcraft_merge::synth::bracket_dngs(1800, 1200, &[-2.0, 0.0, 2.0]).map_err(|e| e.to_string())?.into_iter().enumerate() {
|
||||
let p = Path::new(&dir).join(format!("bracket-{k}.dng"));
|
||||
std::fs::write(&p, b).map_err(|e| e.to_string())?;
|
||||
written.push(p);
|
||||
}
|
||||
}
|
||||
"panorama" | "pano" => {
|
||||
for (k, v) in lightcraft_merge::synth::pano_views(1200, 900, 1000.0, &[-50.0, -25.0, 0.0, 25.0, 50.0]).into_iter().enumerate() {
|
||||
for (k, v) in lightcraft_merge::synth::pano_views(1200, 900, 1000.0, &[-50.0, -25.0, 0.0, 25.0, 50.0])
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let p = Path::new(&dir).join(format!("view-{k}.png"));
|
||||
let img = v.to_srgb8();
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default())
|
||||
@@ -413,7 +430,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut h = match &library {
|
||||
Some(dir) => {
|
||||
let mut h = Headless::default();
|
||||
h.session.open_library(dir, demo).map_err(|e| e.to_string())?;
|
||||
h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
|
||||
h
|
||||
}
|
||||
None if demo => Headless::demo(),
|
||||
@@ -527,11 +544,17 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
.ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
|
||||
}
|
||||
let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?;
|
||||
std::fs::write(&output, &e.bytes).map_err(|err| format!("{output}: {err}"))?;
|
||||
for (ext, bytes) in &e.sidecars {
|
||||
let sc = Path::new(&output).with_extension(ext);
|
||||
std::fs::write(&sc, bytes).map_err(|err| format!("{}: {err}", sc.display()))?;
|
||||
eprintln!("lightcraft-cli: wrote {}", sc.display());
|
||||
// never over the input (or its sidecar), however it is spelled: `render IMG.jpg -o IMG.jpg`
|
||||
let sidecars: Vec<(String, &Vec<u8>)> =
|
||||
e.sidecars.iter().map(|(ext, bytes)| (Path::new(&output).with_extension(ext).to_string_lossy().to_string(), bytes)).collect();
|
||||
let guard = s.original_guard();
|
||||
for p in std::iter::once(&output).chain(sidecars.iter().map(|(p, _)| p)) {
|
||||
guard.check(Path::new(p)).map_err(|err| format!("render: {err}"))?;
|
||||
}
|
||||
lightcraft_engine::export::write_file(&output, &e.bytes)?;
|
||||
for (sc, bytes) in &sidecars {
|
||||
lightcraft_engine::export::write_file(sc, bytes)?;
|
||||
eprintln!("lightcraft-cli: wrote {sc}");
|
||||
}
|
||||
eprintln!("lightcraft-cli: wrote {output} ({}×{})", e.width, e.height);
|
||||
Ok(())
|
||||
@@ -574,7 +597,7 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
let mut session = match &library {
|
||||
Some(dir) => {
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(dir, false).map_err(|e| format!("{dir}: {e}"))?;
|
||||
s.open_library(dir, false).map_err(|e| library_error(dir, e))?;
|
||||
s
|
||||
}
|
||||
None if files.is_empty() => Session::with_demo().with_fs(),
|
||||
@@ -587,18 +610,8 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
eprintln!("lightcraft-cli snapshot: imported {n} files in {:.0} ms", ti.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
let services = lightcraft_ui_egui::Services {
|
||||
write_shared: Some(std::sync::Arc::new(|p: &str, b: &[u8]| {
|
||||
if let Some(dir) = Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(p, b).map_err(|e| format!("{p}: {e}"))
|
||||
})),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| {
|
||||
if let Some(dir) = Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(p, b).map_err(|e| format!("{p}: {e}"))
|
||||
})),
|
||||
write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)),
|
||||
write: Some(Box::new(lightcraft_engine::export::write_file)),
|
||||
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
|
||||
})),
|
||||
|
||||
@@ -86,6 +86,24 @@ fn render_subcommand() {
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown control"));
|
||||
}
|
||||
|
||||
/// Issue #93: `render IMG -o IMG` replaced its own input with the render.
|
||||
#[test]
|
||||
fn render_refuses_to_overwrite_its_input() {
|
||||
let input = tmp("self-in.png");
|
||||
gradient_png(&input);
|
||||
let before = std::fs::read(&input).unwrap();
|
||||
let dir = input.parent().unwrap();
|
||||
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||||
let other_spelling = dir.join("sub/../self-in.png");
|
||||
for out in [&input, &other_spelling] {
|
||||
let o =
|
||||
Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "light.exposure=1"]).output().unwrap();
|
||||
assert!(!o.status.success());
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("never writes over an original"), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
}
|
||||
assert_eq!(std::fs::read(&input).unwrap(), before, "the input is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_export_options() {
|
||||
let input = tmp("o-in.png");
|
||||
@@ -242,3 +260,31 @@ fn devices_are_listed_and_imported_from() {
|
||||
let line: Value = serde_json::from_slice(o.stdout.split(|b| *b == b'\n').next().unwrap()).unwrap();
|
||||
assert_eq!(line["result"]["candidates"].as_array().map(Vec::len), Some(1), "{line}");
|
||||
}
|
||||
|
||||
/// Issue #99: a library open in one process (here `mcp --library`) is refused by a second one,
|
||||
/// with who has it and how to drive the running app instead; free again once the first exits.
|
||||
#[test]
|
||||
fn a_library_open_in_another_process_is_refused() {
|
||||
let lib = tmp("locked-lib");
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
let lib_s = lib.to_str().unwrap();
|
||||
let mut holder =
|
||||
Command::new(BIN).args(["mcp", "--library", lib_s]).stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut err = BufReader::new(holder.stderr.take().unwrap());
|
||||
let mut line = String::new();
|
||||
while !line.contains("opened library") {
|
||||
line.clear();
|
||||
assert!(err.read_line(&mut line).unwrap() > 0, "mcp exited before opening the library");
|
||||
}
|
||||
let (ok, _, stderr) = run_cli(&["--library", lib_s, "library.info"], None);
|
||||
assert!(!ok);
|
||||
assert!(stderr.contains("already open in lightcraft-cli") && stderr.contains(&format!("process {}", holder.id())), "{stderr}");
|
||||
assert!(stderr.contains("mcp --connect"), "{stderr}");
|
||||
|
||||
drop(holder.stdin.take()); // EOF: the server exits and lets go of the library
|
||||
assert!(holder.wait().unwrap().success());
|
||||
let (ok, lines, stderr) = run_cli(&["--library", lib_s, "library.info"], None);
|
||||
assert!(ok, "{stderr}");
|
||||
assert_eq!(lines[0]["ok"], true);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
//! console (see [`bench`]); `?store=idb` forces the IndexedDB backend, `?store=memory` disables
|
||||
//! persistence and `?workers=0` renders on the main thread.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod bench;
|
||||
pub mod files;
|
||||
|
||||
@@ -29,17 +29,18 @@ const LIBRARY_DIR: &str = "library";
|
||||
/// How often view state, UI prefs and the thumbnail index are saved (ms).
|
||||
const SAVE_EVERY_MS: f64 = 1000.0;
|
||||
|
||||
fn window() -> web_sys::Window {
|
||||
web_sys::window().expect("no window")
|
||||
/// The page's window (`None` only outside a browser page, e.g. in a worker).
|
||||
fn window() -> Option<web_sys::Window> {
|
||||
web_sys::window()
|
||||
}
|
||||
|
||||
/// Milliseconds since navigation start.
|
||||
fn perf_now() -> f64 {
|
||||
window().performance().map(|p| p.now()).unwrap_or(0.0)
|
||||
window().and_then(|w| w.performance()).map(|p| p.now()).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn set_status(text: &str) {
|
||||
if let Some(el) = window().document().and_then(|d| d.get_element_by_id("lightcraft_status")) {
|
||||
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_status")) {
|
||||
el.set_text_content(Some(text));
|
||||
}
|
||||
}
|
||||
@@ -59,7 +60,7 @@ struct Options {
|
||||
|
||||
impl Options {
|
||||
fn from_url() -> Options {
|
||||
let q = window().location().search().unwrap_or_default();
|
||||
let q = window().and_then(|w| w.location().search().ok()).unwrap_or_default();
|
||||
let params: Vec<(String, String)> = q
|
||||
.trim_start_matches('?')
|
||||
.split('&')
|
||||
@@ -102,7 +103,7 @@ async fn read_file(originals: Originals, backend: Option<Backend>, file: web_sys
|
||||
|
||||
/// Show the browser's open dialog; picked files are imported asynchronously.
|
||||
fn open_picker(originals: Originals, backend: Option<Backend>, ctx: egui::Context) {
|
||||
let Some(doc) = window().document() else { return };
|
||||
let Some(doc) = window().and_then(|w| w.document()) else { return };
|
||||
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
|
||||
input.set_type("file");
|
||||
input.set_multiple(true);
|
||||
@@ -131,7 +132,7 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
opts.set_type(crate::store::mime_for(name));
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&arr, &opts).map_err(e)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(e)?;
|
||||
let doc = window().document().ok_or("no document")?;
|
||||
let doc = window().and_then(|w| w.document()).ok_or("no document")?;
|
||||
let a: web_sys::HtmlAnchorElement = doc.create_element("a").map_err(e)?.unchecked_into();
|
||||
a.set_href(&url);
|
||||
a.set_download(name);
|
||||
@@ -140,7 +141,9 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let revoke = Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 5000);
|
||||
if let Some(w) = window() {
|
||||
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 5000);
|
||||
}
|
||||
log::info!("exported {name} ({} bytes)", bytes.len());
|
||||
Ok(())
|
||||
}
|
||||
@@ -153,7 +156,10 @@ fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context)
|
||||
})),
|
||||
// Preset files: browser pickers are asynchronous; not wired on the web yet.
|
||||
pick_preset_files: None,
|
||||
pick_tracklog: None,
|
||||
save_preset_file: None,
|
||||
pick_curve_preset_files: None,
|
||||
save_curve_preset_file: None,
|
||||
write: Some(Box::new(download)),
|
||||
// downloads happen on the main thread: exports run in the foreground on the web
|
||||
write_shared: None,
|
||||
@@ -161,6 +167,7 @@ fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context)
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
|
||||
})),
|
||||
reveal: None,
|
||||
open_with: None,
|
||||
open_url: Some(Box::new(|url: &str| {
|
||||
let w = web_sys::window().ok_or("no window")?;
|
||||
w.open_with_url_and_target(url, "_blank").map(|_| ()).map_err(|_| "the browser blocked the new tab".to_string())
|
||||
@@ -312,7 +319,7 @@ impl WebApp {
|
||||
}
|
||||
app.ui = app.ui.sanitized();
|
||||
let n = opts.workers.unwrap_or_else(|| {
|
||||
let cores = window().navigator().hardware_concurrency() as usize;
|
||||
let cores = window().map_or(1, |w| w.navigator().hardware_concurrency() as usize);
|
||||
cores.saturating_sub(1).clamp(1, 4)
|
||||
});
|
||||
let workers =
|
||||
@@ -513,8 +520,10 @@ pub fn start() {
|
||||
log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now());
|
||||
let opts = Options::from_url();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(canvas) =
|
||||
window().document().and_then(|d| d.get_element_by_id("lightcraft_canvas")).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
|
||||
let Some(canvas) = window()
|
||||
.and_then(|w| w.document())
|
||||
.and_then(|d| d.get_element_by_id("lightcraft_canvas"))
|
||||
.and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
|
||||
else {
|
||||
log::error!("missing <canvas id=\"lightcraft_canvas\">");
|
||||
return;
|
||||
@@ -524,7 +533,7 @@ pub fn start() {
|
||||
let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, boot))))).await;
|
||||
match r {
|
||||
Ok(()) => {
|
||||
if let Some(el) = window().document().and_then(|d| d.get_element_by_id("lightcraft_loading")) {
|
||||
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_loading")) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ impl WireJob {
|
||||
let max_edge = match &job.source {
|
||||
SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } => *max_edge,
|
||||
SourceRef::Loaded(img) => img.width.max(img.height),
|
||||
SourceRef::Smart { .. } => 2560,
|
||||
};
|
||||
WireJob {
|
||||
level: job.level,
|
||||
@@ -91,6 +92,7 @@ impl WireJob {
|
||||
// workers render previews (exports run in-process)
|
||||
space: lightcraft_engine::pipeline::OutputSpace::Srgb,
|
||||
depth: lightcraft_engine::pipeline::OutputDepth::U8,
|
||||
proof: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
lightcraft-engine = { workspace = true }
|
||||
lightcraft-sysmem = { workspace = true }
|
||||
lightcraft-ui-egui = { workspace = true }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
|
||||
@@ -1,32 +1,12 @@
|
||||
//! Give memory the system allocator keeps after frees back to the system
|
||||
//! ([`lightcraft_engine::memory::set_release_hook`]).
|
||||
//!
|
||||
//! macOS's allocator keeps freed large blocks mapped as "reusable": they no longer count in the
|
||||
//! process's memory footprint but stay in its resident size until the system runs short. After a
|
||||
//! raw decode (hundreds of MB of temporaries) that made the resident size climb towards the sum of
|
||||
//! everything ever decoded. `malloc_zone_pressure_relief` (libSystem) returns those pages now.
|
||||
//! Elsewhere (glibc, Windows) blocks this large are unmapped when freed: nothing to do.
|
||||
//! ([`lightcraft_engine::memory::set_release_hook`]). The platform call lives in
|
||||
//! `lightcraft-sysmem`, the workspace's only crate allowed `unsafe`.
|
||||
|
||||
/// Install the hook for this platform.
|
||||
/// Install the hook.
|
||||
pub fn install() {
|
||||
#[cfg(target_os = "macos")]
|
||||
lightcraft_engine::memory::set_release_hook(macos::release);
|
||||
lightcraft_engine::memory::set_release_hook(release);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[allow(unsafe_code)]
|
||||
mod macos {
|
||||
unsafe extern "C" {
|
||||
/// libSystem malloc: release free memory of `zone` (all zones when null) back to the
|
||||
/// system, up to `goal` bytes (0 = as much as possible). Returns the bytes released.
|
||||
fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize;
|
||||
}
|
||||
|
||||
pub fn release() {
|
||||
// SAFETY: a documented libSystem function taking a null zone (= every zone) and a size;
|
||||
// it only returns free pages to the system and touches no memory we own.
|
||||
unsafe {
|
||||
malloc_zone_pressure_relief(std::ptr::null_mut(), 0);
|
||||
}
|
||||
}
|
||||
fn release() {
|
||||
let _ = lightcraft_sysmem::release_free_memory();
|
||||
}
|
||||
+58
-11
@@ -12,6 +12,13 @@
|
||||
//! `--control <port>` (or `LIGHTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
||||
//! See `lightcraft_ui_egui::control` for the methods.
|
||||
//!
|
||||
//! On Windows, release builds are GUI-subsystem programs: launching the app opens no console
|
||||
//! window (issue #7). Their `--help` / `--version` output and diagnostics then have no console to
|
||||
//! go to; use `lightcraft-cli` (a console program) from a terminal, or a debug build.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod alloc_release;
|
||||
mod control_server;
|
||||
@@ -95,6 +102,28 @@ fn save_prefs(app: &LightcraftApp) {
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_folder: Some(Box::new(|| rfd::FileDialog::new().set_title("Open Library").pick_folder().map(|p| p.to_string_lossy().to_string()))),
|
||||
open_with: Some(Box::new(|path: &str, app: &str| {
|
||||
// spawned, never waited for: the editor runs alongside
|
||||
let app = app.trim();
|
||||
let mut c = if cfg!(target_os = "macos") {
|
||||
let mut c = std::process::Command::new("open");
|
||||
if !app.is_empty() {
|
||||
c.args(["-a", app]);
|
||||
}
|
||||
c
|
||||
} else if app.is_empty() {
|
||||
if cfg!(target_os = "windows") {
|
||||
let mut c = std::process::Command::new("cmd");
|
||||
c.args(["/C", "start", ""]);
|
||||
c
|
||||
} else {
|
||||
std::process::Command::new("xdg-open")
|
||||
}
|
||||
} else {
|
||||
std::process::Command::new(app)
|
||||
};
|
||||
c.arg(path).spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
})),
|
||||
open_url: Some(Box::new(|url: &str| {
|
||||
if !url.starts_with("https://") {
|
||||
return Err("only https links are opened".into());
|
||||
@@ -139,13 +168,21 @@ fn services() -> Services {
|
||||
pick_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Import Presets")
|
||||
.add_filter("Presets", &["lcpreset", "xmp"])
|
||||
.add_filter("Presets & Profiles", &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"])
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
pick_tracklog: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Auto-Tag from Tracklog")
|
||||
.add_filter("GPS Track Log", &["gpx"])
|
||||
.pick_file()
|
||||
.map(|p| vec![p.to_string_lossy().to_string()])
|
||||
.unwrap_or_default()
|
||||
})),
|
||||
save_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Export Presets")
|
||||
@@ -154,18 +191,27 @@ fn services() -> Services {
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
write_shared: Some(std::sync::Arc::new(|p: &str, b: &[u8]| {
|
||||
if let Some(dir) = std::path::Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(p, b).map_err(|e| e.to_string())
|
||||
pick_curve_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Import Point Curve Presets")
|
||||
.add_filter("Point Curve Presets", &["lccurve", "json"])
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| {
|
||||
if let Some(dir) = std::path::Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(p, b).map_err(|e| e.to_string())
|
||||
save_curve_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Export Point Curve Presets")
|
||||
.add_filter("Point Curve Presets", &["lccurve"])
|
||||
.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)),
|
||||
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
|
||||
})),
|
||||
@@ -218,6 +264,7 @@ OPTIONS:
|
||||
";
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
lightcraft_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log"));
|
||||
alloc_release::install();
|
||||
let mut control_port: Option<u16> = std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
|
||||
@@ -122,6 +122,8 @@ fn accelerator(sc: &str) -> Option<Accelerator> {
|
||||
"'" => Code::Quote,
|
||||
"," => Code::Comma,
|
||||
"Delete" => Code::Backspace,
|
||||
// with modifiers only: a bare Enter belongs to text fields and tools
|
||||
"Enter" if !mods.is_empty() => Code::Enter,
|
||||
"F1" => Code::F1,
|
||||
"F2" => Code::F2,
|
||||
"F3" => Code::F3,
|
||||
@@ -209,7 +211,7 @@ impl NativeMenu {
|
||||
// the application menu
|
||||
let app_menu = Submenu::new("LightCraft", true);
|
||||
let about = MenuItem::with_id("app.about", "About LightCraft", true, None);
|
||||
let settings = MenuItem::with_id(SETTINGS, "Settings…", true, accelerator(SETTINGS_KEY));
|
||||
let settings = MenuItem::with_id(SETTINGS, app.ui.language.tr("Settings…"), true, accelerator(SETTINGS_KEY));
|
||||
let quit = MenuItem::with_id(QUIT, "Quit LightCraft", true, accelerator("Cmd+Q"));
|
||||
let _ = app_menu.append_items(&[
|
||||
&about,
|
||||
@@ -228,7 +230,7 @@ impl NativeMenu {
|
||||
|
||||
for (title, nodes) in &bar {
|
||||
let sub = Submenu::new(title, true);
|
||||
if title == "Window" {
|
||||
if title == app.ui.language.tr("Window") {
|
||||
let _ = sub.append_items(&[
|
||||
&PredefinedMenuItem::minimize(None),
|
||||
&PredefinedMenuItem::maximize(Some("Zoom")),
|
||||
@@ -243,11 +245,15 @@ impl NativeMenu {
|
||||
.collect();
|
||||
let nodes = tidy_separators(nodes);
|
||||
self.append_nodes(&sub, &nodes);
|
||||
if title == "Window" {
|
||||
if title == app.ui.language.tr("File") {
|
||||
// ⌘W, the system's own item
|
||||
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::close_window(None)]);
|
||||
}
|
||||
if title == app.ui.language.tr("Window") {
|
||||
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]);
|
||||
sub.set_as_windows_menu_for_nsapp();
|
||||
}
|
||||
if title == "Help" {
|
||||
if title == app.ui.language.tr("Help") {
|
||||
sub.set_as_help_menu_for_nsapp();
|
||||
}
|
||||
let _ = self.menu.append(&sub);
|
||||
|
||||
@@ -9,6 +9,8 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| Path | Asset | Creator | Source | Licence | Added | Modifications |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `assets/fonts/Inter-*.ttf` (Regular, Medium, SemiBold) | Inter | Rasmus Andersson | https://github.com/rsms/inter | SIL Open Font License 1.1 (`assets/fonts/OFL-Inter.txt`) | 2026-09-30 | none |
|
||||
| `assets/fonts/BIZUDMincho-Regular.ttf` | BIZ UDMincho Regular | The BIZ UDMincho Project Authors (Morisawa Inc., design TypeBank Co., Ltd.) | https://github.com/google/fonts/tree/63833b7d10bb1f86a8f0b91cba2d3ae1f68d1aa3/ofl/bizudmincho | OFL-1.1 (`assets/fonts/OFL-BIZUDMincho.txt`) | 2026-10-05 | none |
|
||||
| `assets/fonts/OFL-BIZUDMincho.txt` | BIZ UDMincho licence | The BIZ UDMincho Project Authors | https://github.com/google/fonts/blob/63833b7d10bb1f86a8f0b91cba2d3ae1f68d1aa3/ofl/bizudmincho/OFL.txt | OFL-1.1 | 2026-10-05 | none |
|
||||
| `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` |
|
||||
| `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 |
|
||||
@@ -17,6 +19,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `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 |
|
||||
|
||||
Notes
|
||||
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The BIZ UDMincho Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,4 @@
|
||||
allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
@@ -15,6 +15,7 @@ serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
crc32fast = { workspace = true }
|
||||
log = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Catalog persistence costs on large synthetic catalogs (issue #37): log append, compaction
|
||||
//! (snapshot serialise / write+fsync / log reset), synchronous and on the background worker, load,
|
||||
//! and a catalog clone.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --release -p lightcraft-catalog --example persist_bench -- <dir> [photos…]
|
||||
//! ```
|
||||
//!
|
||||
//! `<dir>` is a scratch directory (wiped per size). Default sizes: 1000 10000 85000. 90 % of the
|
||||
//! photos are Local browse records, the rest library photos with a few edits and history steps.
|
||||
//! Peak memory: run under `/usr/bin/time -l` (macOS) / `-v` (Linux) with one size;
|
||||
//! `PERSIST_BENCH_NO_LOAD=1` skips the final load, so the peak is building + compacting.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use lightcraft_catalog::*;
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
|
||||
fn ms(t: Instant) -> f64 {
|
||||
t.elapsed().as_secs_f64() * 1e3
|
||||
}
|
||||
|
||||
fn photo(c: &mut Catalog, i: u64) -> Photo {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(
|
||||
id,
|
||||
Source::File { path: format!("/Users/someone/Pictures/2026/09/{:05}/IMG_{i:05}.CR3", i / 500) },
|
||||
&format!("IMG_{i:05}.CR3"),
|
||||
"CR3",
|
||||
6000,
|
||||
4000,
|
||||
"2026-09-30T12:00:00",
|
||||
);
|
||||
p.kind = MediaKind::Raw;
|
||||
p.file_size = 25_000_000 + i;
|
||||
p.captured = Some(format!("2026-09-{:02}T10:{:02}:{:02}", 1 + i % 28, i % 60, (i / 60) % 60));
|
||||
p.content_hash = Some(format!("{:032x}", i.wrapping_mul(0x9e37_79b9_7f4a_7c15)));
|
||||
p.meta.camera = "Camera Model X".into();
|
||||
p.meta.lens = "24-70mm f/2.8".into();
|
||||
p.meta.focal_mm = Some(35.0);
|
||||
p.meta.aperture = Some(4.0);
|
||||
p.meta.shutter = "1/250".into();
|
||||
p.meta.iso = Some(400);
|
||||
p.as_shot_wb = Some((5200.0, 3.0));
|
||||
p.local = !i.is_multiple_of(10);
|
||||
if p.local {
|
||||
// as a browse catalogues it: import defaults, a baseline fingerprint
|
||||
p.develop = Arc::new(p.import_defaults());
|
||||
p.set_local_baseline();
|
||||
}
|
||||
if !p.local {
|
||||
let mut d = DevelopSettings::default();
|
||||
d.light.exposure = (i % 7) as f64 * 0.1;
|
||||
let s = Arc::new(d);
|
||||
p.develop = s.clone();
|
||||
p.edited = Some("2026-09-30T12:00:00".into());
|
||||
p.history = vec![HistoryStep { label: "Exposure".into(), settings: s }];
|
||||
p.rating = (i % 6) as u8;
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn stats(mut v: Vec<f64>) -> String {
|
||||
v.sort_by(f64::total_cmp);
|
||||
let pick = |q: f64| v.get(((v.len() as f64 - 1.0) * q).round() as usize).copied().unwrap_or(0.0);
|
||||
format!("median {:.2} ms, p95 {:.2} ms, max {:.2} ms", pick(0.5), pick(0.95), pick(1.0))
|
||||
}
|
||||
|
||||
fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let dir = std::path::PathBuf::from(args.next().ok_or("usage: persist_bench <scratch dir> [photos…]")?);
|
||||
let mut sizes: Vec<u64> = args.map(|a| a.parse()).collect::<std::result::Result<_, _>>()?;
|
||||
if sizes.is_empty() {
|
||||
sizes = vec![1000, 10_000, 85_000];
|
||||
}
|
||||
for n in sizes {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (mut j, _, _) = Journal::open(Box::new(FsStore::open(&dir)?))?;
|
||||
let mut cat = Catalog::new();
|
||||
// bulk-add (as one import would) and log it, so the first compaction is an import's
|
||||
for chunk in (0..n).collect::<Vec<_>>().chunks(500) {
|
||||
let ops: Vec<Op> = chunk.iter().map(|i| Op::AddPhoto { photo: Box::new(photo(&mut cat, *i)) }).collect();
|
||||
for op in &ops {
|
||||
cat.apply(op.clone())?;
|
||||
}
|
||||
j.append(&ops)?;
|
||||
}
|
||||
j.snapshot(&cat)?;
|
||||
let first = j.stats().last_snapshot;
|
||||
println!("== {n} photos ({} local): snapshot {:.1} MB", cat.photos().filter(|p| p.local).count(), first.bytes as f64 / 1e6);
|
||||
|
||||
let t = Instant::now();
|
||||
let copy = cat.clone();
|
||||
println!(" catalog clone: {:.2} ms", ms(t));
|
||||
drop(copy);
|
||||
|
||||
// single-op appends (a rating change each), as commands do
|
||||
let ids: Vec<PhotoId> = cat.photos().map(|p| p.id).take(1000).collect();
|
||||
let mut appends = Vec::new();
|
||||
for k in 0..300usize {
|
||||
let id = ids.get(k % ids.len().max(1)).copied().unwrap_or(PhotoId(1));
|
||||
let op = Op::SetRating { id, rating: (k % 6) as u8 };
|
||||
cat.apply(op.clone())?;
|
||||
j.append(std::slice::from_ref(&op))?;
|
||||
appends.push(j.stats().last_append_ms);
|
||||
}
|
||||
println!(" append (1 op, fsync): {}", stats(appends));
|
||||
|
||||
// compactions at the default threshold crossing
|
||||
let mut totals = Vec::new();
|
||||
for _ in 0..3 {
|
||||
while !j.wants_snapshot() {
|
||||
let op = Op::SetRating { id: ids.first().copied().unwrap_or(PhotoId(1)), rating: (j.seq() % 6) as u8 };
|
||||
cat.apply(op.clone())?;
|
||||
j.append(&[op])?;
|
||||
}
|
||||
j.snapshot(&cat)?;
|
||||
let s = j.stats().last_snapshot;
|
||||
println!(
|
||||
" compaction ({} records): serialize {:.1} ms, write+sync {:.1} ms, log reset {:.1} ms, total {:.1} ms",
|
||||
s.records, s.serialize_ms, s.write_sync_ms, s.reset_ms, s.total_ms
|
||||
);
|
||||
totals.push(s.total_ms);
|
||||
}
|
||||
println!(" compaction total: {}", stats(totals));
|
||||
|
||||
// the same at the threshold, written by the worker while single-op appends go on
|
||||
let mut blocking = Vec::new();
|
||||
for _ in 0..3 {
|
||||
while !j.wants_snapshot() {
|
||||
let op = Op::SetRating { id: ids.first().copied().unwrap_or(PhotoId(1)), rating: (j.seq() % 6) as u8 };
|
||||
cat.apply(op.clone())?;
|
||||
j.append(&[op])?;
|
||||
}
|
||||
let t = Instant::now();
|
||||
j.snapshot_in_background(&cat)?;
|
||||
let start_ms = ms(t);
|
||||
let mut during = Vec::new();
|
||||
while !j.snapshot_written() {
|
||||
let op = Op::SetRating { id: ids.get(1).copied().unwrap_or(PhotoId(1)), rating: (j.seq() % 6) as u8 };
|
||||
cat.apply(op.clone())?;
|
||||
j.append(&[op])?;
|
||||
during.push(j.stats().last_append_ms);
|
||||
}
|
||||
let t = Instant::now();
|
||||
j.poll()?;
|
||||
let finish_ms = ms(t);
|
||||
let s = j.stats().last_snapshot;
|
||||
println!(
|
||||
" background compaction ({} records): start {start_ms:.1} ms + finish {finish_ms:.1} ms blocking; worker {:.1} ms; {} appends during it: {}",
|
||||
s.records,
|
||||
s.total_ms,
|
||||
during.len(),
|
||||
stats(during)
|
||||
);
|
||||
blocking.push(s.blocking_ms);
|
||||
}
|
||||
println!(" background compaction blocking: {}", stats(blocking));
|
||||
|
||||
// forget the untouched Local records (folders last browsed long ago; every 7th folder
|
||||
// recently; every 50th Local record rated), then compact
|
||||
let folders: std::collections::BTreeSet<String> = cat.photos().filter(|p| p.local).filter_map(|p| folder_of(p)).collect();
|
||||
let mut ops: Vec<Op> = folders
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, f)| Op::SetBrowsed {
|
||||
folder: f.clone(),
|
||||
at: Some(if k % 7 == 0 { "2026-09-30T00:00:00" } else { "2026-06-01T00:00:00" }.into()),
|
||||
})
|
||||
.collect();
|
||||
ops.extend(cat.photos().filter(|p| p.local && p.id.0.is_multiple_of(50)).map(|p| Op::SetRating { id: p.id, rating: 2 }).collect::<Vec<_>>());
|
||||
for op in &ops {
|
||||
cat.apply(op.clone())?;
|
||||
}
|
||||
j.append(&ops)?;
|
||||
j.snapshot(&cat)?;
|
||||
let before = j.stats().last_snapshot.bytes;
|
||||
let t = Instant::now();
|
||||
let plan = cat.forget_local_plan("2026-10-05T00:00:00", DEFAULT_FORGET_DAYS, &|_| false);
|
||||
let plan_ms = ms(t);
|
||||
let t = Instant::now();
|
||||
let ops = plan.ops();
|
||||
for op in &ops {
|
||||
cat.apply(op.clone())?;
|
||||
}
|
||||
j.append(&ops)?;
|
||||
let apply_ms = ms(t);
|
||||
j.snapshot(&cat)?;
|
||||
let after = j.stats().last_snapshot.bytes;
|
||||
println!(
|
||||
" forget Local: {} of {} local records forgotten (kept: {} recent, {} touched, {} in use); plan {plan_ms:.1} ms, apply+append {apply_ms:.1} ms; snapshot {:.1} MB -> {:.1} MB",
|
||||
plan.evict.len(),
|
||||
plan.local,
|
||||
plan.kept_recent,
|
||||
plan.kept_touched,
|
||||
plan.kept_in_use,
|
||||
before as f64 / 1e6,
|
||||
after as f64 / 1e6
|
||||
);
|
||||
|
||||
drop(j);
|
||||
if std::env::var_os("PERSIST_BENCH_NO_LOAD").is_some() {
|
||||
// peak memory of building + compacting only
|
||||
continue;
|
||||
}
|
||||
let t = Instant::now();
|
||||
let (_, loaded, _) = Journal::open(Box::new(FsStore::open(&dir)?))?;
|
||||
println!(" open (load snapshot + replay): {:.1} ms", ms(t));
|
||||
if loaded.len() != cat.len() {
|
||||
return Err("reloaded catalog differs".into());
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 0227748e171237a13833c9175fe02c7fe6ff332266798961a6a43ec37b81d1b4 # shrinks to actions = [(3, 0), (0, 0), (4, 0), (0, 0)]
|
||||
cc 0b0ab2531bd46fbe261b55ecffebea9d3e8949900ab7bc33fb27ca19f5cd7b8b # shrinks to actions = [(4, 443), (3, 474), (2, 27), (5, 858), (5, 466), (0, 901), (4, 907), (3, 839), (0, 630), (7, 578), (4, 13), (2, 361), (6, 136), (6, 12), (6, 223), (1, 394), (6, 78), (5, 837), (6, 765), (4, 207), (2, 185), (2, 583), (2, 56), (5, 430), (1, 702), (1, 547), (5, 552), (4, 762), (0, 443), (0, 851), (7, 240), (4, 438), (3, 599), (6, 70), (4, 694), (1, 327), (7, 859), (6, 814), (4, 336), (3, 438), (0, 131), (0, 831), (3, 18), (2, 963), (6, 10), (3, 114)]
|
||||
@@ -128,13 +128,15 @@ pub fn display_time(iso: &str) -> String {
|
||||
|
||||
/// Header text for a group key.
|
||||
pub fn group_label(key: &str) -> String {
|
||||
let month = |k: &str| k.get(5..7).and_then(|m| m.parse::<usize>().ok()).filter(|m| (1..=12).contains(m)).map(|m| MONTHS[m - 1]);
|
||||
let month = |k: &str| k.get(5..7).and_then(|m| m.parse::<usize>().ok()).and_then(|m| MONTHS.get(m.wrapping_sub(1)));
|
||||
// `get` rather than `[..]`: a non-ASCII key must not split a character
|
||||
let year = key.get(..4).unwrap_or(key);
|
||||
match key.len() {
|
||||
10 => match (weekday(key), month(key), key[8..10].parse::<u32>().ok()) {
|
||||
(Some(w), Some(m), Some(d)) => format!("{w}, {d} {m} {}", &key[..4]),
|
||||
10 => match (weekday(key), month(key), key.get(8..10).and_then(|d| d.parse::<u32>().ok())) {
|
||||
(Some(w), Some(m), Some(d)) => format!("{w}, {d} {m} {year}"),
|
||||
_ => key.to_string(),
|
||||
},
|
||||
7 => month(key).map(|m| format!("{m} {}", &key[..4])).unwrap_or_else(|| key.to_string()),
|
||||
7 => month(key).map(|m| format!("{m} {year}")).unwrap_or_else(|| key.to_string()),
|
||||
4 => key.to_string(),
|
||||
_ => "Unknown Date".into(),
|
||||
}
|
||||
@@ -196,6 +198,9 @@ mod tests {
|
||||
assert_eq!(group_label("2026-02"), "February 2026");
|
||||
assert_eq!(group_label("2026"), "2026");
|
||||
assert_eq!(group_label(""), "Unknown Date");
|
||||
// non-ASCII keys of a date's length used to be sliced mid-character
|
||||
assert_eq!(group_label("2024-01é0"), "2024-01é0");
|
||||
assert_eq!(group_label("202é01"), "January 202é01");
|
||||
assert_eq!(shift_iso("2026-12-31T23:30:00", 3600).as_deref(), Some("2027-01-01T00:30:00"));
|
||||
assert_eq!(shift_iso("2026-03-01T00:00:00.25+02:00", -1).as_deref(), Some("2026-02-28T23:59:59.25+02:00"));
|
||||
assert_eq!(shift_iso("nope", 5), None);
|
||||
|
||||
+485
-30
@@ -1,8 +1,9 @@
|
||||
//! Crash-safe catalog persistence: an append-only op log plus periodic snapshots.
|
||||
//!
|
||||
//! Files (in a [`Store`]):
|
||||
//! - `catalog.snap` — `{"format":"lightcraft-catalog","version":1,"seq":N,"catalog":{…}}`, the
|
||||
//! state after op `N`; replaced atomically (temp + fsync + rename).
|
||||
//! - `catalog.snap` — `{"format":"lightcraft-catalog","version":V,"seq":N,"catalog":{…}}`, the
|
||||
//! state after op `N` in catalog format `V` ([`VERSION`]); replaced atomically (temp + fsync +
|
||||
//! rename).
|
||||
//! - `catalog.log` — JSON lines, one per op applied after the snapshot:
|
||||
//! `{"seq":N,"crc":C,"op":{…}}` where `C` is the CRC-32 of the op's JSON text. Appends are
|
||||
//! fsynced before [`Journal::append`] returns.
|
||||
@@ -10,10 +11,55 @@
|
||||
//! Loading = snapshot + replay of the records with `seq > snapshot seq`. Recovery rules:
|
||||
//! - a **torn final record** (crash mid-append: truncated line or bad CRC at the end) is dropped
|
||||
//! and the file is cut back to the last good record, so later appends start on a clean line;
|
||||
//! - a **failed append** (disk full, a dropped network share) may leave part of its batch in the
|
||||
//! file: [`Journal::append`] cuts the log back to the last whole record before returning the
|
||||
//! error (or, if that fails too, before the next append), so the retry — the same ops with the
|
||||
//! same `seq`s — starts on a clean line. Logs written by builds without that cut-back hold the
|
||||
//! fragment glued to the first retried record on one line; the loader recognises the whole,
|
||||
//! CRC-valid record at the end of such a line and skips only the fragment;
|
||||
//! - records already covered by the snapshot (crash between writing the snapshot and resetting
|
||||
//! the log) are skipped;
|
||||
//! - a bad record **followed by good ones** is real damage: replay stops there, the log is kept
|
||||
//! as `catalog.log.damaged-<seq>`, and a fresh snapshot is written so the library stays usable.
|
||||
//!
|
||||
//! **Background compaction** ([`Journal::snapshot_in_background`], when the store offers a
|
||||
//! [`Store::background_writer`]): a worker thread writes the snapshot of a copy of the catalog at
|
||||
//! op `N` while appends go on (durably, as always) to the same log. Once the snapshot is durable,
|
||||
//! [`Journal::poll`] atomically replaces the log with the records appended since the snapshot
|
||||
//! started (`seq > N`, kept in memory). Every crash point is covered by the rules above:
|
||||
//! - before the rename of `catalog.snap`: the old snapshot + the whole log (nothing was removed);
|
||||
//! - between the rename and the log rewrite: the new snapshot + the whole log, whose records
|
||||
//! `<= N` are skipped as stale;
|
||||
//! - after the log rewrite: the new snapshot + exactly the records `> N`.
|
||||
//!
|
||||
//! At most one snapshot is in flight; a synchronous [`Journal::snapshot`] and dropping the journal
|
||||
//! first wait for it, so an older snapshot can never replace a newer one.
|
||||
//!
|
||||
//! # Format versions
|
||||
//!
|
||||
//! The snapshot's `version` is the catalog format, and it covers the log beside it: the log has
|
||||
//! no header of its own, and is only ever appended to under a snapshot of this build's format.
|
||||
//!
|
||||
//! | Version | Written by | Adds |
|
||||
//! |---|---|---|
|
||||
//! | (none) | early builds: a log without a snapshot | |
|
||||
//! | 1 | up to v0.2.0 | |
|
||||
//! | 2 | after v0.2.0 | `Op::SetBrowsed`, `Catalog.browsed`, `Photo.local_baseline` |
|
||||
//!
|
||||
//! Rules:
|
||||
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
|
||||
//! serialized field to the catalog, a photo, an album, a stack or develop settings that an older
|
||||
//! build would misread or silently drop. New fields must deserialize from older data
|
||||
//! (`#[serde(default)]`), so newer builds keep reading every older format.
|
||||
//! - **Opening an older format upgrades it**: right after loading, [`Journal::open`] writes a
|
||||
//! snapshot in this build's format (also for a new library), before anything is appended — so a
|
||||
//! library is never a mix, and older builds refuse it from then on instead of reading part of it.
|
||||
//! [`LoadReport::upgraded_from`] says when that happened.
|
||||
//! - **A newer format is refused, untouched**: a snapshot whose `version` is above [`VERSION`],
|
||||
//! or a log record whose CRC matches (so it is exactly what was written) but whose op doesn't
|
||||
//! parse, fails [`Journal::open`] with [`CatalogError::Newer`] before any file is modified — it
|
||||
//! is never mistaken for a torn tail or damage. (Builds up to v0.2.0 refuse a v2 snapshot with
|
||||
//! "unsupported format".)
|
||||
|
||||
use crate::store::Store;
|
||||
use crate::{Catalog, CatalogError, Op, Result};
|
||||
@@ -21,7 +67,9 @@ use crate::{Catalog, CatalogError, Op, Result};
|
||||
pub const SNAPSHOT: &str = "catalog.snap";
|
||||
pub const LOG: &str = "catalog.log";
|
||||
const FORMAT: &str = "lightcraft-catalog";
|
||||
const VERSION: u32 = 1;
|
||||
/// 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 = 2;
|
||||
|
||||
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -49,10 +97,75 @@ pub struct LoadReport {
|
||||
pub failed: usize,
|
||||
/// Bytes of a torn final record that were dropped.
|
||||
pub torn_bytes: u64,
|
||||
/// Partial records left mid-log by a failed append that was retried (written by builds that
|
||||
/// didn't cut the log back after a failed write): skipped, the retried records were replayed.
|
||||
pub torn_fragments: usize,
|
||||
/// The damaged log was preserved under this name.
|
||||
pub damaged: Option<String>,
|
||||
/// No catalog files existed (a new library).
|
||||
pub created: bool,
|
||||
/// The library was in an older catalog format (`0` = before format versions: a log without a
|
||||
/// snapshot) and was rewritten in this version's format ([`VERSION`]).
|
||||
pub upgraded_from: Option<u32>,
|
||||
}
|
||||
|
||||
/// Where persistence time goes (reported by `library.info` → `persistence`; printed to stderr
|
||||
/// per write under `LIGHTCRAFT_PROFILE`). Times are wall-clock milliseconds on the calling
|
||||
/// thread, i.e. how long the caller (the UI thread, for the app) was blocked.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersistStats {
|
||||
/// [`Journal::append`] calls that wrote something.
|
||||
pub appends: u64,
|
||||
/// Encode + write + `sync_data` of the last / slowest append.
|
||||
pub last_append_ms: f64,
|
||||
pub max_append_ms: f64,
|
||||
/// Snapshots written (compactions, plus the ones on close / repair).
|
||||
pub snapshots: u64,
|
||||
/// The last snapshot, by stage.
|
||||
pub last_snapshot: SnapshotTiming,
|
||||
/// Total time of the slowest snapshot.
|
||||
pub max_snapshot_ms: f64,
|
||||
/// Longest time a snapshot blocked the caller (for background snapshots: copying the catalog
|
||||
/// and starting the worker, plus rewriting the log when it is done).
|
||||
pub max_blocking_ms: f64,
|
||||
/// A background snapshot is being written.
|
||||
pub snapshot_running: bool,
|
||||
/// Background snapshots that failed (the log was kept whole; retried later).
|
||||
pub failed_snapshots: u64,
|
||||
}
|
||||
|
||||
/// One snapshot (compaction), by stage.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SnapshotTiming {
|
||||
/// Serialising the catalog to JSON, streamed into the temp file (buffered writes included).
|
||||
pub serialize_ms: f64,
|
||||
/// Flushing, `sync_all` and the atomic rename of `catalog.snap`.
|
||||
pub write_sync_ms: f64,
|
||||
/// Resetting `catalog.log` (an atomic rewrite to empty, or to the records appended while a
|
||||
/// background snapshot ran; fsynced).
|
||||
pub reset_ms: f64,
|
||||
/// Start to finish (for a background snapshot: until [`Journal::poll`] saw it done).
|
||||
pub total_ms: f64,
|
||||
/// How long the caller was blocked (equal to `total_ms` unless `background`).
|
||||
pub blocking_ms: f64,
|
||||
/// Written by a worker thread.
|
||||
pub background: bool,
|
||||
/// Size of `catalog.snap`.
|
||||
pub bytes: u64,
|
||||
/// Log records the snapshot compacted.
|
||||
pub records: u64,
|
||||
}
|
||||
|
||||
/// `LIGHTCRAFT_PROFILE` is set: print persistence timings to stderr.
|
||||
fn profiling() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("LIGHTCRAFT_PROFILE").is_some())
|
||||
}
|
||||
|
||||
fn ms_since(t: web_time::Instant) -> f64 {
|
||||
t.elapsed().as_secs_f64() * 1e3
|
||||
}
|
||||
|
||||
pub struct Journal {
|
||||
@@ -63,6 +176,29 @@ pub struct Journal {
|
||||
log_records: u64,
|
||||
log_bytes: u64,
|
||||
pub policy: SnapshotPolicy,
|
||||
stats: PersistStats,
|
||||
/// The background snapshot in flight.
|
||||
pending: Option<Pending>,
|
||||
/// After a failed background snapshot: don't retry before the log has this many records.
|
||||
retry_at_records: u64,
|
||||
/// A failed append may have left part of its records after `log_bytes`, and cutting them off
|
||||
/// failed too: the next append cuts the log back to `log_bytes` first.
|
||||
tail_dirty: bool,
|
||||
}
|
||||
|
||||
/// A background snapshot in flight.
|
||||
struct Pending {
|
||||
/// The snapshot holds the state after this op.
|
||||
seq: u64,
|
||||
/// Log bytes / records appended since it started (all `> seq`): the log once it's done.
|
||||
tail: Vec<u8>,
|
||||
tail_records: u64,
|
||||
/// Log records it compacts.
|
||||
records: u64,
|
||||
started: web_time::Instant,
|
||||
/// Time the caller spent starting it.
|
||||
start_ms: f64,
|
||||
worker: std::thread::JoinHandle<std::io::Result<SnapshotTiming>>,
|
||||
}
|
||||
|
||||
fn io(e: std::io::Error) -> CatalogError {
|
||||
@@ -76,24 +212,93 @@ pub fn encode_record(seq: u64, op: &Op) -> String {
|
||||
format!("{{\"seq\":{seq},\"crc\":{crc},\"op\":{body}}}")
|
||||
}
|
||||
|
||||
/// Parse one log line (without the newline). `None` if it's malformed or the CRC doesn't match.
|
||||
/// Parse one log line (without the newline). `None` if it's malformed, the CRC doesn't match, or
|
||||
/// the op is one this version doesn't know (see [`Journal::open`]).
|
||||
pub fn decode_record(line: &str) -> Option<(u64, Op)> {
|
||||
let rest = line.strip_prefix("{\"seq\":")?;
|
||||
let (seq, rest) = rest.split_once(",\"crc\":")?;
|
||||
let (crc, rest) = rest.split_once(",\"op\":")?;
|
||||
let body = rest.strip_suffix('}')?;
|
||||
let seq: u64 = seq.parse().ok()?;
|
||||
let crc: u32 = crc.parse().ok()?;
|
||||
if crc32fast::hash(body.as_bytes()) != crc {
|
||||
return None;
|
||||
match decode_line(line) {
|
||||
Line::Record(seq, op) => Some((seq, op)),
|
||||
Line::Newer(_) | Line::Bad => None,
|
||||
}
|
||||
serde_json::from_str(body).ok().map(|op| (seq, op))
|
||||
}
|
||||
|
||||
/// One log line, decoded.
|
||||
enum Line {
|
||||
Record(u64, Op),
|
||||
/// The CRC matches — the line is exactly what was written — but the op doesn't parse: it
|
||||
/// was written by a newer LightCraft (an op or field this version doesn't know), not damaged.
|
||||
Newer(u64),
|
||||
/// Malformed or a CRC mismatch: torn or damaged.
|
||||
Bad,
|
||||
}
|
||||
|
||||
fn decode_line(line: &str) -> Line {
|
||||
let parts = || {
|
||||
let rest = line.strip_prefix("{\"seq\":")?;
|
||||
let (seq, rest) = rest.split_once(",\"crc\":")?;
|
||||
let (crc, rest) = rest.split_once(",\"op\":")?;
|
||||
let body = rest.strip_suffix('}')?;
|
||||
Some((seq.parse::<u64>().ok()?, crc.parse::<u32>().ok()?, body))
|
||||
};
|
||||
let Some((seq, crc, body)) = parts() else { return Line::Bad };
|
||||
if crc32fast::hash(body.as_bytes()) != crc {
|
||||
return Line::Bad;
|
||||
}
|
||||
match serde_json::from_str(body) {
|
||||
Ok(op) => Line::Record(seq, op),
|
||||
Err(_) => Line::Newer(seq),
|
||||
}
|
||||
}
|
||||
|
||||
/// The error for a library written by a newer LightCraft.
|
||||
fn newer(what: String) -> CatalogError {
|
||||
CatalogError::Newer(format!("{what}; this version reads catalog format v{VERSION} and older"))
|
||||
}
|
||||
|
||||
/// A line that doesn't decode but ends with a whole record: a fragment of a failed append followed
|
||||
/// by the record that retried it (the retry re-encodes the same ops with the same `seq`s). Returns
|
||||
/// the fragment's length and the record, if that record continues the log (`seq <= last + 1`).
|
||||
/// The record's CRC must match, so damage is never mistaken for it.
|
||||
fn resync(line: &[u8], last: u64) -> Option<(usize, (u64, Op))> {
|
||||
const START: &[u8] = b"{\"seq\":";
|
||||
(1..line.len().saturating_sub(START.len() - 1)).filter(|&i| line[i..].starts_with(START)).find_map(|i| {
|
||||
let rec = std::str::from_utf8(&line[i..]).ok().map(|l| l.trim_end_matches('\r')).and_then(decode_record)?;
|
||||
(rec.0 <= last + 1).then_some((i, rec))
|
||||
})
|
||||
}
|
||||
|
||||
/// Write `catalog.snap` for the state after op `seq`, streaming the JSON into the store (no
|
||||
/// whole-file string). The bytes are exactly
|
||||
/// `{"format":"lightcraft-catalog","version":1,"seq":N,"catalog":<serde_json of the catalog>}\n`,
|
||||
/// as before streaming. Fills in the serialise / write+sync times and the size.
|
||||
fn write_snapshot(store: &mut dyn Store, seq: u64, catalog: &Catalog) -> std::io::Result<SnapshotTiming> {
|
||||
let t0 = web_time::Instant::now();
|
||||
let mut serialize_ms = 0.0;
|
||||
let bytes = store.write_atomic_with(SNAPSHOT, &mut |w| {
|
||||
use std::io::Write;
|
||||
let t = web_time::Instant::now();
|
||||
// serde_json emits many tiny writes: buffer them in a concrete writer (inlined), so only
|
||||
// 1 MiB chunks go through the store's `dyn Write`
|
||||
let mut b = std::io::BufWriter::with_capacity(1 << 20, w);
|
||||
write!(b, "{{\"format\":\"{FORMAT}\",\"version\":{VERSION},\"seq\":{seq},\"catalog\":")?;
|
||||
serde_json::to_writer(&mut b, catalog).map_err(std::io::Error::other)?;
|
||||
b.write_all(b"}\n")?;
|
||||
b.flush()?;
|
||||
serialize_ms = ms_since(t);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(SnapshotTiming { serialize_ms, write_sync_ms: (ms_since(t0) - serialize_ms).max(0.0), bytes, ..Default::default() })
|
||||
}
|
||||
|
||||
/// Just the identification of `catalog.snap` (read before the catalog itself).
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SnapFile {
|
||||
struct SnapHeader {
|
||||
format: String,
|
||||
version: u32,
|
||||
}
|
||||
|
||||
/// The whole `catalog.snap` (after [`SnapHeader`] was checked; `format` and `version` are ignored).
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SnapFile {
|
||||
seq: u64,
|
||||
catalog: Catalog,
|
||||
}
|
||||
@@ -106,18 +311,34 @@ impl Journal {
|
||||
let snap = store.read(SNAPSHOT).map_err(io)?;
|
||||
let log = store.read(LOG).map_err(io)?;
|
||||
report.created = snap.is_none() && log.is_none();
|
||||
let (mut catalog, snapshot_seq) = match snap {
|
||||
let (mut catalog, snapshot_seq, snapshot_version) = match snap {
|
||||
Some(bytes) => {
|
||||
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
|
||||
if s.format != FORMAT || s.version > VERSION {
|
||||
return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: unsupported format {} v{}", s.format, s.version)));
|
||||
// the header first: a newer snapshot may not parse as this version's catalog
|
||||
let h: SnapHeader = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
|
||||
if h.format != FORMAT {
|
||||
return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: not a LightCraft catalog (format {:?})", h.format)));
|
||||
}
|
||||
(s.catalog, s.seq)
|
||||
if h.version > VERSION {
|
||||
return Err(newer(format!("{SNAPSHOT} is catalog format v{}", h.version)));
|
||||
}
|
||||
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
|
||||
(s.catalog, s.seq, Some(h.version))
|
||||
}
|
||||
None => (Catalog::new(), 0),
|
||||
None => (Catalog::new(), 0, None),
|
||||
};
|
||||
report.snapshot_seq = snapshot_seq;
|
||||
let mut j = Journal { store, seq: snapshot_seq, snapshot_seq, log_records: 0, log_bytes: 0, policy: SnapshotPolicy::default() };
|
||||
let mut j = Journal {
|
||||
store,
|
||||
seq: snapshot_seq,
|
||||
snapshot_seq,
|
||||
log_records: 0,
|
||||
log_bytes: 0,
|
||||
policy: SnapshotPolicy::default(),
|
||||
stats: PersistStats::default(),
|
||||
pending: None,
|
||||
retry_at_records: 0,
|
||||
tail_dirty: false,
|
||||
};
|
||||
let log = log.unwrap_or_default();
|
||||
|
||||
// Scan records; `good_end` is the byte offset just past the last good record.
|
||||
@@ -136,7 +357,22 @@ impl Journal {
|
||||
good_end = next;
|
||||
continue;
|
||||
}
|
||||
match line.and_then(decode_record) {
|
||||
let mut record = line.and_then(decode_record);
|
||||
if record.is_none()
|
||||
&& let Some((at, r)) = resync(&log[pos..line_end], j.seq)
|
||||
{
|
||||
// the torn start of a failed append, then the retried records (older builds
|
||||
// appended them right after the fragment)
|
||||
log::warn!("catalog log: skipped a partial record ({at} bytes) left by a failed write");
|
||||
report.torn_fragments += 1;
|
||||
record = Some(r);
|
||||
}
|
||||
if record.is_none()
|
||||
&& let Some(Line::Newer(seq)) = line.map(decode_line)
|
||||
{
|
||||
return Err(newer(format!("{LOG} record {seq} holds a change this version doesn't know")));
|
||||
}
|
||||
match record {
|
||||
Some((seq, op)) if seq <= j.seq => {
|
||||
// already in the snapshot
|
||||
let _ = op;
|
||||
@@ -152,7 +388,15 @@ impl Journal {
|
||||
}
|
||||
_ => {
|
||||
// bad record (or a gap): torn tail if nothing good follows, else damage
|
||||
let rest_has_good = log[next..].split(|b| *b == b'\n').any(|l| std::str::from_utf8(l).ok().and_then(decode_record).is_some());
|
||||
let mut rest_has_good = false;
|
||||
for l in log[next..].split(|b| *b == b'\n') {
|
||||
match std::str::from_utf8(l).map(|l| decode_line(l.trim_end_matches('\r'))) {
|
||||
Ok(Line::Record(..)) => rest_has_good = true,
|
||||
// refused before anything is modified
|
||||
Ok(Line::Newer(seq)) => return Err(newer(format!("{LOG} record {seq} holds a change this version doesn't know"))),
|
||||
Ok(Line::Bad) | Err(_) => {}
|
||||
}
|
||||
}
|
||||
if rest_has_good {
|
||||
damaged_at = Some(pos);
|
||||
}
|
||||
@@ -176,10 +420,34 @@ impl Journal {
|
||||
report.torn_bytes = (log.len() - good_end) as u64;
|
||||
log::warn!("catalog log: dropped a torn final record ({} bytes)", report.torn_bytes);
|
||||
j.store.truncate(LOG, good_end as u64).map_err(io)?;
|
||||
j.log_bytes = good_end as u64;
|
||||
} else if needs_newline {
|
||||
j.store.append(LOG, b"\n").map_err(io)?;
|
||||
j.log_bytes = good_end as u64 + 1;
|
||||
} else {
|
||||
j.log_bytes = good_end as u64;
|
||||
}
|
||||
}
|
||||
// Bring an older (or new, or snapshot-less) library to this version's format before
|
||||
// anything is appended: a snapshot in this format makes older builds refuse the library
|
||||
// instead of misreading ops or dropping fields they don't know.
|
||||
if report.created {
|
||||
// tiny: written directly, not counted as a compaction
|
||||
let empty = format!("{{\"format\":\"{FORMAT}\",\"version\":{VERSION},\"seq\":0,\"catalog\":{}}}\n", catalog.to_snapshot());
|
||||
if let Err(e) = j.store.write_atomic(SNAPSHOT, empty.as_bytes()) {
|
||||
log::warn!("catalog: can't write the new library's snapshot: {e}");
|
||||
}
|
||||
} else if report.damaged.is_none() && snapshot_version != Some(VERSION) {
|
||||
report.upgraded_from = Some(snapshot_version.unwrap_or(0));
|
||||
match j.snapshot(&catalog) {
|
||||
Ok(()) => {
|
||||
if let Some(v) = report.upgraded_from {
|
||||
log::info!("catalog: upgraded from format v{v} to v{VERSION}");
|
||||
}
|
||||
}
|
||||
// nothing lost: the old files are intact and still load; retried on next open
|
||||
Err(e) => log::warn!("catalog: can't write the format v{VERSION} snapshot: {e}"),
|
||||
}
|
||||
j.log_bytes = good_end as u64;
|
||||
}
|
||||
catalog.revision = 0;
|
||||
Ok((j, catalog, report))
|
||||
@@ -190,6 +458,7 @@ impl Journal {
|
||||
if ops.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let t0 = web_time::Instant::now();
|
||||
let mut buf = String::new();
|
||||
let mut seq = self.seq;
|
||||
for op in ops {
|
||||
@@ -197,29 +466,202 @@ impl Journal {
|
||||
buf.push_str(&encode_record(seq, op));
|
||||
buf.push('\n');
|
||||
}
|
||||
self.store.append(LOG, buf.as_bytes()).map_err(io)?;
|
||||
if self.tail_dirty {
|
||||
self.store.truncate(LOG, self.log_bytes).map_err(io)?;
|
||||
self.tail_dirty = false;
|
||||
}
|
||||
if let Err(e) = self.store.append(LOG, buf.as_bytes()) {
|
||||
// Part of the batch may be in the file. Cut it back to the last whole record, so the
|
||||
// retry (same seqs) starts on a clean line instead of after a fragment that would
|
||||
// read back as damage.
|
||||
if let Err(t) = self.store.truncate(LOG, self.log_bytes) {
|
||||
log::error!("catalog: can't cut the log back after a failed append: {t}");
|
||||
self.tail_dirty = true;
|
||||
}
|
||||
return Err(io(e));
|
||||
}
|
||||
if let Some(p) = self.pending.as_mut() {
|
||||
p.tail.extend_from_slice(buf.as_bytes());
|
||||
p.tail_records += ops.len() as u64;
|
||||
}
|
||||
self.seq = seq;
|
||||
self.log_records += ops.len() as u64;
|
||||
self.log_bytes += buf.len() as u64;
|
||||
let ms = ms_since(t0);
|
||||
self.stats.appends += 1;
|
||||
self.stats.last_append_ms = ms;
|
||||
self.stats.max_append_ms = self.stats.max_append_ms.max(ms);
|
||||
if profiling() {
|
||||
eprintln!("catalog: append {} op(s), {} B: {ms:.2} ms", ops.len(), buf.len());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a snapshot of `catalog` (which must reflect every appended op) and reset the log.
|
||||
pub fn snapshot(&mut self, catalog: &Catalog) -> Result<()> {
|
||||
let body = serde_json::to_string(catalog).map_err(|e| CatalogError::Invalid(e.to_string()))?;
|
||||
let file = format!("{{\"format\":\"{FORMAT}\",\"version\":{VERSION},\"seq\":{},\"catalog\":{body}}}\n", self.seq);
|
||||
self.store.write_atomic(SNAPSHOT, file.as_bytes()).map_err(io)?;
|
||||
self.snapshot_with_unlogged(catalog, 0)
|
||||
}
|
||||
|
||||
/// [`Journal::snapshot`] of a catalog that also reflects `unlogged` ops applied after the
|
||||
/// last appended one but never written to the log (their append failed). The snapshot counts
|
||||
/// them (it holds the state after op `seq + unlogged`), so they are saved and must not be
|
||||
/// appended afterwards. Once the snapshot is durable [`Journal::seq`] includes them, even if
|
||||
/// resetting the log then fails (the error is still returned).
|
||||
pub fn snapshot_with_unlogged(&mut self, catalog: &Catalog, unlogged: u64) -> Result<()> {
|
||||
// never two snapshot writers; the one in flight is older, so it must land first
|
||||
if let Err(e) = self.wait() {
|
||||
log::warn!("catalog: background snapshot failed ({e}); writing one now");
|
||||
}
|
||||
let t0 = web_time::Instant::now();
|
||||
let seq = self.seq + unlogged;
|
||||
let mut timing = match write_snapshot(self.store.as_mut(), seq, catalog) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
// nothing lost (the log is whole); don't retry on every append
|
||||
self.stats.failed_snapshots += 1;
|
||||
self.retry_at_records = self.log_records + self.policy.max_records;
|
||||
return Err(io(e));
|
||||
}
|
||||
};
|
||||
// the snapshot is durable: it holds every op up to `seq`
|
||||
self.seq = seq;
|
||||
self.snapshot_seq = seq;
|
||||
let t2 = web_time::Instant::now();
|
||||
// A crash here leaves old records in the log; they are skipped by seq on load.
|
||||
self.store.write_atomic(LOG, b"").map_err(io)?;
|
||||
self.snapshot_seq = self.seq;
|
||||
timing.reset_ms = ms_since(t2);
|
||||
timing.total_ms = ms_since(t0);
|
||||
timing.blocking_ms = timing.total_ms;
|
||||
timing.records = self.log_records;
|
||||
self.record_snapshot(timing);
|
||||
self.log_records = 0;
|
||||
self.log_bytes = 0;
|
||||
self.retry_at_records = 0;
|
||||
self.tail_dirty = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compact like [`Journal::snapshot`], but write the snapshot on a worker thread from a copy
|
||||
/// of `catalog` (cheap: photos are shared) when the store supports it (else synchronously).
|
||||
/// `catalog` must reflect every appended op. Appends go on meanwhile; [`Journal::poll`]
|
||||
/// finishes the compaction. No-op while a snapshot is already in flight.
|
||||
pub fn snapshot_in_background(&mut self, catalog: &Catalog) -> Result<()> {
|
||||
if self.pending.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let t0 = web_time::Instant::now();
|
||||
let Some(mut writer) = self.store.background_writer() else { return self.snapshot(catalog) };
|
||||
let copy = catalog.clone();
|
||||
let seq = self.seq;
|
||||
let spawned = std::thread::Builder::new().name("catalog-snapshot".into()).spawn(move || write_snapshot(writer.as_mut(), seq, ©));
|
||||
let worker = match spawned {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
log::warn!("catalog: can't start a snapshot thread ({e}); writing it now");
|
||||
return self.snapshot(catalog);
|
||||
}
|
||||
};
|
||||
self.pending =
|
||||
Some(Pending { seq, tail: Vec::new(), tail_records: 0, records: self.log_records, started: t0, start_ms: ms_since(t0), worker });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finish a background snapshot if its worker is done (cheap otherwise). Returns whether one
|
||||
/// finished. An error means the snapshot failed: nothing was lost (the log is whole), and
|
||||
/// compaction is retried once the log has grown by another [`SnapshotPolicy::max_records`].
|
||||
pub fn poll(&mut self) -> Result<bool> {
|
||||
if !self.pending.as_ref().is_some_and(|p| p.worker.is_finished()) {
|
||||
return Ok(false);
|
||||
}
|
||||
self.finish().map(|()| true)
|
||||
}
|
||||
|
||||
/// Wait for the background snapshot in flight, if any, and finish it.
|
||||
pub fn wait(&mut self) -> Result<()> {
|
||||
if self.pending.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
self.finish()
|
||||
}
|
||||
|
||||
/// A background snapshot is in flight (until [`Journal::poll`] or [`Journal::wait`] finish it).
|
||||
pub fn snapshot_running(&self) -> bool {
|
||||
self.pending.is_some()
|
||||
}
|
||||
|
||||
/// The background worker is done (its result not yet applied by [`Journal::poll`]).
|
||||
pub fn snapshot_written(&self) -> bool {
|
||||
self.pending.as_ref().is_some_and(|p| p.worker.is_finished())
|
||||
}
|
||||
|
||||
/// Join the worker; on success replace the log with the records appended meanwhile.
|
||||
fn finish(&mut self) -> Result<()> {
|
||||
let Some(p) = self.pending.take() else { return Ok(()) };
|
||||
let t0 = web_time::Instant::now();
|
||||
let written = p.worker.join().unwrap_or_else(|_| Err(std::io::Error::other("snapshot thread panicked")));
|
||||
let mut timing = match written {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
// the log still holds every record: nothing lost, retry later
|
||||
self.stats.failed_snapshots += 1;
|
||||
self.retry_at_records = self.log_records + self.policy.max_records;
|
||||
log::error!("catalog: background snapshot failed: {e}");
|
||||
return Err(io(e));
|
||||
}
|
||||
};
|
||||
// `catalog.snap` (op `p.seq`) is durable; the log's records `<= p.seq` are now stale.
|
||||
// Keep exactly the ones appended since (a crash before this rewrite skips the stale ones).
|
||||
let t1 = web_time::Instant::now();
|
||||
self.snapshot_seq = p.seq;
|
||||
if let Err(e) = self.store.write_atomic(LOG, &p.tail) {
|
||||
// the log stays whole (stale records are skipped on load); compact again later
|
||||
self.retry_at_records = self.log_records + self.policy.max_records;
|
||||
log::error!("catalog: can't trim the log after a snapshot: {e}");
|
||||
return Err(io(e));
|
||||
}
|
||||
timing.reset_ms = ms_since(t1);
|
||||
timing.total_ms = ms_since(p.started);
|
||||
timing.blocking_ms = p.start_ms + ms_since(t0);
|
||||
timing.background = true;
|
||||
timing.records = p.records;
|
||||
self.record_snapshot(timing);
|
||||
self.log_records = p.tail_records;
|
||||
self.log_bytes = p.tail.len() as u64;
|
||||
self.retry_at_records = 0;
|
||||
self.tail_dirty = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_snapshot(&mut self, t: SnapshotTiming) {
|
||||
self.stats.snapshots += 1;
|
||||
self.stats.last_snapshot = t;
|
||||
self.stats.max_snapshot_ms = self.stats.max_snapshot_ms.max(t.total_ms);
|
||||
self.stats.max_blocking_ms = self.stats.max_blocking_ms.max(t.blocking_ms);
|
||||
if profiling() {
|
||||
eprintln!(
|
||||
"catalog: {}snapshot of {} records, {} B: serialize {:.1} ms, write+sync {:.1} ms, log reset {:.1} ms, total {:.1} ms, blocking {:.1} ms",
|
||||
if t.background { "background " } else { "" },
|
||||
t.records,
|
||||
t.bytes,
|
||||
t.serialize_ms,
|
||||
t.write_sync_ms,
|
||||
t.reset_ms,
|
||||
t.total_ms,
|
||||
t.blocking_ms
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where persistence time went so far.
|
||||
pub fn stats(&self) -> PersistStats {
|
||||
PersistStats { snapshot_running: self.pending.is_some(), ..self.stats }
|
||||
}
|
||||
|
||||
/// The log is long enough to be worth compacting.
|
||||
pub fn wants_snapshot(&self) -> bool {
|
||||
self.log_records >= self.policy.max_records || self.log_bytes >= self.policy.max_bytes
|
||||
self.pending.is_none()
|
||||
&& self.log_records >= self.retry_at_records
|
||||
&& (self.log_records >= self.policy.max_records || self.log_bytes >= self.policy.max_bytes)
|
||||
}
|
||||
|
||||
pub fn seq(&self) -> u64 {
|
||||
@@ -238,3 +680,16 @@ impl Journal {
|
||||
self.store.describe()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Journal {
|
||||
/// Wait for a background snapshot (never leave a writer behind that could replace a newer
|
||||
/// snapshot of a journal reopened on the same files). The log is left whole: the snapshot's
|
||||
/// records in it are skipped as stale on the next load.
|
||||
fn drop(&mut self) {
|
||||
if let Some(p) = self.pending.take()
|
||||
&& let Ok(Err(e)) = p.worker.join()
|
||||
{
|
||||
log::error!("catalog: background snapshot failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,38 +51,60 @@ pub struct KeywordNode {
|
||||
impl Catalog {
|
||||
/// The keyword tree: every keyword level with photo counts, sorted by name (case-insensitive).
|
||||
pub fn keyword_tree(&self) -> Vec<KeywordNode> {
|
||||
fn insert(nodes: &mut Vec<KeywordNode>, parts: &[&str], prefix: &str, photo: u64, seen: &mut std::collections::HashSet<(String, u64)>) {
|
||||
let Some((first, rest)) = parts.split_first() else { return };
|
||||
let path = if prefix.is_empty() { first.to_string() } else { format!("{prefix}|{first}") };
|
||||
let i = match nodes.iter().position(|n| n.name.eq_ignore_ascii_case(first)) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
nodes.push(KeywordNode { name: first.to_string(), path: path.clone(), count: 0, children: Vec::new() });
|
||||
nodes.len() - 1
|
||||
}
|
||||
};
|
||||
if seen.insert((path.to_lowercase(), photo)) {
|
||||
nodes[i].count += 1;
|
||||
}
|
||||
let p = nodes[i].path.clone();
|
||||
insert(&mut nodes[i].children, rest, &p, photo, seen);
|
||||
use std::collections::{HashMap, HashSet};
|
||||
// every keyword level (`travel`, `travel|italy`…) by its lower-case path: the name and
|
||||
// path as first written, and the photos counted (once per photo, even when two of its
|
||||
// keywords share a parent)
|
||||
struct Level {
|
||||
name: String,
|
||||
path: String,
|
||||
count: usize,
|
||||
parent: Option<String>,
|
||||
}
|
||||
fn sort(nodes: &mut [KeywordNode]) {
|
||||
nodes.sort_by_key(|n| n.name.to_lowercase());
|
||||
for n in nodes {
|
||||
sort(&mut n.children);
|
||||
}
|
||||
}
|
||||
let mut roots = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut levels: HashMap<String, Level> = HashMap::new();
|
||||
let mut this_photo: HashSet<String> = HashSet::new();
|
||||
for p in self.photos().filter(|p| p.in_library()) {
|
||||
this_photo.clear();
|
||||
for k in &p.meta.keywords {
|
||||
let parts: Vec<&str> = k.split(SEP).map(str::trim).filter(|s| !s.is_empty()).collect();
|
||||
insert(&mut roots, &parts, "", p.id.0, &mut seen);
|
||||
let mut path = String::new();
|
||||
let mut lower = String::new();
|
||||
let mut parent: Option<String> = None;
|
||||
for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) {
|
||||
if !path.is_empty() {
|
||||
path.push(SEP);
|
||||
lower.push(SEP);
|
||||
}
|
||||
path.push_str(part);
|
||||
lower.push_str(&part.to_lowercase());
|
||||
let l = levels.entry(lower.clone()).or_insert_with(|| Level {
|
||||
name: part.to_string(),
|
||||
path: path.clone(),
|
||||
count: 0,
|
||||
parent: parent.clone(),
|
||||
});
|
||||
if this_photo.insert(lower.clone()) {
|
||||
l.count += 1;
|
||||
}
|
||||
parent = Some(lower.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
sort(&mut roots);
|
||||
roots
|
||||
let mut kids: HashMap<Option<String>, Vec<String>> = HashMap::new();
|
||||
for (lower, l) in &levels {
|
||||
kids.entry(l.parent.clone()).or_default().push(lower.clone());
|
||||
}
|
||||
fn build(at: Option<String>, levels: &HashMap<String, Level>, kids: &mut HashMap<Option<String>, Vec<String>>) -> Vec<KeywordNode> {
|
||||
let Some(mut list) = kids.remove(&at) else { return Vec::new() };
|
||||
list.sort_by_key(|l| levels[l].name.to_lowercase());
|
||||
list.into_iter()
|
||||
.map(|l| {
|
||||
let lv = &levels[&l];
|
||||
let children = build(Some(l.clone()), levels, kids);
|
||||
KeywordNode { name: lv.name.clone(), path: lv.path.clone(), count: lv.count, children }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
build(None, &levels, &mut kids)
|
||||
}
|
||||
|
||||
/// `SetMeta` ops for every photo whose keywords `f` changes.
|
||||
|
||||
@@ -5,13 +5,23 @@
|
||||
//! snapshot — crash-safe and diff-friendly (see [`journal`]);
|
||||
//! - **undo/redo**: the engine keeps inverse ops;
|
||||
//! - **determinism**: replaying the log reproduces the state exactly (property-tested).
|
||||
//!
|
||||
//! **Catalog format version** ([`journal::VERSION`], see [`journal`] → *Format versions*): adding
|
||||
//! an [`Op`] variant or a serialized field means bumping it. Newer builds read every older format
|
||||
//! (and upgrade it on open); older builds refuse a newer library with [`CatalogError::Newer`]
|
||||
//! instead of reading part of it.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod dates;
|
||||
pub mod journal;
|
||||
pub mod keywords;
|
||||
pub mod local;
|
||||
pub mod lock;
|
||||
pub mod model;
|
||||
pub mod query;
|
||||
pub mod rules;
|
||||
pub mod safe_file;
|
||||
pub mod stacks;
|
||||
pub mod store;
|
||||
|
||||
@@ -19,11 +29,14 @@ use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use dates::{DateRun, GroupBy};
|
||||
pub use journal::{Journal, LoadReport, SnapshotPolicy};
|
||||
pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTiming};
|
||||
pub use keywords::KeywordNode;
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
pub use local::{DEFAULT_FORGET_DAYS, ForgetPlan, folder_of};
|
||||
pub use lock::{LibraryLock, LockError, LockOwner};
|
||||
pub use model::*;
|
||||
pub use query::{DateGroup, Filter, RatingOp, Sort, SortKey};
|
||||
pub use rules::{Match, Rule, RuleSet};
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use store::{FsStore, MemStore, Store};
|
||||
|
||||
@@ -41,6 +54,10 @@ pub enum CatalogError {
|
||||
Corrupt(String),
|
||||
#[error("catalog storage: {0}")]
|
||||
Io(String),
|
||||
/// The library was written by a newer LightCraft (a newer catalog format, or a change this
|
||||
/// version doesn't know). Nothing was read into the session and nothing was modified.
|
||||
#[error("this library was written by a newer version of LightCraft ({0}); update LightCraft to open it. The library was left unchanged.")]
|
||||
Newer(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, CatalogError>;
|
||||
@@ -147,6 +164,11 @@ pub enum Op {
|
||||
id: PhotoId,
|
||||
captured: Option<String>,
|
||||
},
|
||||
/// Assisted culling scores.
|
||||
SetAnalysis {
|
||||
id: PhotoId,
|
||||
analysis: Option<crate::Analysis>,
|
||||
},
|
||||
/// Rename a photo: its file name and the source it points to. Applying the op never touches
|
||||
/// the disk — the engine moves the file before it commits (and on undo/redo).
|
||||
SetFile {
|
||||
@@ -160,12 +182,34 @@ pub enum Op {
|
||||
id: PhotoId,
|
||||
file_name: String,
|
||||
source: Source,
|
||||
/// The file's format when it changes too (Convert to DNG).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
format: Option<String>,
|
||||
},
|
||||
/// What a photo's file is now (after it changed on disk: Reload).
|
||||
SetContent {
|
||||
id: PhotoId,
|
||||
width: u32,
|
||||
height: u32,
|
||||
file_size: u64,
|
||||
#[serde(default)]
|
||||
content_hash: Option<String>,
|
||||
/// Why the file can only be shown from its embedded preview (see [`Photo::preview_only`]);
|
||||
/// `None` = its raw data decodes (or it isn't a raw).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
preview_only: Option<String>,
|
||||
},
|
||||
/// The name shown for a colour label (`None` = its colour's name).
|
||||
SetLabelName {
|
||||
label: ColorLabel,
|
||||
name: Option<String>,
|
||||
},
|
||||
/// When a Local folder was last browsed (ISO 8601; `None` = forget the time). Not an undo
|
||||
/// step: it drives forgetting untouched Local records (see [`local`]).
|
||||
SetBrowsed {
|
||||
folder: String,
|
||||
at: Option<String>,
|
||||
},
|
||||
/// Several ops as one step (undo applies the inverses in reverse).
|
||||
Batch {
|
||||
ops: Vec<Op>,
|
||||
@@ -185,6 +229,9 @@ pub struct Catalog {
|
||||
/// Custom colour label names.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
label_names: BTreeMap<ColorLabel, String>,
|
||||
/// When each Local folder was last browsed (folder path → ISO 8601), see [`local`].
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
browsed: BTreeMap<String, String>,
|
||||
/// Increments on every applied op.
|
||||
#[serde(skip)]
|
||||
pub revision: u64,
|
||||
@@ -233,6 +280,11 @@ impl Catalog {
|
||||
pub fn albums(&self) -> impl Iterator<Item = &Album> {
|
||||
self.albums.values()
|
||||
}
|
||||
/// The Quick Collection, once something was added to it.
|
||||
pub fn quick_collection(&self) -> Option<AlbumId> {
|
||||
self.albums.values().find(|a| a.quick).map(|a| a.id)
|
||||
}
|
||||
|
||||
/// Albums (regular and smart) that contain the photo.
|
||||
pub fn albums_of(&self, id: PhotoId) -> Vec<AlbumId> {
|
||||
let Some(p) = self.photos.get(&id) else { return Vec::new() };
|
||||
@@ -261,7 +313,8 @@ impl Catalog {
|
||||
/// Number of photos shown for an album in the sources list (excludes deleted photos).
|
||||
pub fn album_count(&self, id: AlbumId) -> usize {
|
||||
match self.albums.get(&id) {
|
||||
Some(Album { smart: Some(_), .. }) => self.album_photos(id).len(),
|
||||
// counted in place: no id list is built just for its length
|
||||
Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).count(),
|
||||
Some(a) => a.photos.iter().filter(|p| self.photos.get(p).is_some_and(|p| !p.deleted)).count(),
|
||||
None => 0,
|
||||
}
|
||||
@@ -288,6 +341,11 @@ impl Catalog {
|
||||
pub fn custom_label_name(&self, l: ColorLabel) -> Option<&str> {
|
||||
self.label_names.get(&l).map(String::as_str)
|
||||
}
|
||||
/// The label a name stands for: a custom name first, then a colour's own name (any case).
|
||||
pub fn label_from_name(&self, name: &str) -> Option<ColorLabel> {
|
||||
let name = name.trim();
|
||||
self.label_names.iter().find(|(_, n)| n.trim().eq_ignore_ascii_case(name)).map(|(l, _)| *l).or_else(|| ColorLabel::parse(name))
|
||||
}
|
||||
|
||||
// ---- writes
|
||||
|
||||
@@ -466,6 +524,10 @@ impl Catalog {
|
||||
s.collapsed = collapsed;
|
||||
old
|
||||
}
|
||||
Op::SetAnalysis { id, analysis } => {
|
||||
let p = self.photo_mut(id)?;
|
||||
Op::SetAnalysis { id, analysis: std::mem::replace(&mut p.analysis, analysis) }
|
||||
}
|
||||
Op::SetCaptured { id, captured } => {
|
||||
if let Some(c) = &captured
|
||||
&& stacks::iso_seconds(c).is_none()
|
||||
@@ -475,10 +537,22 @@ impl Catalog {
|
||||
let p = self.photo_mut(id)?;
|
||||
Op::SetCaptured { id, captured: std::mem::replace(&mut p.captured, captured) }
|
||||
}
|
||||
Op::Relink { id, file_name, source } => {
|
||||
Op::Relink { id, file_name, source, format } => {
|
||||
let p = self.photo_mut(id)?;
|
||||
let old_name = std::mem::replace(&mut p.file_name, file_name);
|
||||
Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source) }
|
||||
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 } => {
|
||||
let p = self.photo_mut(id)?;
|
||||
Op::SetContent {
|
||||
id,
|
||||
width: std::mem::replace(&mut p.width, width),
|
||||
height: std::mem::replace(&mut p.height, height),
|
||||
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),
|
||||
}
|
||||
}
|
||||
Op::SetFile { id, file_name, source } => {
|
||||
if file_name.trim().is_empty() {
|
||||
@@ -496,6 +570,14 @@ impl Catalog {
|
||||
};
|
||||
Op::SetLabelName { label, name: old }
|
||||
}
|
||||
Op::SetBrowsed { folder, at } => {
|
||||
let folder = crate::query::folder_key(&folder);
|
||||
let old = match at {
|
||||
Some(t) => self.browsed.insert(folder.clone(), t),
|
||||
None => self.browsed.remove(&folder),
|
||||
};
|
||||
Op::SetBrowsed { folder, at: old }
|
||||
}
|
||||
Op::Batch { ops } => {
|
||||
let mut inverses = Vec::with_capacity(ops.len());
|
||||
for op in ops {
|
||||
@@ -571,4 +653,14 @@ impl Catalog {
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_background;
|
||||
#[cfg(test)]
|
||||
mod tests_format_version;
|
||||
#[cfg(test)]
|
||||
mod tests_journal;
|
||||
#[cfg(test)]
|
||||
mod tests_local;
|
||||
#[cfg(test)]
|
||||
mod tests_lock;
|
||||
#[cfg(test)]
|
||||
mod tests_torn_append;
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Forgetting untouched Local browse records.
|
||||
//!
|
||||
//! Browsing a folder (Local) catalogues its photos as `local` records so they can be shown,
|
||||
//! rated and edited in place. Most are only ever looked at, and on a large disk they can be most
|
||||
//! of the catalog. A record is **forgotten** (removed from the catalog, never from the disk —
|
||||
//! files and XMP sidecars are not touched) when all of these hold:
|
||||
//!
|
||||
//! - it is a Local record ([`Photo::local`]) of a file, not deleted, not a virtual copy;
|
||||
//! - its folder was last browsed more than `days` ago ([`Catalog::last_browsed`]). A folder with
|
||||
//! no recorded time (a catalog from before this existed) counts as browsed *now*: it is stamped
|
||||
//! with the current time and nothing in it is forgotten this time;
|
||||
//! - it is **untouched** ([`Photo::untouched_local`]): nothing about it changed since it was
|
||||
//! browsed — see below;
|
||||
//! - nothing refers to it: no album holds it or shows it as cover, no stack holds it, no virtual
|
||||
//! copy was made of it; and the caller (the engine) says it isn't in use (visible, selected,
|
||||
//! in the undo history, has a smart preview…).
|
||||
//!
|
||||
//! **Untouched.** A record catalogued by a browse carries a fingerprint of its whole state at
|
||||
//! that moment ([`Photo::local_baseline`]). It is untouched while its state still hashes to that
|
||||
//! fingerprint, i.e. *any* change since — develop settings or history, versions, rating, flag,
|
||||
//! label, any metadata (title, caption, keywords, location, copyright…), capture time, crop,
|
||||
//! rename or relink, culling scores, file content — keeps it. Records without a fingerprint
|
||||
//! (browsed before fingerprints existed) are judged by their data alone and kept unless every
|
||||
//! user-editable field is still empty / default: no rating, flag or label; develop settings equal
|
||||
//! to the import defaults with no history, versions or edit time; no title, caption, alt text,
|
||||
//! description, keywords, creator, copyright, rights, location or GPS; no culling scores.
|
||||
//! When in doubt a record is kept.
|
||||
//!
|
||||
//! Browsing the folder again catalogues its files afresh (new records, new fingerprints).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{Catalog, Op, Photo, PhotoId, Source};
|
||||
|
||||
/// Default for how long a Local folder may go unbrowsed before its untouched records are
|
||||
/// forgotten (days).
|
||||
pub const DEFAULT_FORGET_DAYS: u32 = 30;
|
||||
|
||||
/// Records removed per logged batch (keeps log lines a reasonable size).
|
||||
const BATCH: usize = 2000;
|
||||
|
||||
/// 64-bit FNV-1a over everything written to it (stable across builds and platforms, unlike
|
||||
/// `std`'s hasher), so a photo is hashed as it serialises, without a buffer.
|
||||
struct Fnv(u64);
|
||||
|
||||
impl std::io::Write for Fnv {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
for b in buf {
|
||||
self.0 ^= u64::from(*b);
|
||||
self.0 = self.0.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder a file-backed photo is in (`None` for demo photos).
|
||||
pub fn folder_of(p: &Photo) -> Option<String> {
|
||||
match &p.source {
|
||||
// `/` and `\` are both separators whatever the platform (a catalog can move between them)
|
||||
Source::File { path } => {
|
||||
let key = crate::query::folder_key(path);
|
||||
key.rsplit_once('/').map(|(dir, _)| if dir.is_empty() { "/".to_string() } else { dir.to_string() })
|
||||
}
|
||||
Source::Demo { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Photo {
|
||||
/// A hash of everything about the photo except [`Photo::local_baseline`] itself.
|
||||
pub fn fingerprint(&self) -> u64 {
|
||||
let mut h = Fnv(0xcbf2_9ce4_8422_2325);
|
||||
let r = if self.local_baseline.is_some() {
|
||||
let mut p = self.clone();
|
||||
p.local_baseline = None;
|
||||
serde_json::to_writer(&mut h, &p)
|
||||
} else {
|
||||
serde_json::to_writer(&mut h, self)
|
||||
};
|
||||
// (serialising a photo can't fail; if it ever did, no baseline would match: kept)
|
||||
if r.is_err() { 0 } else { h.0 }
|
||||
}
|
||||
|
||||
/// Record the current state as the browse baseline (done when a browse catalogues it).
|
||||
pub fn set_local_baseline(&mut self) {
|
||||
self.local_baseline = None;
|
||||
self.local_baseline = Some(self.fingerprint());
|
||||
}
|
||||
|
||||
/// A Local record nothing was changed on since it was browsed (see the module docs). Says
|
||||
/// nothing about albums, stacks or virtual copies referring to it.
|
||||
pub fn untouched_local(&self) -> bool {
|
||||
if !self.local || self.deleted || self.copy_of.is_some() || self.copy_name.is_some() || folder_of(self).is_none() {
|
||||
return false;
|
||||
}
|
||||
match self.local_baseline {
|
||||
Some(b) => self.fingerprint() == b,
|
||||
None => self.looks_unedited(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every user-editable field is still empty / default (records without a baseline).
|
||||
fn looks_unedited(&self) -> bool {
|
||||
let m = &self.meta;
|
||||
self.rating == 0
|
||||
&& self.flag == crate::Flag::None
|
||||
&& self.label.is_none()
|
||||
&& self.edited.is_none()
|
||||
&& self.history.is_empty()
|
||||
&& self.versions.is_empty()
|
||||
&& *self.develop == self.import_defaults()
|
||||
&& self.analysis.is_none()
|
||||
&& m.title.is_empty()
|
||||
&& m.caption.is_empty()
|
||||
&& m.alt_text.is_empty()
|
||||
&& m.extended_description.is_empty()
|
||||
&& m.keywords.is_empty()
|
||||
&& m.creator.is_empty()
|
||||
&& m.copyright.is_empty()
|
||||
&& m.copyright_status.is_unknown()
|
||||
&& m.usage_terms.is_empty()
|
||||
&& m.copyright_url.is_empty()
|
||||
&& m.location.is_empty()
|
||||
&& m.city.is_empty()
|
||||
&& m.state.is_empty()
|
||||
&& m.country.is_empty()
|
||||
&& m.gps.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// What forgetting Local records would do / did.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ForgetPlan {
|
||||
/// Local records looked at.
|
||||
pub local: usize,
|
||||
/// Records to forget.
|
||||
pub evict: Vec<PhotoId>,
|
||||
/// Kept: their folder was browsed within the period (or has no time yet).
|
||||
pub kept_recent: usize,
|
||||
/// Kept: changed by the user since they were browsed.
|
||||
pub kept_touched: usize,
|
||||
/// Kept: in an album, a stack, the source of a virtual copy, or in use.
|
||||
pub kept_in_use: usize,
|
||||
/// Folders with Local records but no last-browsed time: stamped now.
|
||||
pub stamp: Vec<String>,
|
||||
/// Folders whose last Local record goes: their time is dropped.
|
||||
pub unstamp: Vec<String>,
|
||||
/// The time stamps are given.
|
||||
#[serde(skip)]
|
||||
pub now: String,
|
||||
}
|
||||
|
||||
impl ForgetPlan {
|
||||
/// The ops that carry the plan out (empty when there is nothing to do).
|
||||
pub fn ops(&self) -> Vec<Op> {
|
||||
let mut ops: Vec<Op> = self.stamp.iter().map(|f| Op::SetBrowsed { folder: f.clone(), at: Some(self.now.clone()) }).collect();
|
||||
ops.extend(self.evict.chunks(BATCH).map(|c| Op::Batch { ops: c.iter().map(|id| Op::RemovePhoto { id: *id }).collect() }));
|
||||
ops.extend(self.unstamp.iter().map(|f| Op::SetBrowsed { folder: f.clone(), at: None }));
|
||||
ops
|
||||
}
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// When `folder` was last browsed (ISO 8601), if known.
|
||||
pub fn last_browsed(&self, folder: &str) -> Option<&str> {
|
||||
self.browsed.get(&crate::query::folder_key(folder)).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Folders with a last-browsed time.
|
||||
pub fn browsed_folders(&self) -> &BTreeMap<String, String> {
|
||||
&self.browsed
|
||||
}
|
||||
|
||||
/// Plan forgetting untouched Local records whose folder wasn't browsed for `days` days (as of
|
||||
/// `now`, ISO 8601). `in_use` keeps records the caller still needs. `days == 0` (never) or an
|
||||
/// unreadable `now` forgets nothing. See the module docs.
|
||||
pub fn forget_local_plan(&self, now: &str, days: u32, in_use: &dyn Fn(&Photo) -> bool) -> ForgetPlan {
|
||||
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));
|
||||
// the fingerprints are the costly part: work them out for the candidates in parallel
|
||||
let candidates: Vec<&Photo> = self
|
||||
.photos
|
||||
.values()
|
||||
.filter(|p| p.local && !referenced.contains(&p.id))
|
||||
.filter(|p| folder_of(p).and_then(|f| self.browsed.get(&f)).is_some_and(|at| is_old(at, now_s, days, max_age)))
|
||||
.map(|p| &**p)
|
||||
.collect();
|
||||
let untouched: HashSet<PhotoId> = untouched_of(&candidates);
|
||||
let mut stamp = BTreeSet::new();
|
||||
let mut remaining: BTreeSet<String> = BTreeSet::new();
|
||||
for p in self.photos.values().filter(|p| p.local) {
|
||||
plan.local += 1;
|
||||
let Some(folder) = folder_of(p) else {
|
||||
plan.kept_in_use += 1;
|
||||
continue;
|
||||
};
|
||||
let recent = match (self.browsed.get(&folder), now_s) {
|
||||
(None, _) => {
|
||||
stamp.insert(folder.clone());
|
||||
true
|
||||
}
|
||||
(Some(at), _) => !is_old(at, now_s, days, max_age),
|
||||
};
|
||||
let keep = if recent {
|
||||
plan.kept_recent += 1;
|
||||
true
|
||||
} else if referenced.contains(&p.id) || in_use(p) {
|
||||
plan.kept_in_use += 1;
|
||||
true
|
||||
} else if !untouched.contains(&p.id) {
|
||||
plan.kept_touched += 1;
|
||||
true
|
||||
} else {
|
||||
plan.evict.push(p.id);
|
||||
false
|
||||
};
|
||||
if keep {
|
||||
remaining.insert(folder);
|
||||
}
|
||||
}
|
||||
plan.stamp = stamp.into_iter().collect();
|
||||
plan.unstamp = self.browsed.keys().filter(|f| !remaining.contains(*f)).cloned().collect();
|
||||
plan
|
||||
}
|
||||
}
|
||||
|
||||
/// A folder browsed at `at` is past the period (never when `days` is 0 or a time is unreadable).
|
||||
fn is_old(at: &str, now_s: Option<i64>, days: u32, max_age: i64) -> bool {
|
||||
days > 0 && matches!((now_s, crate::stacks::iso_seconds(at)), (Some(n), Some(a)) if n - a >= max_age)
|
||||
}
|
||||
|
||||
/// The untouched ones among `photos` ([`Photo::untouched_local`]), checked on several threads
|
||||
/// where there are many.
|
||||
fn untouched_of(photos: &[&Photo]) -> HashSet<PhotoId> {
|
||||
let check = |chunk: &[&Photo]| chunk.iter().filter(|p| p.untouched_local()).map(|p| p.id).collect::<Vec<_>>();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).min(8);
|
||||
if threads > 1 && photos.len() >= 256 {
|
||||
let size = photos.len().div_ceil(threads);
|
||||
return std::thread::scope(|sc| {
|
||||
let jobs: Vec<_> = photos.chunks(size).map(|c| sc.spawn(move || check(c))).collect();
|
||||
// a job that panicked (it can't) leaves its photos out: they are kept
|
||||
jobs.into_iter().filter_map(|j| j.join().ok()).flatten().collect()
|
||||
});
|
||||
}
|
||||
}
|
||||
check(photos).into_iter().collect()
|
||||
}
|
||||
|
||||
impl Op {
|
||||
/// Every photo the op refers to.
|
||||
pub fn photo_ids(&self, out: &mut impl FnMut(PhotoId)) {
|
||||
match self {
|
||||
Op::AddPhoto { photo } => out(photo.id),
|
||||
Op::RemovePhoto { id }
|
||||
| Op::SetRating { id, .. }
|
||||
| Op::SetFlag { id, .. }
|
||||
| Op::SetLabel { id, .. }
|
||||
| Op::SetDevelop { id, .. }
|
||||
| Op::SetMeta { id, .. }
|
||||
| Op::SetDeleted { id, .. }
|
||||
| Op::SetLocal { id, .. }
|
||||
| Op::SetVersions { id, .. }
|
||||
| Op::SetHistory { id, .. }
|
||||
| Op::PushHistory { id, .. }
|
||||
| Op::SetCaptured { id, .. }
|
||||
| Op::SetAnalysis { id, .. }
|
||||
| Op::SetFile { id, .. }
|
||||
| Op::Relink { id, .. }
|
||||
| Op::SetContent { id, .. } => out(*id),
|
||||
Op::AddAlbum { album } => {
|
||||
album.photos.iter().for_each(|p| out(*p));
|
||||
album.cover.into_iter().for_each(&mut *out);
|
||||
}
|
||||
Op::SetAlbumPhotos { photos, .. } | Op::SetStack { photos, .. } => photos.iter().for_each(|p| out(*p)),
|
||||
Op::SetAlbumCover { cover, .. } => cover.iter().for_each(|p| out(*p)),
|
||||
Op::AddStack { stack } => stack.photos.iter().for_each(|p| out(*p)),
|
||||
Op::Batch { ops } => ops.iter().for_each(|o| o.photo_ids(out)),
|
||||
Op::RemoveAlbum { .. }
|
||||
| Op::RenameAlbum { .. }
|
||||
| Op::MoveAlbum { .. }
|
||||
| Op::SetAlbumRules { .. }
|
||||
| Op::RemoveStack { .. }
|
||||
| Op::SetLabelName { .. }
|
||||
| Op::SetBrowsed { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! One process per library: an exclusive OS lock on `<library>/catalog.lock`, held for as long as
|
||||
//! the library is open (issue #99).
|
||||
//!
|
||||
//! Two processes on the same library (the app and `lightcraft-cli --library`, two app instances,
|
||||
//! two computers on a shared folder) would each keep their own catalog and `seq` counter, and
|
||||
//! whichever wrote the last snapshot would silently drop the other's edits. So opening a library
|
||||
//! takes the lock first, and a second opener gets [`LockError::InUse`] instead.
|
||||
//!
|
||||
//! - The lock is the OS's (`flock` on Unix, `LockFileEx` on Windows, via [`std::fs::File::try_lock`]):
|
||||
//! it is released when the [`LibraryLock`] is dropped and, after a crash or a kill, by the OS when
|
||||
//! the process ends. A `catalog.lock` file left behind is therefore never "stale": only a lock
|
||||
//! that's actually held refuses the open.
|
||||
//! - Who holds it (process id, computer, program, version) is written to `catalog.lock.owner`, for
|
||||
//! the error message. It's a separate file because a Windows lock also blocks reading the
|
||||
//! locked file. It's only a hint: it may be missing or left over from a crash.
|
||||
//! - File systems without lock support (some network shares): the library opens unlocked, with a
|
||||
//! warning — refusing it would lock the user out of their own library. Locks over SMB/NFS are
|
||||
//! advisory and depend on the server; the owner file is then the only hint.
|
||||
//! - Browser storage (OPFS) has no file locks: see the web host for its own guard.
|
||||
|
||||
use std::fs::{File, OpenOptions, TryLockError};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The lock file in the library directory.
|
||||
pub const LOCK: &str = "catalog.lock";
|
||||
/// Who holds the lock (JSON, see [`LockOwner`]).
|
||||
pub const OWNER: &str = "catalog.lock.owner";
|
||||
|
||||
/// Who has the library open (as written in [`OWNER`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct LockOwner {
|
||||
pub pid: u32,
|
||||
/// Computer name, if known.
|
||||
pub host: String,
|
||||
/// The program (`LightCraft`, `lightcraft-cli`…) and its version.
|
||||
pub program: String,
|
||||
pub version: String,
|
||||
/// When it opened the library (seconds since 1970).
|
||||
pub since: u64,
|
||||
}
|
||||
|
||||
impl LockOwner {
|
||||
/// This process, as `program`.
|
||||
pub fn this_process(program: &str) -> LockOwner {
|
||||
LockOwner {
|
||||
pid: std::process::id(),
|
||||
host: host_name(),
|
||||
program: program.to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
since: web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// "LightCraft 0.3.0 (process 123 on studio-mac)".
|
||||
pub fn describe(&self) -> String {
|
||||
let program = if self.program.is_empty() { "another LightCraft" } else { self.program.as_str() };
|
||||
let version = if self.version.is_empty() { String::new() } else { format!(" {}", self.version) };
|
||||
let host = if self.host.is_empty() {
|
||||
String::new()
|
||||
} else if self.host == host_name() {
|
||||
" on this computer".to_string()
|
||||
} else {
|
||||
format!(" on {}", self.host)
|
||||
};
|
||||
format!("{program}{version} (process {}{host})", self.pid)
|
||||
}
|
||||
}
|
||||
|
||||
/// The computer's name, best effort (empty if unknown).
|
||||
fn host_name() -> String {
|
||||
let env = ["COMPUTERNAME", "HOSTNAME"].iter().find_map(|k| std::env::var(k).ok().filter(|h| !h.trim().is_empty()));
|
||||
env.or_else(|| std::fs::read_to_string("/etc/hostname").ok()).map(|h| h.trim().to_string()).filter(|h| !h.is_empty()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum LockError {
|
||||
/// Another process has the library open.
|
||||
#[error("This library is already open in {}. Close it there first.", .0.as_ref().map(LockOwner::describe).unwrap_or_else(|| "another LightCraft program".into()))]
|
||||
InUse(Option<LockOwner>),
|
||||
}
|
||||
|
||||
/// The exclusive lock on a library directory; released on drop (and by the OS if the process
|
||||
/// dies).
|
||||
#[derive(Debug)]
|
||||
pub struct LibraryLock {
|
||||
dir: PathBuf,
|
||||
/// `None` when the file system can't lock (the library is open unlocked).
|
||||
_file: Option<File>,
|
||||
held: bool,
|
||||
}
|
||||
|
||||
impl LibraryLock {
|
||||
/// Lock `dir` (which must exist) for this process. Fails only when another process holds it.
|
||||
pub fn acquire(dir: &Path, program: &str) -> Result<LibraryLock, LockError> {
|
||||
let unlocked = |why: io::Error| {
|
||||
log::warn!("library {}: can't lock {LOCK} ({why}); opening it without protection against a second program", dir.display());
|
||||
LibraryLock { dir: dir.to_path_buf(), _file: None, held: false }
|
||||
};
|
||||
let file = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join(LOCK)) {
|
||||
Ok(f) => f,
|
||||
Err(e) => return Ok(unlocked(e)),
|
||||
};
|
||||
match file.try_lock() {
|
||||
Ok(()) => {}
|
||||
Err(TryLockError::WouldBlock) => {
|
||||
let owner = std::fs::read(dir.join(OWNER)).ok().and_then(|b| serde_json::from_slice::<LockOwner>(&b).ok());
|
||||
return Err(LockError::InUse(owner));
|
||||
}
|
||||
Err(TryLockError::Error(e)) => return Ok(unlocked(e)),
|
||||
}
|
||||
let owner = LockOwner::this_process(program);
|
||||
if let Ok(json) = serde_json::to_vec_pretty(&owner)
|
||||
&& let Err(e) = std::fs::write(dir.join(OWNER), json)
|
||||
{
|
||||
log::warn!("library {}: can't write {OWNER}: {e}", dir.display());
|
||||
}
|
||||
Ok(LibraryLock { dir: dir.to_path_buf(), _file: Some(file), held: true })
|
||||
}
|
||||
|
||||
/// The OS lock is held (`false`: the file system can't lock; opened unprotected).
|
||||
pub fn held(&self) -> bool {
|
||||
self.held
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LibraryLock {
|
||||
/// Remove the owner note while still holding the lock (so it can't delete the next holder's),
|
||||
/// then the file handle closes and the OS releases the lock.
|
||||
fn drop(&mut self) {
|
||||
if self.held {
|
||||
let _ = std::fs::remove_file(self.dir.join(OWNER));
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
-1
@@ -93,6 +93,65 @@ pub enum Source {
|
||||
Demo { scene: u32 },
|
||||
}
|
||||
|
||||
/// Copyright status (IPTC / XMP Rights Management `xmpRights:Marked`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum CopyrightStatus {
|
||||
/// Not stated (no `xmpRights:Marked`).
|
||||
#[default]
|
||||
Unknown,
|
||||
/// `xmpRights:Marked` = True.
|
||||
Copyrighted,
|
||||
/// `xmpRights:Marked` = False.
|
||||
PublicDomain,
|
||||
}
|
||||
|
||||
impl CopyrightStatus {
|
||||
pub const ALL: [CopyrightStatus; 3] = [CopyrightStatus::Unknown, CopyrightStatus::Copyrighted, CopyrightStatus::PublicDomain];
|
||||
/// `unknown` / `copyrighted` / `publicDomain` (also `public domain`, `public-domain`).
|
||||
pub fn parse(s: &str) -> Option<CopyrightStatus> {
|
||||
match s.trim().to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() {
|
||||
"unknown" | "" => Some(CopyrightStatus::Unknown),
|
||||
"copyrighted" => Some(CopyrightStatus::Copyrighted),
|
||||
"publicdomain" => Some(CopyrightStatus::PublicDomain),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The id used by commands (`photo.setMeta`'s `copyrightStatus`).
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
CopyrightStatus::Unknown => "unknown",
|
||||
CopyrightStatus::Copyrighted => "copyrighted",
|
||||
CopyrightStatus::PublicDomain => "publicDomain",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
CopyrightStatus::Unknown => "Unknown",
|
||||
CopyrightStatus::Copyrighted => "Copyrighted",
|
||||
CopyrightStatus::PublicDomain => "Public Domain",
|
||||
}
|
||||
}
|
||||
/// As `xmpRights:Marked`: `Some(true)` copyrighted, `Some(false)` public domain.
|
||||
pub fn marked(self) -> Option<bool> {
|
||||
match self {
|
||||
CopyrightStatus::Unknown => None,
|
||||
CopyrightStatus::Copyrighted => Some(true),
|
||||
CopyrightStatus::PublicDomain => Some(false),
|
||||
}
|
||||
}
|
||||
pub fn from_marked(m: Option<bool>) -> CopyrightStatus {
|
||||
match m {
|
||||
None => CopyrightStatus::Unknown,
|
||||
Some(true) => CopyrightStatus::Copyrighted,
|
||||
Some(false) => CopyrightStatus::PublicDomain,
|
||||
}
|
||||
}
|
||||
pub fn is_unknown(&self) -> bool {
|
||||
*self == CopyrightStatus::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Descriptive + capture metadata.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -115,6 +174,13 @@ pub struct Meta {
|
||||
pub alt_text: String,
|
||||
pub extended_description: String,
|
||||
pub copyright: String,
|
||||
/// Copyright status, rights usage terms and copyright info URL (IPTC Core rights fields).
|
||||
#[serde(skip_serializing_if = "CopyrightStatus::is_unknown")]
|
||||
pub copyright_status: CopyrightStatus,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub usage_terms: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub copyright_url: String,
|
||||
pub creator: String,
|
||||
pub keywords: Vec<String>,
|
||||
}
|
||||
@@ -197,6 +263,32 @@ pub struct Photo {
|
||||
/// unedited, and Reset returns to them.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub import_look: Option<Arc<DevelopSettings>>,
|
||||
/// Assisted culling scores (`None` until analysed).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub analysis: Option<Analysis>,
|
||||
/// A raw file whose sensor data can't be decoded yet (an unsupported raw variant): why (the
|
||||
/// raw decoder's reason, e.g. "Nikon Huffman-compressed NEF …"). The photo is shown and
|
||||
/// edited from the camera's embedded JPEG preview — a rendered image with the camera's
|
||||
/// 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>,
|
||||
/// 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")]
|
||||
pub local_baseline: Option<u64>,
|
||||
}
|
||||
|
||||
/// What assisted culling measured on a photo.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Analysis {
|
||||
/// Focus: 0 (blurred) .. 100 (crisp), from the detail in the photo's sharpest area.
|
||||
pub sharpness: f32,
|
||||
/// Exposure: share of clipped pixels, 0..1 (shadows + highlights).
|
||||
pub clipped: f32,
|
||||
/// Similar-shot group (burst), when the photo has neighbours that look alike.
|
||||
pub group: Option<u32>,
|
||||
/// The best photo of its group.
|
||||
pub best: bool,
|
||||
}
|
||||
|
||||
impl Photo {
|
||||
@@ -229,8 +321,16 @@ impl Photo {
|
||||
copy_of: None,
|
||||
copy_name: None,
|
||||
import_look: None,
|
||||
analysis: None,
|
||||
preview_only: None,
|
||||
local_baseline: None,
|
||||
}
|
||||
}
|
||||
/// 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 {
|
||||
self.kind == MediaKind::Raw && self.preview_only.is_none()
|
||||
}
|
||||
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
|
||||
/// default preset applied on top of them ([`Photo::import_look`]).
|
||||
pub fn import_defaults(&self) -> DevelopSettings {
|
||||
@@ -283,12 +383,15 @@ pub struct Album {
|
||||
/// live; `photos` stays empty.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub smart: Option<Box<crate::Filter>>,
|
||||
/// The Quick Collection: a regular album that B adds to (one per library).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub quick: bool,
|
||||
}
|
||||
|
||||
impl Album {
|
||||
/// A regular (manual) album.
|
||||
pub fn new(id: AlbumId, name: impl Into<String>) -> Album {
|
||||
Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None }
|
||||
Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None, quick: false }
|
||||
}
|
||||
pub fn is_smart(&self) -> bool {
|
||||
self.smart.is_some()
|
||||
|
||||
+110
-1
@@ -24,6 +24,12 @@ pub struct Filter {
|
||||
pub rating_op: RatingOp,
|
||||
pub flag: Option<Flag>,
|
||||
pub label: Option<ColorLabel>,
|
||||
/// Only these photos (Find Similar results…); empty = no constraint.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub only: Vec<PhotoId>,
|
||||
/// Any of these labels (the filter bar's multi-select); empty = no constraint.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub labels: Vec<ColorLabel>,
|
||||
pub kind: Option<MediaKind>,
|
||||
pub edited: Option<bool>,
|
||||
pub album: Option<AlbumId>,
|
||||
@@ -40,11 +46,18 @@ pub struct Filter {
|
||||
/// `dateTo` as a prefix upper bound (`2026-04` includes all of April).
|
||||
pub date_from: Option<String>,
|
||||
pub date_to: Option<String>,
|
||||
/// Import date prefix (Recently Added).
|
||||
/// Import date prefix.
|
||||
pub imported: Option<String>,
|
||||
/// Imported at or after this time (ISO; Recently Added).
|
||||
pub imported_from: Option<String>,
|
||||
/// Photo Merge results: `hdr`, `panorama`, `hdrPanorama` or `any` (see [`merged_kind`]).
|
||||
pub merged: Option<String>,
|
||||
/// A folder on disk: its files only (browsed ones too); `subfolders` includes everything below.
|
||||
pub folder: Option<String>,
|
||||
pub subfolders: bool,
|
||||
/// Smart-album rules (all / any / none, nested groups; see [`crate::rules`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rule_set: Option<crate::RuleSet>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -130,6 +143,9 @@ impl Filter {
|
||||
if let Some(l) = self.label {
|
||||
v.push(format!("label {}", format!("{l:?}").to_lowercase()));
|
||||
}
|
||||
if !self.labels.is_empty() {
|
||||
v.push(format!("label {}", self.labels.iter().map(|l| format!("{l:?}").to_lowercase()).collect::<Vec<_>>().join(" or ")));
|
||||
}
|
||||
if let Some(k) = self.kind {
|
||||
v.push(format!("kind {}", format!("{k:?}").to_lowercase()));
|
||||
}
|
||||
@@ -152,9 +168,17 @@ impl Filter {
|
||||
if !self.text.trim().is_empty() {
|
||||
v.push(format!("“{}”", self.text.trim()));
|
||||
}
|
||||
if let Some(rs) = self.rule_set.as_ref().filter(|r| !r.rules.is_empty()) {
|
||||
v.push(rs.describe());
|
||||
}
|
||||
if v.is_empty() { "all photos".into() } else { v.join(", ") }
|
||||
}
|
||||
|
||||
/// Whether matches depend on the clock (only "in the last…" rules do).
|
||||
pub fn depends_on_now(&self) -> bool {
|
||||
self.rule_set.as_ref().is_some_and(crate::RuleSet::depends_on_now)
|
||||
}
|
||||
|
||||
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
|
||||
if p.deleted != self.deleted {
|
||||
return false;
|
||||
@@ -186,6 +210,23 @@ impl Filter {
|
||||
if self.edited.is_some_and(|e| e != p.is_edited()) {
|
||||
return false;
|
||||
}
|
||||
if let Some(rs) = &self.rule_set
|
||||
&& !rs.matches(p, cat)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !self.labels.is_empty() && !p.label.is_some_and(|l| self.labels.contains(&l)) {
|
||||
return false;
|
||||
}
|
||||
if !self.only.is_empty() && !self.only.contains(&p.id) {
|
||||
return false;
|
||||
}
|
||||
if let Some(want) = &self.merged {
|
||||
match merged_kind(&p.file_name) {
|
||||
Some(k) if want == "any" || want == k => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
if let Some(a) = self.album
|
||||
&& !cat.album_contains(a, p)
|
||||
{
|
||||
@@ -216,6 +257,11 @@ impl Filter {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(d) = &self.imported_from
|
||||
&& p.imported.as_str() < d.as_str()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(k) = &self.keyword
|
||||
&& !p.meta.keywords.iter().any(|x| crate::keywords::is_under(x, k))
|
||||
{
|
||||
@@ -304,3 +350,66 @@ pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool {
|
||||
let Some(rest) = rest.strip_prefix(['/', '\\']) else { return false };
|
||||
!rest.is_empty() && (deep || !rest.contains(['/', '\\']))
|
||||
}
|
||||
|
||||
/// A folder path's identity, for telling whether two spellings name the same folder: `/` and
|
||||
/// `\\` are both separators, repeated separators, `.` and a trailing separator are dropped,
|
||||
/// `..` is resolved lexically, a Windows verbatim prefix (`\\?\`) is removed and the drive
|
||||
/// letter lower-cased; on Windows (case-insensitive file names) the whole path is lower-cased.
|
||||
/// No file-system access: the paths compared should already be absolute.
|
||||
pub fn folder_key(path: &str) -> String {
|
||||
let mut s = path.replace('\\', "/");
|
||||
if let Some(rest) = s.strip_prefix("//?/").or_else(|| s.strip_prefix("//./")) {
|
||||
s = match rest.strip_prefix("UNC/") {
|
||||
Some(unc) => format!("//{unc}"),
|
||||
None => rest.to_string(),
|
||||
};
|
||||
}
|
||||
// the part `..` can't climb above: `//server/share`, `c:` or `/`
|
||||
let (prefix, rest) = if let Some(unc) = s.strip_prefix("//").filter(|u| !u.is_empty() && !u.starts_with('/')) {
|
||||
let mut it = unc.splitn(3, '/');
|
||||
let (server, share, rest) = (it.next().unwrap_or(""), it.next().unwrap_or(""), it.next().unwrap_or(""));
|
||||
(format!("//{server}/{share}"), rest.to_string())
|
||||
} else if s.len() >= 2 && s.as_bytes()[1] == b':' && s.as_bytes()[0].is_ascii_alphabetic() {
|
||||
(s[..2].to_ascii_lowercase(), s[2..].to_string())
|
||||
} else {
|
||||
(String::new(), s.clone())
|
||||
};
|
||||
let absolute = rest.starts_with('/') || !prefix.is_empty();
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
for c in rest.split('/') {
|
||||
match c {
|
||||
"" | "." => {}
|
||||
".." if parts.last().is_some_and(|p| *p != "..") => {
|
||||
parts.pop();
|
||||
}
|
||||
".." if absolute => {}
|
||||
c => parts.push(c),
|
||||
}
|
||||
}
|
||||
let key = if absolute { format!("{prefix}/{}", parts.join("/")) } else { parts.join("/") };
|
||||
if cfg!(windows) { key.to_lowercase() } else { key }
|
||||
}
|
||||
|
||||
/// Whether `path` is folder `root` or lies somewhere inside it, however either is spelled
|
||||
/// (see [`folder_key`]).
|
||||
pub fn folder_within(path: &str, root: &str) -> bool {
|
||||
let (p, r) = (folder_key(path), folder_key(root));
|
||||
p == r || (p.starts_with(&r) && (r.ends_with('/') || p[r.len()..].starts_with('/')))
|
||||
}
|
||||
|
||||
/// The kind of Photo Merge result a file is, from the name merges give it (`IMG_1-HDR.dng`,
|
||||
/// `IMG_1-Pano.dng`, `IMG_1-HDR-Pano.dng` — also the names other raw editors use):
|
||||
/// `hdr`, `panorama` or `hdrPanorama`.
|
||||
pub fn merged_kind(file_name: &str) -> Option<&'static str> {
|
||||
let stem = file_name.rsplit_once('.').map_or(file_name, |(s, _)| s).to_ascii_lowercase();
|
||||
let stem = stem.trim_end_matches(|c: char| c.is_ascii_digit() || c == '-' || c == ' ');
|
||||
if stem.ends_with("-hdr-pano") {
|
||||
Some("hdrPanorama")
|
||||
} else if stem.ends_with("-hdr") {
|
||||
Some("hdr")
|
||||
} else if stem.ends_with("-pano") {
|
||||
Some("panorama")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
//! Smart-album rules: a list of conditions matched all / any / none, with nested groups.
|
||||
//!
|
||||
//! Each [`Rule`] is `{field, op, value}`; [`FIELDS`] lists the fields with their kind, which
|
||||
//! decides the operators ([`ops_for`]). Text compares case-insensitively; dates are ISO strings
|
||||
//! compared by prefix; "in the last N days/weeks/months/years" is relative to [`now`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{Catalog, Photo};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Match {
|
||||
#[default]
|
||||
All,
|
||||
Any,
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuleSet {
|
||||
#[serde(default, rename = "match")]
|
||||
pub mode: Match,
|
||||
#[serde(default)]
|
||||
pub rules: Vec<Rule>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Rule {
|
||||
/// A nested group with its own match mode.
|
||||
Group { group: RuleSet },
|
||||
Field {
|
||||
field: String,
|
||||
op: String,
|
||||
#[serde(default)]
|
||||
value: Value,
|
||||
},
|
||||
}
|
||||
|
||||
/// How a field's value is compared.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Text,
|
||||
/// Keywords: text ops over each keyword (any keyword matches; `isEmpty` = none).
|
||||
Keywords,
|
||||
Number,
|
||||
Date,
|
||||
/// One of a fixed set (`choices`).
|
||||
Choice(&'static [&'static str]),
|
||||
Bool,
|
||||
}
|
||||
|
||||
/// The fields rules can test: (id, label, kind).
|
||||
pub const FIELDS: &[(&str, &str, Kind)] = &[
|
||||
("rating", "Rating", Kind::Number),
|
||||
("flag", "Pick Flag", Kind::Choice(&["pick", "reject", "none"])),
|
||||
("label", "Color Label", Kind::Choice(&["red", "yellow", "green", "blue", "purple", "none"])),
|
||||
("kind", "File Type", Kind::Choice(&["image", "raw", "video"])),
|
||||
("edited", "Has Edits", Kind::Bool),
|
||||
("keywords", "Keywords", Kind::Keywords),
|
||||
("text", "Any Searchable Text", Kind::Text),
|
||||
("fileName", "Filename", Kind::Text),
|
||||
("format", "File Format", Kind::Text),
|
||||
("title", "Title", Kind::Text),
|
||||
("caption", "Caption", Kind::Text),
|
||||
("camera", "Camera", Kind::Text),
|
||||
("lens", "Lens", Kind::Text),
|
||||
("location", "Location", Kind::Text),
|
||||
("creator", "Creator", Kind::Text),
|
||||
("copyright", "Copyright", Kind::Text),
|
||||
("copyrightStatus", "Copyright Status", Kind::Choice(&["copyrighted", "publicDomain", "unknown"])),
|
||||
("captureDate", "Capture Date", Kind::Date),
|
||||
("importDate", "Import Date", Kind::Date),
|
||||
("editDate", "Edit Date", Kind::Date),
|
||||
("iso", "ISO Speed", Kind::Number),
|
||||
("aperture", "Aperture", Kind::Number),
|
||||
("focalLength", "Focal Length", Kind::Number),
|
||||
("megapixels", "Megapixels", Kind::Number),
|
||||
("hasGps", "Has GPS", Kind::Bool),
|
||||
("virtualCopy", "Virtual Copy", Kind::Bool),
|
||||
("album", "Album", Kind::Number),
|
||||
("sharpness", "Focus (assisted culling)", Kind::Number),
|
||||
("bestOfGroup", "Best of Similar Shots", Kind::Bool),
|
||||
];
|
||||
|
||||
/// The operators for a field kind: (id, label).
|
||||
pub fn ops_for(kind: Kind) -> &'static [(&'static str, &'static str)] {
|
||||
match kind {
|
||||
Kind::Text => &[
|
||||
("contains", "contains"),
|
||||
("notContains", "doesn't contain"),
|
||||
("is", "is"),
|
||||
("isNot", "isn't"),
|
||||
("startsWith", "starts with"),
|
||||
("endsWith", "ends with"),
|
||||
("isEmpty", "is empty"),
|
||||
("isNotEmpty", "isn't empty"),
|
||||
],
|
||||
Kind::Keywords => &[
|
||||
("contains", "contains"),
|
||||
("notContains", "doesn't contain"),
|
||||
("is", "is"),
|
||||
("startsWith", "starts with"),
|
||||
("isEmpty", "are empty"),
|
||||
("isNotEmpty", "aren't empty"),
|
||||
],
|
||||
Kind::Number => {
|
||||
&[("is", "is"), ("isNot", "isn't"), ("gte", "is ≥"), ("lte", "is ≤"), ("gt", "is >"), ("lt", "is <"), ("between", "is between")]
|
||||
}
|
||||
Kind::Date => &[
|
||||
("is", "is"),
|
||||
("after", "is after"),
|
||||
("before", "is before"),
|
||||
("between", "is between"),
|
||||
("inLast", "is in the last"),
|
||||
("notInLast", "isn't in the last"),
|
||||
("isEmpty", "is unknown"),
|
||||
],
|
||||
Kind::Choice(_) => &[("is", "is"), ("isNot", "isn't")],
|
||||
Kind::Bool => &[("is", "is")],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn field_kind(field: &str) -> Option<Kind> {
|
||||
FIELDS.iter().find(|f| f.0 == field).map(|f| f.2)
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static NOW: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Set the time "in the last…" rules count back from (the engine's clock; tests).
|
||||
pub fn set_now(iso: Option<String>) {
|
||||
NOW.with(|n| *n.borrow_mut() = iso);
|
||||
}
|
||||
|
||||
/// The current time as ISO 8601: [`set_now`]'s, else the system clock (UTC).
|
||||
pub fn now() -> String {
|
||||
if let Some(n) = NOW.with(|n| n.borrow().clone()) {
|
||||
return n;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
|
||||
crate::dates::civil(secs)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
"2026-01-01T00:00:00".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn lower(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.trim().to_lowercase(),
|
||||
Value::Null => String::new(),
|
||||
other => other.to_string().to_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_op(op: &str, have: &str, want: &str) -> bool {
|
||||
let have = have.trim().to_lowercase();
|
||||
match op {
|
||||
"contains" => want.split_whitespace().all(|w| have.contains(w)),
|
||||
"notContains" => !want.split_whitespace().any(|w| have.contains(w)),
|
||||
"is" => have == want,
|
||||
"isNot" => have != want,
|
||||
"startsWith" => have.starts_with(want),
|
||||
"endsWith" => have.ends_with(want),
|
||||
"isEmpty" => have.is_empty(),
|
||||
"isNotEmpty" => !have.is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn number(v: &Value) -> Option<f64> {
|
||||
match v {
|
||||
Value::Number(n) => n.as_f64(),
|
||||
Value::String(s) => s.trim().trim_start_matches("f/").trim_end_matches("mm").trim().parse().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn num_op(op: &str, have: Option<f64>, value: &Value) -> bool {
|
||||
if op == "between" {
|
||||
let (a, b) = match value {
|
||||
Value::Array(v) if v.len() == 2 => (number(&v[0]), number(&v[1])),
|
||||
_ => (None, None),
|
||||
};
|
||||
return matches!((have, a, b), (Some(h), Some(a), Some(b)) if h >= a.min(b) && h <= a.max(b));
|
||||
}
|
||||
let (Some(h), Some(w)) = (have, number(value)) else { return op == "isNot" && have.is_none() };
|
||||
match op {
|
||||
"is" => (h - w).abs() < 1e-6,
|
||||
"isNot" => (h - w).abs() >= 1e-6,
|
||||
"gte" => h >= w - 1e-9,
|
||||
"lte" => h <= w + 1e-9,
|
||||
"gt" => h > w,
|
||||
"lt" => h < w,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `value` for in-the-last rules: `{n, unit: days|weeks|months|years}` or a number of days.
|
||||
fn last_secs(value: &Value) -> Option<i64> {
|
||||
let (n, unit) = match value {
|
||||
Value::Object(o) => (o.get("n").and_then(number)?, o.get("unit").and_then(Value::as_str).unwrap_or("days")),
|
||||
v => (number(v)?, "days"),
|
||||
};
|
||||
let day = 86_400.0;
|
||||
let per = match unit {
|
||||
"hours" => 3600.0,
|
||||
"weeks" => 7.0 * day,
|
||||
"months" => 30.4375 * day,
|
||||
"years" => 365.25 * day,
|
||||
_ => day,
|
||||
};
|
||||
Some((n * per) as i64)
|
||||
}
|
||||
|
||||
fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool {
|
||||
if op == "isEmpty" {
|
||||
return have.is_none_or(str::is_empty);
|
||||
}
|
||||
let Some(h) = have.filter(|h| !h.is_empty()) else { return op == "notInLast" };
|
||||
let s = |v: &Value| v.as_str().map(str::trim).unwrap_or("").to_string();
|
||||
match op {
|
||||
// a prefix: 2026, 2026-04, 2026-04-12
|
||||
"is" => {
|
||||
let w = s(value);
|
||||
!w.is_empty() && h.starts_with(&w)
|
||||
}
|
||||
"after" => {
|
||||
let w = s(value);
|
||||
!w.is_empty() && h > w.as_str() && !h.starts_with(&w)
|
||||
}
|
||||
"before" => {
|
||||
let w = s(value);
|
||||
!w.is_empty() && h < w.as_str()
|
||||
}
|
||||
"between" => match value {
|
||||
Value::Array(v) if v.len() == 2 => {
|
||||
let (a, b) = (s(&v[0]), s(&v[1]));
|
||||
h >= a.as_str() && (h <= b.as_str() || h.starts_with(&b))
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
"inLast" | "notInLast" => {
|
||||
let Some(secs) = last_secs(value) else { return false };
|
||||
let from = crate::dates::shift_iso(&now(), -secs).unwrap_or_default();
|
||||
(h >= from.as_str()) == (op == "inLast")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Rule {
|
||||
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
|
||||
let (field, op, value) = match self {
|
||||
Rule::Group { group } => return group.matches(p, cat),
|
||||
Rule::Field { field, op, value } => (field.as_str(), op.as_str(), value),
|
||||
};
|
||||
let m = &p.meta;
|
||||
let want = lower(value);
|
||||
let text = |have: &str| text_op(op, have, &want);
|
||||
match field {
|
||||
"rating" => num_op(op, Some(p.rating as f64), value),
|
||||
"flag" => {
|
||||
let f = format!("{:?}", p.flag).to_lowercase();
|
||||
(f == want || (want == "picked" && f == "pick") || (want == "rejected" && f == "reject")) == (op == "is")
|
||||
}
|
||||
"label" => {
|
||||
let l = p.label.map(|l| format!("{l:?}").to_lowercase()).unwrap_or_else(|| "none".into());
|
||||
// custom label names count too
|
||||
let named = p.label.is_some_and(|l| cat.label_name(l).to_lowercase() == want);
|
||||
(l == want || named) == (op == "is")
|
||||
}
|
||||
"kind" => (format!("{:?}", p.kind).to_lowercase() == want) == (op == "is"),
|
||||
"edited" => p.is_edited() == value.as_bool().unwrap_or(true),
|
||||
"hasGps" => m.gps.is_some() == value.as_bool().unwrap_or(true),
|
||||
"virtualCopy" => p.copy_of.is_some() == value.as_bool().unwrap_or(true),
|
||||
"keywords" => match op {
|
||||
"isEmpty" => m.keywords.is_empty(),
|
||||
"isNotEmpty" => !m.keywords.is_empty(),
|
||||
"notContains" => !m.keywords.iter().any(|k| text_op("contains", k, &want)),
|
||||
_ => m.keywords.iter().any(|k| text_op(op, k, &want) || (op == "is" && k.to_lowercase().split('|').any(|part| part == want))),
|
||||
},
|
||||
"text" => {
|
||||
let all = [
|
||||
p.file_name.as_str(),
|
||||
&m.title,
|
||||
&m.caption,
|
||||
&m.camera,
|
||||
&m.lens,
|
||||
&m.location,
|
||||
&m.city,
|
||||
&m.country,
|
||||
&p.format,
|
||||
&m.keywords.join(" "),
|
||||
]
|
||||
.join(" ");
|
||||
text(&all)
|
||||
}
|
||||
"fileName" => text(&p.file_name),
|
||||
"format" => text(&p.format),
|
||||
"title" => text(&m.title),
|
||||
"caption" => text(&m.caption),
|
||||
"camera" => text(&m.camera),
|
||||
"lens" => text(&m.lens),
|
||||
"location" => text(&[m.location.as_str(), &m.city, &m.state, &m.country].join(" ")),
|
||||
"creator" => text(&m.creator),
|
||||
"copyright" => text(&m.copyright),
|
||||
"copyrightStatus" => {
|
||||
let want = crate::CopyrightStatus::parse(&want);
|
||||
(want == Some(m.copyright_status)) == (op == "is")
|
||||
}
|
||||
"captureDate" => date_op(op, p.captured.as_deref(), value),
|
||||
"importDate" => date_op(op, Some(&p.imported), value),
|
||||
"editDate" => date_op(op, p.edited.as_deref(), value),
|
||||
"iso" => num_op(op, m.iso.map(|v| v as f64), value),
|
||||
"aperture" => num_op(op, m.aperture.map(|v| v as f64), value),
|
||||
"focalLength" => num_op(op, m.focal_mm.map(|v| v as f64), value),
|
||||
"megapixels" => num_op(op, Some(p.width as f64 * p.height as f64 / 1e6), value),
|
||||
"sharpness" => num_op(op, p.analysis.map(|a| a.sharpness as f64), value),
|
||||
"bestOfGroup" => p.analysis.is_some_and(|a| a.best || a.group.is_none()) == value.as_bool().unwrap_or(true),
|
||||
"album" => {
|
||||
let id = number(value).map(|v| crate::AlbumId(v as u64));
|
||||
id.is_some_and(|a| cat.album(a).is_some_and(|al| !al.is_smart()) && cat.album_contains(a, p)) == (op == "is")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RuleSet {
|
||||
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
|
||||
if self.rules.is_empty() {
|
||||
return self.mode != Match::Any;
|
||||
}
|
||||
match self.mode {
|
||||
Match::All => self.rules.iter().all(|r| r.matches(p, cat)),
|
||||
Match::Any => self.rules.iter().any(|r| r.matches(p, cat)),
|
||||
Match::None => !self.rules.iter().any(|r| r.matches(p, cat)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether matches depend on the clock ("in the last…" rules, nested groups included): the
|
||||
/// same photos can enter or leave the set without any catalog change.
|
||||
pub fn depends_on_now(&self) -> bool {
|
||||
self.rules.iter().any(|r| match r {
|
||||
Rule::Group { group } => group.depends_on_now(),
|
||||
Rule::Field { op, .. } => op == "inLast" || op == "notInLast",
|
||||
})
|
||||
}
|
||||
|
||||
/// Unknown fields or operators (for command validation), as readable messages.
|
||||
pub fn problems(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for r in &self.rules {
|
||||
match r {
|
||||
Rule::Group { group } => out.extend(group.problems()),
|
||||
Rule::Field { field, op, .. } => match field_kind(field) {
|
||||
None => out.push(format!("unknown field `{field}`")),
|
||||
Some(k) if !ops_for(k).iter().any(|o| o.0 == op) => out.push(format!("`{field}` has no operator `{op}`")),
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A short readable summary ("rating is ≥ 3 and keywords contains travel").
|
||||
pub fn describe(&self) -> String {
|
||||
let join = match self.mode {
|
||||
Match::All => " and ",
|
||||
Match::Any => " or ",
|
||||
Match::None => " nor ",
|
||||
};
|
||||
let parts: Vec<String> = self
|
||||
.rules
|
||||
.iter()
|
||||
.map(|r| match r {
|
||||
Rule::Group { group } => format!("({})", group.describe()),
|
||||
Rule::Field { field, op, value } => {
|
||||
let label = FIELDS.iter().find(|f| f.0 == field).map_or(field.as_str(), |f| f.1).to_lowercase();
|
||||
let op = field_kind(field).and_then(|k| ops_for(k).iter().find(|o| o.0 == op)).map_or(op.as_str(), |o| o.1);
|
||||
let v = match value {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Null => String::new(),
|
||||
Value::Object(o) => format!(
|
||||
"{} {}",
|
||||
o.get("n").map(|n| n.to_string()).unwrap_or_default(),
|
||||
o.get("unit").and_then(Value::as_str).unwrap_or("days")
|
||||
),
|
||||
Value::Array(a) => a.iter().map(|x| x.as_str().map_or_else(|| x.to_string(), str::to_string)).collect::<Vec<_>>().join(" – "),
|
||||
other => other.to_string(),
|
||||
};
|
||||
format!("{label} {op} {v}").trim().to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let s = parts.join(join);
|
||||
if self.mode == Match::None { format!("none of: {s}") } else { s }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{ColorLabel, Flag, PhotoId, Source};
|
||||
use serde_json::json;
|
||||
|
||||
fn photo(id: u64) -> Photo {
|
||||
let mut p = Photo::new(PhotoId(id), Source::Demo { scene: 0 }, "IMG_0042.CR2", "CR2", 6000, 4000, "2026-09-20T10:00:00");
|
||||
p.rating = 4;
|
||||
p.flag = Flag::Pick;
|
||||
p.label = Some(ColorLabel::Red);
|
||||
p.captured = Some("2026-08-14T18:30:00".into());
|
||||
p.meta.keywords = vec!["travel|italy|rome".into(), "food".into()];
|
||||
p.meta.camera = "Model X2".into();
|
||||
p.meta.iso = Some(1600);
|
||||
p.meta.aperture = Some(2.8);
|
||||
p
|
||||
}
|
||||
|
||||
fn rs(v: serde_json::Value) -> RuleSet {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_ops_and_groups() {
|
||||
let cat = Catalog::new();
|
||||
let p = photo(1);
|
||||
let yes = |v: serde_json::Value| assert!(rs(v.clone()).matches(&p, &cat), "{v}");
|
||||
let no = |v: serde_json::Value| assert!(!rs(v.clone()).matches(&p, &cat), "{v}");
|
||||
yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "flag", "op": "is", "value": "pick"}]}));
|
||||
no(json!({"rules": [{"field": "rating", "op": "gt", "value": 4}]}));
|
||||
yes(json!({"match": "any", "rules": [{"field": "rating", "op": "is", "value": 1}, {"field": "label", "op": "is", "value": "red"}]}));
|
||||
no(json!({"match": "none", "rules": [{"field": "label", "op": "is", "value": "red"}]}));
|
||||
yes(json!({"rules": [{"field": "keywords", "op": "is", "value": "italy"}]}));
|
||||
yes(
|
||||
json!({"rules": [{"field": "keywords", "op": "contains", "value": "ROME"}, {"field": "keywords", "op": "notContains", "value": "beach"}]}),
|
||||
);
|
||||
no(json!({"rules": [{"field": "keywords", "op": "isEmpty"}]}));
|
||||
yes(json!({"rules": [{"field": "fileName", "op": "startsWith", "value": "img_"}, {"field": "fileName", "op": "endsWith", "value": ".cr2"}]}));
|
||||
yes(json!({"rules": [{"field": "camera", "op": "contains", "value": "x2"}, {"field": "title", "op": "isEmpty"}]}));
|
||||
yes(json!({"rules": [{"field": "iso", "op": "between", "value": [800, 3200]}, {"field": "aperture", "op": "lte", "value": "f/4"}]}));
|
||||
yes(json!({"rules": [{"field": "megapixels", "op": "gte", "value": 24}]}));
|
||||
yes(
|
||||
json!({"rules": [{"field": "captureDate", "op": "is", "value": "2026-08"}, {"field": "captureDate", "op": "before", "value": "2026-09-01"}]}),
|
||||
);
|
||||
yes(json!({"rules": [{"field": "captureDate", "op": "between", "value": ["2026-08-01", "2026-08"]}]}));
|
||||
no(json!({"rules": [{"field": "captureDate", "op": "after", "value": "2026-08"}]}));
|
||||
yes(json!({"rules": [{"field": "kind", "op": "isNot", "value": "video"}, {"field": "edited", "op": "is", "value": false}]}));
|
||||
// copyright status (unknown until set)
|
||||
yes(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "unknown"}]}));
|
||||
no(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "copyrighted"}]}));
|
||||
let mut pd = photo(2);
|
||||
pd.meta.copyright_status = crate::CopyrightStatus::PublicDomain;
|
||||
assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}]})).matches(&pd, &cat));
|
||||
assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "isNot", "value": "copyrighted"}]})).matches(&pd, &cat));
|
||||
// nested: rating ≥ 4 and (label is blue or keywords contain food)
|
||||
yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 4}, {"group": {"match": "any", "rules": [
|
||||
{"field": "label", "op": "is", "value": "blue"}, {"field": "keywords", "op": "contains", "value": "food"}]}}]}));
|
||||
// in the last N days, relative to now
|
||||
set_now(Some("2026-09-01T00:00:00".into()));
|
||||
yes(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 30, "unit": "days"}}]}));
|
||||
no(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 1, "unit": "weeks"}}]}));
|
||||
yes(json!({"rules": [{"field": "captureDate", "op": "notInLast", "value": 7}]}));
|
||||
set_now(None);
|
||||
// an empty rule list: all → everything, any → nothing
|
||||
yes(json!({"rules": []}));
|
||||
no(json!({"match": "any", "rules": []}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn problems_and_description() {
|
||||
let r = rs(json!({"rules": [{"field": "rating", "op": "contains", "value": 1}, {"group": {"rules": [{"field": "nope", "op": "is"}]}}]}));
|
||||
assert_eq!(r.problems(), vec!["`rating` has no operator `contains`".to_string(), "unknown field `nope`".to_string()]);
|
||||
let r = rs(
|
||||
json!({"match": "any", "rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "weeks"}}]}),
|
||||
);
|
||||
assert_eq!(r.describe(), "rating is ≥ 3 or capture date is in the last 2 weeks");
|
||||
// every field has a kind with operators
|
||||
for (f, _, k) in FIELDS {
|
||||
assert!(!ops_for(*k).is_empty(), "{f}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
//! Crash-safe writes of user-visible files: exports, renders, DNG conversions, smart previews and
|
||||
//! XMP sidecars all go through here (the catalog's own files use [`crate::store::FsStore`]).
|
||||
//!
|
||||
//! - [`write_atomic`] replaces a file so that after a crash, a full disk or an unplugged drive
|
||||
//! either the old or the complete new content is there — never a truncated mix. The content
|
||||
//! goes to a new temp file in the same folder (unique name, `create_new`), is synced to disk and
|
||||
//! renamed over the target; then the folder is synced (POSIX). Any failure removes the temp
|
||||
//! file and leaves the target as it was.
|
||||
//! - [`write_new`] / [`write_new_unique`] write a file that must not exist yet: the same temp file
|
||||
//! and sync, plus a read-back check, and the file then appears under its final name **without
|
||||
//! replacing anything** (a hard link, which fails on a taken name; a check-then-rename where the
|
||||
//! volume has no hard links).
|
||||
//! - [`same_file`] tells whether two paths name the same file on disk (device + inode on Unix,
|
||||
//! resolved paths elsewhere), for "is this target an original?" checks.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
thread_local! {
|
||||
static FAIL_AFTER: Cell<Option<u64>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Fault injection for tests (in any crate): while the returned guard lives, every write made by
|
||||
/// this module **on the calling thread** fails once `bytes` bytes of a file were written — as a
|
||||
/// full disk or an unplugged drive would.
|
||||
#[doc(hidden)]
|
||||
pub fn fail_writes_after(bytes: u64) -> FaultGuard {
|
||||
FAIL_AFTER.with(|c| c.set(Some(bytes)));
|
||||
FaultGuard(())
|
||||
}
|
||||
|
||||
/// Ends a [`fail_writes_after`] when dropped.
|
||||
#[doc(hidden)]
|
||||
pub struct FaultGuard(());
|
||||
|
||||
impl Drop for FaultGuard {
|
||||
fn drop(&mut self) {
|
||||
FAIL_AFTER.with(|c| c.set(None));
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts what is written; fails past an injected limit.
|
||||
struct Limited<W> {
|
||||
inner: W,
|
||||
n: u64,
|
||||
limit: Option<u64>,
|
||||
}
|
||||
|
||||
impl<W: Write> Write for Limited<W> {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let buf = match self.limit {
|
||||
Some(l) => {
|
||||
let room = usize::try_from(l.saturating_sub(self.n)).unwrap_or(usize::MAX);
|
||||
if room == 0 && !buf.is_empty() {
|
||||
return Err(io::Error::other("write failed (injected fault)"));
|
||||
}
|
||||
buf.get(..room.min(buf.len())).unwrap_or(buf)
|
||||
}
|
||||
None => buf,
|
||||
};
|
||||
let k = self.inner.write(buf)?;
|
||||
self.n += k as u64;
|
||||
Ok(k)
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_of(path: &Path) -> &Path {
|
||||
path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."))
|
||||
}
|
||||
|
||||
/// Make a rename or a new name in `dir` durable (POSIX). Opening a directory fails on Windows,
|
||||
/// where NTFS journals the rename itself, so errors are ignored.
|
||||
pub fn sync_dir(dir: &Path) {
|
||||
if let Ok(d) = File::open(dir) {
|
||||
let _ = d.sync_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// A new, empty temp file next to `path` (hidden, unique in this process and folder).
|
||||
fn create_temp(path: &Path) -> io::Result<(PathBuf, File)> {
|
||||
static N: AtomicU64 = AtomicU64::new(0);
|
||||
if path.file_name().is_none() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, format!("{} is not a file path", path.display())));
|
||||
}
|
||||
let dir = parent_of(path);
|
||||
for _ in 0..1000 {
|
||||
let tmp = dir.join(format!(".lightcraft-{}-{}.tmp", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
match OpenOptions::new().write(true).create_new(true).open(&tmp) {
|
||||
Ok(f) => return Ok((tmp, f)),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("no free temp file name in {}", dir.display())))
|
||||
}
|
||||
|
||||
/// Write a temp file next to `path` with what `fill` writes, synced to disk. On failure the temp
|
||||
/// file is removed. Returns its path and length.
|
||||
fn write_temp(path: &Path, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>) -> io::Result<(PathBuf, u64)> {
|
||||
let (tmp, file) = create_temp(path)?;
|
||||
let r = (|| {
|
||||
let limit = FAIL_AFTER.with(Cell::get);
|
||||
let mut w = Limited { inner: io::BufWriter::with_capacity(1 << 20, file), n: 0, limit };
|
||||
fill(&mut w)?;
|
||||
w.flush()?;
|
||||
let n = w.n;
|
||||
let f = w.inner.into_inner().map_err(|e| e.into_error())?;
|
||||
f.sync_all()?;
|
||||
Ok(n)
|
||||
})();
|
||||
match r {
|
||||
Ok(n) => Ok((tmp, n)),
|
||||
Err(e) => {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace (or create) `path` with `data` atomically and durably; see the module docs. The
|
||||
/// folder must exist.
|
||||
pub fn write_atomic(path: &Path, data: &[u8]) -> io::Result<()> {
|
||||
write_atomic_with(path, &mut |w| w.write_all(data)).map(|_| ())
|
||||
}
|
||||
|
||||
/// [`write_atomic`] with streamed content: `fill` writes it (an error aborts and leaves the old
|
||||
/// file). Returns the length written.
|
||||
pub fn write_atomic_with(path: &Path, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>) -> io::Result<u64> {
|
||||
let (tmp, n) = write_temp(path, fill)?;
|
||||
if let Err(e) = fs::rename(&tmp, path) {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
sync_dir(parent_of(path));
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Write `data` as the new file `path`: never replaces an existing file (`AlreadyExists`), and
|
||||
/// the file only appears once it is complete, synced and read back identical.
|
||||
pub fn write_new(path: &Path, data: &[u8]) -> io::Result<()> {
|
||||
write_new_unique(&mut std::iter::once(path.to_path_buf()), data).map(|_| ())
|
||||
}
|
||||
|
||||
/// [`write_new`] under the first free name of `candidates` (all in the same folder, e.g.
|
||||
/// `IMG_1.dng`, `IMG_1-2.dng`, …): the file is written once and then given the first name that
|
||||
/// is not taken. Returns that name; `AlreadyExists` when every candidate is taken.
|
||||
pub fn write_new_unique(candidates: &mut dyn Iterator<Item = PathBuf>, data: &[u8]) -> io::Result<PathBuf> {
|
||||
let mut candidates = candidates.peekable();
|
||||
let Some(first) = candidates.peek().cloned() else {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "no file name to write"));
|
||||
};
|
||||
let (tmp, _) = write_temp(&first, &mut |w| w.write_all(data))?;
|
||||
let r = (|| {
|
||||
if !same_content(&tmp, data)? {
|
||||
return Err(io::Error::other("the written file reads back different (failing drive or connection?)"));
|
||||
}
|
||||
for path in candidates.by_ref().take(100_000) {
|
||||
match publish_new(&tmp, &path) {
|
||||
Ok(()) => {
|
||||
sync_dir(parent_of(&path));
|
||||
return Ok(path);
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{}: no free file name", first.display())))
|
||||
})();
|
||||
// after a hard link the temp name is a second name of the new file; after a rename it's gone
|
||||
let _ = fs::remove_file(&tmp);
|
||||
r
|
||||
}
|
||||
|
||||
/// Give the complete temp file `tmp` the name `path`, unless that is taken.
|
||||
fn publish_new(tmp: &Path, path: &Path) -> io::Result<()> {
|
||||
match fs::hard_link(tmp, path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(e),
|
||||
// no hard links on this volume (FAT/exFAT, some shares): check, then rename
|
||||
Err(_) => {
|
||||
if fs::symlink_metadata(path).is_ok() {
|
||||
return Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{} exists", path.display())));
|
||||
}
|
||||
fs::rename(tmp, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the file at `path` hold exactly `data`?
|
||||
fn same_content(path: &Path, data: &[u8]) -> io::Result<bool> {
|
||||
use std::io::Read;
|
||||
let mut f = File::open(path)?;
|
||||
if f.metadata()?.len() != data.len() as u64 {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut buf = vec![0u8; 1 << 20];
|
||||
let mut at = 0usize;
|
||||
loop {
|
||||
let k = match f.read(&mut buf) {
|
||||
Ok(0) => return Ok(at == data.len()),
|
||||
Ok(k) => k,
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if data.get(at..at + k) != buf.get(..k) {
|
||||
return Ok(false);
|
||||
}
|
||||
at += k;
|
||||
}
|
||||
}
|
||||
|
||||
/// Do `a` and `b` name the same existing file (through links, `..`, case-insensitive names)?
|
||||
pub fn same_file(a: &Path, b: &Path) -> bool {
|
||||
let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) else { return false };
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
ma.dev() == mb.dev() && ma.ino() == mb.ino()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
if ma.len() != mb.len() {
|
||||
return false;
|
||||
}
|
||||
match (fs::canonicalize(a), fs::canonicalize(b)) {
|
||||
(Ok(x), Ok(y)) => x == y || x.to_string_lossy().to_lowercase() == y.to_string_lossy().to_lowercase(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-safe-file-{tag}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn names(dir: &Path) -> Vec<String> {
|
||||
let mut v: Vec<String> = fs::read_dir(dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_write_replaces_and_leaves_no_temp() {
|
||||
let d = temp("replace");
|
||||
let p = d.join("out.jpg");
|
||||
write_atomic(&p, b"one").unwrap();
|
||||
write_atomic(&p, b"two two").unwrap();
|
||||
assert_eq!(fs::read(&p).unwrap(), b"two two");
|
||||
assert_eq!(names(&d), vec!["out.jpg"]);
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_mid_write_keeps_the_old_file() {
|
||||
let d = temp("midwrite");
|
||||
let p = d.join("photo.jpg");
|
||||
fs::write(&p, b"the original bytes").unwrap();
|
||||
{
|
||||
let _f = fail_writes_after(4);
|
||||
let e = write_atomic(&p, &[7u8; 10_000]).unwrap_err();
|
||||
assert!(e.to_string().contains("injected"), "{e}");
|
||||
assert!(write_new(&d.join("new.dng"), &[1u8; 100]).is_err());
|
||||
}
|
||||
assert_eq!(fs::read(&p).unwrap(), b"the original bytes", "the target is untouched");
|
||||
assert_eq!(names(&d), vec!["photo.jpg"], "no temp file and no partial new file");
|
||||
// a fill error aborts the same way
|
||||
let r = write_atomic_with(&p, &mut |w| {
|
||||
w.write_all(b"half")?;
|
||||
Err(io::Error::other("encoder failed"))
|
||||
});
|
||||
assert!(r.is_err());
|
||||
assert_eq!(fs::read(&p).unwrap(), b"the original bytes");
|
||||
assert_eq!(names(&d), vec!["photo.jpg"]);
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_files_never_replace_anything() {
|
||||
let d = temp("new");
|
||||
let p = d.join("a.dng");
|
||||
fs::write(&p, b"keep").unwrap();
|
||||
assert_eq!(write_new(&p, b"other").unwrap_err().kind(), io::ErrorKind::AlreadyExists);
|
||||
assert_eq!(fs::read(&p).unwrap(), b"keep");
|
||||
let mut c = (1..).map(|k| if k == 1 { d.join("a.dng") } else { d.join(format!("a-{k}.dng")) });
|
||||
let got = write_new_unique(&mut c, b"fresh").unwrap();
|
||||
assert_eq!(got, d.join("a-2.dng"));
|
||||
assert_eq!(fs::read(&got).unwrap(), b"fresh");
|
||||
assert_eq!(names(&d), vec!["a-2.dng", "a.dng"], "the temp file is gone");
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_file_sees_through_other_spellings() {
|
||||
let d = temp("same");
|
||||
let p = d.join("x.jpg");
|
||||
fs::write(&p, b"x").unwrap();
|
||||
fs::write(d.join("y.jpg"), b"x").unwrap();
|
||||
fs::create_dir_all(d.join("sub")).unwrap();
|
||||
assert!(same_file(&p, &d.join("sub/../x.jpg")));
|
||||
assert!(!same_file(&p, &d.join("y.jpg")));
|
||||
assert!(!same_file(&p, &d.join("missing.jpg")));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,42 @@ impl Catalog {
|
||||
self.group_ops(result, sources, true)
|
||||
}
|
||||
|
||||
/// Move `photo` `delta` places within its stack (towards the top for negative; index 0 is the
|
||||
/// top). `None` when it isn't stacked or can't move.
|
||||
pub fn move_in_stack_ops(&self, photo: PhotoId, delta: isize) -> Option<Op> {
|
||||
let st = self.stack_of(photo)?;
|
||||
let i = st.photos.iter().position(|p| *p == photo)?;
|
||||
let to = (i as isize + delta).clamp(0, st.photos.len() as isize - 1) as usize;
|
||||
if to == i {
|
||||
return None;
|
||||
}
|
||||
let mut photos = st.photos.clone();
|
||||
let p = photos.remove(i);
|
||||
photos.insert(to, p);
|
||||
Some(Op::SetStack { id: st.id, photos, collapsed: st.collapsed })
|
||||
}
|
||||
|
||||
/// Split `photo`'s stack in two: the photos before it stay, it and the ones after it form a
|
||||
/// new stack (topped by it). A part with a single photo is no longer stacked.
|
||||
pub fn split_stack_ops(&mut self, photo: PhotoId) -> Option<Op> {
|
||||
let st = self.stack_of(photo)?.clone();
|
||||
let i = st.photos.iter().position(|p| *p == photo)?;
|
||||
if i == 0 {
|
||||
return None;
|
||||
}
|
||||
let (head, tail) = st.photos.split_at(i);
|
||||
let mut ops = vec![if head.len() >= 2 {
|
||||
Op::SetStack { id: st.id, photos: head.to_vec(), collapsed: st.collapsed }
|
||||
} else {
|
||||
Op::RemoveStack { id: st.id }
|
||||
}];
|
||||
if tail.len() >= 2 {
|
||||
let id = self.alloc_stack_id();
|
||||
ops.push(Op::AddStack { stack: Stack { id, photos: tail.to_vec(), collapsed: st.collapsed } });
|
||||
}
|
||||
Some(Op::Batch { ops })
|
||||
}
|
||||
|
||||
/// Ops that dissolve every stack containing one of `photos`.
|
||||
pub fn ungroup_ops(&self, photos: &[PhotoId]) -> Vec<Op> {
|
||||
self.stacks.values().filter(|s| s.photos.iter().any(|p| photos.contains(p))).map(|s| Op::RemoveStack { id: s.id }).collect()
|
||||
@@ -241,7 +277,7 @@ mod tests {
|
||||
#[test]
|
||||
fn group_merge_set_top_remove_and_arrange() {
|
||||
let (mut c, ids) = cat(6, &[]);
|
||||
let [a, b, x, d, e, f] = ids[..] else { unreachable!() };
|
||||
let [a, b, x, d, e, f] = ids[..] else { panic!("six ids") };
|
||||
let op = c.group_ops(b, &[a, b, x], false).unwrap();
|
||||
let inv = c.apply(op).unwrap();
|
||||
let s = c.stack_of(a).unwrap().clone();
|
||||
|
||||
@@ -16,12 +16,33 @@ pub trait Store: Send {
|
||||
/// Replace a file atomically: after a crash either the old or the new content is visible,
|
||||
/// never a mix (native: write temp + fsync + rename + fsync directory).
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> io::Result<()>;
|
||||
/// Append and make durable (fsync) before returning.
|
||||
/// [`Store::write_atomic`] with streamed content: `fill` writes the new content into the
|
||||
/// given writer (an error aborts and leaves the old file). Same durability: the file is
|
||||
/// replaced only once all of it is written (native: and fsynced). Returns its length.
|
||||
///
|
||||
/// The default buffers the content in memory; [`FsStore`] streams it into the temp file, so
|
||||
/// a large snapshot is never held in memory as a whole.
|
||||
fn write_atomic_with(&mut self, name: &str, fill: &mut dyn FnMut(&mut dyn io::Write) -> io::Result<()>) -> io::Result<u64> {
|
||||
let mut buf = Vec::new();
|
||||
fill(&mut buf)?;
|
||||
self.write_atomic(name, &buf)?;
|
||||
Ok(buf.len() as u64)
|
||||
}
|
||||
/// Append and make durable (fsync) before returning. On an error, part of `data` may have
|
||||
/// been written (a full disk, a dropped network share): the journal cuts the file back to its
|
||||
/// previous length with [`Store::truncate`] before appending again.
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()>;
|
||||
/// Cut a file to `len` bytes (drop a torn tail before appending again).
|
||||
/// Cut a file to `len` bytes (drop a torn tail before appending again). A missing file is
|
||||
/// left missing.
|
||||
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()>;
|
||||
/// Human-readable location (diagnostics).
|
||||
fn describe(&self) -> String;
|
||||
/// Another handle on the same files that a worker thread can write `catalog.snap` through
|
||||
/// while this one keeps appending to the log (background compaction). `None` (the default):
|
||||
/// snapshots are written synchronously.
|
||||
fn background_writer(&self) -> Option<Box<dyn Store>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Files in a directory.
|
||||
@@ -77,6 +98,32 @@ impl Store for FsStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_atomic_with(&mut self, name: &str, fill: &mut dyn FnMut(&mut dyn io::Write) -> io::Result<()>) -> io::Result<u64> {
|
||||
use std::io::Write;
|
||||
if self.appender.as_ref().is_some_and(|(n, _)| n == name) {
|
||||
self.appender = None;
|
||||
}
|
||||
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 };
|
||||
fill(&mut w)?;
|
||||
w.flush()?;
|
||||
let f = w.inner.into_inner().map_err(|e| e.into_error())?;
|
||||
f.sync_all()?;
|
||||
Ok(w.n)
|
||||
})();
|
||||
let n = match written {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
std::fs::rename(&tmp, self.path(name))?;
|
||||
self.sync_dir();
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
|
||||
use std::io::Write;
|
||||
if self.appender.as_ref().is_none_or(|(n, _)| n != name) {
|
||||
@@ -87,16 +134,27 @@ impl Store for FsStore {
|
||||
}
|
||||
self.appender = Some((name.to_string(), f));
|
||||
}
|
||||
let (_, f) = self.appender.as_mut().expect("appender just opened");
|
||||
f.write_all(data)?;
|
||||
f.sync_data()
|
||||
let Some((_, f)) = self.appender.as_mut() else { return Err(io::Error::other("appender not open")) };
|
||||
let written = f.write_all(data).and_then(|()| f.sync_data());
|
||||
if written.is_err() {
|
||||
// The handle may be dead (a network share that reconnected, a removed volume): the next
|
||||
// attempt reopens the file. Part of `data` may be in the file; the caller cuts it back
|
||||
// ([`Store::truncate`]) before appending again.
|
||||
self.appender = None;
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()> {
|
||||
if self.appender.as_ref().is_some_and(|(n, _)| n == name) {
|
||||
self.appender = None;
|
||||
}
|
||||
let f = std::fs::OpenOptions::new().write(true).open(self.path(name))?;
|
||||
let f = match std::fs::OpenOptions::new().write(true).open(self.path(name)) {
|
||||
Ok(f) => f,
|
||||
// nothing to cut (an append that failed before creating the file)
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
f.set_len(len)?;
|
||||
f.sync_all()
|
||||
}
|
||||
@@ -104,12 +162,36 @@ impl Store for FsStore {
|
||||
fn describe(&self) -> String {
|
||||
self.dir.display().to_string()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn background_writer(&self) -> Option<Box<dyn Store>> {
|
||||
Some(Box::new(FsStore { dir: self.dir.clone(), appender: None }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts the bytes written through it.
|
||||
struct Counter<W> {
|
||||
inner: W,
|
||||
n: u64,
|
||||
}
|
||||
|
||||
impl<W: io::Write> io::Write for Counter<W> {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let k = self.inner.write(buf)?;
|
||||
self.n += k as u64;
|
||||
Ok(k)
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory files. Clones share the same files (so a test can "reopen" or corrupt them).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct MemStore {
|
||||
pub files: Arc<Mutex<BTreeMap<String, Vec<u8>>>>,
|
||||
/// Offer a [`Store::background_writer`] (off by default: snapshots stay synchronous).
|
||||
pub background: bool,
|
||||
}
|
||||
|
||||
impl MemStore {
|
||||
@@ -145,4 +227,7 @@ impl Store for MemStore {
|
||||
fn describe(&self) -> String {
|
||||
"memory".into()
|
||||
}
|
||||
fn background_writer(&self) -> Option<Box<dyn Store>> {
|
||||
self.background.then(|| Box::new(self.clone()) as Box<dyn Store>)
|
||||
}
|
||||
}
|
||||
@@ -57,11 +57,15 @@ fn albums_and_folders() {
|
||||
let mut c = Catalog::new();
|
||||
let a = photo(&mut c, "a.jpg", "2026-04-01");
|
||||
let f = c.alloc_album_id();
|
||||
c.apply(Op::AddAlbum { album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None } })
|
||||
.unwrap();
|
||||
c.apply(Op::AddAlbum {
|
||||
album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false },
|
||||
})
|
||||
.unwrap();
|
||||
let al = c.alloc_album_id();
|
||||
c.apply(Op::AddAlbum { album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None, smart: None } })
|
||||
.unwrap();
|
||||
c.apply(Op::AddAlbum {
|
||||
album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None, smart: None, quick: false },
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(c.albums_of(a), vec![al]);
|
||||
assert!(c.apply(Op::RemoveAlbum { id: f }).is_err(), "non-empty folder");
|
||||
assert!(c.apply(Op::SetAlbumPhotos { id: f, photos: vec![a] }).is_err(), "folders hold no photos");
|
||||
@@ -216,3 +220,80 @@ fn folder_paths() {
|
||||
assert!(!in_folder("/a/bc/d.jpg", "/a/b", true), "a sibling with the same prefix");
|
||||
assert!(in_folder("C:\\Pics\\a.jpg", "C:\\Pics", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_results_are_recognised() {
|
||||
use crate::query::merged_kind;
|
||||
assert_eq!(merged_kind("IMG_1-HDR.dng"), Some("hdr"));
|
||||
assert_eq!(merged_kind("IMG_1-Pano.dng"), Some("panorama"));
|
||||
assert_eq!(merged_kind("IMG_1-HDR-Pano.dng"), Some("hdrPanorama"));
|
||||
assert_eq!(merged_kind("IMG_1-HDR-2.dng"), Some("hdr"), "a second merge of the same photo");
|
||||
assert_eq!(merged_kind("hdr.jpg"), None);
|
||||
assert_eq!(merged_kind("panorama.jpg"), None);
|
||||
}
|
||||
|
||||
/// Preview-only raws (an undecodable raw variant shown from its embedded JPEG): the reason is
|
||||
/// stored with the photo, old catalogs without it still load, and Reload's op sets / clears it
|
||||
/// with an exact inverse that survives serialisation (the journal).
|
||||
#[test]
|
||||
fn preview_only_survives_serde_and_set_content_undo() {
|
||||
let mut c = Catalog::new();
|
||||
let a = c.alloc_photo_id();
|
||||
let mut p = Photo::new(a, Source::File { path: "/x/DSC_0001.NEF".into() }, "DSC_0001.NEF", "NEF", 6000, 4000, "2026-10-01T00:00:00");
|
||||
p.kind = MediaKind::Raw;
|
||||
p.preview_only = Some("Nikon Huffman-compressed NEF".into());
|
||||
assert!(!p.develops_raw());
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
// the catalog round-trips it
|
||||
let back: Catalog = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
|
||||
assert_eq!(back.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF"));
|
||||
// an older catalog (no field) loads as decodable
|
||||
let mut v = serde_json::to_value(&c).unwrap();
|
||||
let photos = v["photos"].as_object_mut().unwrap();
|
||||
let ph = photos.values_mut().next().unwrap();
|
||||
ph.as_object_mut().unwrap().remove("preview_only").expect("serialised when set");
|
||||
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 };
|
||||
let inv = c.apply(op).unwrap();
|
||||
assert!(c.photo(a).unwrap().develops_raw());
|
||||
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().width, 6000);
|
||||
}
|
||||
|
||||
/// Equivalent spellings of one folder (separators, trailing or doubled separators, `.`/`..`,
|
||||
/// drive-letter case, verbatim and UNC prefixes) share an identity; different folders don't.
|
||||
#[test]
|
||||
fn folder_identity_ignores_spelling() {
|
||||
use crate::query::{folder_key, folder_within};
|
||||
let same = |a: &str, b: &str| assert_eq!(folder_key(a), folder_key(b), "{a} vs {b}");
|
||||
let differ = |a: &str, b: &str| assert_ne!(folder_key(a), folder_key(b), "{a} vs {b}");
|
||||
for (a, b) in [
|
||||
("D:/Example/Photos", "D:\\Example\\Photos"),
|
||||
("D:/Example/Photos", "d:\\Example\\Photos\\"),
|
||||
("D:\\Example/Photos", "D:/Example//Photos/."),
|
||||
("D:/Example/Photos", "D:/Example/Other/../Photos"),
|
||||
("D:/Example/Photos", "\\\\?\\D:\\Example\\Photos"),
|
||||
("\\\\server\\share\\Photos", "//server/share/Photos/"),
|
||||
("\\\\?\\UNC\\server\\share\\Photos", "//server/share/Photos"),
|
||||
("/home/me/Photos", "/home/me/Photos/"),
|
||||
("/home/me/Photos", "/home//me/./Photos"),
|
||||
("/", "//"),
|
||||
("C:\\", "c:/"),
|
||||
] {
|
||||
same(a, b);
|
||||
}
|
||||
differ("D:/Example/Photos", "D:/Example/Photos2");
|
||||
differ("D:/Example/Photos", "E:/Example/Photos");
|
||||
differ("/home/me/Photos", "/home/me");
|
||||
assert_eq!(folder_key("/a/../../b"), "/b", "`..` stops at the root");
|
||||
assert!(folder_within("D:\\Example\\Photos\\2026", "D:/Example/Photos/"));
|
||||
assert!(folder_within("D:/Example/Photos", "d:\\Example\\Photos"));
|
||||
assert!(!folder_within("D:/Example/Photos2", "D:/Example/Photos"));
|
||||
assert!(folder_within("/a/b", "/"));
|
||||
assert!(folder_within("C:\\x", "c:\\"));
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
//! Background compaction (issue #37): a snapshot written by a worker while appends go on.
|
||||
//! "Crashes" are simulated by copying the files at a given moment and loading the copy: every
|
||||
//! acknowledged (appended) op must be there, in order, whatever the snapshot was doing.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
use crate::journal::{LOG, SNAPSHOT};
|
||||
use crate::*;
|
||||
|
||||
/// A MemStore whose snapshot writes wait for a gate (to hold a background snapshot mid-flight)
|
||||
/// and can be made to fail.
|
||||
#[derive(Clone)]
|
||||
struct Gated {
|
||||
files: MemStore,
|
||||
gate: Arc<(Mutex<bool>, Condvar)>,
|
||||
fail: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Gated {
|
||||
fn new() -> Gated {
|
||||
Gated { files: MemStore::new(), gate: Arc::new((Mutex::new(false), Condvar::new())), fail: Arc::new(AtomicBool::new(false)) }
|
||||
}
|
||||
fn set_gate(&self, open: bool) {
|
||||
*self.gate.0.lock().unwrap() = open;
|
||||
self.gate.1.notify_all();
|
||||
}
|
||||
/// The files as a crash at this moment would leave them.
|
||||
fn crash(&self) -> MemStore {
|
||||
let m = MemStore::new();
|
||||
*m.files.lock().unwrap() = self.files.files.lock().unwrap().clone();
|
||||
m
|
||||
}
|
||||
fn snapshot_seq_on_disk(&self) -> Option<u64> {
|
||||
let v: serde_json::Value = serde_json::from_slice(&self.files.get(SNAPSHOT)?).ok()?;
|
||||
v["seq"].as_u64()
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for Gated {
|
||||
fn read(&mut self, name: &str) -> std::io::Result<Option<Vec<u8>>> {
|
||||
self.files.read(name)
|
||||
}
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.files.write_atomic(name, data)
|
||||
}
|
||||
fn write_atomic_with(&mut self, name: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> std::io::Result<u64> {
|
||||
if name == SNAPSHOT {
|
||||
let (lock, cv) = &*self.gate;
|
||||
let (open, timeout) = cv.wait_timeout_while(lock.lock().unwrap(), Duration::from_secs(20), |open| !*open).unwrap();
|
||||
if !*open || timeout.timed_out() {
|
||||
return Err(std::io::Error::other("gate never opened"));
|
||||
}
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
fill(&mut buf)?;
|
||||
if self.fail.load(Ordering::SeqCst) {
|
||||
return Err(std::io::Error::other("disk full"));
|
||||
}
|
||||
self.files.write_atomic(name, &buf)?;
|
||||
Ok(buf.len() as u64)
|
||||
}
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.files.append(name, data)
|
||||
}
|
||||
fn truncate(&mut self, name: &str, len: u64) -> std::io::Result<()> {
|
||||
self.files.truncate(name, len)
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"gated".into()
|
||||
}
|
||||
fn background_writer(&self) -> Option<Box<dyn Store>> {
|
||||
Some(Box::new(self.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply and durably append one edit (two ops in one append every fourth time).
|
||||
fn edit(j: &mut Journal, live: &mut Catalog, k: u64) {
|
||||
let one = |live: &mut Catalog, k: u64| {
|
||||
let op = if live.len() < 4 || k.is_multiple_of(3) {
|
||||
let id = live.alloc_photo_id();
|
||||
Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, "p.jpg", "JPEG", 4, 3, "t")) }
|
||||
} else {
|
||||
Op::SetRating { id: PhotoId(1 + k % live.len() as u64), rating: (k % 6) as u8 }
|
||||
};
|
||||
live.apply(op.clone()).unwrap();
|
||||
op
|
||||
};
|
||||
let mut ops = vec![one(live, k)];
|
||||
if k.is_multiple_of(4) {
|
||||
ops.push(one(live, k / 4 + 1));
|
||||
}
|
||||
j.append(&ops).unwrap();
|
||||
}
|
||||
|
||||
/// Load the files a crash now would leave: they must hold exactly the live state.
|
||||
fn assert_recovers(g: &Gated, live: &Catalog) -> LoadReport {
|
||||
let (_, c, r) = Journal::open(Box::new(g.crash())).unwrap();
|
||||
assert_eq!(c.to_snapshot(), live.to_snapshot(), "{r:?}");
|
||||
assert_eq!((r.failed, r.torn_bytes, r.damaged.clone()), (0, 0, None));
|
||||
r
|
||||
}
|
||||
|
||||
fn wait_written(j: &Journal) {
|
||||
for _ in 0..20_000 {
|
||||
if j.snapshot_written() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
panic!("background snapshot never finished");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_snapshot_keeps_edits_made_while_it_runs() {
|
||||
let g = Gated::new();
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
|
||||
for k in 0..30 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
let n = j.seq();
|
||||
j.snapshot_in_background(&live).unwrap();
|
||||
assert!(j.snapshot_running() && !j.wants_snapshot() && j.stats().snapshot_running);
|
||||
|
||||
// edits while the snapshot is being written are appended (durably) as usual
|
||||
let mut during = 0;
|
||||
for k in 30..50 {
|
||||
edit(&mut j, &mut live, k);
|
||||
during = j.seq() - n;
|
||||
}
|
||||
// crash before the snapshot landed: no snapshot yet, the whole log replays
|
||||
let r = assert_recovers(&g, &live);
|
||||
assert_eq!((r.snapshot_seq, r.replayed as u64), (0, j.seq()));
|
||||
|
||||
// the snapshot lands (op n); crash before the log was trimmed: its records are stale
|
||||
g.set_gate(true);
|
||||
wait_written(&j);
|
||||
assert_eq!(g.snapshot_seq_on_disk(), Some(n));
|
||||
for k in 50..55 {
|
||||
edit(&mut j, &mut live, k);
|
||||
during = j.seq() - n;
|
||||
}
|
||||
let r = assert_recovers(&g, &live);
|
||||
assert_eq!((r.snapshot_seq, r.stale as u64, r.replayed as u64), (n, n, during));
|
||||
|
||||
// finishing trims the log to exactly the records after the snapshot, in order
|
||||
assert!(j.poll().unwrap());
|
||||
assert!(!j.snapshot_running());
|
||||
assert_eq!((j.snapshot_seq(), j.log_records()), (n, during));
|
||||
let log = String::from_utf8(g.files.get(LOG).unwrap()).unwrap();
|
||||
let seqs: Vec<u64> = log.lines().map(|l| journal::decode_record(l).unwrap().0).collect();
|
||||
assert_eq!(seqs, ((n + 1)..=j.seq()).collect::<Vec<_>>());
|
||||
assert_eq!(j.log_bytes(), log.len() as u64);
|
||||
let r = assert_recovers(&g, &live);
|
||||
assert_eq!((r.snapshot_seq, r.stale, r.replayed as u64), (n, 0, during));
|
||||
let s = j.stats();
|
||||
assert!(s.last_snapshot.background && s.last_snapshot.records == n && !s.snapshot_running, "{s:?}");
|
||||
|
||||
// and appending goes on after it
|
||||
edit(&mut j, &mut live, 99);
|
||||
assert_recovers(&g, &live);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_background_snapshot_loses_nothing_and_retries_later() {
|
||||
let g = Gated::new();
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
|
||||
j.policy = SnapshotPolicy { max_records: 10, max_bytes: u64::MAX };
|
||||
for k in 0..12 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
assert!(j.wants_snapshot());
|
||||
g.fail.store(true, Ordering::SeqCst);
|
||||
g.set_gate(true);
|
||||
j.snapshot_in_background(&live).unwrap();
|
||||
edit(&mut j, &mut live, 12);
|
||||
wait_written(&j);
|
||||
assert!(j.poll().is_err());
|
||||
assert_eq!((j.snapshot_seq(), j.stats().failed_snapshots), (0, 1));
|
||||
// still the (empty) snapshot written when the library was created
|
||||
assert_eq!(g.snapshot_seq_on_disk(), Some(0));
|
||||
assert_recovers(&g, &live);
|
||||
// no retry storm: the next attempt waits for another `max_records` records
|
||||
assert!(!j.wants_snapshot());
|
||||
g.fail.store(false, Ordering::SeqCst);
|
||||
while !j.wants_snapshot() {
|
||||
let k = j.seq();
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
j.snapshot_in_background(&live).unwrap();
|
||||
j.wait().unwrap();
|
||||
assert_eq!((j.snapshot_seq(), j.log_records()), (j.seq(), 0));
|
||||
let r = assert_recovers(&g, &live);
|
||||
assert_eq!((r.snapshot_seq, r.replayed), (j.seq(), 0));
|
||||
}
|
||||
|
||||
/// A synchronous snapshot (close, "Optimize Library") waits for the background one, so the older
|
||||
/// snapshot can't land after (and replace) the newer one.
|
||||
#[test]
|
||||
fn sync_snapshot_waits_for_the_background_one() {
|
||||
let g = Gated::new();
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
|
||||
for k in 0..10 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
j.snapshot_in_background(&live).unwrap();
|
||||
for k in 10..20 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
let opener = {
|
||||
let g = g.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
g.set_gate(true);
|
||||
})
|
||||
};
|
||||
j.snapshot(&live).unwrap();
|
||||
opener.join().unwrap();
|
||||
assert!(!j.snapshot_running());
|
||||
assert_eq!(g.snapshot_seq_on_disk(), Some(j.seq()));
|
||||
assert_eq!((j.snapshot_seq(), j.log_records()), (j.seq(), 0));
|
||||
assert!(g.files.get(LOG).unwrap().is_empty());
|
||||
assert_eq!(j.stats().snapshots, 2);
|
||||
assert_recovers(&g, &live);
|
||||
}
|
||||
|
||||
/// Dropping the journal (e.g. reopening the library) waits for the snapshot in flight and leaves
|
||||
/// the log whole: reopening finds every op.
|
||||
#[test]
|
||||
fn dropping_the_journal_waits_for_its_snapshot() {
|
||||
let g = Gated::new();
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
|
||||
for k in 0..10 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
let n = j.seq();
|
||||
j.snapshot_in_background(&live).unwrap();
|
||||
for k in 10..15 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
let opener = {
|
||||
let g = g.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
g.set_gate(true);
|
||||
})
|
||||
};
|
||||
let total = j.seq();
|
||||
drop(j);
|
||||
opener.join().unwrap();
|
||||
assert_eq!(g.snapshot_seq_on_disk(), Some(n));
|
||||
let (_, c, r) = Journal::open(Box::new(g.files.clone())).unwrap();
|
||||
assert_eq!(c.to_snapshot(), live.to_snapshot());
|
||||
assert_eq!((r.snapshot_seq, r.stale as u64, r.replayed as u64), (n, n, total - n));
|
||||
}
|
||||
|
||||
/// On disk, with a real worker thread: a crash mid-write leaves only a temp file, which loading
|
||||
/// ignores; edits during the snapshot survive; reopening resumes appends.
|
||||
#[test]
|
||||
fn fs_store_background_snapshot() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-journal-bg-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
|
||||
for k in 0..200 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
// a previous crash mid-snapshot left a partial temp file
|
||||
std::fs::write(dir.join(format!("{SNAPSHOT}.tmp")), b"{\"format\":\"lightcraft-catal").unwrap();
|
||||
let (mut j2, c2, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
|
||||
assert_eq!((c2.to_snapshot(), r.snapshot_seq), (live.to_snapshot(), 0));
|
||||
drop(j);
|
||||
let n = j2.seq();
|
||||
j2.snapshot_in_background(&live).unwrap();
|
||||
for k in 200..260 {
|
||||
edit(&mut j2, &mut live, k);
|
||||
}
|
||||
j2.wait().unwrap();
|
||||
assert_eq!((j2.snapshot_seq(), j2.log_records()), (n, j2.seq() - n));
|
||||
assert!(!dir.join(format!("{SNAPSHOT}.tmp")).exists());
|
||||
edit(&mut j2, &mut live, 999);
|
||||
drop(j2);
|
||||
let (_, c3, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
|
||||
assert_eq!(c3.to_snapshot(), live.to_snapshot());
|
||||
assert_eq!((r.snapshot_seq, r.stale), (n, 0));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(48))]
|
||||
|
||||
/// Any interleaving of edits, background snapshots (started, landed, finished), synchronous
|
||||
/// snapshots and crashes: a crash at any point recovers exactly the acknowledged ops.
|
||||
#[test]
|
||||
fn background_compaction_never_loses_acknowledged_ops(actions in proptest::collection::vec((0u8..8, 0u64..1000), 1..60)) {
|
||||
let g = Gated::new();
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
|
||||
for (ctl, k) in actions {
|
||||
match ctl {
|
||||
0..=2 => edit(&mut j, &mut live, k),
|
||||
3 => j.snapshot_in_background(&live).unwrap(),
|
||||
4 | 5 => {
|
||||
// the worker writes its snapshot; finish it now or later
|
||||
if j.snapshot_running() {
|
||||
g.set_gate(true);
|
||||
wait_written(&j);
|
||||
g.set_gate(false);
|
||||
if ctl == 4 {
|
||||
prop_assert!(j.poll().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
6 => {
|
||||
g.set_gate(true);
|
||||
j.snapshot(&live).unwrap();
|
||||
g.set_gate(false);
|
||||
prop_assert_eq!(g.snapshot_seq_on_disk(), Some(j.seq()));
|
||||
}
|
||||
_ => {
|
||||
// a crash now
|
||||
let (_, c, r) = Journal::open(Box::new(g.crash())).unwrap();
|
||||
prop_assert_eq!(c.to_snapshot(), live.to_snapshot());
|
||||
prop_assert_eq!(r.failed, 0);
|
||||
prop_assert!(r.damaged.is_none());
|
||||
}
|
||||
}
|
||||
// the snapshot on disk never covers more than what was appended
|
||||
prop_assert!(g.snapshot_seq_on_disk().unwrap_or(0) <= j.seq());
|
||||
}
|
||||
g.set_gate(true);
|
||||
let (_, c, _) = Journal::open(Box::new(g.crash())).unwrap();
|
||||
prop_assert_eq!(c.to_snapshot(), live.to_snapshot());
|
||||
drop(j);
|
||||
let (_, c, _) = Journal::open(Box::new(g.files.clone())).unwrap();
|
||||
prop_assert_eq!(c.to_snapshot(), live.to_snapshot());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Issue #102: catalog format versions. Older formats load and are upgraded; a newer format is
|
||||
//! refused with a clear error and left untouched (never read in part, truncated or set aside).
|
||||
|
||||
use crate::journal::{LOG, SNAPSHOT, VERSION, encode_record};
|
||||
use crate::*;
|
||||
|
||||
fn photo(c: &mut Catalog) -> Op {
|
||||
let id = c.alloc_photo_id();
|
||||
Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, "p.jpg", "JPEG", 4, 3, "2026-01-01")) }
|
||||
}
|
||||
|
||||
/// A catalog with two photos and the log line for a third op on top of it.
|
||||
fn legacy_parts() -> (Catalog, String, Catalog) {
|
||||
let mut c = Catalog::new();
|
||||
for _ in 0..2 {
|
||||
let op = photo(&mut c);
|
||||
c.apply(op).unwrap();
|
||||
}
|
||||
let base = c.clone();
|
||||
let id = c.photos().next().unwrap().id;
|
||||
let op = Op::SetRating { id, rating: 4 };
|
||||
c.apply(op.clone()).unwrap();
|
||||
(base, format!("{}\n", encode_record(3, &op)), c)
|
||||
}
|
||||
|
||||
fn snapshot_version(m: &MemStore) -> u64 {
|
||||
let v: serde_json::Value = serde_json::from_slice(&m.get(SNAPSHOT).unwrap()).unwrap();
|
||||
v["version"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_library_loads_and_is_upgraded() {
|
||||
let (base, log, full) = legacy_parts();
|
||||
let m = MemStore::new();
|
||||
m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":1,\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes());
|
||||
m.set(LOG, log.into_bytes());
|
||||
let (mut j, c, r) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
assert_eq!(c.to_snapshot(), full.to_snapshot());
|
||||
assert_eq!((r.replayed, r.upgraded_from), (1, Some(1)));
|
||||
// rewritten in this format before anything is appended
|
||||
assert_eq!(snapshot_version(&m), u64::from(VERSION));
|
||||
assert!(m.get(LOG).unwrap().is_empty());
|
||||
assert_eq!(j.seq(), 3);
|
||||
j.append(&[Op::SetLabelName { label: ColorLabel::ALL[0], name: Some("x".into()) }]).unwrap();
|
||||
drop(j);
|
||||
let (_, _, r) = Journal::open(Box::new(m)).unwrap();
|
||||
assert_eq!((r.replayed, r.upgraded_from), (1, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versionless_log_only_library_is_upgraded() {
|
||||
let mut c = Catalog::new();
|
||||
let op = photo(&mut c);
|
||||
c.apply(op.clone()).unwrap();
|
||||
let m = MemStore::new();
|
||||
m.set(LOG, format!("{}\n", encode_record(1, &op)).into_bytes());
|
||||
let (_, loaded, r) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
assert_eq!(loaded.to_snapshot(), c.to_snapshot());
|
||||
assert_eq!(r.upgraded_from, Some(0));
|
||||
assert_eq!(snapshot_version(&m), u64::from(VERSION));
|
||||
}
|
||||
|
||||
/// A new library gets a snapshot in this format at once, so an older build never sees a bare log
|
||||
/// it would read in part.
|
||||
#[test]
|
||||
fn new_library_is_written_in_this_format() {
|
||||
let m = MemStore::new();
|
||||
let (_, _, r) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
assert!(r.created);
|
||||
assert_eq!(r.upgraded_from, None);
|
||||
assert_eq!(snapshot_version(&m), u64::from(VERSION));
|
||||
}
|
||||
|
||||
fn assert_refused_untouched(m: &MemStore) {
|
||||
let before = m.files.lock().unwrap().clone();
|
||||
let e = Journal::open(Box::new(m.clone())).err().expect("refused");
|
||||
assert!(matches!(e, CatalogError::Newer(_)), "{e:?}");
|
||||
let msg = e.to_string();
|
||||
assert!(msg.contains("newer version of LightCraft") && msg.contains("left unchanged"), "{msg}");
|
||||
assert_eq!(*m.files.lock().unwrap(), before, "nothing modified, nothing renamed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_snapshot_is_refused_untouched() {
|
||||
let (base, log, _) = legacy_parts();
|
||||
let m = MemStore::new();
|
||||
m.set(
|
||||
SNAPSHOT,
|
||||
format!("{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":2,\"catalog\":{}}}\n", VERSION + 1, base.to_snapshot()).into_bytes(),
|
||||
);
|
||||
m.set(LOG, log.into_bytes());
|
||||
assert_refused_untouched(&m);
|
||||
// even when its catalog doesn't parse as this version's
|
||||
let m = MemStore::new();
|
||||
m.set(
|
||||
SNAPSHOT,
|
||||
format!("{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":2,\"catalog\":{{\"photos\":7}}}}\n", VERSION + 1).into_bytes(),
|
||||
);
|
||||
assert_refused_untouched(&m);
|
||||
let e = Journal::open(Box::new(m)).err().unwrap().to_string();
|
||||
assert!(e.contains(&format!("v{}", VERSION + 1)), "{e}");
|
||||
}
|
||||
|
||||
/// A log record with a valid CRC whose op this version doesn't know: refused, not taken for a
|
||||
/// torn tail (truncated) or damage (renamed) — wherever it is in the log.
|
||||
#[test]
|
||||
fn unknown_op_in_the_log_is_refused_untouched() {
|
||||
let (base, log, _) = legacy_parts();
|
||||
let future = |seq: u64| {
|
||||
let body = r#"{"op":"fromTheFuture","id":1,"what":"something new"}"#;
|
||||
format!("{{\"seq\":{seq},\"crc\":{},\"op\":{body}}}\n", crc32fast::hash(body.as_bytes()))
|
||||
};
|
||||
let snap = format!("{{\"format\":\"lightcraft-catalog\",\"version\":{VERSION},\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot());
|
||||
let id = base.photos().next().unwrap().id;
|
||||
let after = format!("{}\n", encode_record(5, &Op::SetRating { id, rating: 1 }));
|
||||
for log in [format!("{log}{}", future(4)), format!("{log}{}{after}", future(4)), format!("{}{log}", future(3))] {
|
||||
let m = MemStore::new();
|
||||
m.set(SNAPSHOT, snap.clone().into_bytes());
|
||||
m.set(LOG, log.into_bytes());
|
||||
assert_refused_untouched(&m);
|
||||
}
|
||||
// a known op with a field of an unknown shape is "newer" too
|
||||
let body = r#"{"op":"setRating","id":1,"rating":"five stars"}"#;
|
||||
let m = MemStore::new();
|
||||
m.set(SNAPSHOT, snap.into_bytes());
|
||||
m.set(LOG, format!("{log}{{\"seq\":4,\"crc\":{},\"op\":{body}}}\n", crc32fast::hash(body.as_bytes())).into_bytes());
|
||||
assert_refused_untouched(&m);
|
||||
}
|
||||
|
||||
/// A CRC mismatch is still damage / a torn tail (not "newer").
|
||||
#[test]
|
||||
fn bad_crc_is_still_a_torn_tail() {
|
||||
let (base, log, _) = legacy_parts();
|
||||
let m = MemStore::new();
|
||||
m.set(
|
||||
SNAPSHOT,
|
||||
format!("{{\"format\":\"lightcraft-catalog\",\"version\":{VERSION},\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes(),
|
||||
);
|
||||
let body = r#"{"op":"fromTheFuture"}"#;
|
||||
m.set(LOG, format!("{log}{{\"seq\":4,\"crc\":1,\"op\":{body}}}\n").into_bytes());
|
||||
let (_, _, r) = Journal::open(Box::new(m)).unwrap();
|
||||
assert!(r.torn_bytes > 0 && r.damaged.is_none(), "{r:?}");
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn op_variants_are_versioned() {
|
||||
fn since(op: &Op) -> u32 {
|
||||
match op {
|
||||
Op::AddPhoto { .. }
|
||||
| Op::RemovePhoto { .. }
|
||||
| Op::SetRating { .. }
|
||||
| Op::SetFlag { .. }
|
||||
| Op::SetLabel { .. }
|
||||
| Op::SetDevelop { .. }
|
||||
| Op::SetMeta { .. }
|
||||
| Op::SetDeleted { .. }
|
||||
| Op::SetLocal { .. }
|
||||
| Op::SetVersions { .. }
|
||||
| Op::SetHistory { .. }
|
||||
| Op::PushHistory { .. }
|
||||
| Op::AddAlbum { .. }
|
||||
| Op::RemoveAlbum { .. }
|
||||
| Op::RenameAlbum { .. }
|
||||
| Op::MoveAlbum { .. }
|
||||
| Op::SetAlbumPhotos { .. }
|
||||
| Op::SetAlbumCover { .. }
|
||||
| Op::SetAlbumRules { .. }
|
||||
| Op::AddStack { .. }
|
||||
| Op::RemoveStack { .. }
|
||||
| Op::SetStack { .. }
|
||||
| Op::SetCaptured { .. }
|
||||
| Op::SetAnalysis { .. }
|
||||
| Op::SetFile { .. }
|
||||
| Op::Relink { .. }
|
||||
| Op::SetContent { .. }
|
||||
| Op::SetLabelName { .. }
|
||||
| Op::Batch { .. } => 1,
|
||||
Op::SetBrowsed { .. } => 2,
|
||||
}
|
||||
}
|
||||
let newest = since(&Op::SetBrowsed { folder: String::new(), at: None });
|
||||
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
|
||||
}
|
||||
@@ -50,7 +50,16 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
|
||||
5 => {
|
||||
let id = c.alloc_album_id();
|
||||
Op::AddAlbum {
|
||||
album: Album { id, name: format!("A{b}"), parent: None, folder: b.is_multiple_of(4), photos: vec![], cover: None, smart: None },
|
||||
album: Album {
|
||||
id,
|
||||
name: format!("A{b}"),
|
||||
parent: None,
|
||||
folder: b.is_multiple_of(4),
|
||||
photos: vec![],
|
||||
cover: None,
|
||||
smart: None,
|
||||
quick: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
6 => Op::SetAlbumPhotos { id: aid, photos: photos.iter().copied().filter(|p| (p.0 + b as u64).is_multiple_of(3)).collect() },
|
||||
@@ -265,3 +274,53 @@ fn fs_store_roundtrip() {
|
||||
assert_eq!(c3.to_snapshot(), c.to_snapshot());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The streamed snapshot is byte-for-byte the format written before streaming (issue #37), so
|
||||
/// existing libraries load and older builds can read new snapshots; FsStore and MemStore agree.
|
||||
#[test]
|
||||
fn streamed_snapshot_keeps_the_on_disk_format() {
|
||||
let (m, _, full) = sample_catalog_with_log();
|
||||
let (mut j, c, _) = open(&m);
|
||||
assert_eq!(c.to_snapshot(), full.to_snapshot());
|
||||
j.snapshot(&c).unwrap();
|
||||
let legacy = format!(
|
||||
"{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":{},\"catalog\":{}}}\n",
|
||||
crate::journal::VERSION,
|
||||
j.seq(),
|
||||
c.to_snapshot()
|
||||
);
|
||||
assert_eq!(String::from_utf8(m.get(SNAPSHOT).unwrap()).unwrap(), legacy);
|
||||
assert_eq!(j.stats().last_snapshot.bytes, legacy.len() as u64);
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("lc-journal-stream-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// an old-style snapshot (written by a build before streaming) loads, and is rewritten the same
|
||||
std::fs::write(dir.join(SNAPSHOT), legacy.as_bytes()).unwrap();
|
||||
let (mut fj, fc, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
|
||||
assert_eq!((fc.to_snapshot(), r.snapshot_seq), (c.to_snapshot(), j.seq()));
|
||||
fj.snapshot(&fc).unwrap();
|
||||
assert_eq!(std::fs::read(dir.join(SNAPSHOT)).unwrap(), legacy.as_bytes());
|
||||
drop(fj);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A snapshot that fails while streaming (serialisation or I/O error) leaves the old file in
|
||||
/// place and removes its temp file.
|
||||
#[test]
|
||||
fn failed_streamed_write_keeps_the_old_file() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-journal-streamfail-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut s = FsStore::open(&dir).unwrap();
|
||||
s.write_atomic(SNAPSHOT, b"old").unwrap();
|
||||
let r = s.write_atomic_with(SNAPSHOT, &mut |w| {
|
||||
w.write_all(b"partial new content")?;
|
||||
Err(std::io::Error::other("serialisation failed"))
|
||||
});
|
||||
assert!(r.is_err());
|
||||
assert_eq!(std::fs::read(dir.join(SNAPSHOT)).unwrap(), b"old");
|
||||
assert!(!dir.join(format!("{SNAPSHOT}.tmp")).exists());
|
||||
assert_eq!(s.write_atomic_with(SNAPSHOT, &mut |w| w.write_all(b"new")).unwrap(), 3);
|
||||
assert_eq!(std::fs::read(dir.join(SNAPSHOT)).unwrap(), b"new");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
//! Forgetting untouched Local browse records (see [`crate::local`]).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
|
||||
use crate::*;
|
||||
|
||||
const OLD: &str = "2026-08-01T10:00:00";
|
||||
const NOW: &str = "2026-10-05T10:00:00";
|
||||
|
||||
/// A Local record of `/pics/<folder>/IMG_<i>.JPG` as a browse catalogues it (with its baseline).
|
||||
fn local(c: &mut Catalog, folder: &str, i: u32) -> PhotoId {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::File { path: format!("/pics/{folder}/IMG_{i}.JPG") }, &format!("IMG_{i}.JPG"), "JPEG", 60, 40, OLD);
|
||||
p.meta.camera = "Cam".into();
|
||||
p.meta.creator = "From EXIF".into(); // file metadata is part of the baseline, not a user change
|
||||
p.focal_and_wb_for_test();
|
||||
p.local = true;
|
||||
p.set_local_baseline();
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
impl Photo {
|
||||
fn focal_and_wb_for_test(&mut self) {
|
||||
self.meta.focal_mm = Some(35.3);
|
||||
self.as_shot_wb = Some((5123.4, 3.1));
|
||||
}
|
||||
}
|
||||
|
||||
fn browsed(c: &mut Catalog, folder: &str, at: &str) {
|
||||
c.apply(Op::SetBrowsed { folder: format!("/pics/{folder}"), at: Some(at.into()) }).unwrap();
|
||||
}
|
||||
|
||||
fn plan(c: &Catalog, days: u32) -> ForgetPlan {
|
||||
c.forget_local_plan(NOW, days, &|_| false)
|
||||
}
|
||||
|
||||
fn run(c: &mut Catalog, days: u32) -> ForgetPlan {
|
||||
let p = plan(c, days);
|
||||
for op in p.ops() {
|
||||
c.apply(op).unwrap();
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untouched_records_of_old_folders_are_forgotten() {
|
||||
let mut c = Catalog::new();
|
||||
let old: Vec<_> = (0..5).map(|i| local(&mut c, "old", i)).collect();
|
||||
let recent: Vec<_> = (0..3).map(|i| local(&mut c, "recent", i)).collect();
|
||||
browsed(&mut c, "old", OLD);
|
||||
browsed(&mut c, "recent", "2026-10-01T09:00:00");
|
||||
// a library photo in the old folder is never a candidate
|
||||
let lib = c.alloc_photo_id();
|
||||
let mut p = Photo::new(lib, Source::File { path: "/pics/old/LIB.JPG".into() }, "LIB.JPG", "JPEG", 6, 4, OLD);
|
||||
p.set_local_baseline();
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
|
||||
let dry = plan(&c, 30);
|
||||
assert_eq!((dry.local, dry.evict.len(), dry.kept_recent), (8, 5, 3), "{dry:?}");
|
||||
assert_eq!(dry.evict, old);
|
||||
assert_eq!(c.len(), 9, "a plan changes nothing");
|
||||
let done = run(&mut c, 30);
|
||||
assert_eq!(done.evict.len(), 5);
|
||||
assert!(old.iter().all(|id| c.photo(*id).is_none()));
|
||||
assert!(recent.iter().all(|id| c.photo(*id).is_some()));
|
||||
assert!(c.photo(lib).is_some());
|
||||
// the folder has no Local records left: its time goes
|
||||
assert_eq!(done.unstamp, vec!["/pics/old".to_string()]);
|
||||
assert_eq!(c.last_browsed("/pics/old"), None);
|
||||
assert_eq!(c.last_browsed("/pics/recent/"), Some("2026-10-01T09:00:00"));
|
||||
// 0 days = never
|
||||
browsed(&mut c, "recent", OLD);
|
||||
assert!(plan(&c, 0).evict.is_empty());
|
||||
assert_eq!(plan(&c, 30).evict.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_user_change_keeps_a_record() {
|
||||
type Change = Box<dyn Fn(&mut Catalog, PhotoId)>;
|
||||
let set = |f: fn(&mut Photo)| -> Change {
|
||||
Box::new(move |c: &mut Catalog, id: PhotoId| {
|
||||
let mut p = (**c.photo(id).unwrap()).clone();
|
||||
f(&mut p);
|
||||
c.apply(Op::RemovePhoto { id }).unwrap();
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
})
|
||||
};
|
||||
let op = |f: fn(PhotoId) -> Op| -> Change { Box::new(move |c: &mut Catalog, id: PhotoId| drop(c.apply(f(id)).unwrap())) };
|
||||
let edited = || {
|
||||
let mut d = DevelopSettings::default();
|
||||
d.light.exposure = 0.3;
|
||||
Arc::new(d)
|
||||
};
|
||||
let changes: Vec<(&str, Change)> = vec![
|
||||
("rating", op(|id| Op::SetRating { id, rating: 3 })),
|
||||
("flag", op(|id| Op::SetFlag { id, flag: Flag::Reject })),
|
||||
("label", op(|id| Op::SetLabel { id, label: Some(ColorLabel::Red) })),
|
||||
("develop", Box::new(move |c, id| drop(c.apply(Op::SetDevelop { id, settings: edited(), label: "Exposure".into(), edited: None }).unwrap()))),
|
||||
("crop", set(|p| Arc::make_mut(&mut p.develop).crop.flip_h = true)),
|
||||
(
|
||||
"history only",
|
||||
Box::new(|c, id| {
|
||||
let step = HistoryStep { label: "Reset".into(), settings: Arc::new(DevelopSettings::default()) };
|
||||
drop(c.apply(Op::PushHistory { id, step }).unwrap())
|
||||
}),
|
||||
),
|
||||
("versions", set(|p| p.versions.push(Version { name: "v".into(), created: NOW.into(), settings: p.develop.clone(), auto: false }))),
|
||||
("title", set(|p| p.meta.title = "T".into())),
|
||||
("caption", set(|p| p.meta.caption = "C".into())),
|
||||
("keywords", set(|p| p.meta.keywords.push("k".into()))),
|
||||
("location", set(|p| p.meta.city = "Oslo".into())),
|
||||
("gps", set(|p| p.meta.gps = Some((1.0, 2.0)))),
|
||||
("copyright", set(|p| p.meta.copyright = "© me".into())),
|
||||
("creator edit", set(|p| p.meta.creator = "Me".into())),
|
||||
("capture time", op(|id| Op::SetCaptured { id, captured: Some("2020-01-01T00:00:00".into()) })),
|
||||
("rename", set(|p| p.file_name = "renamed.JPG".into())),
|
||||
("culling", op(|id| Op::SetAnalysis { id, analysis: Some(Analysis::default()) })),
|
||||
("deleted", op(|id| Op::SetDeleted { id, deleted: true })),
|
||||
("pick", op(|id| Op::SetFlag { id, flag: Flag::Pick })),
|
||||
(
|
||||
"album",
|
||||
Box::new(|c, id| {
|
||||
let a = c.alloc_album_id();
|
||||
let mut al = Album::new(a, "A");
|
||||
al.photos.push(id);
|
||||
drop(c.apply(Op::AddAlbum { album: al }).unwrap())
|
||||
}),
|
||||
),
|
||||
(
|
||||
"stack",
|
||||
Box::new(|c, id| {
|
||||
let other = local(c, "elsewhere", 99);
|
||||
let s = c.alloc_stack_id();
|
||||
drop(c.apply(Op::AddStack { stack: Stack { id: s, photos: vec![id, other], collapsed: false } }).unwrap())
|
||||
}),
|
||||
),
|
||||
(
|
||||
"virtual copy",
|
||||
Box::new(|c, id| {
|
||||
let mut v = (**c.photo(id).unwrap()).clone();
|
||||
v.id = c.alloc_photo_id();
|
||||
v.copy_of = Some(id);
|
||||
v.copy_name = Some("Copy 1".into());
|
||||
v.local = false;
|
||||
drop(c.apply(Op::AddPhoto { photo: Box::new(v) }).unwrap())
|
||||
}),
|
||||
),
|
||||
];
|
||||
for (what, change) in changes {
|
||||
let mut c = Catalog::new();
|
||||
let id = local(&mut c, "old", 1);
|
||||
let control = local(&mut c, "old", 2);
|
||||
browsed(&mut c, "old", OLD);
|
||||
change(&mut c, id);
|
||||
let p = plan(&c, 30);
|
||||
assert_eq!(p.evict, vec![control], "{what}: {p:?}");
|
||||
assert_eq!(p.kept_touched + p.kept_in_use, 1, "{what}: {p:?}");
|
||||
// the stamp stays: a record in the folder remains
|
||||
assert!(p.unstamp.is_empty(), "{what}");
|
||||
}
|
||||
// a change undone (back to the browse state) is untouched again
|
||||
let mut c = Catalog::new();
|
||||
let id = local(&mut c, "old", 1);
|
||||
browsed(&mut c, "old", OLD);
|
||||
let inv = c.apply(Op::SetRating { id, rating: 2 }).unwrap();
|
||||
assert!(plan(&c, 30).evict.is_empty());
|
||||
c.apply(inv).unwrap();
|
||||
assert_eq!(plan(&c, 30).evict, vec![id]);
|
||||
}
|
||||
|
||||
/// Catalogs from before last-browsed times (and baselines): nothing is forgotten on the first
|
||||
/// run — the folders are stamped now — and baseline-less records are judged by their data.
|
||||
#[test]
|
||||
fn upgrade_without_times_forgets_nothing_then_counts_from_then() {
|
||||
let mut c = Catalog::new();
|
||||
let a = local(&mut c, "a", 1);
|
||||
// legacy records: no baseline
|
||||
let legacy = |c: &mut Catalog, i: u32, f: fn(&mut Photo)| {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::File { path: format!("/pics/a/L{i}.JPG") }, "L.JPG", "JPEG", 6, 4, OLD);
|
||||
p.meta.camera = "Cam".into();
|
||||
p.local = true;
|
||||
f(&mut p);
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
id
|
||||
};
|
||||
let clean = legacy(&mut c, 1, |_| {});
|
||||
let rated = legacy(&mut c, 2, |p| p.rating = 1);
|
||||
let with_creator = legacy(&mut c, 3, |p| p.meta.creator = "maybe the user".into());
|
||||
let p = run(&mut c, 30);
|
||||
assert!(p.evict.is_empty(), "{p:?}");
|
||||
assert_eq!(p.stamp, vec!["/pics/a".to_string()]);
|
||||
assert_eq!(c.last_browsed("/pics/a"), Some(NOW));
|
||||
// 30+ days later
|
||||
let later = c.forget_local_plan("2026-11-06T10:00:00", 30, &|_| false);
|
||||
let mut evict = later.evict.clone();
|
||||
evict.sort();
|
||||
assert_eq!(evict, vec![a, clean]);
|
||||
assert_eq!(later.kept_touched, 2, "{rated:?} {with_creator:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_use_records_are_kept() {
|
||||
let mut c = Catalog::new();
|
||||
let a = local(&mut c, "old", 1);
|
||||
let b = local(&mut c, "old", 2);
|
||||
browsed(&mut c, "old", OLD);
|
||||
let p = c.forget_local_plan(NOW, 30, &|p| p.id == a);
|
||||
assert_eq!((p.evict.clone(), p.kept_in_use), (vec![b], 1));
|
||||
}
|
||||
|
||||
/// The ops are journaled: a crash right after forgetting reloads the same catalog, and a
|
||||
/// fingerprint survives the snapshot round trip (a reloaded record is still untouched).
|
||||
#[test]
|
||||
fn forgetting_is_journaled_and_replays() {
|
||||
let m = MemStore::new();
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
let mut ops = Vec::new();
|
||||
for i in 0..10 {
|
||||
local(&mut c, if i % 2 == 0 { "old" } else { "new" }, i);
|
||||
}
|
||||
// log what was built so far: the ops are the records as added
|
||||
for p in c.photos() {
|
||||
ops.push(Op::AddPhoto { photo: Box::new((**p).clone()) });
|
||||
}
|
||||
ops.push(Op::SetBrowsed { folder: "/pics/old".into(), at: Some(OLD.into()) });
|
||||
ops.push(Op::SetBrowsed { folder: "/pics/new".into(), at: Some(NOW.into()) });
|
||||
browsed(&mut c, "old", OLD);
|
||||
browsed(&mut c, "new", NOW);
|
||||
j.append(&ops).unwrap();
|
||||
j.snapshot(&c).unwrap();
|
||||
drop(j);
|
||||
// reload from the snapshot: baselines still match
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
let p = c.forget_local_plan(NOW, 30, &|_| false);
|
||||
assert_eq!(p.evict.len(), 5, "{p:?}");
|
||||
let ops = p.ops();
|
||||
for op in &ops {
|
||||
c.apply(op.clone()).unwrap();
|
||||
}
|
||||
j.append(&ops).unwrap();
|
||||
let expect = c.to_snapshot();
|
||||
drop(j); // crash: replay the log
|
||||
let (_, c2, r) = Journal::open(Box::new(m)).unwrap();
|
||||
assert_eq!(r.failed, 0);
|
||||
assert_eq!(c2.to_snapshot(), expect);
|
||||
assert_eq!(c2.len(), 5);
|
||||
// nothing left to forget
|
||||
assert!(c2.forget_local_plan(NOW, 30, &|_| false).evict.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn op_photo_ids_cover_nested_ops() {
|
||||
let op = Op::Batch {
|
||||
ops: vec![
|
||||
Op::SetRating { id: PhotoId(1), rating: 1 },
|
||||
Op::SetAlbumPhotos { id: AlbumId(1), photos: vec![PhotoId(2), PhotoId(3)] },
|
||||
Op::Batch { ops: vec![Op::RemovePhoto { id: PhotoId(4) }] },
|
||||
],
|
||||
};
|
||||
let mut ids = Vec::new();
|
||||
op.photo_ids(&mut |id| ids.push(id.0));
|
||||
assert_eq!(ids, vec![1, 2, 3, 4]);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Issue #99: one process per library.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::lock::{LOCK, OWNER};
|
||||
use crate::*;
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-lock-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_opener_is_refused_until_the_first_lets_go() {
|
||||
let dir = temp_dir("twice");
|
||||
let first = LibraryLock::acquire(&dir, "LightCraft").unwrap();
|
||||
assert!(first.held());
|
||||
let owner: LockOwner = serde_json::from_slice(&std::fs::read(dir.join(OWNER)).unwrap()).unwrap();
|
||||
assert_eq!((owner.pid, owner.program.as_str()), (std::process::id(), "LightCraft"));
|
||||
|
||||
let e = LibraryLock::acquire(&dir, "lightcraft-cli").unwrap_err();
|
||||
let LockError::InUse(Some(who)) = &e else { panic!("{e:?}") };
|
||||
assert_eq!(who.pid, std::process::id());
|
||||
let msg = e.to_string();
|
||||
assert!(msg.contains("already open in LightCraft") && msg.contains(&format!("process {}", std::process::id())), "{msg}");
|
||||
|
||||
drop(first);
|
||||
assert!(!dir.join(OWNER).exists(), "the owner note goes with the lock");
|
||||
assert!(dir.join(LOCK).exists(), "the lock file itself may stay: only a held lock refuses");
|
||||
let again = LibraryLock::acquire(&dir, "lightcraft-cli").unwrap();
|
||||
assert!(again.held());
|
||||
drop(again);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Files left behind by a crash (lock file + owner note) don't lock anyone out.
|
||||
#[test]
|
||||
fn leftover_lock_files_are_not_a_lock() {
|
||||
let dir = temp_dir("stale");
|
||||
std::fs::write(dir.join(LOCK), b"").unwrap();
|
||||
std::fs::write(dir.join(OWNER), br#"{"pid":999999,"host":"elsewhere","program":"LightCraft","version":"0.2.0","since":1}"#).unwrap();
|
||||
let l = LibraryLock::acquire(&dir, "LightCraft").unwrap();
|
||||
assert!(l.held());
|
||||
let owner: LockOwner = serde_json::from_slice(&std::fs::read(dir.join(OWNER)).unwrap()).unwrap();
|
||||
assert_eq!(owner.pid, std::process::id(), "the note now names this process");
|
||||
drop(l);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Another process holds the lock; when it is killed (a crash), the OS releases it.
|
||||
#[test]
|
||||
fn lock_held_by_another_process_is_released_when_it_dies() {
|
||||
let dir = temp_dir("child");
|
||||
let exe = std::env::current_exe().unwrap();
|
||||
let mut child = std::process::Command::new(exe)
|
||||
.args(["--exact", "tests_lock::lock_holder_child", "--ignored", "--test-threads=1"])
|
||||
.env("LC_LOCK_CHILD_DIR", &dir)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
while !dir.join("child-locked").exists() {
|
||||
assert!(child.try_wait().unwrap().is_none(), "child exited before locking");
|
||||
assert!(std::time::Instant::now() < deadline, "child never locked");
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
let e = LibraryLock::acquire(&dir, "LightCraft").unwrap_err();
|
||||
let LockError::InUse(Some(who)) = &e else { panic!("{e:?}") };
|
||||
assert_eq!(who.pid, child.id());
|
||||
assert!(e.to_string().contains("on this computer") || who.host.is_empty(), "{e}");
|
||||
|
||||
child.kill().unwrap(); // SIGKILL: no clean-up runs, like a crash
|
||||
child.wait().unwrap();
|
||||
assert!(dir.join(OWNER).exists(), "the crashed holder's note is left behind");
|
||||
let l = LibraryLock::acquire(&dir, "LightCraft").expect("the OS released the dead process's lock");
|
||||
assert!(l.held());
|
||||
drop(l);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Run by [`lock_held_by_another_process_is_released_when_it_dies`] in a child process: take the
|
||||
/// lock, say so, and wait to be killed.
|
||||
#[test]
|
||||
#[ignore = "helper process for lock_held_by_another_process_is_released_when_it_dies"]
|
||||
fn lock_holder_child() {
|
||||
let Some(dir) = std::env::var_os("LC_LOCK_CHILD_DIR") else { return };
|
||||
let _l = LibraryLock::acquire(std::path::Path::new(&dir), "lightcraft-cli").unwrap();
|
||||
std::fs::write(std::path::Path::new(&dir).join("child-locked"), b"").unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_secs(120));
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Issue #101: an append that fails part-way (disk full, a dropped network share) and is then
|
||||
//! retried must not read back as log damage, and must lose nothing.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::journal::LOG;
|
||||
use crate::*;
|
||||
|
||||
/// A store whose next append writes only a prefix of its data, then fails.
|
||||
#[derive(Clone, Default)]
|
||||
struct Torn {
|
||||
files: MemStore,
|
||||
/// The next append writes this many bytes, then returns an error.
|
||||
torn_at: Arc<Mutex<Option<usize>>>,
|
||||
/// `truncate` fails (the cut-back after a failed append can't be done right away).
|
||||
truncate_fails: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Torn {
|
||||
fn tear_next_append(&self, bytes: usize) {
|
||||
*self.torn_at.lock().unwrap() = Some(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for Torn {
|
||||
fn read(&mut self, name: &str) -> std::io::Result<Option<Vec<u8>>> {
|
||||
self.files.read(name)
|
||||
}
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.files.write_atomic(name, data)
|
||||
}
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
if let Some(n) = self.torn_at.lock().unwrap().take() {
|
||||
self.files.append(name, &data[..n.min(data.len())])?;
|
||||
return Err(std::io::Error::other("disk full"));
|
||||
}
|
||||
self.files.append(name, data)
|
||||
}
|
||||
fn truncate(&mut self, name: &str, len: u64) -> std::io::Result<()> {
|
||||
if self.truncate_fails.load(Ordering::SeqCst) {
|
||||
return Err(std::io::Error::other("share unavailable"));
|
||||
}
|
||||
self.files.truncate(name, len)
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"torn".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn add_photo(c: &mut Catalog) -> Op {
|
||||
let id = c.alloc_photo_id();
|
||||
Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, "p.jpg", "JPEG", 4, 3, "2026-01-01")) }
|
||||
}
|
||||
|
||||
/// Apply `ops` to the live catalog (like a command) and return them for the journal.
|
||||
fn apply(c: &mut Catalog, ops: Vec<Op>) -> Vec<Op> {
|
||||
for op in &ops {
|
||||
c.apply(op.clone()).unwrap();
|
||||
}
|
||||
ops
|
||||
}
|
||||
|
||||
/// Every tear position of a batch, then the retry (same ops, same seqs, like the engine's queue)
|
||||
/// and more appends: reloading replays everything, reports no damage and no torn bytes.
|
||||
#[test]
|
||||
fn partial_append_then_retry_loads_cleanly() {
|
||||
let probe = MemStore::new();
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(probe.clone())).unwrap();
|
||||
let add = add_photo(&mut c);
|
||||
let first = apply(&mut c, vec![add]);
|
||||
j.append(&first).unwrap();
|
||||
let id = c.photos().next().unwrap().id;
|
||||
let batch = apply(&mut c, vec![Op::SetRating { id, rating: 3 }, Op::SetFlag { id, flag: Flag::Pick }, Op::SetRating { id, rating: 4 }]);
|
||||
let before = probe.get(LOG).unwrap().len();
|
||||
j.append(&batch).unwrap();
|
||||
let batch_len = probe.get(LOG).unwrap().len() - before;
|
||||
|
||||
for tear in 1..batch_len {
|
||||
let t = Torn::default();
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(t.clone())).unwrap();
|
||||
let add = add_photo(&mut c);
|
||||
let first = apply(&mut c, vec![add]);
|
||||
j.append(&first).unwrap();
|
||||
let good_len = t.files.get(LOG).unwrap().len();
|
||||
let batch = apply(&mut c, vec![Op::SetRating { id, rating: 3 }, Op::SetFlag { id, flag: Flag::Pick }, Op::SetRating { id, rating: 4 }]);
|
||||
t.tear_next_append(tear);
|
||||
assert!(j.append(&batch).is_err(), "tear at {tear}");
|
||||
// cut back to the last whole record
|
||||
assert_eq!(t.files.get(LOG).unwrap().len(), good_len, "tear at {tear}");
|
||||
j.append(&batch).unwrap();
|
||||
let more = apply(&mut c, vec![Op::SetLabel { id, label: Some(ColorLabel::ALL[0]) }]);
|
||||
j.append(&more).unwrap();
|
||||
drop(j);
|
||||
|
||||
let (_, loaded, r) = Journal::open(Box::new(t.clone())).unwrap();
|
||||
assert!(r.damaged.is_none(), "tear at {tear}: {r:?}");
|
||||
assert_eq!((r.torn_bytes, r.torn_fragments, r.replayed), (0, 0, 5), "tear at {tear}");
|
||||
assert_eq!(loaded.to_snapshot(), c.to_snapshot(), "tear at {tear}");
|
||||
}
|
||||
}
|
||||
|
||||
/// When cutting back fails too (the share is gone), the next append cuts back first; until that
|
||||
/// works nothing more is appended after the fragment.
|
||||
#[test]
|
||||
fn failed_cut_back_is_done_before_the_next_append() {
|
||||
let t = Torn::default();
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(t.clone())).unwrap();
|
||||
let add = add_photo(&mut c);
|
||||
let first = apply(&mut c, vec![add]);
|
||||
j.append(&first).unwrap();
|
||||
let good_len = t.files.get(LOG).unwrap().len();
|
||||
let id = c.photos().next().unwrap().id;
|
||||
let batch = apply(&mut c, vec![Op::SetRating { id, rating: 2 }, Op::SetRating { id, rating: 5 }]);
|
||||
t.truncate_fails.store(true, Ordering::SeqCst);
|
||||
t.tear_next_append(30);
|
||||
assert!(j.append(&batch).is_err());
|
||||
assert_eq!(t.files.get(LOG).unwrap().len(), good_len + 30, "the fragment is still there");
|
||||
// still unreachable: the retry fails without writing anything after the fragment
|
||||
assert!(j.append(&batch).is_err());
|
||||
assert_eq!(t.files.get(LOG).unwrap().len(), good_len + 30);
|
||||
t.truncate_fails.store(false, Ordering::SeqCst);
|
||||
j.append(&batch).unwrap();
|
||||
drop(j);
|
||||
let (_, loaded, r) = Journal::open(Box::new(t.clone())).unwrap();
|
||||
assert!(r.damaged.is_none(), "{r:?}");
|
||||
assert_eq!(r.replayed, 3);
|
||||
assert_eq!(loaded.to_snapshot(), c.to_snapshot());
|
||||
}
|
||||
|
||||
/// Logs written before the fix: the fragment of the failed append is glued to the first retried
|
||||
/// record on one line, followed by the rest of the retry. They load in full (the fragment is
|
||||
/// skipped), instead of stopping at the fragment and setting the later records aside as damage.
|
||||
#[test]
|
||||
fn legacy_fragment_glued_to_the_retry_is_skipped() {
|
||||
let m = MemStore::new();
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
let add = add_photo(&mut c);
|
||||
let first = apply(&mut c, vec![add]);
|
||||
j.append(&first).unwrap();
|
||||
let good = m.get(LOG).unwrap();
|
||||
let id = c.photos().next().unwrap().id;
|
||||
let batch = apply(
|
||||
&mut c,
|
||||
vec![
|
||||
Op::SetRating { id, rating: 3 },
|
||||
Op::SetFlag { id, flag: Flag::Pick },
|
||||
Op::SetLabelName { label: ColorLabel::ALL[1], name: Some("Größe ✓".into()) },
|
||||
],
|
||||
);
|
||||
j.append(&batch).unwrap();
|
||||
let full = m.get(LOG).unwrap();
|
||||
let batch_bytes = &full[good.len()..];
|
||||
let later = apply(&mut c, vec![Op::SetRating { id, rating: 1 }]);
|
||||
j.append(&later).unwrap();
|
||||
let later_bytes = m.get(LOG).unwrap()[full.len()..].to_vec();
|
||||
drop(j);
|
||||
|
||||
// every tear (inside the first record, after it, inside a multi-byte character…)
|
||||
for tear in 1..batch_bytes.len() {
|
||||
if batch_bytes[tear - 1] == b'\n' {
|
||||
continue; // a tear on a record boundary leaves no fragment
|
||||
}
|
||||
let mut log = good.clone();
|
||||
log.extend_from_slice(&batch_bytes[..tear]);
|
||||
log.extend_from_slice(batch_bytes);
|
||||
log.extend_from_slice(&later_bytes);
|
||||
let t = MemStore::new();
|
||||
t.set(LOG, log);
|
||||
let (_, loaded, r) = Journal::open(Box::new(t)).unwrap();
|
||||
assert!(r.damaged.is_none(), "tear at {tear}: {r:?}");
|
||||
assert_eq!(r.torn_fragments, 1, "tear at {tear}");
|
||||
assert_eq!(loaded.to_snapshot(), c.to_snapshot(), "tear at {tear}");
|
||||
}
|
||||
|
||||
// a damaged record whose tail happens to be a whole record from the future is still damage
|
||||
let mut log = good.clone();
|
||||
log.extend_from_slice(b"garbage");
|
||||
log.extend_from_slice(&later_bytes);
|
||||
log.extend_from_slice(&later_bytes);
|
||||
let t = MemStore::new();
|
||||
t.set(LOG, log);
|
||||
let (_, _, r) = Journal::open(Box::new(t)).unwrap();
|
||||
assert!(r.damaged.is_some(), "{r:?}");
|
||||
}
|
||||
|
||||
/// A snapshot can absorb ops whose append failed: it records them as saved (`seq` moves past
|
||||
/// them), and appending after it continues from there.
|
||||
#[test]
|
||||
fn snapshot_with_unlogged_ops_saves_them() {
|
||||
let t = Torn::default();
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(t.clone())).unwrap();
|
||||
let add = add_photo(&mut c);
|
||||
let first = apply(&mut c, vec![add]);
|
||||
j.append(&first).unwrap();
|
||||
let id = c.photos().next().unwrap().id;
|
||||
let queued = apply(&mut c, vec![Op::SetRating { id, rating: 2 }, Op::SetFlag { id, flag: Flag::Reject }]);
|
||||
t.tear_next_append(10);
|
||||
assert!(j.append(&queued).is_err());
|
||||
j.snapshot_with_unlogged(&c, queued.len() as u64).unwrap();
|
||||
assert_eq!(j.seq(), 3);
|
||||
let after = apply(&mut c, vec![Op::SetRating { id, rating: 5 }]);
|
||||
j.append(&after).unwrap();
|
||||
drop(j);
|
||||
let (j, loaded, r) = Journal::open(Box::new(t.clone())).unwrap();
|
||||
assert_eq!((r.snapshot_seq, r.replayed), (3, 1));
|
||||
assert_eq!(j.seq(), 4);
|
||||
assert_eq!(loaded.to_snapshot(), c.to_snapshot());
|
||||
}
|
||||
|
||||
/// `FsStore::truncate` on a file that was never created (an append that failed first) is a no-op.
|
||||
#[test]
|
||||
fn fs_store_truncates_a_missing_file_quietly() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-torn-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut s = FsStore::open(&dir).unwrap();
|
||||
s.truncate(LOG, 0).unwrap();
|
||||
assert!(!dir.join(LOG).exists());
|
||||
s.append(LOG, b"abc\n").unwrap();
|
||||
s.truncate(LOG, 0).unwrap();
|
||||
assert_eq!(std::fs::read(dir.join(LOG)).unwrap(), b"");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -323,7 +323,9 @@ fn cmyk(raw: &Raw, ch: usize, icc_bytes: Option<&[u8]>, info: Option<&icc::IccIn
|
||||
}
|
||||
// Naive: RGB = (1 - C)(1 - K) … treated as sRGB-encoded.
|
||||
let px = samples
|
||||
.chunks_exact(4)
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let k = 1.0 - c[3];
|
||||
[(1.0 - c[0]) * k, (1.0 - c[1]) * k, (1.0 - c[2]) * k].map(lightcraft_color::transfer::srgb_to_linear)
|
||||
|
||||
@@ -91,7 +91,7 @@ pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsamplin
|
||||
let n = img.width as usize * img.height as usize;
|
||||
let (data, ct): (std::borrow::Cow<[u8]>, jpeg_encoder::ColorType) = match img.channels {
|
||||
1 => (data[..n].into(), jpeg_encoder::ColorType::Luma),
|
||||
2 => (data.chunks_exact(2).take(n).map(|c| c[0]).collect::<Vec<_>>().into(), jpeg_encoder::ColorType::Luma),
|
||||
2 => (data.as_chunks::<2>().0.iter().take(n).map(|c| c[0]).collect::<Vec<_>>().into(), jpeg_encoder::ColorType::Luma),
|
||||
3 => (data[..n * 3].into(), jpeg_encoder::ColorType::Rgb),
|
||||
_ => (data[..n * 4].into(), jpeg_encoder::ColorType::Rgba),
|
||||
};
|
||||
@@ -254,7 +254,7 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod
|
||||
}};
|
||||
}
|
||||
fn expand_ga<T: Copy>(s: &[T], n: usize) -> Vec<T> {
|
||||
s.chunks_exact(2).take(n).flat_map(|c| [c[0], c[0], c[0], c[1]]).collect()
|
||||
s.as_chunks::<2>().0.iter().take(n).flat_map(|c| [c[0], c[0], c[0], c[1]]).collect()
|
||||
}
|
||||
match img.samples {
|
||||
Samples::U8(s) => match ch {
|
||||
|
||||
@@ -236,7 +236,7 @@ pub(crate) fn cms_to_linear_rec2020(icc: &[u8], samples: &[f32], channels: usize
|
||||
for (s, d) in src_chunks.zip(dst_chunks) {
|
||||
t.transform(s, d).ok()?;
|
||||
}
|
||||
Some(out.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect())
|
||||
Some(out.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect())
|
||||
};
|
||||
std::panic::catch_unwind(run).ok().flatten()
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>)
|
||||
let (model, buf, depth) = match info.pixel_format {
|
||||
PixelFormat::L8 => (Model::Gray, Buf::U8(px), 8),
|
||||
PixelFormat::L16 => {
|
||||
let v = px.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
|
||||
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::RGB24 => (Model::Rgb, Buf::U8(px), 8),
|
||||
@@ -226,7 +226,7 @@ fn mpf_images(m: &Markers) -> Vec<(usize, usize)> {
|
||||
let Some(e) = entries.iter().find(|e| e.tag == 0xB002) else { return vec![] };
|
||||
let Some(list) = t.bytes(e) else { return vec![] };
|
||||
let mut out = Vec::new();
|
||||
for (i, rec) in list.chunks_exact(16).enumerate().take(16) {
|
||||
for (i, rec) in list.as_chunks::<16>().0.iter().enumerate().take(16) {
|
||||
if i == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -274,7 +274,7 @@ pub fn encode(
|
||||
{
|
||||
let rows: Vec<(usize, Vec<&mut [f32]>)> = {
|
||||
let mut its: Vec<_> = full.iter_mut().map(|p| p.chunks_mut(width)).collect();
|
||||
(0..height).map(|y| (y, its.iter_mut().map(|it| it.next().unwrap()).collect())).collect()
|
||||
(0..height).map_while(|y| Some((y, its.iter_mut().map(|it| it.next()).collect::<Option<_>>()?))).collect()
|
||||
};
|
||||
let conv = |(y, mut out): (usize, Vec<&mut [f32]>)| {
|
||||
let src = &data[y * width * channels..(y + 1) * width * channels];
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//!
|
||||
//! No decoder panics on malformed input (property-tested).
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod convert;
|
||||
pub mod encode;
|
||||
|
||||
@@ -37,7 +37,7 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
};
|
||||
buf.truncate(out.buffer_size());
|
||||
let (samples, depth) = match out.bit_depth {
|
||||
png::BitDepth::Sixteen => (Buf::U16(buf.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect()), 16),
|
||||
png::BitDepth::Sixteen => (Buf::U16(buf.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect()), 16),
|
||||
_ => (Buf::U8(buf), info.bit_depth as u8),
|
||||
};
|
||||
let raw = Raw { width: out.width as usize, height: out.height as usize, model, alpha, premultiplied: false, buf: samples, bit_depth: depth };
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//!
|
||||
//! The pipeline's working space is **linear Rec.2020, D65** ([`WORKING`]).
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod cct;
|
||||
pub mod perceptual;
|
||||
@@ -125,14 +126,17 @@ pub const WORKING: RgbSpace = REC2020;
|
||||
|
||||
impl RgbSpace {
|
||||
/// Matrix taking linear RGB in this space to XYZ (white Y = 1) relative to its own white.
|
||||
/// Degenerate (collinear) primaries, which readers reject before building a space from a
|
||||
/// file, leave the primaries unscaled instead of panicking.
|
||||
pub fn to_xyz(&self) -> Mat3 {
|
||||
let p = [self.r.to_xyz(), self.g.to_xyz(), self.b.to_xyz()];
|
||||
let m = Mat3([[p[0][0], p[1][0], p[2][0]], [p[0][1], p[1][1], p[2][1]], [p[0][2], p[1][2], p[2][2]]]);
|
||||
let s = m.inverse().expect("primaries are independent").apply(self.white.to_xyz());
|
||||
let s = m.inverse().map_or([1.0; 3], |inv| inv.apply(self.white.to_xyz()));
|
||||
m.mul(&Mat3::diag(s[0], s[1], s[2]))
|
||||
}
|
||||
/// Inverse of [`to_xyz`](Self::to_xyz); the identity for degenerate primaries.
|
||||
pub fn from_xyz(&self) -> Mat3 {
|
||||
self.to_xyz().inverse().expect("invertible")
|
||||
self.to_xyz().inverse().unwrap_or(Mat3::IDENTITY)
|
||||
}
|
||||
/// Luminance weights (the Y row of `to_xyz`).
|
||||
pub fn luma(&self) -> [f64; 3] {
|
||||
@@ -154,7 +158,8 @@ pub fn bradford(src: Xy, dst: Xy) -> Mat3 {
|
||||
}
|
||||
let s = BRADFORD.apply(src.to_xyz());
|
||||
let d = BRADFORD.apply(dst.to_xyz());
|
||||
let inv = BRADFORD.inverse().expect("invertible");
|
||||
// a constant, invertible matrix: the fallback (no adaptation) can't happen
|
||||
let Some(inv) = BRADFORD.inverse() else { return Mat3::IDENTITY };
|
||||
inv.mul(&Mat3::diag(d[0] / s[0], d[1] / s[1], d[2] / s[2])).mul(&BRADFORD)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,9 +28,10 @@ fn mats() -> &'static OkMats {
|
||||
let to_lms = XYZ_TO_LMS.mul(&REC2020.to_xyz());
|
||||
OkMats {
|
||||
to_lms: to_lms.to_f32(),
|
||||
from_lms: to_lms.inverse().expect("invertible").to_f32(),
|
||||
// constant, invertible matrices (round trips tested below): the identity fallback can't happen
|
||||
from_lms: to_lms.inverse().unwrap_or(Mat3::IDENTITY).to_f32(),
|
||||
to_lab: LMS_TO_LAB.to_f32(),
|
||||
from_lab: LMS_TO_LAB.inverse().expect("invertible").to_f32(),
|
||||
from_lab: LMS_TO_LAB.inverse().unwrap_or(Mat3::IDENTITY).to_f32(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! The pipeline evaluates it; the catalog stores it; presets, copy/paste, sync, versions and history
|
||||
//! are operations on it. Numeric sliders are addressed by id through [`controls`].
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod controls;
|
||||
pub mod presets;
|
||||
@@ -105,6 +106,7 @@ mod tests {
|
||||
s.masks.push(Mask {
|
||||
id: 1,
|
||||
components: vec![MaskComponent {
|
||||
name: None,
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::Radial {
|
||||
|
||||
@@ -208,8 +208,29 @@ impl Preset {
|
||||
pub fn from_settings(id: &str, name: &str, group: &str, s: &DevelopSettings, groups: &[SettingsGroup]) -> Preset {
|
||||
Preset { id: id.into(), name: name.into(), group: group.into(), settings: extract_groups(s, groups), favorite: false, builtin: false }
|
||||
}
|
||||
/// Apply at `amount` (1.0 = 100 %). Masks in the preset are added to the photo's own (ones it
|
||||
/// already has are skipped), their effect scaled by the amount.
|
||||
pub fn apply(&self, s: &DevelopSettings, amount: f64) -> DevelopSettings {
|
||||
apply_partial(s, &self.settings, amount)
|
||||
let Some(masks) = self.settings.get("masks").and_then(Value::as_array).filter(|m| !m.is_empty()) else {
|
||||
return apply_partial(s, &self.settings, amount);
|
||||
};
|
||||
let mut rest = self.settings.clone();
|
||||
if let Some(o) = rest.as_object_mut() {
|
||||
o.remove("masks");
|
||||
}
|
||||
let mut out = apply_partial(s, &rest, amount);
|
||||
let mut next = out.masks.iter().map(|m| m.id).max().unwrap_or(0);
|
||||
for m in masks {
|
||||
let Ok(mut m) = serde_json::from_value::<crate::Mask>(m.clone()) else { continue };
|
||||
if out.masks.iter().any(|q| q.name == m.name && q.components == m.components) {
|
||||
continue;
|
||||
}
|
||||
next += 1;
|
||||
m.id = next;
|
||||
m.adjust.amount = (m.adjust.amount * amount).clamp(0.0, 200.0);
|
||||
out.masks.push(m);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -786,6 +786,9 @@ pub enum MaskShape {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MaskComponent {
|
||||
/// A name given in the Masking panel (Rename); `None` shows the shape's kind.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub op: MaskOp,
|
||||
#[serde(default)]
|
||||
@@ -802,11 +805,18 @@ pub struct Mask {
|
||||
pub invert: bool,
|
||||
pub components: Vec<MaskComponent>,
|
||||
pub adjust: LocalAdjustments,
|
||||
/// Refine Edges 0..100: the mask's edges snap to the photo's (guided filter).
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
pub refine: f64,
|
||||
}
|
||||
|
||||
fn is_zero(v: &f64) -> bool {
|
||||
*v == 0.0
|
||||
}
|
||||
|
||||
impl Default for Mask {
|
||||
fn default() -> Self {
|
||||
Self { id: 0, name: "Mask 1".into(), visible: true, invert: false, components: Vec::new(), adjust: LocalAdjustments::default() }
|
||||
Self { id: 0, name: "Mask 1".into(), visible: true, invert: false, components: Vec::new(), adjust: LocalAdjustments::default(), refine: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,12 @@ lightcraft-scenes = { workspace = true }
|
||||
lightcraft-pipeline = { workspace = true }
|
||||
lightcraft-gpu = { workspace = true }
|
||||
lightcraft-catalog = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
lightcraft-preview = { workspace = true }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
lightcraft-raw = { workspace = true }
|
||||
lightcraft-meta = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
lightcraft-merge = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
//! The folder's photos are probed in place as `local` photos — only folder views list them —
|
||||
//! and edits go to the files' XMP sidecars like any photo added in place. "Add to My Photos"
|
||||
//! makes them ordinary library photos.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
@@ -35,12 +40,113 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
v
|
||||
};
|
||||
let report = import_with(s, &files, &ImportOptions { mode: ImportMode::Add, local: true, ..Default::default() })?;
|
||||
stamp_browsed(s, &dir_s, &files);
|
||||
s.browse = Some(Browse { path: dir_s.clone(), subfolders });
|
||||
s.source = LibrarySource::Folder;
|
||||
let shown = s.visible().len();
|
||||
Ok(json!({"path": dir_s, "subfolders": subfolders, "photos": shown, "new": report.imported.len(), "failed": report.failed.len()}))
|
||||
}
|
||||
|
||||
/// Folders whose browse time is refreshed at most this often (keeps repeated browsing from
|
||||
/// growing the log; far finer than the days the eviction counts in).
|
||||
const RESTAMP_SECS: i64 = 3600;
|
||||
|
||||
/// Record that `root` and the folders of `files` were browsed now (journaled, not an undo step).
|
||||
fn stamp_browsed(s: &mut Session, root: &str, files: &[String]) {
|
||||
let now = (s.clock)();
|
||||
let now_s = lightcraft_catalog::stacks::iso_seconds(&now);
|
||||
let mut folders: std::collections::BTreeSet<String> =
|
||||
files.iter().filter_map(|f| Path::new(f).parent().map(|d| d.to_string_lossy().trim_end_matches(['/', '\\']).to_string())).collect();
|
||||
folders.insert(root.to_string());
|
||||
let ops: Vec<Op> = folders
|
||||
.into_iter()
|
||||
.filter(|f| {
|
||||
let at = s.catalog.last_browsed(f).and_then(lightcraft_catalog::stacks::iso_seconds);
|
||||
match (at, now_s) {
|
||||
(Some(at), Some(n)) => n - at >= RESTAMP_SECS || n < at,
|
||||
_ => true,
|
||||
}
|
||||
})
|
||||
.map(|folder| Op::SetBrowsed { folder, at: Some(now.clone()) })
|
||||
.collect();
|
||||
if !ops.is_empty() {
|
||||
let op = Op::Batch { ops };
|
||||
if s.catalog.apply(op.clone()).is_ok() {
|
||||
s.pending_log.push(op);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
keep.extend(s.selection.ids.iter().copied());
|
||||
keep.extend(s.selection.active);
|
||||
keep.extend(s.previous_active);
|
||||
keep.extend(s.interaction.as_ref().map(|i| i.photo));
|
||||
keep.extend(s.before.keys().copied());
|
||||
for e in s.undo.iter().chain(s.redo.iter()) {
|
||||
e.op.photo_ids(&mut |id| {
|
||||
keep.insert(id);
|
||||
});
|
||||
}
|
||||
keep
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Forget untouched Local records whose folder wasn't browsed for `days` (default
|
||||
/// [`Session::forget_local_days`]; 0 = never). With `dry_run`, only report what would go.
|
||||
/// Journaled (durable, replayed after a crash) but not an undo step; nothing on disk changes.
|
||||
pub fn forget_local(&mut self, dry_run: bool, days: Option<u32>) -> lightcraft_catalog::ForgetPlan {
|
||||
let days = days.unwrap_or(self.forget_local_days);
|
||||
let now = (self.clock)();
|
||||
let keep = in_use(self);
|
||||
let smart = self.media.smart_dir.clone();
|
||||
let plan = self
|
||||
.catalog
|
||||
.forget_local_plan(&now, days, &|p| keep.contains(&p.id) || smart.as_ref().is_some_and(|d| d.join(crate::smart::file_name(p)).exists()));
|
||||
if dry_run {
|
||||
return plan;
|
||||
}
|
||||
for op in plan.ops() {
|
||||
match self.catalog.apply(op.clone()) {
|
||||
Ok(_) => self.pending_log.push(op),
|
||||
Err(e) => log::warn!("forget Local records: {e}"),
|
||||
}
|
||||
}
|
||||
if !plan.evict.is_empty() {
|
||||
log::info!("forgot {} untouched Local record(s) of folders not browsed for {days} days", plan.evict.len());
|
||||
}
|
||||
plan
|
||||
}
|
||||
}
|
||||
|
||||
fn forget_local(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dry_run = bool_or(p, "dryRun", false);
|
||||
let days = match p.get("days") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
Some(v.as_u64().ok_or_else(|| bad("library.forgetLocal", "days must be a whole number (0 = never)"))?.min(u64::from(u32::MAX)) as u32)
|
||||
}
|
||||
};
|
||||
let used = days.unwrap_or(s.forget_local_days);
|
||||
let plan = s.forget_local(dry_run, days);
|
||||
if !dry_run && !plan.evict.is_empty() {
|
||||
s.compact_soon();
|
||||
}
|
||||
Ok(json!({
|
||||
"dryRun": dry_run,
|
||||
"days": used,
|
||||
"local": plan.local,
|
||||
"forgotten": plan.evict.len(),
|
||||
"keptRecent": plan.kept_recent,
|
||||
"keptTouched": plan.kept_touched,
|
||||
"keptInUse": plan.kept_in_use,
|
||||
"foldersStamped": plan.stamp.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The browsed photos among `ids` (default: the selection) become library photos.
|
||||
fn add_to_library(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect();
|
||||
@@ -55,6 +161,40 @@ fn add_to_library(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"folder.rename",
|
||||
"Rename Folder",
|
||||
[],
|
||||
None,
|
||||
"{path, name} — rename a folder on disk (its files and sidecars go along) and relink the photos in it; not an undo step → {path, relinked}",
|
||||
always,
|
||||
|s, p| {
|
||||
let from = str_param(p, "path").ok_or_else(|| bad("folder.rename", "missing path"))?.trim_end_matches(['/', '\\']).to_string();
|
||||
let name = str_param(p, "name")
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty() && !n.contains(['/', '\\']) && *n != "." && *n != "..")
|
||||
.ok_or_else(|| bad("folder.rename", "give a folder name (no slashes)"))?;
|
||||
let to = Path::new(&from).with_file_name(name).to_string_lossy().to_string();
|
||||
let n = move_folder(s, &from, &to).map_err(|e| bad("folder.rename", e))?;
|
||||
Ok(json!({"path": to, "relinked": n}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"folder.move",
|
||||
"Move Folder",
|
||||
[],
|
||||
None,
|
||||
"{path, into: destination folder} — move a folder on disk into another and relink the photos in it; not an undo step → {path, relinked}",
|
||||
always,
|
||||
|s, p| {
|
||||
let from = str_param(p, "path").ok_or_else(|| bad("folder.move", "missing path"))?.trim_end_matches(['/', '\\']).to_string();
|
||||
let into = str_param(p, "into").ok_or_else(|| bad("folder.move", "missing into"))?;
|
||||
let name = Path::new(&from).file_name().ok_or_else(|| bad("folder.move", "bad path"))?;
|
||||
let to = Path::new(into).join(name).to_string_lossy().to_string();
|
||||
let n = move_folder(s, &from, &to).map_err(|e| bad("folder.move", e))?;
|
||||
Ok(json!({"path": to, "relinked": n}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"library.browse",
|
||||
"Browse Folder",
|
||||
@@ -64,6 +204,71 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
browse
|
||||
),
|
||||
cmd!(
|
||||
"library.forgetLocal",
|
||||
"Forget Unchanged Local Photos",
|
||||
[],
|
||||
None,
|
||||
"{dryRun?: bool, days?: n (default: the library preference, 0 = never)} — remove Local (browsed, not added) photos nobody changed, of folders not browsed for `days`, from the catalog; files and sidecars stay, browsing the folder again brings them back; not an undo step → {local, forgotten, keptRecent, keptTouched, keptInUse, foldersStamped, days, dryRun}",
|
||||
always,
|
||||
forget_local
|
||||
),
|
||||
cmd!("photo.addToLibrary", "Add to My Photos", ["Photo"], None, "{ids?} — browsed (Local) photos join the library", always, add_to_library),
|
||||
]
|
||||
}
|
||||
|
||||
/// Rename or move a folder on disk (everything in it goes along, sidecars included) and relink
|
||||
/// the photos inside. Like a rename in the file manager it is not an undo step: undoing would
|
||||
/// point the photos back at a folder that no longer exists.
|
||||
pub(crate) fn move_folder(s: &mut crate::Session, from: &str, to: &str) -> std::result::Result<usize, String> {
|
||||
use std::path::Path;
|
||||
let (src, dst) = (Path::new(from), Path::new(to));
|
||||
if !src.is_dir() {
|
||||
return Err(format!("{from} is not a folder"));
|
||||
}
|
||||
if dst.exists() {
|
||||
return Err(format!("{to} already exists"));
|
||||
}
|
||||
if dst.starts_with(src) {
|
||||
return Err("a folder can't move into itself".into());
|
||||
}
|
||||
if s.library.as_ref().is_some_and(|l| l.dir.starts_with(src) || src.starts_with(&l.dir)) {
|
||||
return Err("the library's own folders can't be moved here".into());
|
||||
}
|
||||
if let Some(parent) = dst.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
}
|
||||
std::fs::rename(src, dst).map_err(|e| format!("{from} → {to}: {e}"))?;
|
||||
let mut n = 0;
|
||||
let ops: Vec<lightcraft_catalog::Op> = s
|
||||
.catalog
|
||||
.photos()
|
||||
.filter_map(|p| match &p.source {
|
||||
lightcraft_catalog::Source::File { path } => Path::new(path).strip_prefix(src).ok().map(|rel| (p.id, p.file_name.clone(), dst.join(rel))),
|
||||
_ => None,
|
||||
})
|
||||
.map(|(id, file_name, np)| {
|
||||
n += 1;
|
||||
lightcraft_catalog::Op::Relink {
|
||||
id,
|
||||
file_name,
|
||||
source: lightcraft_catalog::Source::File { path: np.to_string_lossy().to_string() },
|
||||
format: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for op in ops {
|
||||
if s.catalog.apply(op.clone()).is_ok() {
|
||||
s.pending_log.push(op);
|
||||
}
|
||||
}
|
||||
// a browsed folder at or below it follows
|
||||
if let Some(b) = &mut s.browse
|
||||
&& let Ok(rel) = Path::new(&b.path).strip_prefix(src)
|
||||
{
|
||||
b.path = dst.join(rel).to_string_lossy().to_string();
|
||||
let f = b.path.clone();
|
||||
s.filter.folder = Some(f);
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Convert to DNG: a raw photo's original is re-encoded as a lossless DNG (its develop settings
|
||||
//! embedded as XMP) next to it, and the photo is relinked to the DNG. The original stays on
|
||||
//! disk; undo relinks the photo to it.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use lightcraft_catalog::{MediaKind, Op, Source};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_active, has_selection};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// `<stem>.dng`, then `<stem>-2.dng`, `<stem>-3.dng`… next to `path`.
|
||||
fn dng_names(path: &str) -> impl Iterator<Item = std::path::PathBuf> {
|
||||
let p = Path::new(path);
|
||||
let dir = p.parent().unwrap_or(Path::new("")).to_path_buf();
|
||||
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into());
|
||||
(1u64..).map(move |n| if n == 1 { dir.join(format!("{stem}.dng")) } else { dir.join(format!("{stem}-{n}.dng")) })
|
||||
}
|
||||
|
||||
/// Write the DNG for raw file `path` (develop settings in `packet`) next to it under a free name;
|
||||
/// returns the new path. An empty `packet` writes no XMP.
|
||||
///
|
||||
/// The DNG is checked before anyone relies on it (Convert to DNG relinks the photo to it, Copy as
|
||||
/// DNG deletes the raw copy): the encoded file must decode to the same raw data, and it is
|
||||
/// written to a temp file, synced, read back identical and only then given its name — never
|
||||
/// replacing an existing file. On any failure no DNG is left behind and the raw stays in use.
|
||||
pub(crate) fn write_dng_for(s: &Session, path: &str, packet: String) -> std::result::Result<String, String> {
|
||||
let bytes = match &s.media.file_bytes {
|
||||
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}"))?;
|
||||
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())?;
|
||||
let back = lightcraft_raw::decode(&dng).map_err(|e| format!("{path}: the DNG written for it doesn't decode ({e}); the raw is kept"))?;
|
||||
let same = match (&back.data, &raw.data) {
|
||||
(lightcraft_raw::RawData::U16(a), lightcraft_raw::RawData::U16(b)) => a == b,
|
||||
(lightcraft_raw::RawData::F32(a), lightcraft_raw::RawData::F32(b)) => {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.to_bits() == y.to_bits())
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if (back.width, back.height, back.cpp) != (raw.width, raw.height, raw.cpp) || !same {
|
||||
return Err(format!("{path}: the DNG written for it doesn't hold the same raw data; the raw is kept"));
|
||||
}
|
||||
drop((raw, back));
|
||||
let out = lightcraft_catalog::safe_file::write_new_unique(&mut dng_names(path), &dng)
|
||||
.map_err(|e| format!("{path}: could not write its DNG ({e}); the raw is kept"))?;
|
||||
Ok(out.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.convertToDng";
|
||||
let ids = s.targets(p);
|
||||
let mut ops = Vec::new();
|
||||
let mut converted = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
for id in ids {
|
||||
let Some(ph) = s.catalog.photo(id).cloned() else { continue };
|
||||
let Source::File { path } = &ph.source else {
|
||||
skipped.push(json!([id.0, "not a file"]));
|
||||
continue;
|
||||
};
|
||||
if ph.kind != MediaKind::Raw || ph.format.eq_ignore_ascii_case("DNG") || ph.copy_of.is_some() {
|
||||
skipped.push(json!([
|
||||
id.0,
|
||||
if ph.kind != MediaKind::Raw {
|
||||
"not a raw photo"
|
||||
} else if ph.copy_of.is_some() {
|
||||
"a virtual copy"
|
||||
} else {
|
||||
"already a DNG"
|
||||
}
|
||||
]));
|
||||
continue;
|
||||
}
|
||||
let packet = crate::sidecar::sidecar_packet(&ph, &s.catalog);
|
||||
match write_dng_for(s, path, packet) {
|
||||
Ok(out) => {
|
||||
let file_name = Path::new(&out).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
// virtual copies of this photo follow it to the DNG
|
||||
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
|
||||
ops.push(Op::Relink {
|
||||
id: c.id,
|
||||
file_name: file_name.clone(),
|
||||
source: Source::File { path: out.clone() },
|
||||
format: Some("DNG".into()),
|
||||
});
|
||||
}
|
||||
ops.push(Op::Relink { id, file_name, source: Source::File { path: out.clone() }, format: Some("DNG".into()) });
|
||||
converted.push(json!({"id": id.0, "path": out, "original": path}));
|
||||
}
|
||||
Err(e) => skipped.push(json!([id.0, e])),
|
||||
}
|
||||
}
|
||||
if !ops.is_empty() {
|
||||
let n = converted.len();
|
||||
s.commit(&format!("Convert {n} Photo{} to DNG", if n == 1 { "" } else { "s" }), Op::Batch { ops }).map_err(|e| bad(C, e.to_string()))?;
|
||||
}
|
||||
Ok(json!({"converted": converted, "skipped": skipped}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"photo.convertToDng",
|
||||
"Convert to DNG",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?} — write each raw photo as a lossless DNG next to it (settings embedded) and relink the photo to it; originals are kept → {converted: [{id, path, original}], skipped}",
|
||||
has_selection,
|
||||
convert
|
||||
)]
|
||||
}
|
||||
|
||||
/// Edit in an external editor, the engine half: render the photo with its edits as a 16-bit
|
||||
/// TIFF next to the original (`<name>-Edit.tif`, never overwriting), add it to the library and
|
||||
/// stack it on top of the original. The host opens the file in the editor.
|
||||
fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.editExternal";
|
||||
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
|
||||
let ph = s.catalog.photo(id).cloned().ok_or_else(|| bad(C, "no photo"))?;
|
||||
let dir = match &ph.source {
|
||||
Source::File { path } => Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(),
|
||||
Source::Demo { .. } => match super::str_param(p, "dir") {
|
||||
Some(d) => d.to_string(),
|
||||
None => return Err(bad(C, "a generated demo photo has no folder: give `dir`")),
|
||||
},
|
||||
};
|
||||
let space = match super::str_param(p, "colorSpace").unwrap_or("adobeRgb") {
|
||||
"srgb" => lightcraft_pipeline::OutputSpace::Srgb,
|
||||
"displayP3" => lightcraft_pipeline::OutputSpace::DisplayP3,
|
||||
"prophoto" | "proPhoto" => lightcraft_pipeline::OutputSpace::ProPhoto,
|
||||
_ => lightcraft_pipeline::OutputSpace::AdobeRgb,
|
||||
};
|
||||
let opts = crate::export::ExportOptions {
|
||||
format: crate::export::ExportFormat::Tiff,
|
||||
bit_depth: Some(16),
|
||||
color_space: space,
|
||||
naming: "{name}-Edit".into(),
|
||||
conflict: crate::export::Conflict::Unique,
|
||||
..Default::default()
|
||||
};
|
||||
let mut written = Vec::new();
|
||||
let mut write = |path: &str, bytes: &[u8]| -> std::result::Result<(), String> {
|
||||
crate::export::write_file(path, bytes)?;
|
||||
written.push(path.to_string());
|
||||
Ok(())
|
||||
};
|
||||
let to = crate::export::Destination { dir, exact: None };
|
||||
crate::export::export_batch(s, &[id], &opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(C, e))?;
|
||||
let out = written.into_iter().find(|w| w.ends_with(".tif")).ok_or_else(|| bad(C, "nothing was written"))?;
|
||||
let r = s.execute("library.import", &json!({"paths": [out]}))?;
|
||||
let new = r["imported"].get(0).and_then(Value::as_u64).ok_or_else(|| bad(C, "the edit copy could not be added"))?;
|
||||
// stack: the edit on top of the original, expanded so both show
|
||||
let _ = s.execute("stack.group", &json!({"ids": [new, id.0], "top": new, "collapsed": false}));
|
||||
s.selection = crate::Selection::single(lightcraft_catalog::PhotoId(new));
|
||||
Ok(json!({"path": out, "id": new, "original": id.0}))
|
||||
}
|
||||
|
||||
/// 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`].
|
||||
pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: crate::media::ProbeInfo) -> Option<Op> {
|
||||
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;
|
||||
(!same).then_some(Op::SetContent {
|
||||
id,
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
file_size: info.file_size,
|
||||
content_hash: info.content_hash,
|
||||
preview_only: info.preview_only,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let ids = s.targets(p);
|
||||
let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids
|
||||
.iter()
|
||||
.filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) {
|
||||
Some(Source::File { path }) => Some((*id, path)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let probed = crate::import::probe_paths(s, &paths.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>());
|
||||
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 };
|
||||
let Some(op) = content_op(id, ph, info.clone()) else { continue };
|
||||
ops.push(op);
|
||||
// virtual copies share the file
|
||||
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
|
||||
ops.extend(content_op(c.id, c, info.clone()));
|
||||
reloaded.push(c.id);
|
||||
}
|
||||
reloaded.push(id);
|
||||
}
|
||||
for id in &reloaded {
|
||||
s.media.forget(*id);
|
||||
}
|
||||
if !ops.is_empty() {
|
||||
s.commit("Reload Changed Files", Op::Batch { ops })?;
|
||||
}
|
||||
Ok(json!({"reloaded": reloaded.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Duplicate: copy the file next to itself (`<name>-copy`, never overwriting) and add it with the
|
||||
/// same settings, metadata, rating, flag, label and albums. → {ids}
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.duplicate";
|
||||
let mut made = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
let Some(ph) = s.catalog.photo(id).map(|p| (**p).clone()) else { continue };
|
||||
let Source::File { path } = &ph.source else { return Err(bad(C, "a generated demo photo has no file to duplicate")) };
|
||||
let src = Path::new(path);
|
||||
let stem = src.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into());
|
||||
let ext = src.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
|
||||
let dir = src.parent().unwrap_or(Path::new(""));
|
||||
let mut dst = dir.join(format!("{stem}-copy{ext}"));
|
||||
let mut n = 2;
|
||||
while dst.exists() {
|
||||
dst = dir.join(format!("{stem}-copy-{n}{ext}"));
|
||||
n += 1;
|
||||
}
|
||||
std::fs::copy(src, &dst).map_err(|e| bad(C, format!("{path}: {e}")))?;
|
||||
let new = s.catalog.alloc_photo_id();
|
||||
let mut q = ph.clone();
|
||||
q.id = new;
|
||||
q.source = Source::File { path: dst.to_string_lossy().to_string() };
|
||||
q.file_name = dst.file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
|
||||
// the same bytes, but its own photo: no duplicate-content clash, no shared history
|
||||
q.content_hash = q.content_hash.map(|h| format!("{h}:dup{}", new.0));
|
||||
q.copy_of = None;
|
||||
q.copy_name = None;
|
||||
q.history.clear();
|
||||
q.versions.retain(|v| !v.auto);
|
||||
let mut ops = vec![Op::AddPhoto { photo: Box::new(q) }];
|
||||
for a in s.catalog.albums().filter(|a| !a.is_smart() && !a.folder && a.photos.contains(&id)) {
|
||||
let mut photos = a.photos.clone();
|
||||
photos.push(new);
|
||||
ops.push(Op::SetAlbumPhotos { id: a.id, photos });
|
||||
}
|
||||
s.commit("Duplicate", Op::Batch { ops })?;
|
||||
made.push(new);
|
||||
}
|
||||
s.merge_undo(made.len(), "Duplicate");
|
||||
if let Some(last) = made.last() {
|
||||
s.selection = crate::Selection { ids: made.clone(), active: Some(*last) };
|
||||
}
|
||||
Ok(json!({"ids": made.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
pub fn edit_specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"photo.duplicate",
|
||||
"Duplicate",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?} — copy each photo's file next to it (`-copy`) and add it with the same settings, metadata and albums (a real file, unlike a virtual copy) → {ids}",
|
||||
has_selection,
|
||||
duplicate
|
||||
),
|
||||
cmd!(
|
||||
"photo.reload",
|
||||
"Reload from Disk",
|
||||
[],
|
||||
None,
|
||||
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now → {reloaded}",
|
||||
has_selection,
|
||||
reload
|
||||
),
|
||||
cmd!(
|
||||
"photo.editExternal",
|
||||
"Edit Copy for External Editor",
|
||||
[],
|
||||
None,
|
||||
"{colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, dir?} — render the active photo with its edits as a 16-bit TIFF `<name>-Edit.tif` next to it, add it stacked on the original and select it → {path, id, original} (the app then opens it in the external editor)",
|
||||
has_active,
|
||||
edit_external
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Assisted culling: score photos for focus and clipping, group similar shots taken close
|
||||
//! together (bursts) and mark the sharpest of each group; optionally reject blurry ones and pick
|
||||
//! each group's best. Scores are kept on the photos (rules / smart albums: `sharpness`,
|
||||
//! `bestOfGroup`).
|
||||
|
||||
use lightcraft_catalog::{Analysis, Flag, Op, PhotoId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, cmd};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Seconds between shots of the same burst at most.
|
||||
const BURST_GAP: i64 = 10;
|
||||
/// Signature similarity for "the same scene".
|
||||
const SAME: f32 = 0.93;
|
||||
|
||||
fn secs(iso: &str) -> Option<i64> {
|
||||
let d = lightcraft_meta::DateTime::parse_iso(iso)?;
|
||||
Some(((d.year as i64 * 372 + d.month as i64 * 31 + d.day as i64) * 24 + d.hour as i64) * 3600 + d.minute as i64 * 60 + d.second as i64)
|
||||
}
|
||||
|
||||
fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<PhotoId> = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() };
|
||||
let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32);
|
||||
let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false);
|
||||
// measure (thumbnail-level sources: fast, and enough for focus at 512 px)
|
||||
let mut rows: Vec<(PhotoId, Option<i64>, f32, f32, [f32; 64])> = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
for id in ids {
|
||||
match s.source_now(id, crate::media::SourceLevel::Thumb) {
|
||||
Ok(src) => {
|
||||
let t = s.catalog.photo(id).and_then(|p| p.captured.as_deref().and_then(secs));
|
||||
use lightcraft_pipeline::cull;
|
||||
rows.push((id, t, cull::sharpness(&src), cull::clipped(&src), cull::signature(&src)));
|
||||
}
|
||||
Err(e) => failed.push(json!([id.0, e])),
|
||||
}
|
||||
}
|
||||
// bursts: consecutive (by capture time) look-alike shots
|
||||
rows.sort_by_key(|r| (r.1.unwrap_or(i64::MAX), r.0));
|
||||
let mut group_of: Vec<Option<u32>> = vec![None; rows.len()];
|
||||
let mut next = 1u32;
|
||||
for i in 1..rows.len() {
|
||||
let (a, b) = (&rows[i - 1], &rows[i]);
|
||||
let close = matches!((a.1, b.1), (Some(x), Some(y)) if (y - x).abs() <= BURST_GAP);
|
||||
if close && lightcraft_pipeline::cull::similarity(&a.4, &b.4) >= SAME {
|
||||
let g = *group_of[i - 1].get_or_insert_with(|| {
|
||||
next += 1;
|
||||
next - 1
|
||||
});
|
||||
group_of[i] = Some(g);
|
||||
}
|
||||
}
|
||||
let mut best: std::collections::HashMap<u32, (usize, f32)> = Default::default();
|
||||
for (i, g) in group_of.iter().enumerate() {
|
||||
if let Some(g) = g {
|
||||
let e = best.entry(*g).or_insert((i, -1.0));
|
||||
if rows[i].2 > e.1 {
|
||||
*e = (i, rows[i].2);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ops = Vec::new();
|
||||
let (mut rejected, mut picked) = (0, 0);
|
||||
let mut out = Vec::new();
|
||||
for (i, (id, _, sharp, clip, _)) in rows.iter().enumerate() {
|
||||
let g = group_of[i];
|
||||
let is_best = g.is_some_and(|g| best.get(&g).is_some_and(|b| b.0 == i));
|
||||
ops.push(Op::SetAnalysis { id: *id, analysis: Some(Analysis { sharpness: *sharp, clipped: *clip, group: g, best: is_best }) });
|
||||
let flag = s.catalog.photo(*id).map(|p| p.flag).unwrap_or_default();
|
||||
if reject_below.is_some_and(|t| *sharp < t) && flag != Flag::Reject {
|
||||
ops.push(Op::SetFlag { id: *id, flag: Flag::Reject });
|
||||
rejected += 1;
|
||||
} else if pick_best && is_best && flag == Flag::None {
|
||||
ops.push(Op::SetFlag { id: *id, flag: Flag::Pick });
|
||||
picked += 1;
|
||||
}
|
||||
out.push(json!({"id": id.0, "sharpness": (sharp * 10.0).round() / 10.0, "clipped": clip, "group": g, "best": is_best}));
|
||||
}
|
||||
if !ops.is_empty() {
|
||||
s.commit("Assisted Culling", Op::Batch { ops })?;
|
||||
}
|
||||
Ok(json!({"photos": out, "groups": best.len(), "rejected": rejected, "picked": picked, "failed": failed}))
|
||||
}
|
||||
|
||||
/// Find Similar: photos that look like `id` (signatures from thumbnail-level sources, cached by
|
||||
/// content), most similar first; the view is filtered to them.
|
||||
fn find_similar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.findSimilar";
|
||||
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| super::bad(C, "no photo"))?;
|
||||
let min = p.get("similarity").and_then(Value::as_f64).unwrap_or(0.8) as f32;
|
||||
let all: Vec<PhotoId> = s.catalog.photos().filter(|q| q.in_library()).map(|q| q.id).collect();
|
||||
let sig = |s: &mut Session, id: PhotoId| -> Option<[f32; 64]> {
|
||||
let key = s.catalog.photo(id).map(|p| crate::media::content_key(p))?;
|
||||
if let Some(v) = s.signatures.get(&key) {
|
||||
return Some(*v);
|
||||
}
|
||||
let src = s.source_now(id, crate::media::SourceLevel::Thumb).ok()?;
|
||||
let v = lightcraft_pipeline::cull::signature(&src);
|
||||
s.signatures.insert(key, v);
|
||||
Some(v)
|
||||
};
|
||||
let me = sig(s, id).ok_or_else(|| super::bad(C, "can't read the photo"))?;
|
||||
let mut hits: Vec<(PhotoId, f32)> = Vec::new();
|
||||
for q in all {
|
||||
if let Some(v) = sig(s, q) {
|
||||
let sim = lightcraft_pipeline::cull::similarity(&me, &v);
|
||||
if sim >= min {
|
||||
hits.push((q, sim));
|
||||
}
|
||||
}
|
||||
}
|
||||
hits.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
if p.get("filter").and_then(Value::as_bool).unwrap_or(true) {
|
||||
s.filter.only = hits.iter().map(|h| h.0).collect();
|
||||
}
|
||||
Ok(json!({"photos": hits.iter().map(|(i, v)| json!({"id": i.0, "similarity": (v * 1000.0).round() / 1000.0})).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"library.findSimilar",
|
||||
"Find Similar Photos",
|
||||
["Photo"],
|
||||
None,
|
||||
"{id?, similarity?: 0..1 (0.8), filter?: bool (true)} — photos that look like the active one (composition and tones), most similar first; filters the view to them (Clear Filters to go back) → {photos: [{id, similarity}]}",
|
||||
always,
|
||||
find_similar
|
||||
),
|
||||
cmd!(
|
||||
"photo.analyze",
|
||||
"Assisted Culling",
|
||||
[],
|
||||
None,
|
||||
"{ids?, rejectBelow?: sharpness 0..100, pickBest?: bool} — score the selected photos (else all in view) for focus (0..100) and clipping, group look-alike shots taken within 10 s and mark the sharpest of each; optionally reject blurry photos and pick each group's best; one undo step → {photos: [{id, sharpness, clipped, group, best}], groups, rejected, picked}",
|
||||
always,
|
||||
analyze
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
//! Tone-curve commands: reset a channel or the whole curve; point-curve presets (built-in Linear /
|
||||
//! Medium / Strong Contrast plus the user's, saved with the library preferences, imported and
|
||||
//! exported as small JSON files).
|
||||
|
||||
use lightcraft_develop::ToneCurve;
|
||||
use lightcraft_geom::Point;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, has_active, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Channels `curve.reset` accepts.
|
||||
pub const RESET_CHANNELS: [&str; 7] = ["all", "point", "parametric", "master", "red", "green", "blue"];
|
||||
|
||||
/// `c` with `channel` back at its default: `all` (the whole Tone Curve, incl. Refine Saturation),
|
||||
/// `point` (the four point curves), `parametric` (region sliders and splits) or one point channel.
|
||||
pub fn reset_channel(c: &mut ToneCurve, channel: &str) -> std::result::Result<(), String> {
|
||||
let d = ToneCurve::default();
|
||||
match channel {
|
||||
"all" => *c = d,
|
||||
"point" => {
|
||||
c.master.clear();
|
||||
c.red.clear();
|
||||
c.green.clear();
|
||||
c.blue.clear();
|
||||
}
|
||||
"parametric" => {
|
||||
(c.highlights, c.lights, c.darks, c.shadows) = (d.highlights, d.lights, d.darks, d.shadows);
|
||||
(c.split_shadows, c.split_mid, c.split_highlights) = (d.split_shadows, d.split_mid, d.split_highlights);
|
||||
}
|
||||
"master" | "rgb" | "luma" => c.master.clear(),
|
||||
"red" => c.red.clear(),
|
||||
"green" => c.green.clear(),
|
||||
"blue" => c.blue.clear(),
|
||||
other => return Err(format!("unknown channel `{other}` (one of {})", RESET_CHANNELS.join(", "))),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.reset";
|
||||
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
|
||||
let channel = str_param(p, "channel").unwrap_or("all");
|
||||
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
reset_channel(&mut d.curve, channel).map_err(|e| bad(C, e))?;
|
||||
let label = if channel == "all" { "Reset Tone Curve".to_string() } else { format!("Reset Tone Curve ({channel})") };
|
||||
s.set_develop(id, d, &label)?;
|
||||
Ok(json!({"channel": channel}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"curve.reset",
|
||||
"Reset Tone Curve",
|
||||
[],
|
||||
None,
|
||||
"{channel?: all|point|parametric|master|red|green|blue (default all)} — the active photo's tone curve (or one channel) back to linear",
|
||||
has_active,
|
||||
reset
|
||||
),
|
||||
cmd!(
|
||||
query "curve.presets",
|
||||
"Point Curve Presets",
|
||||
[],
|
||||
None,
|
||||
"{} → {presets: [{name, master, red, green, blue: [[x,y],...], builtin?}], current: name of the preset the active photo's point curves match, or null}",
|
||||
always,
|
||||
|s, _| Ok(list(s))
|
||||
),
|
||||
cmd!(
|
||||
"curve.applyPreset",
|
||||
"Apply Point Curve Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} — sets the active photo's point curves (all four channels; the parametric curve stays)",
|
||||
has_active,
|
||||
apply_preset
|
||||
),
|
||||
cmd!(
|
||||
"curve.savePreset",
|
||||
"Save Point Curve Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} — the active photo's point curves under a name (replaces a user preset of that name; built-in names are reserved), saved with the library",
|
||||
has_active,
|
||||
save_preset
|
||||
),
|
||||
cmd!(
|
||||
"curve.deletePreset",
|
||||
"Delete Point Curve Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} — a user preset (built-ins can't be deleted)",
|
||||
always,
|
||||
delete_preset
|
||||
),
|
||||
cmd!(
|
||||
"curve.importPresets",
|
||||
"Import Point Curve Presets",
|
||||
[],
|
||||
None,
|
||||
"{path | paths: [file]} — .lccurve JSON files ({format, version, presets: [...]}, an array of presets, or one preset); same-named user presets are replaced → {imported: [name], failed: [[file, error]]}",
|
||||
always,
|
||||
import_presets
|
||||
),
|
||||
cmd!(
|
||||
"curve.exportPresets",
|
||||
"Export Point Curve Presets",
|
||||
[],
|
||||
None,
|
||||
"{path, names?: [name] (default: every user preset)} — writes a .lccurve JSON file → {path, count}",
|
||||
always,
|
||||
export_presets
|
||||
),
|
||||
]
|
||||
}
|
||||
// ---------------------------------------------------------------- point-curve presets
|
||||
|
||||
/// File extension of exported curve presets (JSON).
|
||||
pub const CURVE_PRESET_EXT: &str = "lccurve";
|
||||
/// `format` tag of an exported curve-preset file.
|
||||
const FILE_FORMAT: &str = "lightcraft.curvePresets";
|
||||
|
||||
/// A named set of point curves (master + red/green/blue, points in 0..1; empty = linear).
|
||||
/// Built-ins ship with the app; the user's are saved with the library preferences.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CurvePreset {
|
||||
pub name: String,
|
||||
pub master: Vec<[f64; 2]>,
|
||||
pub red: Vec<[f64; 2]>,
|
||||
pub green: Vec<[f64; 2]>,
|
||||
pub blue: Vec<[f64; 2]>,
|
||||
/// Built-in presets can't be deleted or overwritten (never stored).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub builtin: bool,
|
||||
}
|
||||
|
||||
/// Points clamped to 0..1, sorted by x, duplicates in x dropped; a linear curve becomes empty.
|
||||
fn normalize(v: &[[f64; 2]]) -> Vec<[f64; 2]> {
|
||||
let mut out: Vec<[f64; 2]> =
|
||||
v.iter().filter(|p| p[0].is_finite() && p[1].is_finite()).map(|p| [p[0].clamp(0.0, 1.0), p[1].clamp(0.0, 1.0)]).collect();
|
||||
out.sort_by(|a, b| a[0].total_cmp(&b[0]));
|
||||
out.dedup_by(|a, b| (a[0] - b[0]).abs() < 1e-6);
|
||||
if out.len() < 2 || out.iter().all(|p| (p[0] - p[1]).abs() < 1e-9) {
|
||||
out.clear();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn from_points(v: &[Point]) -> Vec<[f64; 2]> {
|
||||
normalize(&v.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn to_points(v: &[[f64; 2]]) -> Vec<Point> {
|
||||
normalize(v).iter().map(|p| Point::new(p[0], p[1])).collect()
|
||||
}
|
||||
|
||||
impl CurvePreset {
|
||||
/// The point curves of `c` under `name`.
|
||||
pub fn from_curve(name: &str, c: &ToneCurve) -> CurvePreset {
|
||||
CurvePreset {
|
||||
name: name.into(),
|
||||
master: from_points(&c.master),
|
||||
red: from_points(&c.red),
|
||||
green: from_points(&c.green),
|
||||
blue: from_points(&c.blue),
|
||||
builtin: false,
|
||||
}
|
||||
}
|
||||
/// Set the four point curves of `c` (the parametric curve and Refine Saturation stay).
|
||||
pub fn apply(&self, c: &mut ToneCurve) {
|
||||
c.master = to_points(&self.master);
|
||||
c.red = to_points(&self.red);
|
||||
c.green = to_points(&self.green);
|
||||
c.blue = to_points(&self.blue);
|
||||
}
|
||||
/// Does `c` carry exactly this preset's point curves?
|
||||
pub fn matches(&self, c: &ToneCurve) -> bool {
|
||||
let same = |a: &[[f64; 2]], b: &[Point]| {
|
||||
let (a, b) = (normalize(a), from_points(b));
|
||||
a.len() == b.len() && a.iter().zip(&b).all(|(p, q)| (p[0] - q[0]).abs() < 1e-6 && (p[1] - q[1]).abs() < 1e-6)
|
||||
};
|
||||
same(&self.master, &c.master) && same(&self.red, &c.red) && same(&self.green, &c.green) && same(&self.blue, &c.blue)
|
||||
}
|
||||
fn is_linear(&self) -> bool {
|
||||
[&self.master, &self.red, &self.green, &self.blue].iter().all(|v| normalize(v).is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// The presets that ship with the app: a linear curve and two symmetric S-curves (our own
|
||||
/// values: the quarter-tones pulled 0.05 / 0.11 towards the ends, midpoint fixed).
|
||||
pub fn builtin_presets() -> Vec<CurvePreset> {
|
||||
let s = |name: &str, master: Vec<[f64; 2]>| CurvePreset { name: name.into(), master, builtin: true, ..Default::default() };
|
||||
vec![
|
||||
s("Linear", Vec::new()),
|
||||
s("Medium Contrast", vec![[0.0, 0.0], [0.25, 0.2], [0.5, 0.5], [0.75, 0.8], [1.0, 1.0]]),
|
||||
s("Strong Contrast", vec![[0.0, 0.0], [0.25, 0.14], [0.5, 0.5], [0.75, 0.86], [1.0, 1.0]]),
|
||||
]
|
||||
}
|
||||
|
||||
/// Built-in presets, then the user's.
|
||||
pub fn all_presets(s: &Session) -> Vec<CurvePreset> {
|
||||
let mut v = builtin_presets();
|
||||
v.extend(s.curve_presets.iter().cloned());
|
||||
v
|
||||
}
|
||||
|
||||
/// Name of the preset whose point curves `c` carries (built-ins first), if any.
|
||||
pub fn matching_preset(s: &Session, c: &ToneCurve) -> Option<String> {
|
||||
all_presets(s).into_iter().find(|p| p.matches(c)).map(|p| p.name)
|
||||
}
|
||||
|
||||
fn is_builtin_name(name: &str) -> bool {
|
||||
builtin_presets().iter().any(|p| p.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// Add or replace (same name, ignoring case) a user preset.
|
||||
fn upsert(s: &mut Session, p: CurvePreset) {
|
||||
match s.curve_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&p.name)) {
|
||||
Some(x) => *x = p,
|
||||
None => s.curve_presets.push(p),
|
||||
}
|
||||
}
|
||||
|
||||
fn name_param(p: &Value, c: &str) -> Result<String> {
|
||||
str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).ok_or_else(|| bad(c, "missing `name`"))
|
||||
}
|
||||
|
||||
fn list(s: &Session) -> Value {
|
||||
let current = s.active().and_then(|id| s.develop_of(id)).and_then(|d| matching_preset(s, &d.curve));
|
||||
json!({"presets": all_presets(s), "current": current})
|
||||
}
|
||||
|
||||
fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.applyPreset";
|
||||
let name = name_param(p, C)?;
|
||||
let preset =
|
||||
all_presets(s).into_iter().find(|x| x.name.eq_ignore_ascii_case(&name)).ok_or_else(|| bad(C, format!("no curve preset `{name}`")))?;
|
||||
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
|
||||
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
preset.apply(&mut d.curve);
|
||||
s.set_develop(id, d, &format!("Point Curve: {}", preset.name))?;
|
||||
Ok(json!({"name": preset.name}))
|
||||
}
|
||||
|
||||
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.savePreset";
|
||||
let name = name_param(p, C)?;
|
||||
if is_builtin_name(&name) {
|
||||
return Err(bad(C, format!("`{name}` is a built-in preset: choose another name")));
|
||||
}
|
||||
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
|
||||
let preset = CurvePreset::from_curve(&name, &s.develop_of(id).unwrap_or_default().curve);
|
||||
if preset.is_linear() {
|
||||
return Err(bad(C, "the point curve is linear: shape it first"));
|
||||
}
|
||||
upsert(s, preset);
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
fn delete_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.deletePreset";
|
||||
let name = name_param(p, C)?;
|
||||
if is_builtin_name(&name) {
|
||||
return Err(bad(C, "built-in presets can't be deleted"));
|
||||
}
|
||||
let n = s.curve_presets.len();
|
||||
s.curve_presets.retain(|x| !x.name.eq_ignore_ascii_case(&name));
|
||||
if n == s.curve_presets.len() {
|
||||
return Err(bad(C, format!("no curve preset `{name}`")));
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
/// Presets in a curve-preset file: `{format, version, presets: [...]}`, a bare array, or one preset.
|
||||
pub fn parse_file(bytes: &[u8]) -> std::result::Result<Vec<CurvePreset>, String> {
|
||||
let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not a curve preset file: {e}"))?;
|
||||
let items = match &v {
|
||||
Value::Object(o) if o.contains_key("presets") => o["presets"].clone(),
|
||||
Value::Array(_) => v.clone(),
|
||||
Value::Object(_) => Value::Array(vec![v.clone()]),
|
||||
_ => return Err("not a curve preset file".into()),
|
||||
};
|
||||
let list: Vec<CurvePreset> = serde_json::from_value(items).map_err(|e| format!("not a curve preset file: {e}"))?;
|
||||
let list: Vec<CurvePreset> = list.into_iter().filter(|p| !p.name.trim().is_empty()).collect();
|
||||
if list.is_empty() {
|
||||
return Err("no curve presets in the file".into());
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// The JSON of a curve-preset file holding `presets`.
|
||||
pub fn to_file(presets: &[CurvePreset]) -> String {
|
||||
let presets: Vec<CurvePreset> = presets.iter().map(|p| CurvePreset { builtin: false, ..p.clone() }).collect();
|
||||
serde_json::to_string_pretty(&json!({"format": FILE_FORMAT, "version": 1, "presets": presets})).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn import_presets(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.importPresets";
|
||||
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 (mut imported, mut failed) = (Vec::new(), Vec::new());
|
||||
for f in &paths {
|
||||
match std::fs::read(f).map_err(|e| e.to_string()).and_then(|b| parse_file(&b)) {
|
||||
Ok(list) => {
|
||||
for mut preset in list {
|
||||
preset.name = preset.name.trim().to_string();
|
||||
preset.builtin = false;
|
||||
if is_builtin_name(&preset.name) {
|
||||
preset.name = format!("{} (imported)", preset.name);
|
||||
}
|
||||
imported.push(preset.name.clone());
|
||||
upsert(s, preset);
|
||||
}
|
||||
}
|
||||
Err(e) => failed.push(json!([f, e])),
|
||||
}
|
||||
}
|
||||
if !imported.is_empty() {
|
||||
s.save_prefs()?;
|
||||
}
|
||||
Ok(json!({"imported": imported, "failed": failed}))
|
||||
}
|
||||
|
||||
fn export_presets(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.exportPresets";
|
||||
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(CURVE_PRESET_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 chosen: Vec<CurvePreset> = match &names {
|
||||
Some(n) => all_presets(s).into_iter().filter(|x| n.iter().any(|m| m.eq_ignore_ascii_case(&x.name))).collect(),
|
||||
None => s.curve_presets.clone(),
|
||||
};
|
||||
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())))?;
|
||||
Ok(json!({"path": path.display().to_string(), "count": chosen.len()}))
|
||||
}
|
||||
@@ -193,6 +193,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
cmd!(
|
||||
"develop.autoBwMix",
|
||||
"Auto B&W Mix",
|
||||
[],
|
||||
None,
|
||||
"{} — sets the black & white mix from the photo's colours (and switches to B&W)",
|
||||
has_active,
|
||||
|s, _| {
|
||||
let id = active(s, "develop.autoBwMix")?;
|
||||
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.autoBwMix", e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let m = lightcraft_pipeline::auto::auto_bw_mix(&src, &info, &d);
|
||||
edit(s, "develop.autoBwMix", "Auto B&W Mix", |d| {
|
||||
d.treatment = Treatment::Bw;
|
||||
let b = &mut d.bw_mix;
|
||||
[b.red, b.orange, b.yellow, b.green, b.aqua, b.blue, b.purple, b.magenta] = m;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"mix": m}))
|
||||
}
|
||||
),
|
||||
cmd!("develop.auto", "Auto Settings", ["Photo"], Some("Shift+A"), "{}", has_active, |s, _| {
|
||||
let id = active(s, "develop.auto")?;
|
||||
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.auto", e))?;
|
||||
@@ -288,7 +310,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
cmd!("develop.profile", "Set Profile", [], None, "{id: profile id (see profiles.list), amount?: 0..200}", has_active, |s, p| {
|
||||
let id = str_param(p, "id").ok_or_else(|| bad("develop.profile", "missing id"))?.to_string();
|
||||
if !crate::presets::PROFILES.iter().any(|x| x.id == id) {
|
||||
if !crate::presets::PROFILES.iter().any(|x| x.id == id) && !s.lut_profiles.iter().any(|x| x.id == id) {
|
||||
return Err(bad("develop.profile", format!("unknown profile `{id}`")));
|
||||
}
|
||||
edit(s, "develop.profile", "Profile", |d| {
|
||||
@@ -301,7 +323,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
cmd!("profile.favorite", "Favorite Profile", [], None, "{id, favorite?: bool} (toggles when omitted)", always, |s, p| {
|
||||
let id = str_param(p, "id").ok_or_else(|| bad("profile.favorite", "missing id"))?.to_string();
|
||||
if crate::presets::profile(&id).is_none() {
|
||||
if crate::presets::profile(&id).is_none() && !s.lut_profiles.iter().any(|x| x.id == id) {
|
||||
return Err(bad("profile.favorite", format!("unknown profile `{id}`")));
|
||||
}
|
||||
let on = s.profile_favorites.contains(&id);
|
||||
@@ -616,6 +638,92 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"develop.quickAdjust",
|
||||
"Quick Develop",
|
||||
[],
|
||||
None,
|
||||
"{control: develop control id, delta, ids?} — add `delta` to the control on every target photo (each from its own value; Quick Develop) in one undo step → {changed}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let c = "develop.quickAdjust";
|
||||
let ctl = str_param(p, "control").ok_or_else(|| bad(c, "missing control"))?.to_string();
|
||||
let spec = controls::find(&ctl).ok_or_else(|| bad(c, format!("unknown control `{ctl}`")))?;
|
||||
let delta = f64_req(p, "delta", c)?;
|
||||
let label = format!("Quick Develop: {}", spec.label);
|
||||
let ops: Vec<Op> = s
|
||||
.targets(p)
|
||||
.into_iter()
|
||||
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
|
||||
.filter_map(|(id, d)| {
|
||||
let mut nd = (*d).clone();
|
||||
let v = controls::get(&nd, &ctl).unwrap_or(spec.default);
|
||||
controls::set(&mut nd, &ctl, v + delta);
|
||||
if ctl == "wb.temp" || ctl == "wb.tint" {
|
||||
nd.wb.mode = WbMode::Custom;
|
||||
}
|
||||
s.develop_op(id, nd, &label)
|
||||
})
|
||||
.collect();
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
s.commit(&label, Op::Batch { ops })?;
|
||||
}
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"develop.matchExposure",
|
||||
"Match Total Exposures",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?} — set each selected photo's Exposure so its total exposure (shutter × ISO ÷ aperture², plus the slider) equals the active photo's; photos without exposure data are skipped → {changed, skipped}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let c = "develop.matchExposure";
|
||||
let reference = active(s, c)?;
|
||||
// log2 of the light the photo gathered (shutter s × ISO / f-number²)
|
||||
let gathered = |s: &Session, id: PhotoId| -> Option<f64> {
|
||||
let m = &s.catalog.photo(id)?.meta;
|
||||
let t = match m.shutter.split_once('/') {
|
||||
Some((a, b)) => a.trim().parse::<f64>().ok()? / b.trim().parse::<f64>().ok()?,
|
||||
None => m.shutter.trim().trim_end_matches('s').trim().parse::<f64>().ok()?,
|
||||
};
|
||||
let (n, iso) = (m.aperture? as f64, m.iso? as f64);
|
||||
(t > 0.0 && n > 0.0 && iso > 0.0).then(|| (t * iso / (n * n)).log2())
|
||||
};
|
||||
let r_ev = gathered(s, reference).ok_or_else(|| bad(c, "the active photo has no shutter / aperture / ISO"))?;
|
||||
let r_slider = s.develop_of(reference).map(|d| d.light.exposure).unwrap_or(0.0);
|
||||
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|x| *x != reference).collect();
|
||||
let (mut ops, mut skipped) = (Vec::new(), Vec::new());
|
||||
for id in ids {
|
||||
let (Some(ev), Some(d)) = (gathered(s, id), s.develop_of(id)) else {
|
||||
skipped.push(id.0);
|
||||
continue;
|
||||
};
|
||||
let mut nd = (*d).clone();
|
||||
nd.light.exposure = (r_slider + r_ev - ev).clamp(-5.0, 5.0);
|
||||
ops.extend(s.develop_op(id, nd, "Match Total Exposures"));
|
||||
}
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
s.commit("Match Total Exposures", Op::Batch { ops })?;
|
||||
}
|
||||
Ok(json!({"changed": n, "skipped": skipped}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"develop.autoSync",
|
||||
"Auto Sync",
|
||||
["Edit"],
|
||||
Some("Cmd+Alt+Shift+A"),
|
||||
"{on?: bool} (toggles when omitted) — edits to the active photo also change the other selected photos (only the settings that changed; not spot removal / red eye) → {on}",
|
||||
always,
|
||||
|s, p| {
|
||||
s.auto_sync = p.get("on").and_then(Value::as_bool).unwrap_or(!s.auto_sync);
|
||||
Ok(json!({"on": s.auto_sync}))
|
||||
}
|
||||
),
|
||||
// ---- presets
|
||||
cmd!("preset.apply", "Apply Preset", [], None, "{id: presetId, amount?: 0..200 (percent), ids?}", has_selection, |s, p| {
|
||||
let pid = str_param(p, "id").ok_or_else(|| bad("preset.apply", "missing id"))?;
|
||||
|
||||
@@ -96,5 +96,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
save
|
||||
),
|
||||
cmd!("export.deletePreset", "Delete Export Preset", [], None, "{name} — removes a user preset → presets", always, delete),
|
||||
cmd!(
|
||||
query "export.checkTarget",
|
||||
"Check Output Path",
|
||||
[],
|
||||
None,
|
||||
"{path} — fails when writing `path` would replace a photo's original (or its XMP sidecar) in the library; front ends call it before saving a render or screenshot to a user-given path → {path}",
|
||||
always,
|
||||
|s, p| {
|
||||
let path = p.get("path").and_then(Value::as_str).ok_or_else(|| bad("export.checkTarget", "missing `path`"))?;
|
||||
s.check_write_target(path).map_err(|e| bad("export.checkTarget", e))?;
|
||||
Ok(json!({"path": path}))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Filter presets: the filter bar's settings saved under a name ("Five stars", "Unedited raws")
|
||||
//! and applied in one step. Saved with the library.
|
||||
|
||||
use lightcraft_catalog::Filter;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FilterPreset {
|
||||
pub name: String,
|
||||
pub filter: Filter,
|
||||
}
|
||||
|
||||
fn list(s: &Session) -> Value {
|
||||
json!(s.filter_presets)
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "filter.savePreset";
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?.to_string();
|
||||
if s.filter == Filter::default() {
|
||||
return Err(bad(C, "the filter is empty: set some filters first"));
|
||||
}
|
||||
let preset = FilterPreset { name: name.clone(), filter: s.filter.clone() };
|
||||
match s.filter_presets.iter_mut().find(|f| f.name.eq_ignore_ascii_case(&name)) {
|
||||
Some(f) => *f = preset,
|
||||
None => s.filter_presets.push(preset),
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "filter.applyPreset";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let f = s.filter_presets.iter().find(|f| f.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(C, format!("no filter preset `{name}`")))?;
|
||||
s.filter = f.filter.clone();
|
||||
Ok(json!({"photos": s.visible().len()}))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "filter.deletePreset";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let n = s.filter_presets.len();
|
||||
s.filter_presets.retain(|f| !f.name.eq_ignore_ascii_case(name));
|
||||
if n == s.filter_presets.len() {
|
||||
return Err(bad(C, format!("no filter preset `{name}`")));
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "filter.presets", "Filter Presets", [], None, "{} → [{name, filter}]", always, |s, _| Ok(list(s))),
|
||||
cmd!(
|
||||
"filter.savePreset",
|
||||
"Save Filter Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} — the current filter (library.filter), saved with the library",
|
||||
always,
|
||||
save
|
||||
),
|
||||
cmd!("filter.applyPreset", "Apply Filter Preset", [], None, "{name} — replaces the current filter → {photos}", always, apply),
|
||||
cmd!("filter.deletePreset", "Delete Filter Preset", [], None, "{name}", always, delete),
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge.
|
||||
//! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge;
|
||||
//! keyword sets (nine keywords a keystroke away: ⌥1–⌥9) and Recent Keywords.
|
||||
|
||||
use lightcraft_catalog::keywords::{clean, is_under};
|
||||
use serde_json::{Value, json};
|
||||
@@ -30,8 +31,139 @@ fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, fol
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
|
||||
/// A named set of up to nine keywords.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct KeywordSet {
|
||||
pub name: String,
|
||||
pub keywords: Vec<String>,
|
||||
}
|
||||
|
||||
/// The set name meaning "the nine most recently added keywords".
|
||||
pub const RECENT: &str = "Recent Keywords";
|
||||
|
||||
/// Remember `added` as the most recent keywords (newest first, nine kept).
|
||||
pub fn note_recent(s: &mut Session, added: &[String]) {
|
||||
for k in added.iter().rev() {
|
||||
let k = clean(k);
|
||||
if k.is_empty() {
|
||||
continue;
|
||||
}
|
||||
s.recent_keywords.retain(|x| !x.eq_ignore_ascii_case(&k));
|
||||
s.recent_keywords.insert(0, k);
|
||||
}
|
||||
s.recent_keywords.truncate(9);
|
||||
let _ = s.save_prefs();
|
||||
}
|
||||
|
||||
/// The nine keywords ⌥1–⌥9 apply: the current set's, or the recent ones.
|
||||
pub fn current_keywords(s: &Session) -> Vec<String> {
|
||||
let set = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().find(|x| x.name.eq_ignore_ascii_case(n)));
|
||||
let mut v = set.map_or_else(|| s.recent_keywords.clone(), |x| x.keywords.clone());
|
||||
v.truncate(9);
|
||||
v
|
||||
}
|
||||
|
||||
/// `{sets: [{name, keywords}], current, keywords}` (Recent Keywords first).
|
||||
pub fn keyword_sets_json(s: &Session) -> Value {
|
||||
let mut sets = vec![json!({"name": RECENT, "keywords": s.recent_keywords})];
|
||||
sets.extend(s.keyword_sets.iter().map(|x| json!({"name": x.name, "keywords": x.keywords})));
|
||||
json!({"sets": sets, "current": s.keyword_set.clone().unwrap_or_else(|| RECENT.into()), "keywords": current_keywords(s)})
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "keyword.sets", "Keyword Sets", [], None, "{} → {sets: [{name, keywords}], current, keywords: the nine ⌥1–⌥9 apply}", always, |s, _| Ok(keyword_sets_json(s))),
|
||||
cmd!("keyword.useSet", "Use Keyword Set", [], None, "{name} (\"Recent Keywords\" = the recently added ones)", always, |s, p| {
|
||||
let name = str_param(p, "name").map(str::trim).unwrap_or(RECENT);
|
||||
s.keyword_set = if name.eq_ignore_ascii_case(RECENT) || name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
s.keyword_sets
|
||||
.iter()
|
||||
.find(|x| x.name.eq_ignore_ascii_case(name))
|
||||
.ok_or_else(|| bad("keyword.useSet", format!("no keyword set `{name}`")))?
|
||||
.name
|
||||
.clone(),
|
||||
)
|
||||
};
|
||||
s.save_prefs()?;
|
||||
Ok(keyword_sets_json(s))
|
||||
}),
|
||||
cmd!(
|
||||
"keyword.saveSet",
|
||||
"Save Keyword Set",
|
||||
[],
|
||||
None,
|
||||
"{name, keywords?: [up to 9] (default: the current nine)} — replaces a set of that name and makes it current",
|
||||
always,
|
||||
|s, p| {
|
||||
let name = str_param(p, "name")
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty() && !n.eq_ignore_ascii_case(RECENT))
|
||||
.ok_or_else(|| bad("keyword.saveSet", "missing or reserved `name`"))?
|
||||
.to_string();
|
||||
let mut keywords: Vec<String> = if p.get("keywords").is_some() {
|
||||
strs(p, "keywords").iter().map(|k| clean(k)).filter(|k| !k.is_empty()).collect()
|
||||
} else {
|
||||
current_keywords(s)
|
||||
};
|
||||
keywords.truncate(9);
|
||||
let set = KeywordSet { name: name.clone(), keywords };
|
||||
match s.keyword_sets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
|
||||
Some(x) => *x = set,
|
||||
None => s.keyword_sets.push(set),
|
||||
}
|
||||
s.keyword_set = Some(name);
|
||||
s.save_prefs()?;
|
||||
Ok(keyword_sets_json(s))
|
||||
}
|
||||
),
|
||||
cmd!("keyword.deleteSet", "Delete Keyword Set", [], None, "{name}", always, |s, p| {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("keyword.deleteSet", "missing `name`"))?;
|
||||
let before = s.keyword_sets.len();
|
||||
s.keyword_sets.retain(|x| !x.name.eq_ignore_ascii_case(name));
|
||||
if s.keyword_sets.len() == before {
|
||||
return Err(bad("keyword.deleteSet", format!("no keyword set `{name}`")));
|
||||
}
|
||||
if s.keyword_set.as_deref().is_some_and(|c| c.eq_ignore_ascii_case(name)) {
|
||||
s.keyword_set = None;
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(keyword_sets_json(s))
|
||||
}),
|
||||
cmd!(
|
||||
"keyword.toggleFromSet",
|
||||
"Toggle Keyword from Set",
|
||||
[],
|
||||
None,
|
||||
"{index: 1..9, ids?} — the set's keyword N: added to the target photos, or removed when they all have it",
|
||||
super::has_selection,
|
||||
|s, p| {
|
||||
let i = p
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.filter(|i| (1..=9).contains(i))
|
||||
.ok_or_else(|| bad("keyword.toggleFromSet", "`index` must be 1..9"))?;
|
||||
let Some(k) = current_keywords(s).get(i as usize - 1).cloned() else {
|
||||
return Ok(json!({"changed": 0}));
|
||||
};
|
||||
let ids = s.targets(p);
|
||||
let all = !ids.is_empty()
|
||||
&& ids.iter().all(|id| s.catalog.photo(*id).is_some_and(|ph| ph.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k))));
|
||||
let ids: Vec<u64> = ids.iter().map(|i| i.0).collect();
|
||||
let key = if all { "removeKeywords" } else { "addKeywords" };
|
||||
// applying from Recent Keywords mustn't reshuffle the numbers under the keys
|
||||
let recent = s.recent_keywords.clone();
|
||||
let mut r = s.execute("photo.setMeta", &json!({"ids": ids, key: [k.clone()]}))?;
|
||||
if s.keyword_set.is_none() {
|
||||
s.recent_keywords = recent;
|
||||
}
|
||||
r["keyword"] = json!(k);
|
||||
r["added"] = json!(!all);
|
||||
Ok(r)
|
||||
}
|
||||
),
|
||||
cmd!(query "keyword.list", "Keywords", [], None, "{} → [{name, path, count, children}] keyword tree (`a|b|c` keywords are hierarchical)", always, |s, _| {
|
||||
Ok(serde_json::to_value(s.catalog.keyword_tree()).unwrap_or_default())
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Library commands: view source, filter/sort, selection, ratings/flags/labels, rotate, delete,
|
||||
//! metadata, albums, import.
|
||||
|
||||
use lightcraft_catalog::{Album, AlbumId, ColorLabel, Flag, GroupBy, Op, PhotoId, Sort, SortKey};
|
||||
use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Flag, GroupBy, Op, PhotoId, Sort, SortKey};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param};
|
||||
@@ -128,34 +128,43 @@ fn select_by(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
// ---- view source / filter / sort
|
||||
cmd!("library.source", "Show Source", [], None, "{kind: all|recentlyAdded|album|recentlyDeleted|picks, id?: albumId}", always, |s, p| {
|
||||
let kind = str_param(p, "kind").unwrap_or("all");
|
||||
s.source = match kind {
|
||||
"all" => LibrarySource::All,
|
||||
"recentlyAdded" => LibrarySource::RecentlyAdded,
|
||||
"recentlyDeleted" => LibrarySource::RecentlyDeleted,
|
||||
"picks" => LibrarySource::Picks,
|
||||
"album" => {
|
||||
let a = album_param(p, "id", "library.source")?;
|
||||
if s.catalog.album(a).is_none_or(|a| a.folder) {
|
||||
return Err(bad("library.source", "no such album"));
|
||||
cmd!(
|
||||
"library.source",
|
||||
"Show Source",
|
||||
[],
|
||||
None,
|
||||
"{kind: all|recentlyAdded|album|recentlyDeleted|picks|missing, id?: albumId}",
|
||||
always,
|
||||
|s, p| {
|
||||
let kind = str_param(p, "kind").unwrap_or("all");
|
||||
s.source = match kind {
|
||||
"all" => LibrarySource::All,
|
||||
"recentlyAdded" => LibrarySource::RecentlyAdded,
|
||||
"recentlyDeleted" => LibrarySource::RecentlyDeleted,
|
||||
"picks" => LibrarySource::Picks,
|
||||
"missing" => LibrarySource::Missing,
|
||||
"album" => {
|
||||
let a = album_param(p, "id", "library.source")?;
|
||||
if s.catalog.album(a).is_none_or(|a| a.folder) {
|
||||
return Err(bad("library.source", "no such album"));
|
||||
}
|
||||
LibrarySource::Album(a)
|
||||
}
|
||||
LibrarySource::Album(a)
|
||||
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();
|
||||
}
|
||||
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()}))
|
||||
}
|
||||
Ok(json!({"count": vis.len()}))
|
||||
}),
|
||||
),
|
||||
cmd!(
|
||||
"library.filter",
|
||||
"Filter",
|
||||
[],
|
||||
None,
|
||||
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, edited?, date?, keyword?, camera?}",
|
||||
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, keyword?, camera?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let mut v = serde_json::to_value(&s.filter).unwrap_or_default();
|
||||
@@ -241,13 +250,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"selected": s.selection.ids.len()}))
|
||||
}),
|
||||
cmd!(query "library.devices", "Cameras and Cards", [], None, "{} → [{name, path (its DCIM folder), root}] — mounted volumes with a DCIM folder", always, |_, _| {
|
||||
Ok(serde_json::to_value(crate::devices::devices()).unwrap_or_default())
|
||||
Ok(serde_json::to_value(crate::devices::devices_now()).unwrap_or_default())
|
||||
}),
|
||||
cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], None, "{} — title, caption, copyright, creator, location and keywords of the active photo", has_active, |s, _| {
|
||||
cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], None, "{} — title, caption, copyright (notice, status, usage terms, info URL), creator, location and keywords of the active photo", has_active, |s, _| {
|
||||
let id = s.active().ok_or_else(|| bad("photo.copyMetadata", "no active photo"))?;
|
||||
let m = &s.catalog.photo(id).ok_or_else(|| bad("photo.copyMetadata", "no photo"))?.meta;
|
||||
let v = json!({"title": m.title, "caption": m.caption, "altText": m.alt_text, "extendedDescription": m.extended_description,
|
||||
"copyright": m.copyright, "creator": m.creator, "location": m.location, "city": m.city, "state": m.state, "country": m.country,
|
||||
"copyright": m.copyright, "copyrightStatus": m.copyright_status.id(), "usageTerms": m.usage_terms, "copyrightUrl": m.copyright_url,
|
||||
"creator": m.creator, "location": m.location, "city": m.city, "state": m.state, "country": m.country,
|
||||
"keywords": m.keywords});
|
||||
s.meta_clipboard = Some(v.clone());
|
||||
Ok(v)
|
||||
@@ -257,7 +267,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste Metadata",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?, fields?: [title|caption|copyright|creator|location|keywords] (default: all copied)}",
|
||||
"{ids?, fields?: [title|caption|copyright|copyrightStatus|usageTerms|copyrightUrl|creator|location|keywords] (default: all copied)}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let c = "photo.pasteMetadata";
|
||||
@@ -361,20 +371,49 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Info",
|
||||
[],
|
||||
None,
|
||||
"{ids?, title?, caption?, altText?, extendedDescription?, copyright?, creator?, location?, city?, state?, country?, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}",
|
||||
"{ids?, title?, caption?, altText?, extendedDescription?, copyright?, copyrightStatus?: unknown|copyrighted|publicDomain, usageTerms?, copyrightUrl?, creator?, location?, city?, state?, country?, gps?: \"lat, lon\" | [lat, lon] | null, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let strs =
|
||||
|k: &str| p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::<Vec<_>>());
|
||||
let targets = s.targets(p);
|
||||
// GPS: "lat, lon" (decimal or 51°30'26"N 0°7'39"W), [lat, lon], or null / "" to clear
|
||||
let gps: Option<Option<(f64, f64)>> = match p.get("gps") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::String(t)) if t.trim().is_empty() => Some(None),
|
||||
Some(Value::String(t)) => Some(Some(
|
||||
parse_gps(t).ok_or_else(|| bad("photo.setMeta", format!("can't read `{t}` as coordinates (e.g. 51.5072, -0.1276)")))?,
|
||||
)),
|
||||
Some(Value::Array(a)) if a.len() == 2 => match (a[0].as_f64(), a[1].as_f64()) {
|
||||
(Some(la), Some(lo)) if la.abs() <= 90.0 && lo.abs() <= 180.0 => Some(Some((la, lo))),
|
||||
_ => return Err(bad("photo.setMeta", "gps is [latitude, longitude] in degrees")),
|
||||
},
|
||||
Some(_) => return Err(bad("photo.setMeta", "gps is \"lat, lon\", [lat, lon] or null")),
|
||||
};
|
||||
let status = match str_param(p, "copyrightStatus") {
|
||||
Some(t) => Some(
|
||||
CopyrightStatus::parse(t)
|
||||
.ok_or_else(|| bad("photo.setMeta", format!("copyrightStatus `{t}`: unknown, copyrighted or publicDomain")))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let mut ops = Vec::new();
|
||||
for id in targets {
|
||||
let Some(ph) = s.catalog.photo(id) else { continue };
|
||||
let mut m = ph.meta.clone();
|
||||
if let Some(g) = gps {
|
||||
m.gps = g;
|
||||
}
|
||||
if let Some(st) = status {
|
||||
m.copyright_status = st;
|
||||
}
|
||||
for (k, field) in [
|
||||
("title", &mut m.title),
|
||||
("caption", &mut m.caption),
|
||||
("copyright", &mut m.copyright),
|
||||
("usageTerms", &mut m.usage_terms),
|
||||
("copyrightUrl", &mut m.copyright_url),
|
||||
("creator", &mut m.creator),
|
||||
("location", &mut m.location),
|
||||
("city", &mut m.city),
|
||||
@@ -402,6 +441,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
let n = ops.len();
|
||||
s.commit("Edit Info", Op::Batch { ops })?;
|
||||
// keywords just added become the Recent Keywords set
|
||||
let added: Vec<String> = strs("addKeywords").unwrap_or_default().into_iter().chain(strs("keywords").unwrap_or_default()).collect();
|
||||
if n > 0 && !added.is_empty() {
|
||||
crate::cmd::keywords::note_recent(s, &added);
|
||||
}
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
),
|
||||
@@ -418,7 +462,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let cover = photos.first().copied();
|
||||
s.commit(
|
||||
if folder { "New Folder" } else { "New Album" },
|
||||
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None } },
|
||||
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false } },
|
||||
)?;
|
||||
Ok(json!({"id": id.0}))
|
||||
}),
|
||||
@@ -427,7 +471,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Smart Album…",
|
||||
[],
|
||||
None,
|
||||
"{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album), parent?: folderId} — without `rules`, saves the current view (source + filter)",
|
||||
"{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album, ruleSet: {match: all|any|none, rules: [{field, op, value} | {group: ruleSet}]} — see album.ruleFields), parent?: folderId} — without `rules`, saves the current view (source + filter)",
|
||||
always,
|
||||
|s, p| {
|
||||
let name = str_param(p, "name").unwrap_or("Smart Album").trim().to_string();
|
||||
@@ -445,6 +489,27 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"id": id.0, "count": s.catalog.album_count(id)}))
|
||||
}
|
||||
),
|
||||
cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind, ops: [{op, label}], choices?}] for ruleSet rules", always, |_, _| {
|
||||
use lightcraft_catalog::rules::{FIELDS, Kind, ops_for};
|
||||
Ok(json!(FIELDS
|
||||
.iter()
|
||||
.map(|(id, label, kind)| {
|
||||
let k = match kind {
|
||||
Kind::Text => "text",
|
||||
Kind::Keywords => "keywords",
|
||||
Kind::Number => "number",
|
||||
Kind::Date => "date",
|
||||
Kind::Choice(_) => "choice",
|
||||
Kind::Bool => "bool",
|
||||
};
|
||||
let mut v = json!({"field": id, "label": label, "kind": k, "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::<Vec<_>>()});
|
||||
if let Kind::Choice(c) = kind {
|
||||
v["choices"] = json!(c);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect::<Vec<_>>()))
|
||||
}),
|
||||
cmd!(
|
||||
"album.setRules",
|
||||
"Edit Smart Album",
|
||||
@@ -504,6 +569,145 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?;
|
||||
Ok(json!({"added": added}))
|
||||
}),
|
||||
cmd!(
|
||||
"album.toggleTarget",
|
||||
"Add to Target Album",
|
||||
[],
|
||||
None,
|
||||
"{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
|
||||
let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder));
|
||||
let id = match target.or_else(|| s.catalog.quick_collection()) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let id = s.catalog.alloc_album_id();
|
||||
let album = Album { quick: true, ..Album::new(id, "Quick Collection") };
|
||||
s.commit("New Quick Collection", Op::AddAlbum { album })?;
|
||||
id
|
||||
}
|
||||
};
|
||||
let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?;
|
||||
let all_in = !targets.is_empty() && targets.iter().all(|t| al.photos.contains(t));
|
||||
let photos: Vec<PhotoId> = if all_in {
|
||||
al.photos.iter().copied().filter(|x| !targets.contains(x)).collect()
|
||||
} else {
|
||||
al.photos.iter().copied().chain(targets.iter().copied().filter(|t| !al.photos.contains(t))).collect()
|
||||
};
|
||||
let name = al.name.clone();
|
||||
let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied());
|
||||
s.commit(
|
||||
if all_in { "Remove from Target Album" } else { "Add to Target Album" },
|
||||
Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] },
|
||||
)?;
|
||||
Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id)}))
|
||||
}
|
||||
),
|
||||
cmd!("album.setTarget", "Set as Target Album", [], None, "{id: albumId | null} (null = the Quick Collection)", always, |s, p| {
|
||||
s.target_album = match p.get("id").and_then(Value::as_u64) {
|
||||
Some(a) => {
|
||||
let id = AlbumId(a);
|
||||
let al = s.catalog.album(id).ok_or_else(|| bad("album.setTarget", "no such album"))?;
|
||||
if al.is_smart() || al.folder {
|
||||
return Err(bad("album.setTarget", "smart albums and folders can't hold photos"));
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok(json!({"target": s.target_album.map(|a| a.0)}))
|
||||
}),
|
||||
cmd!("album.clearQuick", "Clear Quick Collection", [], None, "{}", always, |s, _| {
|
||||
let Some(id) = s.catalog.quick_collection() else { return ok() };
|
||||
s.commit(
|
||||
"Clear Quick Collection",
|
||||
Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos: vec![] }, Op::SetAlbumCover { id, cover: None }] },
|
||||
)?;
|
||||
ok()
|
||||
}),
|
||||
cmd!(
|
||||
"library.autoImport",
|
||||
"Auto Import Settings",
|
||||
[],
|
||||
None,
|
||||
"{folder?: path | null (off), copy?: bool (copy into the library's Originals, else add in place), album?: name | null} — a watched folder whose new photos are added as they arrive (library.autoImportScan; the app scans every few seconds) → the settings",
|
||||
always,
|
||||
|s, p| {
|
||||
if let Some(f) = p.get("folder") {
|
||||
s.import_defaults.auto_folder = match f.as_str().map(str::trim).filter(|f| !f.is_empty()) {
|
||||
Some(f) if std::path::Path::new(f).is_dir() || cfg!(target_arch = "wasm32") => Some(f.to_string()),
|
||||
Some(f) => return Err(bad("library.autoImport", format!("`{f}` is not a folder"))),
|
||||
None => None,
|
||||
};
|
||||
}
|
||||
if let Some(c) = p.get("copy").and_then(Value::as_bool) {
|
||||
s.import_defaults.auto_copy = c;
|
||||
}
|
||||
if let Some(a) = p.get("album") {
|
||||
s.import_defaults.auto_album = a.as_str().map(str::trim).filter(|a| !a.is_empty()).map(str::to_string);
|
||||
}
|
||||
s.save_prefs()?;
|
||||
let d = &s.import_defaults;
|
||||
Ok(json!({"folder": d.auto_folder, "copy": d.auto_copy, "album": d.auto_album}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"library.autoImportScan",
|
||||
"Auto Import Now",
|
||||
[],
|
||||
None,
|
||||
"{} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder}",
|
||||
always,
|
||||
|s, _| {
|
||||
let Some(folder) = s.import_defaults.auto_folder.clone() else { return Ok(json!({"imported": [], "folder": null})) };
|
||||
// only files that stopped growing: a file still being written is left for later
|
||||
let known: std::collections::HashSet<String> = s
|
||||
.catalog
|
||||
.photos()
|
||||
.filter_map(|p| match &p.source {
|
||||
lightcraft_catalog::Source::File { path } => Some(path.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut fresh = Vec::new();
|
||||
for e in std::fs::read_dir(&folder).map_err(|e| bad("library.autoImportScan", format!("{folder}: {e}")))?.flatten() {
|
||||
let path = e.path();
|
||||
let ps = path.to_string_lossy().to_string();
|
||||
if !path.is_file() || known.contains(&ps) || path.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
|
||||
continue;
|
||||
}
|
||||
// each file is tried once (a non-photo isn't retried every scan)
|
||||
if s.auto_import_seen.get(&ps) == Some(&u64::MAX) {
|
||||
continue;
|
||||
}
|
||||
let size = e.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
let seen = s.auto_import_seen.insert(ps.clone(), size);
|
||||
if size > 0 && seen == Some(size) {
|
||||
fresh.push(ps);
|
||||
}
|
||||
}
|
||||
if fresh.is_empty() {
|
||||
return Ok(json!({"imported": [], "folder": folder}));
|
||||
}
|
||||
let mode = if s.import_defaults.auto_copy { "copy" } else { "add" };
|
||||
let mut params = json!({"paths": fresh, "mode": mode});
|
||||
if let Some(a) = s.import_defaults.auto_album.clone() {
|
||||
match s.catalog.albums().find(|al| al.name.eq_ignore_ascii_case(&a) && !al.folder && !al.is_smart()) {
|
||||
Some(al) => params["album"] = json!(al.id.0),
|
||||
None => params["albumName"] = json!(a),
|
||||
}
|
||||
}
|
||||
for f in &fresh {
|
||||
s.auto_import_seen.insert(f.clone(), u64::MAX);
|
||||
}
|
||||
let sel = s.selection.clone();
|
||||
let r = s.execute("library.import", ¶ms)?;
|
||||
// arriving photos don't take over the selection
|
||||
s.selection = sel;
|
||||
Ok(json!({"imported": r["imported"], "folder": folder}))
|
||||
}
|
||||
),
|
||||
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));
|
||||
@@ -527,7 +731,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Review Import",
|
||||
[],
|
||||
None,
|
||||
"{paths: [file or folder (recursive)]} → {candidates: [{path, name, format, kind, width, height, fileSize, captured, duplicate?: path|content, existing?, error?}], duplicates, scanned} — nothing is added",
|
||||
"{paths: [file or folder (recursive)]} → {candidates: [{path, name, format, kind, width, height, fileSize, captured, duplicate?: path|content, existing?, error?, previewOnly?: why a raw can only be shown from its embedded preview}], duplicates, scanned} — nothing is added",
|
||||
always,
|
||||
|s, p| {
|
||||
let paths = strs(p, "paths");
|
||||
@@ -541,10 +745,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(
|
||||
"library.import",
|
||||
"Add Photos…",
|
||||
"Import Photos",
|
||||
["File"],
|
||||
Some("Cmd+Shift+I"),
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}",
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let paths = strs(p, "paths");
|
||||
@@ -554,7 +758,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let mode = match str_param(p, "mode").unwrap_or("add") {
|
||||
"add" => crate::import::ImportMode::Add,
|
||||
"copy" => crate::import::ImportMode::Copy,
|
||||
other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy)"))),
|
||||
"move" => crate::import::ImportMode::Move,
|
||||
other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy|move)"))),
|
||||
};
|
||||
let preset = match str_param(p, "preset").filter(|x| !x.is_empty()) {
|
||||
Some(id) => {
|
||||
@@ -568,7 +773,38 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
{
|
||||
return Err(bad("library.import", "album must be a regular album"));
|
||||
}
|
||||
let opts = crate::import::ImportOptions { mode, preset, keywords: strs(p, "keywords"), ..Default::default() };
|
||||
let organize = match str_param(p, "organize") {
|
||||
Some(o) => crate::import::Organize::parse(o).ok_or_else(|| {
|
||||
bad(
|
||||
"library.import",
|
||||
format!("unknown organize `{o}` (date|month|flat, or a folder template like {{date:%Y}}/{{date:%Y%m%d}})"),
|
||||
)
|
||||
})?,
|
||||
None => Default::default(),
|
||||
};
|
||||
if let crate::import::Organize::Template(t) = &organize
|
||||
&& let Some(e) = crate::rename::folder_template_error(t)
|
||||
{
|
||||
return Err(bad("library.import", e));
|
||||
}
|
||||
let metadata_preset = str_param(p, "metadataPreset").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
if let Some(n) = &metadata_preset
|
||||
&& !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n))
|
||||
{
|
||||
return Err(bad("library.import", format!("unknown metadata preset `{n}`")));
|
||||
}
|
||||
let opts = crate::import::ImportOptions {
|
||||
mode,
|
||||
preset,
|
||||
keywords: strs(p, "keywords"),
|
||||
destination: str_param(p, "destination").map(str::to_string),
|
||||
organize,
|
||||
rename: str_param(p, "rename").map(str::to_string),
|
||||
rename_start: p.get("renameStart").and_then(Value::as_u64).unwrap_or(1) as usize,
|
||||
metadata_preset,
|
||||
convert_dng: super::bool_or(p, "dng", false),
|
||||
local: super::bool_or(p, "local", false),
|
||||
};
|
||||
let undo0 = s.undo.len();
|
||||
let mut report = serde_json::to_value(crate::import::import_with(s, &paths, &opts)?).unwrap_or_default();
|
||||
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
|
||||
@@ -582,6 +818,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if let Some(a) = album
|
||||
&& !imported.is_empty()
|
||||
{
|
||||
// (album.addPhotos wants a selection: the new photos are about to be it)
|
||||
s.selection = Selection::single(PhotoId(imported[0]));
|
||||
s.execute("album.addPhotos", &json!({"id": a, "ids": imported}))?;
|
||||
report["album"] = json!(a);
|
||||
}
|
||||
@@ -629,6 +867,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"logRecords": j.log_records(),
|
||||
"logBytes": j.log_bytes(),
|
||||
"lastError": lib.last_error,
|
||||
// changes applied in memory whose write failed (retried by every save)
|
||||
"unsavedOps": s.unsaved().map_or(0, |u| u.0),
|
||||
"unsavedError": s.unsaved().map(|u| u.1),
|
||||
// untouched Local records forgotten when the library opened
|
||||
"forgotLocal": lib.forgot_local.as_ref().map(|p| json!({"forgotten": p.evict.len(), "local": p.local, "keptRecent": p.kept_recent, "keptTouched": p.kept_touched, "keptInUse": p.kept_in_use})),
|
||||
"persistence": j.stats(),
|
||||
"cache": cache,
|
||||
"load": {
|
||||
"created": lib.report.created,
|
||||
@@ -653,7 +897,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result<lightcraft_catalog::Filter> {
|
||||
let mut v = serde_json::to_value(base).unwrap_or_default();
|
||||
lightcraft_develop::presets::deep_merge(&mut v, patch);
|
||||
serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))
|
||||
let f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?;
|
||||
if let Some(problem) = f.rule_set.as_ref().and_then(|r| r.problems().into_iter().next()) {
|
||||
return Err(bad(c, problem));
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// The current view (source + filter) as smart-album rules. Viewing a smart album starts from
|
||||
@@ -697,3 +945,48 @@ impl Session {
|
||||
fn has_library(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.library.is_some() { Ok(()) } else { Err("no library is open (in-memory session)".into()) }
|
||||
}
|
||||
|
||||
/// Coordinates typed by a person: "51.5072, -0.1276", "51.5072 N 0.1276 W",
|
||||
/// "51°30'26\"N 0°7'39\"W" (degrees, minutes, seconds; N/S/E/W or signs).
|
||||
pub(crate) fn parse_gps(t: &str) -> Option<(f64, f64)> {
|
||||
let t = t.trim();
|
||||
// split into the two coordinates: at a comma, else after the first N/S hemisphere letter
|
||||
let (a, b) = match t.split_once(',') {
|
||||
Some((a, b)) => (a.trim().to_string(), b.trim().to_string()),
|
||||
None => {
|
||||
let i = t.find(['N', 'S', 'n', 's']).map(|i| i + 1).or_else(|| t.find(char::is_whitespace))?;
|
||||
(t[..i].trim().to_string(), t[i..].trim().to_string())
|
||||
}
|
||||
};
|
||||
let one = |s: &str, pos: char, neg: char| -> Option<f64> {
|
||||
let up = s.to_ascii_uppercase();
|
||||
let sign = if up.contains(neg) || up.trim_start().starts_with('-') { -1.0 } else { 1.0 };
|
||||
let nums: Vec<f64> =
|
||||
up.split(|c: char| !(c.is_ascii_digit() || c == '.')).filter(|x| !x.is_empty()).map(|x| x.parse::<f64>().ok()).collect::<Option<_>>()?;
|
||||
let v = match nums.as_slice() {
|
||||
[d] => *d,
|
||||
[d, m] => d + m / 60.0,
|
||||
[d, m, s] => d + m / 60.0 + s / 3600.0,
|
||||
_ => return None,
|
||||
};
|
||||
let _ = pos;
|
||||
Some(sign * v)
|
||||
};
|
||||
let (la, lo) = (one(&a, 'N', 'S')?, one(&b, 'E', 'W')?);
|
||||
(la.abs() <= 90.0 && lo.abs() <= 180.0).then_some((la, lo))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gps_tests {
|
||||
#[test]
|
||||
fn coordinates_people_type() {
|
||||
let p = super::parse_gps;
|
||||
assert_eq!(p("51.5072, -0.1276"), Some((51.5072, -0.1276)));
|
||||
let (la, lo) = p("51°30'26\"N 0°7'39\"W").unwrap();
|
||||
assert!((la - 51.50722).abs() < 1e-4 && (lo + 0.1275).abs() < 1e-4, "{la} {lo}");
|
||||
let (la, lo) = p("33.8688 S, 151.2093 E").unwrap();
|
||||
assert!((la + 33.8688).abs() < 1e-9 && (lo - 151.2093).abs() < 1e-9);
|
||||
assert_eq!(p("95, 10"), None);
|
||||
assert_eq!(p("hello"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Imported profiles: `.cube` 3D LUTs used as creative profiles (Amount 0–200 % like the
|
||||
//! built-in ones). A library on disk keeps a copy of each file in its `Profiles/` folder; the
|
||||
//! list lives in the library's prefs and the LUTs are registered with the pipeline when the
|
||||
//! library opens.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// One imported LUT profile.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct LutProfile {
|
||||
/// `lut:<slug>`.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub group: String,
|
||||
/// The `.cube` file (the library's copy when there is a library on disk).
|
||||
pub file: String,
|
||||
}
|
||||
|
||||
fn profiles_dir(s: &Session) -> Option<PathBuf> {
|
||||
s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join("Profiles"))
|
||||
}
|
||||
|
||||
/// Register every imported profile's LUT with the pipeline (missing files are skipped).
|
||||
pub fn register_all(s: &Session) {
|
||||
for p in &s.lut_profiles {
|
||||
if let Some(l) = std::fs::read_to_string(&p.file).ok().and_then(|t| lightcraft_pipeline::lut::Lut3d::parse_cube(&t).ok()) {
|
||||
lightcraft_pipeline::lut::register(&p.id, l);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `.cube` files in `paths` (folders searched, zip bundles opened).
|
||||
fn cube_files(paths: &[String]) -> Vec<(String, String, Option<String>)> {
|
||||
// (name, text, group)
|
||||
let mut out = Vec::new();
|
||||
for p in paths {
|
||||
let path = Path::new(p);
|
||||
if path.is_dir() {
|
||||
let mut stack = vec![path.to_path_buf()];
|
||||
while let Some(d) = stack.pop() {
|
||||
let Ok(rd) = std::fs::read_dir(&d) else { continue };
|
||||
for e in rd.flatten() {
|
||||
let ep = e.path();
|
||||
if ep.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
|
||||
continue;
|
||||
}
|
||||
if ep.is_dir() {
|
||||
stack.push(ep);
|
||||
} else if ep.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube"))
|
||||
&& let Ok(t) = std::fs::read_to_string(&ep)
|
||||
{
|
||||
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
|
||||
out.push((ep.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, group));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if path.extension().is_some_and(|x| x.eq_ignore_ascii_case("zip")) {
|
||||
let Ok(b) = std::fs::read(path) else { continue };
|
||||
for (inner, data) in crate::preset_import::read_zip(&b).unwrap_or_default() {
|
||||
if inner.to_ascii_lowercase().ends_with(".cube") {
|
||||
let ip = Path::new(&inner);
|
||||
let group = ip
|
||||
.parent()
|
||||
.and_then(|d| d.file_name())
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.or_else(|| path.file_stem().map(|n| n.to_string_lossy().to_string()));
|
||||
out.push((
|
||||
ip.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(),
|
||||
String::from_utf8_lossy(&data).to_string(),
|
||||
group,
|
||||
));
|
||||
}
|
||||
}
|
||||
} else if let Ok(t) = std::fs::read_to_string(path) {
|
||||
out.push((path.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, None));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "profile.import";
|
||||
let 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 paths.is_empty() {
|
||||
return Err(bad(C, "no paths"));
|
||||
}
|
||||
let dir = profiles_dir(s);
|
||||
if let Some(d) = &dir {
|
||||
std::fs::create_dir_all(d).map_err(|e| bad(C, format!("{}: {e}", d.display())))?;
|
||||
}
|
||||
let (mut imported, mut failed) = (Vec::new(), Vec::new());
|
||||
for (name, text, group) in cube_files(&paths) {
|
||||
let lut = match lightcraft_pipeline::lut::Lut3d::parse_cube(&text) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
failed.push(json!([name, e]));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let display = lut.title.clone().filter(|t| !t.trim().is_empty()).unwrap_or_else(|| name.clone());
|
||||
let group = super::super::preset_import::group_from_dir(&group.unwrap_or_default()).unwrap_or_else(|| "Imported".into());
|
||||
let slug = crate::presets::slug(&format!("{group}-{display}"));
|
||||
let mut id = format!("lut:{slug}");
|
||||
let mut n = 2;
|
||||
while s.lut_profiles.iter().any(|x| x.id == id) {
|
||||
id = format!("lut:{slug}-{n}");
|
||||
n += 1;
|
||||
}
|
||||
let file = match &dir {
|
||||
Some(d) => {
|
||||
let f = d.join(format!("{}.cube", id.trim_start_matches("lut:")));
|
||||
std::fs::write(&f, &text).map_err(|e| bad(C, format!("{}: {e}", f.display())))?;
|
||||
f.to_string_lossy().to_string()
|
||||
}
|
||||
None => {
|
||||
// no library on disk: remember the original (the in-memory session ends with the app)
|
||||
let f = std::env::temp_dir().join(format!("lightcraft-{}.cube", id.trim_start_matches("lut:")));
|
||||
std::fs::write(&f, &text).map_err(|e| bad(C, e.to_string()))?;
|
||||
f.to_string_lossy().to_string()
|
||||
}
|
||||
};
|
||||
lightcraft_pipeline::lut::register(&id, lut);
|
||||
s.lut_profiles.push(LutProfile { id: id.clone(), name: display.clone(), group: group.clone(), file });
|
||||
imported.push(json!({"id": id, "name": display, "group": group}));
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(json!({"imported": imported, "failed": failed}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"profile.import",
|
||||
"Import Profiles",
|
||||
[],
|
||||
None,
|
||||
"{paths: [.cube file, folder or .zip]} — 3D LUTs become creative profiles (Amount 0–200 %; rendered on the CPU) in the profile browser's groups (folder / zip name, else Imported) → {imported: [{id, name, group}], failed}",
|
||||
always,
|
||||
import
|
||||
),
|
||||
cmd!("profile.deleteImported", "Delete Imported Profile", [], None, "{id: lut:…}", always, |s, p| {
|
||||
let id = str_param(p, "id").ok_or_else(|| bad("profile.deleteImported", "missing id"))?.to_string();
|
||||
let Some(i) = s.lut_profiles.iter().position(|x| x.id == id) else {
|
||||
return Err(bad("profile.deleteImported", format!("no imported profile `{id}`")));
|
||||
};
|
||||
let gone = s.lut_profiles.remove(i);
|
||||
if profiles_dir(s).is_some_and(|d| Path::new(&gone.file).starts_with(d)) {
|
||||
let _ = std::fs::remove_file(&gone.file);
|
||||
}
|
||||
lightcraft_pipeline::lut::unregister(&id);
|
||||
s.save_prefs()?;
|
||||
Ok(json!({"deleted": id}))
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Photo management commands: batch rename, capture time, colour label names.
|
||||
//! Photo management commands: batch rename, capture time, colour label names and sets.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -13,9 +13,149 @@ fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec<lightcraft
|
||||
Ok((s.targets(p), template, start))
|
||||
}
|
||||
|
||||
/// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name).
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct LabelSet {
|
||||
pub name: String,
|
||||
pub names: [String; 5],
|
||||
}
|
||||
|
||||
/// Our built-in sets.
|
||||
pub fn builtin_label_sets() -> Vec<LabelSet> {
|
||||
let set = |name: &str, n: [&str; 5]| LabelSet { name: name.into(), names: n.map(str::to_string) };
|
||||
vec![set("Colors", ["", "", "", "", ""]), set("Review", ["Reject", "Needs Work", "Approved", "Retouch", "Print"])]
|
||||
}
|
||||
|
||||
fn all_label_sets(s: &crate::Session) -> Vec<LabelSet> {
|
||||
builtin_label_sets().into_iter().chain(s.label_sets.iter().cloned()).collect()
|
||||
}
|
||||
|
||||
/// The set whose names are the catalog's current ones.
|
||||
fn current_label_set(s: &crate::Session) -> Option<String> {
|
||||
let cur: [String; 5] = ColorLabel::ALL.map(|l| s.catalog.custom_label_name(l).unwrap_or("").to_string());
|
||||
all_label_sets(s)
|
||||
.into_iter()
|
||||
.find(|x| x.names.iter().zip(&cur).all(|(a, b)| a.trim() == b.trim() || (a.trim().is_empty() && b.is_empty())))
|
||||
.map(|x| x.name)
|
||||
}
|
||||
|
||||
/// Every set (built-ins first) and the one in use: `{sets: [{name, names, builtin}], current}`.
|
||||
pub fn label_sets_json(s: &crate::Session) -> Value {
|
||||
let builtin = builtin_label_sets().len();
|
||||
let sets: Vec<Value> =
|
||||
all_label_sets(s).iter().enumerate().map(|(i, x)| json!({"name": x.name, "names": x.names, "builtin": i < builtin})).collect();
|
||||
json!({"sets": sets, "current": current_label_set(s)})
|
||||
}
|
||||
|
||||
fn set_names_ops(s: &crate::Session, names: &[String; 5]) -> Vec<Op> {
|
||||
ColorLabel::ALL
|
||||
.iter()
|
||||
.zip(names)
|
||||
.filter_map(|(l, n)| {
|
||||
let n = n.trim();
|
||||
let name = (!n.is_empty() && !n.eq_ignore_ascii_case(&format!("{l:?}"))).then(|| n.to_string());
|
||||
(s.catalog.custom_label_name(*l) != name.as_deref()).then_some(Op::SetLabelName { label: *l, name })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Geotag photos from a GPX track log by capture time (Lightroom Classic's Map ▸ Tracklog ▸
|
||||
/// Auto-Tag Photos). GPX times are UTC; capture times are the camera's local clock, so a photo's
|
||||
/// recorded zone (Exif `OffsetTimeOriginal`) or the `offset` parameter converts them.
|
||||
fn auto_tag_tracklog(s: &mut crate::Session, p: &Value) -> Result<Value> {
|
||||
use lightcraft_meta::{DateTime, Match, parse_gpx};
|
||||
const C: &str = "photo.autoTagTracklog";
|
||||
let text = match (str_param(p, "gpx"), str_param(p, "path")) {
|
||||
(Some(t), _) => t.to_string(),
|
||||
(None, Some(path)) => std::fs::read_to_string(path).map_err(|e| bad(C, format!("can't read {path}: {e}")))?,
|
||||
(None, None) => return Err(bad(C, "missing `path` (a .gpx file) or `gpx` (its text)")),
|
||||
};
|
||||
let log = parse_gpx(&text).map_err(|e| bad(C, e.to_string()))?;
|
||||
let (start, end) = log.span().ok_or_else(|| bad(C, "the track log has no timed track points"))?;
|
||||
// minutes east of UTC; `None` = not given (zone-less capture times are then read as UTC)
|
||||
let offset: Option<i64> = match p.get("offset") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(o)) if o.trim().is_empty() => None,
|
||||
Some(Value::Number(h)) => {
|
||||
let h = h.as_f64().unwrap_or(0.0);
|
||||
if h.abs() > 18.0 {
|
||||
return Err(bad(C, "offset is at most ±18 hours"));
|
||||
}
|
||||
Some((h * 60.0).round() as i64)
|
||||
}
|
||||
Some(Value::String(o)) => {
|
||||
Some(DateTime::parse_offset(o).ok_or_else(|| bad(C, format!("can't read offset `{o}` (e.g. +02:00 or -5)")))? as i64)
|
||||
}
|
||||
Some(_) => return Err(bad(C, "offset is `+HH:MM` or a number of hours")),
|
||||
};
|
||||
let max_gap = f64_or(p, "maxGap", 600.0);
|
||||
if max_gap.is_nan() || max_gap < 0.0 {
|
||||
return Err(bad(C, "maxGap must be ≥ 0 seconds"));
|
||||
}
|
||||
let replace = bool_or(p, "replace", false);
|
||||
let (mut no_time, mut outside, mut has_gps, mut assumed_utc, mut interpolated) = (0, 0, 0, 0, 0);
|
||||
let mut ops = Vec::new();
|
||||
let mut photos = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
let Some(ph) = s.catalog.photo(id) else { continue };
|
||||
if ph.meta.gps.is_some() && !replace {
|
||||
has_gps += 1;
|
||||
continue;
|
||||
}
|
||||
let Some(dt) = ph.captured.as_deref().and_then(DateTime::parse_iso) else {
|
||||
no_time += 1;
|
||||
continue;
|
||||
};
|
||||
// UTC seconds: the recorded zone wins, else the caller's offset
|
||||
let utc = match dt.offset_minutes {
|
||||
Some(_) => dt.unix_seconds(),
|
||||
None => {
|
||||
if offset.is_none() {
|
||||
assumed_utc += 1;
|
||||
}
|
||||
dt.unix_seconds() - offset.unwrap_or(0) * 60
|
||||
}
|
||||
} as f64
|
||||
+ dt.millis as f64 / 1000.0;
|
||||
let Some((g, m)) = log.locate(utc, max_gap) else {
|
||||
outside += 1;
|
||||
continue;
|
||||
};
|
||||
if m == Match::Interpolated {
|
||||
interpolated += 1;
|
||||
}
|
||||
let pos = ((g.latitude * 1e7).round() / 1e7, (g.longitude * 1e7).round() / 1e7);
|
||||
photos.push(json!({"id": id.0, "gps": [pos.0, pos.1], "match": if m == Match::Interpolated { "interpolated" } else { "nearest" }}));
|
||||
if ph.meta.gps != Some(pos) {
|
||||
let mut meta = ph.meta.clone();
|
||||
meta.gps = Some(pos);
|
||||
ops.push(Op::SetMeta { id, meta: Box::new(meta) });
|
||||
}
|
||||
}
|
||||
let tagged = photos.len();
|
||||
if !bool_or(p, "dryRun", false) && !ops.is_empty() {
|
||||
s.commit("Auto-Tag from Tracklog", Op::Batch { ops })?;
|
||||
}
|
||||
let iso = |t: f64| format!("{}Z", lightcraft_catalog::dates::civil(t.floor() as i64));
|
||||
Ok(json!({
|
||||
"tagged": tagged,
|
||||
"interpolated": interpolated,
|
||||
"nearest": tagged - interpolated,
|
||||
"skipped": {"noTime": no_time, "outside": outside, "hasGps": has_gps},
|
||||
"assumedUtc": assumed_utc,
|
||||
"points": log.points.len(),
|
||||
"start": iso(start),
|
||||
"end": iso(end),
|
||||
"photos": photos,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {seq} {seq:N} {date} {date:%Y%m%d} {camera} {title} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| {
|
||||
cmd!(query "photo.renameTokens", "Rename Template Tags", [], None, "{} → {tokens: [{tag, aliases, meaning, example}], dateDirectives: [{directive, meaning}], notes: [..], sample} — the file-name template tags shared by photo.rename, library.import (rename) and app.export (naming); examples are for a sample photo", always, |_, _| {
|
||||
Ok(crate::rename::token_help_json())
|
||||
}),
|
||||
cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| {
|
||||
let (ids, template, start) = rename_args(s, p, "photo.renamePreview")?;
|
||||
Ok(serde_json::to_value(s.plan_rename(&ids, &template, start)).unwrap_or_default())
|
||||
}),
|
||||
@@ -24,7 +164,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rename Photos",
|
||||
[],
|
||||
None,
|
||||
"{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {seq} {seq:N} {date} {date:%Y%m%d} {camera} {title} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable",
|
||||
"{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let (ids, template, start) = rename_args(s, p, "photo.rename")?;
|
||||
@@ -81,6 +221,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"changed": n, "captured": out}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"photo.autoTagTracklog",
|
||||
"Auto-Tag Photos from Tracklog",
|
||||
[],
|
||||
None,
|
||||
"{path?: GPX file | gpx?: GPX text, ids?, offset?: camera clock's UTC offset for photos whose capture time has no zone (`+02:00`, or hours; default UTC), maxGap?: seconds (600), replace?: bool (also photos that already have GPS), dryRun?: bool} → {tagged, interpolated, nearest, skipped: {noTime, outside, hasGps}, assumedUtc, points, start, end, photos: [{id, gps, match}]} — positions from the track log by capture time; one undo step",
|
||||
has_selection,
|
||||
auto_tag_tracklog
|
||||
),
|
||||
cmd!(query "label.names", "Color Label Names", [], None, "{} → [{label, name, custom}]", always, |s, _| {
|
||||
Ok(json!(ColorLabel::ALL
|
||||
.iter()
|
||||
@@ -112,5 +261,51 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
),
|
||||
cmd!(query "label.sets", "Color Label Sets", [], None, "{} → {sets: [{name, names: [red, yellow, green, blue, purple], builtin}], current: name|null}", always, |s, _| Ok(label_sets_json(s))),
|
||||
cmd!("label.applySet", "Apply Color Label Set", [], None, "{name} — use a set's label names (undoable)", always, |s, p| {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("label.applySet", "missing `name`"))?;
|
||||
let set = all_label_sets(s)
|
||||
.into_iter()
|
||||
.find(|x| x.name.eq_ignore_ascii_case(name))
|
||||
.ok_or_else(|| bad("label.applySet", format!("no label set `{name}`")))?;
|
||||
let ops = set_names_ops(s, &set.names);
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
s.commit(&format!("Label Set: {}", set.name), Op::Batch { ops })?;
|
||||
}
|
||||
Ok(json!({"changed": n}))
|
||||
}),
|
||||
cmd!(
|
||||
"label.saveSet",
|
||||
"Save Color Label Set",
|
||||
[],
|
||||
None,
|
||||
"{name} — save the current label names as a set (replaces a user set of that name)",
|
||||
always,
|
||||
|s, p| {
|
||||
let name =
|
||||
str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("label.saveSet", "missing `name`"))?.to_string();
|
||||
if builtin_label_sets().iter().any(|b| b.name.eq_ignore_ascii_case(&name)) {
|
||||
return Err(bad("label.saveSet", format!("`{name}` is a built-in set")));
|
||||
}
|
||||
let set = LabelSet { name: name.clone(), names: ColorLabel::ALL.map(|l| s.catalog.custom_label_name(l).unwrap_or("").to_string()) };
|
||||
match s.label_sets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
|
||||
Some(x) => *x = set,
|
||||
None => s.label_sets.push(set),
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(json!({"name": name}))
|
||||
}
|
||||
),
|
||||
cmd!("label.deleteSet", "Delete Color Label Set", [], None, "{name} — user sets only", always, |s, p| {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("label.deleteSet", "missing `name`"))?;
|
||||
let before = s.label_sets.len();
|
||||
s.label_sets.retain(|x| !x.name.eq_ignore_ascii_case(name));
|
||||
if s.label_sets.len() == before {
|
||||
return Err(bad("label.deleteSet", format!("no user label set `{name}`")));
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(json!({"deleted": name}))
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
masks.push(Mask {
|
||||
id: next,
|
||||
name: name.unwrap_or_else(|| format!("Mask {next}")),
|
||||
components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }],
|
||||
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
|
||||
..Default::default()
|
||||
});
|
||||
*active = Some(next);
|
||||
@@ -102,7 +102,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let mid = mask_id(p, active, "mask.addComponent")?;
|
||||
masks_edit(s, "mask.addComponent", "Edit Mask", |masks, _| {
|
||||
let i = find(masks, mid, "mask.addComponent")?;
|
||||
masks[i].components.push(MaskComponent { op, invert: bool_or(p, "invert", false), shape });
|
||||
masks[i].components.push(MaskComponent { name: None, op, invert: bool_or(p, "invert", false), shape });
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -144,12 +144,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
};
|
||||
let m = &mut masks[i];
|
||||
if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) =
|
||||
if let Some(MaskComponent { name: None, shape: MaskShape::Brush { strokes }, .. }) =
|
||||
m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. }))
|
||||
{
|
||||
strokes.push(stroke);
|
||||
} else {
|
||||
m.components.push(MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke] } });
|
||||
m.components.push(MaskComponent {
|
||||
name: None,
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::Brush { strokes: vec![stroke] },
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
@@ -167,6 +172,64 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
cmd!(
|
||||
"mask.sampleColor",
|
||||
"Sample Mask Color",
|
||||
[],
|
||||
None,
|
||||
"{x, y: normalized image point, add?: bool (⇧: add to the samples, max 5), id?, component?} — the colour range of the selected mask (its first colour-range component) samples the colour there → {samples}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "mask.sampleColor";
|
||||
let (x, y) = (super::f64_req(p, "x", c)?, super::f64_req(p, "y", c)?);
|
||||
let add = p.get("add").and_then(Value::as_bool).unwrap_or(false);
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let src = s.source_now(id, crate::media::SourceLevel::Thumb).map_err(|e| bad(c, e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let req = lightcraft_pipeline::RenderRequest::fit(384, 384);
|
||||
let lab = lightcraft_pipeline::color_range_sample(&src, &info, &d, &req, lightcraft_geom::Point::new(x, y))
|
||||
.ok_or_else(|| bad(c, "the point is outside the photo"))?;
|
||||
let mid = mask_id(p, s.active_mask, c)?;
|
||||
let comp = p.get("component").and_then(Value::as_u64).map(|v| v as usize);
|
||||
let mut out = Vec::new();
|
||||
masks_edit(s, c, "Color Range", |masks, _| {
|
||||
let i = find(masks, mid, c)?;
|
||||
let k = comp
|
||||
.or_else(|| masks[i].components.iter().position(|x| matches!(x.shape, MaskShape::ColorRange { .. })))
|
||||
.ok_or_else(|| bad(c, "the mask has no colour range"))?;
|
||||
let Some(MaskShape::ColorRange { samples, .. }) = masks[i].components.get_mut(k).map(|x| &mut x.shape) else {
|
||||
return Err(bad(c, "not a colour range component"));
|
||||
};
|
||||
if !add {
|
||||
samples.clear();
|
||||
}
|
||||
if samples.len() < 5 {
|
||||
samples.push(lab);
|
||||
}
|
||||
out = samples.clone();
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"samples": out}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"mask.refine",
|
||||
"Refine Mask Edges",
|
||||
[],
|
||||
None,
|
||||
"{id?, value: 0..100} — the mask's edges snap to the photo's (guided filter; rendered on the CPU)",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let v = f64_or(p, "value", 0.0).clamp(0.0, 100.0);
|
||||
let mid = mask_id(p, s.active_mask, "mask.refine")?;
|
||||
masks_edit(s, "mask.refine", "Refine Edges", |masks, _| {
|
||||
let i = find(masks, mid, "mask.refine")?;
|
||||
masks[i].refine = v;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"mask.adjust",
|
||||
"Set Mask Adjustments",
|
||||
@@ -258,6 +321,54 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"mask.component",
|
||||
"Edit Mask Component",
|
||||
[],
|
||||
None,
|
||||
"{id?, component: index, action: invert|duplicate|delete|rename|op, name? (rename; empty clears), op?: add|subtract|intersect} — one component of a mask (deleting the last one deletes the mask)",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "mask.component";
|
||||
let mid = mask_id(p, s.active_mask, c)?;
|
||||
let k = p.get("component").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing component"))? as usize;
|
||||
let action = str_param(p, "action").ok_or_else(|| bad(c, "missing action"))?.to_string();
|
||||
let op: Option<MaskOp> = match p.get("op") {
|
||||
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(c, e.to_string()))?),
|
||||
None => None,
|
||||
};
|
||||
let label = match action.as_str() {
|
||||
"invert" => "Invert Component",
|
||||
"duplicate" => "Duplicate Component",
|
||||
"delete" => "Delete Component",
|
||||
"rename" => "Rename Component",
|
||||
"op" => "Change Component Mode",
|
||||
a => return Err(bad(c, format!("unknown action {a:?} (invert|duplicate|delete|rename|op)"))),
|
||||
};
|
||||
masks_edit(s, c, label, |masks, active| {
|
||||
let i = find(masks, mid, c)?;
|
||||
let comps = &mut masks[i].components;
|
||||
let comp = comps.get_mut(k).ok_or_else(|| bad(c, "no such component"))?;
|
||||
match action.as_str() {
|
||||
"invert" => comp.invert = !comp.invert,
|
||||
"rename" => comp.name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string),
|
||||
"op" => comp.op = op.ok_or_else(|| bad(c, "missing op"))?,
|
||||
"duplicate" => {
|
||||
let copy = comp.clone();
|
||||
comps.insert(k + 1, copy);
|
||||
}
|
||||
_ => {
|
||||
comps.remove(k);
|
||||
if comps.is_empty() {
|
||||
masks.remove(i);
|
||||
*active = masks.last().map(|m| m.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!("mask.move", "Move Mask", [], None, "{id?, to?: index (0 = top), delta?: ±n} — reorder the masks list", has_active, |s, p| {
|
||||
let mid = mask_id(p, s.active_mask, "mask.move")?;
|
||||
let mut masks = s.active().and_then(|id| s.develop_of(id)).map(|d| d.masks.clone()).unwrap_or_default();
|
||||
@@ -279,6 +390,42 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
}),
|
||||
// ---- Remove tool (spots)
|
||||
cmd!(
|
||||
"spot.findDust",
|
||||
"Find Dust Spots",
|
||||
[],
|
||||
None,
|
||||
"{sensitivity?: 0..100 (50), add?: bool (true)} — find sensor-dust spots (small soft dark spots on smooth areas) and add a heal spot on each (one undo step) → {spots: [{x, y, size}], added}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "spot.findDust";
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
// the uncropped photo, so positions are the spots' own coordinates
|
||||
let job = s.render_job(id, 1600, 1600, false, false).ok_or_else(|| bad(c, "no photo"))?;
|
||||
let img = job.run().rendered.map_err(|e| bad(c, e))?.image;
|
||||
let found = lightcraft_pipeline::dust::detect(&img, f64_or(p, "sensitivity", 50.0) as f32);
|
||||
let out: Vec<Value> = found.iter().map(|d| json!({"x": d.x, "y": d.y, "size": d.radius})).collect();
|
||||
if !p.get("add").and_then(Value::as_bool).unwrap_or(true) || found.is_empty() {
|
||||
return Ok(json!({"spots": out, "added": 0}));
|
||||
}
|
||||
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
let base = Spot::default();
|
||||
for d in &found {
|
||||
let mut spot = Spot {
|
||||
mode: SpotMode::Heal,
|
||||
points: vec![Point::new(d.x, d.y)],
|
||||
size: d.radius.clamp(SPOT_SIZE.0, SPOT_SIZE.1),
|
||||
feather: base.feather,
|
||||
opacity: 100.0,
|
||||
source_offset: None,
|
||||
};
|
||||
spot.source_offset = pick_source(s, id, &dd, &spot, None);
|
||||
dd.spots.push(spot);
|
||||
}
|
||||
s.set_develop(id, dd, "Find Dust Spots")?;
|
||||
Ok(json!({"spots": out, "added": found.len()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"spot.add",
|
||||
"Add Remove Spot",
|
||||
|
||||
@@ -30,7 +30,8 @@ fn run(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||||
if let (Some(path), Some(img)) = (str_param(p, "previewPath"), &out.preview) {
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default())
|
||||
.map_err(|e| bad(id, e.to_string()))?;
|
||||
std::fs::write(path, png).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
s.check_write_target(path).map_err(|e| bad(id, e))?;
|
||||
crate::export::write_file(path, &png).map_err(|e| bad(id, e))?;
|
||||
info["previewPath"] = json!(path);
|
||||
}
|
||||
if let Some(img) = &out.preview {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Metadata presets: named sets of descriptive fields (copyright, creator, place, keywords…)
|
||||
//! applied to photos in one step or to every import (Settings → Import). Saved with the library.
|
||||
|
||||
use lightcraft_catalog::{CopyrightStatus, Meta};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The fields a metadata preset can hold (photo.setMeta names).
|
||||
pub const FIELDS: &[&str] = &[
|
||||
"title",
|
||||
"caption",
|
||||
"copyright",
|
||||
"copyrightStatus",
|
||||
"usageTerms",
|
||||
"copyrightUrl",
|
||||
"creator",
|
||||
"location",
|
||||
"city",
|
||||
"state",
|
||||
"country",
|
||||
"keywords",
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MetadataPreset {
|
||||
pub name: String,
|
||||
/// photo.setMeta fields; `keywords` are added, not replaced.
|
||||
pub fields: Value,
|
||||
}
|
||||
|
||||
fn text_field<'a>(m: &'a mut Meta, k: &str) -> Option<&'a mut String> {
|
||||
Some(match k {
|
||||
"title" => &mut m.title,
|
||||
"caption" => &mut m.caption,
|
||||
"copyright" => &mut m.copyright,
|
||||
"usageTerms" => &mut m.usage_terms,
|
||||
"copyrightUrl" => &mut m.copyright_url,
|
||||
"creator" => &mut m.creator,
|
||||
"location" => &mut m.location,
|
||||
"city" => &mut m.city,
|
||||
"state" => &mut m.state,
|
||||
"country" => &mut m.country,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply preset `fields` to `m`: text fields replace, keywords are added.
|
||||
pub fn apply_to(m: &mut Meta, fields: &Value) {
|
||||
for (k, v) in fields.as_object().into_iter().flatten() {
|
||||
if k == "copyrightStatus" {
|
||||
if let Some(st) = v.as_str().and_then(CopyrightStatus::parse) {
|
||||
m.copyright_status = st;
|
||||
}
|
||||
} else if k == "keywords" {
|
||||
for kw in v.as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
if !m.keywords.iter().any(|x| x.eq_ignore_ascii_case(kw)) {
|
||||
m.keywords.push(kw.to_string());
|
||||
}
|
||||
}
|
||||
} else if let (Some(f), Some(t)) = (text_field(m, k), v.as_str()) {
|
||||
*f = t.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn list(s: &Session) -> Value {
|
||||
json!(s.metadata_presets)
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "metadata.savePreset";
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?.to_string();
|
||||
let fields = match p.get("fields") {
|
||||
Some(Value::Object(o)) => {
|
||||
if let Some(k) = o.keys().find(|k| !FIELDS.contains(&k.as_str())) {
|
||||
return Err(bad(C, format!("unknown field `{k}` ({})", FIELDS.join(", "))));
|
||||
}
|
||||
if let Some(v) = o.get("copyrightStatus").filter(|v| v.as_str().and_then(CopyrightStatus::parse).is_none()) {
|
||||
return Err(bad(C, format!("copyrightStatus {v}: unknown, copyrighted or publicDomain")));
|
||||
}
|
||||
Value::Object(o.clone())
|
||||
}
|
||||
Some(_) => return Err(bad(C, "`fields` must be an object")),
|
||||
// from the active photo: its non-empty fields among `only` (default: copyright, creator, place)
|
||||
None => {
|
||||
let id = s.active().ok_or_else(|| bad(C, "no `fields` and no active photo"))?;
|
||||
let mut m = s.catalog.photo(id).ok_or_else(|| bad(C, "no photo"))?.meta.clone();
|
||||
let only: Vec<String> =
|
||||
p.get("only").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_else(
|
||||
|| {
|
||||
["copyright", "copyrightStatus", "usageTerms", "copyrightUrl", "creator", "location", "city", "state", "country"]
|
||||
.map(String::from)
|
||||
.to_vec()
|
||||
},
|
||||
);
|
||||
let mut o = Map::new();
|
||||
for k in only.iter().filter(|k| FIELDS.contains(&k.as_str())) {
|
||||
if k == "keywords" {
|
||||
if !m.keywords.is_empty() {
|
||||
o.insert(k.clone(), json!(m.keywords));
|
||||
}
|
||||
} else if k == "copyrightStatus" {
|
||||
if !m.copyright_status.is_unknown() {
|
||||
o.insert(k.clone(), json!(m.copyright_status.id()));
|
||||
}
|
||||
} else if let Some(v) = text_field(&mut m, k).filter(|v| !v.trim().is_empty()) {
|
||||
o.insert(k.clone(), json!(v.clone()));
|
||||
}
|
||||
}
|
||||
Value::Object(o)
|
||||
}
|
||||
};
|
||||
let preset = MetadataPreset { name: name.clone(), fields };
|
||||
match s.metadata_presets.iter_mut().find(|m| m.name.eq_ignore_ascii_case(&name)) {
|
||||
Some(m) => *m = preset,
|
||||
None => s.metadata_presets.push(preset),
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "metadata.deletePreset";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let n = s.metadata_presets.len();
|
||||
s.metadata_presets.retain(|m| !m.name.eq_ignore_ascii_case(name));
|
||||
if s.metadata_presets.len() == n {
|
||||
return Err(bad(C, format!("no metadata preset `{name}`")));
|
||||
}
|
||||
if s.import_defaults.metadata_preset.as_deref().is_some_and(|m| m.eq_ignore_ascii_case(name)) {
|
||||
s.import_defaults.metadata_preset = None;
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
/// photo.setMeta with the preset's fields (keywords added) on `ids` / the selection.
|
||||
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "metadata.applyPreset";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let preset = s
|
||||
.metadata_presets
|
||||
.iter()
|
||||
.find(|m| m.name.eq_ignore_ascii_case(name))
|
||||
.cloned()
|
||||
.ok_or_else(|| bad(C, format!("no metadata preset `{name}`")))?;
|
||||
let mut params = Map::new();
|
||||
for (k, v) in preset.fields.as_object().into_iter().flatten() {
|
||||
params.insert(if k == "keywords" { "addKeywords".into() } else { k.clone() }, v.clone());
|
||||
}
|
||||
if let Some(ids) = p.get("ids") {
|
||||
params.insert("ids".into(), ids.clone());
|
||||
}
|
||||
s.execute("photo.setMeta", &Value::Object(params))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "metadata.presets", "Metadata Presets", [], None, "{} → [{name, fields}]", always, |s, _| Ok(list(s))),
|
||||
cmd!(
|
||||
"metadata.savePreset",
|
||||
"Save Metadata Preset",
|
||||
[],
|
||||
None,
|
||||
"{name, fields?: {copyright?, copyrightStatus?: unknown|copyrighted|publicDomain, usageTerms?, copyrightUrl?, creator?, title?, caption?, location?, city?, state?, country?, keywords?: [..]}, only?: [field] (from the active photo when `fields` is absent; default the copyright fields, creator, place)} — adds or replaces",
|
||||
always,
|
||||
save
|
||||
),
|
||||
cmd!("metadata.deletePreset", "Delete Metadata Preset", [], None, "{name}", always, delete),
|
||||
cmd!(
|
||||
"metadata.applyPreset",
|
||||
"Apply Metadata Preset",
|
||||
[],
|
||||
None,
|
||||
"{name, ids?} — text fields replace, keywords are added; one undo step",
|
||||
always,
|
||||
apply
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -1,33 +1,58 @@
|
||||
//! Missing originals: photos whose file is no longer where the library expects it (moved,
|
||||
//! renamed, on an unplugged drive), and relinking them — one at a time (`photo.relink`) or by
|
||||
//! searching a folder for files with the same name and size (`library.findMissing`).
|
||||
//! searching a folder for files with the same name and size, or the same content
|
||||
//! (`library.findMissing`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId, Source};
|
||||
use lightcraft_catalog::{Catalog, Op, Photo, PhotoId, Source};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The file Missing Photos checks for `p`, if it is in scope: a library photo (not in Recently
|
||||
/// Deleted, not a Local-only record seen while browsing a folder) whose original is a file.
|
||||
///
|
||||
/// Local browse records are out of scope on purpose: browsing can leave tens of thousands of
|
||||
/// them in the catalog, they never show in library views (Missing Photos included), and a
|
||||
/// folder that is gone is simply not browsed again. The sidebar count, the Missing Photos view,
|
||||
/// `library.missing` and Find Missing Photos all use this one rule.
|
||||
pub fn checked_path(p: &Photo) -> Option<&str> {
|
||||
match &p.source {
|
||||
Source::File { path } if p.in_library() => Some(path),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every in-scope photo's file ([`checked_path`]), without touching the disk.
|
||||
pub fn candidates(cat: &Catalog) -> Vec<String> {
|
||||
cat.photos().filter_map(|p| checked_path(p).map(str::to_string)).collect()
|
||||
}
|
||||
|
||||
/// [`missing`] with the existence check supplied (tests count the file-system calls).
|
||||
pub fn missing_with(cat: &Catalog, mut exists: impl FnMut(&str) -> bool) -> Vec<(PhotoId, String)> {
|
||||
cat.photos().filter_map(|p| checked_path(p).filter(|f| !exists(f)).map(|f| (p.id, f.to_string()))).collect()
|
||||
}
|
||||
|
||||
/// Library photos whose original file can't be found: (id, path).
|
||||
pub fn missing(s: &Session) -> Vec<(PhotoId, String)> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Vec::new();
|
||||
}
|
||||
s.catalog
|
||||
.photos()
|
||||
.filter_map(|p| match &p.source {
|
||||
Source::File { path } if !p.deleted && !Path::new(path).exists() => Some((p.id, path.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
missing_with(&s.catalog, |f| Path::new(f).exists())
|
||||
}
|
||||
|
||||
/// Whether photo `id` is in scope and its file is gone (the Missing Photos view checks only
|
||||
/// the photos its query already narrowed to).
|
||||
pub fn is_missing(cat: &Catalog, id: PhotoId) -> bool {
|
||||
!cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| !Path::new(f).exists())
|
||||
}
|
||||
|
||||
fn relink_op(id: PhotoId, path: &str) -> Op {
|
||||
let file_name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
Op::Relink { id, file_name, source: Source::File { path: path.to_string() } }
|
||||
Op::Relink { id, file_name, source: Source::File { path: path.to_string() }, format: None }
|
||||
}
|
||||
|
||||
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -42,13 +67,46 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "only photos from files can be relinked"));
|
||||
}
|
||||
let abs = abs.to_string_lossy().to_string();
|
||||
s.commit("Relink Photo", relink_op(id, &abs))?;
|
||||
// the file may differ from the one imported (another export, a raw that decodes now or not):
|
||||
// its size, dimensions and preview-only state follow it
|
||||
let mut ops = vec![relink_op(id, &abs)];
|
||||
if s.media.file_probe.is_some()
|
||||
&& let (Some(Ok(info)), Some(ph)) = (crate::import::probe_paths(s, std::slice::from_ref(&abs)).pop(), s.catalog.photo(id))
|
||||
{
|
||||
ops.extend(crate::cmd::convert::content_op(id, ph, info));
|
||||
}
|
||||
let op = match <[Op; 1]>::try_from(ops) {
|
||||
Ok([op]) => op,
|
||||
Err(ops) => Op::Batch { ops },
|
||||
};
|
||||
s.commit("Relink Photo", op)?;
|
||||
s.media.forget(id);
|
||||
Ok(json!({"id": id.0, "path": abs}))
|
||||
}
|
||||
|
||||
/// Search `folder` (recursively) for each missing photo's file name — and its size when the
|
||||
/// library knows it — and relink every match in one undoable step.
|
||||
/// The stored content hash of a photo, without the `:dupN` suffix duplicates get.
|
||||
fn stored_hash(p: &Photo) -> Option<&str> {
|
||||
p.content_hash.as_deref().map(|h| h.split(':').next().unwrap_or(h)).filter(|h| !h.is_empty())
|
||||
}
|
||||
|
||||
/// The content hash of the file at `path` (as import computes it), if it can be read.
|
||||
fn file_hash(path: &str) -> Option<String> {
|
||||
std::fs::read(path).ok().map(|b| lightcraft_preview::hash_bytes(&b).to_string())
|
||||
}
|
||||
|
||||
/// Search `folder` (recursively) for each missing photo's file and relink every match in one
|
||||
/// undoable step.
|
||||
///
|
||||
/// Matching, per photo:
|
||||
/// 1. a file with the same name (any letter case), the same size and the same content hash;
|
||||
/// 2. renamed: a file of the same size with the same content hash (only same-size files are
|
||||
/// read, so this stays cheap);
|
||||
/// 3. without a hash to confirm (or the file was edited in place): the one file with the same
|
||||
/// name and size. Several such files are ambiguous: the photo is skipped and reported in
|
||||
/// `ambiguous`, never guessed (two cameras' `IMG_0001.CR3` of one size are different photos).
|
||||
///
|
||||
/// Files that are already some photo's original, or that another photo was just matched with,
|
||||
/// are not used.
|
||||
fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.findMissing";
|
||||
let folder = str_param(p, "folder").ok_or_else(|| bad(C, "missing `folder`"))?;
|
||||
@@ -57,27 +115,72 @@ fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let lost = missing(s);
|
||||
if lost.is_empty() {
|
||||
return Ok(json!({"found": [], "missing": 0}));
|
||||
return Ok(json!({"found": [], "missing": 0, "ambiguous": []}));
|
||||
}
|
||||
// file name (lower case) → candidate paths
|
||||
let in_use: HashSet<String> = s.catalog.photos().filter_map(|p| checked_path(p).map(str::to_string)).collect();
|
||||
// file name (lower case) → candidate paths; size → paths (filled lazily)
|
||||
let mut by_name: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for f in crate::import::expand(&[folder.to_string()], None) {
|
||||
if let Some(n) = Path::new(&f).file_name() {
|
||||
by_name.entry(n.to_string_lossy().to_lowercase()).or_default().push(f);
|
||||
let files: Vec<String> = crate::import::expand(&[folder.to_string()], None).into_iter().filter(|f| !in_use.contains(f)).collect();
|
||||
for f in &files {
|
||||
if let Some(n) = Path::new(f).file_name() {
|
||||
by_name.entry(n.to_string_lossy().to_lowercase()).or_default().push(f.clone());
|
||||
}
|
||||
}
|
||||
let mut by_size: Option<HashMap<u64, Vec<String>>> = None;
|
||||
let mut hashes: HashMap<String, Option<String>> = HashMap::new();
|
||||
let mut hash_of = |f: &str| hashes.entry(f.to_string()).or_insert_with(|| file_hash(f)).clone();
|
||||
let size_of = |f: &str| std::fs::metadata(f).map(|m| m.len()).ok();
|
||||
let mut used: HashSet<String> = HashSet::new();
|
||||
let mut ops = Vec::new();
|
||||
let mut found = Vec::new();
|
||||
let mut ambiguous = Vec::new();
|
||||
for (id, old) in &lost {
|
||||
let Some(ph) = s.catalog.photo(*id) else { continue };
|
||||
let name = Path::new(old).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
|
||||
let size = s.catalog.photo(*id).map(|p| p.file_size).unwrap_or(0);
|
||||
let hit = by_name.get(&name).and_then(|c| {
|
||||
c.iter().find(|f| size == 0 || std::fs::metadata(f).is_ok_and(|m| m.len() == size)).or_else(|| (c.len() == 1 && size == 0).then(|| &c[0]))
|
||||
});
|
||||
if let Some(path) = hit {
|
||||
ops.push(relink_op(*id, path));
|
||||
found.push(json!({"id": id.0, "from": old, "to": path}));
|
||||
let size = ph.file_size;
|
||||
let hash = stored_hash(ph).map(str::to_string);
|
||||
let named: Vec<&String> = by_name
|
||||
.get(&name)
|
||||
.map(|c| c.iter().filter(|f| !used.contains(*f) && (size == 0 || size_of(f) == Some(size))).collect())
|
||||
.unwrap_or_default();
|
||||
// 1. same name and size, confirmed by the content hash
|
||||
let mut hit: Option<(String, &str)> =
|
||||
hash.as_deref().and_then(|h| named.iter().find(|f| hash_of(f).as_deref() == Some(h))).map(|f| (f.to_string(), "name"));
|
||||
// 2. renamed: same size and content
|
||||
if hit.is_none()
|
||||
&& let Some(h) = &hash
|
||||
&& size > 0
|
||||
{
|
||||
let sizes = by_size.get_or_insert_with(|| {
|
||||
let mut m: HashMap<u64, Vec<String>> = HashMap::new();
|
||||
for f in &files {
|
||||
if let Some(n) = size_of(f) {
|
||||
m.entry(n).or_default().push(f.clone());
|
||||
}
|
||||
}
|
||||
m
|
||||
});
|
||||
hit = sizes
|
||||
.get(&size)
|
||||
.and_then(|c| c.iter().find(|f| !used.contains(*f) && hash_of(f).as_deref() == Some(h.as_str())))
|
||||
.map(|f| (f.clone(), "content"));
|
||||
}
|
||||
// 3. same name and size only (no hash, or the file was edited in place): one candidate
|
||||
// is taken, several are ambiguous and skipped rather than guessed
|
||||
if hit.is_none() {
|
||||
match named.len() {
|
||||
1 => hit = Some((named[0].clone(), "name")),
|
||||
0 => {}
|
||||
_ => {
|
||||
ambiguous.push(json!({"id": id.0, "from": old, "candidates": named}));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((path, by)) = hit else { continue };
|
||||
ops.push(relink_op(*id, &path));
|
||||
found.push(json!({"id": id.0, "from": old, "to": path, "by": by}));
|
||||
used.insert(path);
|
||||
}
|
||||
let still = lost.len() - found.len();
|
||||
if !ops.is_empty() {
|
||||
@@ -88,7 +191,7 @@ fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"found": found, "missing": still}))
|
||||
Ok(json!({"found": found, "missing": still, "ambiguous": ambiguous}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -110,7 +213,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Find Missing Photos",
|
||||
[],
|
||||
None,
|
||||
"{folder} — relink every missing photo whose file (same name and size) is somewhere in the folder → {found: [{id, from, to}], missing}",
|
||||
"{folder} — relink every missing photo whose file is somewhere in the folder: same name and size (and content, when the library knows its hash), or — renamed — same size and content → {found: [{id, from, to, by: name|content}], missing, ambiguous: [{id, from, candidates}] (several same-name candidates and no hash to tell them apart: skipped)}",
|
||||
always,
|
||||
find_missing
|
||||
),
|
||||
|
||||
@@ -7,18 +7,25 @@
|
||||
mod before;
|
||||
mod browse;
|
||||
mod color;
|
||||
pub(crate) mod convert;
|
||||
mod cull;
|
||||
pub mod curves;
|
||||
mod develop;
|
||||
mod edit;
|
||||
mod export;
|
||||
mod keywords;
|
||||
pub mod filters;
|
||||
pub mod keywords;
|
||||
mod library;
|
||||
mod manage;
|
||||
pub mod lut_profiles;
|
||||
pub mod manage;
|
||||
mod masks;
|
||||
mod merge;
|
||||
mod missing;
|
||||
pub mod metadata;
|
||||
pub mod missing;
|
||||
mod organize;
|
||||
mod prefs;
|
||||
mod preset_files;
|
||||
pub mod previews;
|
||||
mod query;
|
||||
mod xmp;
|
||||
|
||||
@@ -112,10 +119,16 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(library::specs());
|
||||
v.extend(develop::specs());
|
||||
v.extend(color::specs());
|
||||
v.extend(curves::specs());
|
||||
v.extend(masks::specs());
|
||||
v.extend(organize::specs());
|
||||
v.extend(keywords::specs());
|
||||
v.extend(manage::specs());
|
||||
v.extend(previews::specs());
|
||||
v.extend(lut_profiles::specs());
|
||||
v.extend(cull::specs());
|
||||
v.extend(convert::specs());
|
||||
v.extend(convert::edit_specs());
|
||||
v.extend(merge::specs());
|
||||
v.extend(query::specs());
|
||||
v.extend(xmp::specs());
|
||||
@@ -125,6 +138,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(before::specs());
|
||||
v.extend(browse::specs());
|
||||
v.extend(missing::specs());
|
||||
v.extend(metadata::specs());
|
||||
v.extend(filters::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use lightcraft_catalog::{HistoryStep, Op, PhotoId, Stack, StackId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, has_selection};
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, has_selection, str_param};
|
||||
use crate::{Result, Selection, Session};
|
||||
|
||||
/// Stacks containing any of `targets`, in first-seen order.
|
||||
@@ -65,8 +65,8 @@ fn copy_number(name: Option<&str>) -> u32 {
|
||||
|
||||
/// Create one virtual copy of `src`: a new photo sharing its file, with the same settings and
|
||||
/// metadata, added to the same albums and stacked right after it (the stack is expanded so the
|
||||
/// copy is visible).
|
||||
fn virtual_copy(s: &mut Session, src: PhotoId) -> Result<Option<PhotoId>> {
|
||||
/// copy is visible). `name` replaces the default "Copy N".
|
||||
fn virtual_copy(s: &mut Session, src: PhotoId, name: Option<&str>) -> Result<Option<PhotoId>> {
|
||||
let Some(orig) = s.catalog.photo(src).cloned() else { return Ok(None) };
|
||||
let master = orig.copy_of.unwrap_or(src);
|
||||
let n = 1 + s.catalog.photos().filter(|p| p.copy_of == Some(master)).map(|p| copy_number(p.copy_name.as_deref())).max().unwrap_or(0);
|
||||
@@ -74,7 +74,7 @@ fn virtual_copy(s: &mut Session, src: PhotoId) -> Result<Option<PhotoId>> {
|
||||
let mut c = (*orig).clone();
|
||||
c.id = id;
|
||||
c.copy_of = Some(master);
|
||||
c.copy_name = Some(format!("Copy {n}"));
|
||||
c.copy_name = Some(name.map_or_else(|| format!("Copy {n}"), str::to_string));
|
||||
c.versions.clear();
|
||||
c.history = vec![HistoryStep { label: "Virtual Copy".into(), settings: c.develop.clone() }];
|
||||
c.deleted = false;
|
||||
@@ -108,13 +108,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Virtual Copy",
|
||||
["Photo"],
|
||||
Some("Cmd+'"),
|
||||
"{ids?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named \"Copy N\"",
|
||||
"{ids?, name?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named `name` or \"Copy N\"",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let targets = s.targets(p);
|
||||
let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string);
|
||||
let mut made = Vec::new();
|
||||
for t in targets {
|
||||
if let Some(id) = virtual_copy(s, t)? {
|
||||
if let Some(id) = virtual_copy(s, t, name.as_deref())? {
|
||||
made.push(id);
|
||||
}
|
||||
}
|
||||
@@ -164,6 +165,29 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let op = s.catalog.set_top_ops(id);
|
||||
Ok(json!({"changed": commit_ops(s, "Set Top of Stack", op.into_iter().collect())?}))
|
||||
}),
|
||||
cmd!("stack.moveUp", "Move Up in Stack", ["Photo", "Stack"], None, "{id?} — one place towards the top", in_stack, |s, p| {
|
||||
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.moveUp", "no photo"))?;
|
||||
let op = s.catalog.move_in_stack_ops(id, -1);
|
||||
Ok(json!({"changed": commit_ops(s, "Move in Stack", op.into_iter().collect())?}))
|
||||
}),
|
||||
cmd!("stack.moveDown", "Move Down in Stack", ["Photo", "Stack"], None, "{id?} — one place away from the top", in_stack, |s, p| {
|
||||
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.moveDown", "no photo"))?;
|
||||
let op = s.catalog.move_in_stack_ops(id, 1);
|
||||
Ok(json!({"changed": commit_ops(s, "Move in Stack", op.into_iter().collect())?}))
|
||||
}),
|
||||
cmd!(
|
||||
"stack.split",
|
||||
"Split Stack",
|
||||
["Photo", "Stack"],
|
||||
None,
|
||||
"{id?} — this photo and the ones after it become their own stack",
|
||||
in_stack,
|
||||
|s, p| {
|
||||
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.split", "no photo"))?;
|
||||
let op = s.catalog.split_stack_ops(id).ok_or_else(|| bad("stack.split", "pick a photo below the top of a stack"))?;
|
||||
Ok(json!({"changed": commit_ops(s, "Split Stack", vec![op])?}))
|
||||
}
|
||||
),
|
||||
cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| {
|
||||
let stacks = stacks_of(s, &s.targets(p));
|
||||
let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false);
|
||||
|
||||
@@ -14,6 +14,7 @@ fn current(s: &Session) -> Value {
|
||||
"xmp": s.xmp,
|
||||
"import": s.import_defaults,
|
||||
"cacheMb": if s.cache_mb == 0 { s.cache_bytes() >> 20 } else { u64::from(s.cache_mb) },
|
||||
"forgetLocalDays": s.forget_local_days,
|
||||
"persistent": s.library.is_some(),
|
||||
})
|
||||
}
|
||||
@@ -48,6 +49,17 @@ fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = i.get("creator").and_then(Value::as_str) {
|
||||
d.creator = v.trim().to_string();
|
||||
}
|
||||
match i.get("metadataPreset") {
|
||||
Some(Value::String(n)) if n.is_empty() || n == "none" => d.metadata_preset = None,
|
||||
Some(Value::String(n)) => {
|
||||
if !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n)) {
|
||||
return Err(bad(ID, format!("unknown metadata preset `{n}` (see metadata.presets)")));
|
||||
}
|
||||
d.metadata_preset = Some(n.clone());
|
||||
}
|
||||
Some(Value::Null) => d.metadata_preset = None,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(a) = i.get("cameras").and_then(Value::as_array) {
|
||||
d.cameras.clear();
|
||||
for c in a {
|
||||
@@ -75,6 +87,11 @@ fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
changed = true;
|
||||
}
|
||||
s.import_defaults = d;
|
||||
if let Some(v) = p.get("forgetLocalDays") {
|
||||
let days = v.as_u64().ok_or_else(|| bad(ID, "forgetLocalDays must be a whole number of days (0 = never)"))?;
|
||||
s.forget_local_days = days.min(36_500) as u32;
|
||||
changed = true;
|
||||
}
|
||||
if let Some(mb) = p.get("cacheMb") {
|
||||
let mb = mb.as_u64().ok_or_else(|| bad(ID, "cacheMb must be a number of megabytes"))?;
|
||||
s.set_cache_mb(mb.clamp(0, 1 << 20) as u32)?;
|
||||
@@ -90,7 +107,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)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, 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)}, 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
|
||||
)]
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Preset files: import `.lcpreset` / XMP presets, export `.lcpreset`.
|
||||
//! Preset files: import `.lcpreset`, XMP, `.lrtemplate`, DNG-preset, Luminar looks and zip bundles; export `.lcpreset`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::presets::{LCPRESET_EXT, expand_preset_paths, parse_preset_file, to_lcpreset};
|
||||
use crate::preset_import::{group_from_dir, read_presets};
|
||||
use crate::presets::{LCPRESET_EXT, expand_preset_paths, to_lcpreset};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn strings(p: &Value, key: &str) -> Option<Vec<String>> {
|
||||
@@ -12,23 +13,36 @@ fn strings(p: &Value, key: &str) -> Option<Vec<String>> {
|
||||
|
||||
fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let paths = strings(p, "paths").filter(|v| !v.is_empty()).ok_or_else(|| bad("preset.import", "no paths"))?;
|
||||
let group = str_param(p, "group").map(str::trim).filter(|g| !g.is_empty()).map(str::to_string);
|
||||
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
|
||||
let mut imported = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let mut skipped = 0usize;
|
||||
for f in expand_preset_paths(&paths) {
|
||||
let parsed = std::fs::read(&f).map_err(|e| e.to_string()).and_then(|b| parse_preset_file(&f, &b));
|
||||
match parsed {
|
||||
Ok(list) => {
|
||||
let n = list.len();
|
||||
let ids = s.add_presets(list);
|
||||
skipped += n - ids.len();
|
||||
for id in ids {
|
||||
if let Some(pr) = s.presets.iter().find(|x| x.id == id) {
|
||||
imported.push(json!({"id": pr.id, "name": pr.name, "group": pr.group}));
|
||||
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)) {
|
||||
// presets in a folder go to a group named after it (unless the file names its own)
|
||||
// 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));
|
||||
match parsed {
|
||||
Ok(list) => {
|
||||
for mut it in list {
|
||||
if let Some(g) = &group {
|
||||
it.preset.group = g.clone();
|
||||
}
|
||||
let (name, grp) = (it.preset.name.clone(), it.preset.group.clone());
|
||||
let id = if dry { Some(it.preset.id.clone()) } else { s.add_presets(vec![it.preset]).pop() };
|
||||
match id {
|
||||
Some(id) => imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped})),
|
||||
None => skipped += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => failed.push(json!([f, e])),
|
||||
}
|
||||
Err(e) => failed.push(json!([f, e])),
|
||||
}
|
||||
}
|
||||
Ok(json!({"imported": imported, "skipped": skipped, "failed": failed}))
|
||||
@@ -69,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Import Presets",
|
||||
[],
|
||||
None,
|
||||
"{paths: [.lcpreset / .xmp file or folder]} — groups preserved; XMP presets map crs: fields (docs/xmp-interop.md) → {imported: [{id,name,group}], 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 → {imported: [{id,name,group,file,unmapped}], skipped, failed}",
|
||||
always,
|
||||
import
|
||||
),
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Build previews ahead of time: each photo's grid thumbnail and its loupe view (standard size,
|
||||
//! or 1:1), rendered into the memory + disk preview cache so browsing and the loupe are instant.
|
||||
//! Runs on a background thread (the app keeps working; progress / cancel by command), or inline
|
||||
//! with `wait` (CLI, MCP, tests).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::media::{RenderJob, THUMB_SIZES};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The long edge of a standard-sized preview.
|
||||
pub const STANDARD_EDGE: usize = 2048;
|
||||
|
||||
/// A preview build in progress.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PreviewBuild {
|
||||
pub total: usize,
|
||||
pub done: AtomicUsize,
|
||||
pub failed: AtomicUsize,
|
||||
pub cancel: AtomicBool,
|
||||
pub finished: AtomicBool,
|
||||
}
|
||||
|
||||
impl PreviewBuild {
|
||||
pub fn json(&self) -> Value {
|
||||
json!({
|
||||
"total": self.total,
|
||||
"done": self.done.load(Ordering::Relaxed),
|
||||
"failed": self.failed.load(Ordering::Relaxed),
|
||||
"running": !self.finished.load(Ordering::Relaxed),
|
||||
"cancelled": self.cancel.load(Ordering::Relaxed),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The jobs that build `id`'s previews: the largest grid thumbnail, then the view render at
|
||||
/// `edge` (`None` = 1:1, the photo's full size).
|
||||
fn jobs_for(s: &mut Session, id: lightcraft_catalog::PhotoId, edge: Option<usize>) -> Vec<RenderJob> {
|
||||
let Some(p) = s.catalog.photo(id) else { return Vec::new() };
|
||||
let full = p.width.max(p.height) as usize;
|
||||
let e = edge.unwrap_or(full).min(full.max(1)).max(1);
|
||||
let mut v = Vec::new();
|
||||
v.extend(s.thumb_job(id, THUMB_SIZES[THUMB_SIZES.len() - 1]));
|
||||
v.extend(s.loupe_job(id, e, e, true));
|
||||
v
|
||||
}
|
||||
|
||||
fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild) {
|
||||
for photo_jobs in jobs {
|
||||
if state.cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let ok = photo_jobs.into_iter().all(|j| j.run().rendered.is_ok());
|
||||
if !ok {
|
||||
state.failed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
state.done.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
state.finished.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn build(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.buildPreviews";
|
||||
let edge = match str_param(p, "size").unwrap_or("standard") {
|
||||
"standard" => Some(p.get("edge").and_then(Value::as_u64).map_or(STANDARD_EDGE, |e| e.clamp(256, 8192) as usize)),
|
||||
"full" | "1:1" => None,
|
||||
other => return Err(bad(C, format!("unknown size `{other}` (standard|full)"))),
|
||||
};
|
||||
if s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) {
|
||||
return Err(bad(C, "a preview build is already running"));
|
||||
}
|
||||
// explicit ids, else the selection, else everything in view
|
||||
let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
|
||||
let jobs: Vec<Vec<RenderJob>> = ids.iter().map(|id| jobs_for(s, *id, edge)).filter(|j| !j.is_empty()).collect();
|
||||
let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() });
|
||||
s.preview_build = Some(state.clone());
|
||||
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false) || cfg!(target_arch = "wasm32");
|
||||
if wait {
|
||||
run_all(jobs, &state);
|
||||
return Ok(state.json());
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let st = state.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("lc-build-previews".into())
|
||||
.spawn(move || run_all(jobs, &st))
|
||||
.map_err(|e| bad(C, format!("could not start: {e}")))?;
|
||||
}
|
||||
Ok(state.json())
|
||||
}
|
||||
|
||||
/// Smart previews: build (from the original, at preview size) or discard the proxies that keep
|
||||
/// photos editable while their originals are offline.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.smartPreviews";
|
||||
let dir = s.media.smart_dir.clone().ok_or_else(|| bad(C, "smart previews need a library on disk"))?;
|
||||
let discard = p.get("discard").and_then(Value::as_bool).unwrap_or(false);
|
||||
let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
|
||||
let (mut built, mut repaired, mut removed, mut failed) = (0usize, 0usize, 0usize, Vec::new());
|
||||
if !discard {
|
||||
// a chosen folder on another drive must be there: never recreate it on this one
|
||||
if s.smart_previews_dir.is_some() {
|
||||
crate::smart::check_writable(&dir).map_err(|e| bad(C, e))?;
|
||||
} else {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?;
|
||||
}
|
||||
}
|
||||
for id in ids {
|
||||
let Some(ph) = s.catalog.photo(id).cloned() else { continue };
|
||||
if !matches!(ph.source, lightcraft_catalog::Source::File { .. }) {
|
||||
continue;
|
||||
}
|
||||
let path = dir.join(crate::smart::file_name(&ph));
|
||||
if discard {
|
||||
if std::fs::remove_file(&path).is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// a complete proxy is kept; a missing or damaged one (cut short by a crash or a full
|
||||
// drive) is (re)built
|
||||
let damaged = path.exists();
|
||||
if damaged && crate::smart::is_valid(&path) {
|
||||
built += 1;
|
||||
continue;
|
||||
}
|
||||
// atomic: a failed write leaves no partial proxy that would pass for a built one
|
||||
let r = s
|
||||
.source_now(id, crate::media::SourceLevel::Preview)
|
||||
.and_then(|src| crate::smart::encode(&src))
|
||||
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display())));
|
||||
match r {
|
||||
Ok(()) => {
|
||||
built += 1;
|
||||
repaired += usize::from(damaged);
|
||||
}
|
||||
Err(e) => failed.push(json!([id.0, e])),
|
||||
}
|
||||
}
|
||||
Ok(json!({"built": built, "repaired": repaired, "removed": removed, "failed": failed}))
|
||||
}
|
||||
|
||||
/// The smart previews folder: where it is, whether it is custom, what it holds, whether it can be
|
||||
/// used now (a chosen folder on an unplugged drive is `available: false`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn location_json(s: &Session) -> Value {
|
||||
let dir = s.media.smart_dir.clone();
|
||||
let default = s.library.as_ref().filter(|l| l.on_disk).map(|l| crate::smart::dir(&l.dir));
|
||||
let (count, bytes) = dir.as_deref().map_or((0, 0), crate::smart::stats);
|
||||
json!({
|
||||
"path": dir.as_ref().map(|d| d.to_string_lossy()),
|
||||
"default": default.map(|d| d.to_string_lossy().to_string()),
|
||||
"custom": s.smart_previews_dir.is_some(),
|
||||
"available": dir.as_ref().is_some_and(|d| d.is_dir()) || s.smart_previews_dir.is_none(),
|
||||
"count": count,
|
||||
"bytes": bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Show or change where smart previews are kept (per library).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn smart_location(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.smartPreviewsLocation";
|
||||
let current = s.media.smart_dir.clone().ok_or_else(|| bad(C, "smart previews need a library on disk"))?;
|
||||
let reset = p.get("reset").and_then(Value::as_bool).unwrap_or(false);
|
||||
let chosen = str_param(p, "path").filter(|x| !x.trim().is_empty());
|
||||
if !reset && chosen.is_none() {
|
||||
return Ok(location_json(s));
|
||||
}
|
||||
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()).ok_or_else(|| bad(C, "smart previews need a library on disk"))?;
|
||||
let default = crate::smart::dir(&lib_dir);
|
||||
let (new_dir, custom) = match chosen {
|
||||
Some(x) if !reset => {
|
||||
let d = std::path::PathBuf::from(x.trim());
|
||||
if !d.is_absolute() {
|
||||
return Err(bad(C, "path must be an absolute folder path"));
|
||||
}
|
||||
(d.clone(), (d != default).then_some(d))
|
||||
}
|
||||
_ => (default, None),
|
||||
};
|
||||
if new_dir == current {
|
||||
return Ok(location_json(s));
|
||||
}
|
||||
// the new place must work before anything moves; there is no fallback to another drive
|
||||
crate::smart::check_writable(&new_dir).map_err(|e| bad(C, e))?;
|
||||
let (held, _) = crate::smart::stats(¤t);
|
||||
let existing = match str_param(p, "existing") {
|
||||
Some(x) => Some(crate::smart::Existing::parse(x).ok_or_else(|| bad(C, "existing must be move, leave or discard"))?),
|
||||
None if held == 0 => Some(crate::smart::Existing::Leave),
|
||||
None => None,
|
||||
};
|
||||
let Some(existing) = existing else {
|
||||
return Err(bad(C, format!("{held} smart preview(s) are in {}: pass existing = move, leave or discard", current.display())));
|
||||
};
|
||||
let (moved, failed) = crate::smart::migrate(¤t, &new_dir, existing);
|
||||
s.smart_previews_dir = custom;
|
||||
s.media.smart_dir = Some(new_dir);
|
||||
s.save_prefs()?;
|
||||
let mut r = location_json(s);
|
||||
r["handled"] = json!(moved);
|
||||
r["failed"] = json!(failed);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Whether photo `id` has a smart preview.
|
||||
pub fn has_smart_preview(s: &Session, id: lightcraft_catalog::PhotoId) -> bool {
|
||||
match (&s.media.smart_dir, s.catalog.photo(id)) {
|
||||
(Some(dir), Some(p)) => crate::smart::is_valid(&dir.join(crate::smart::file_name(p))),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cmd!(
|
||||
"library.smartPreviews",
|
||||
"Build Smart Previews",
|
||||
[],
|
||||
None,
|
||||
"{ids?, discard?: bool} — build (or discard) the smart previews of the selected photos (else all in view): compact proxies in the library that keep photos editable and exportable (at proxy size) while their originals are offline; a damaged proxy (cut short) is rebuilt → {built, repaired, removed, failed}",
|
||||
always,
|
||||
smart
|
||||
),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cmd!(
|
||||
"library.smartPreviewsLocation",
|
||||
"Smart Previews Location",
|
||||
[],
|
||||
None,
|
||||
"{path?: absolute folder, reset?: bool, existing?: move|leave|discard} — without path/reset: where this library keeps its smart previews → {path, default, custom, available, count, bytes}. With path (or reset = back to `Smart Previews` in the library): use that folder instead (must exist or have an existing parent, and accept writes; never falls back to another drive). Smart previews already in the old folder need `existing`: move them, leave them (build again), or discard them → also {handled, failed}",
|
||||
always,
|
||||
smart_location
|
||||
),
|
||||
cmd!(query "photo.smartPreview", "Smart Preview Status", [], None, "{id?} → {smartPreview: bool, originalOnline: bool}", always, |s, p| {
|
||||
let id = s.targets(p).first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?;
|
||||
let online = match s.catalog.photo(id).map(|p| p.source.clone()) {
|
||||
Some(lightcraft_catalog::Source::File { path }) => std::path::Path::new(&path).exists(),
|
||||
_ => true,
|
||||
};
|
||||
Ok(json!({"smartPreview": has_smart_preview(s, id), "originalOnline": online}))
|
||||
}),
|
||||
cmd!(
|
||||
"library.buildPreviews",
|
||||
"Build Previews",
|
||||
[],
|
||||
None,
|
||||
"{size?: standard (2048 px, or `edge`) | full (1:1), ids?, wait?: bool} — render the grid thumbnail and loupe view of the selected photos (else all in view) into the preview cache, in the background unless `wait` → {total, done, failed, running}",
|
||||
always,
|
||||
build
|
||||
),
|
||||
cmd!(query "library.previewProgress", "Preview Build Progress", [], None, "{} → {total, done, failed, running, cancelled} | null", always, |s, _| {
|
||||
Ok(s.preview_build.as_ref().map_or(Value::Null, |b| b.json()))
|
||||
}),
|
||||
cmd!("library.cancelPreviews", "Cancel Preview Build", [], None, "{}", always, |s, _| {
|
||||
if let Some(b) = &s.preview_build {
|
||||
b.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
Ok(s.preview_build.as_ref().map_or(Value::Null, |b| b.json()))
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -26,6 +26,8 @@ pub fn photo_summary(p: &Photo) -> Value {
|
||||
"deleted": p.deleted,
|
||||
"copyOf": p.copy_of.map(|c| c.0),
|
||||
"copyName": p.copy_name,
|
||||
// an undecodable raw variant, shown and edited from its embedded JPEG: why
|
||||
"previewOnly": p.preview_only,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -95,6 +97,29 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"clipboard": s.clipboard.is_some(),
|
||||
}))
|
||||
}),
|
||||
cmd!(query "photo.allMetadata", "All Metadata", [], None, "{id?} → {exif: [{group, tag, name, value}], xmp: [{name, value}]} — every EXIF / TIFF / GPS tag of the file and its XMP properties", always, |s, p| {
|
||||
let id = photo_arg(s, p, "photo.allMetadata")?;
|
||||
let ph = s.catalog.photo(id).ok_or_else(|| super::bad("photo.allMetadata", "no such photo"))?.clone();
|
||||
let lightcraft_catalog::Source::File { path } = &ph.source else {
|
||||
return Ok(json!({"exif": [], "xmp": [], "note": "a generated demo photo has no file"}));
|
||||
};
|
||||
let bytes = match &s.media.file_bytes {
|
||||
Some(r) => r(path),
|
||||
None => std::fs::read(path).map_err(|e| format!("{path}: {e}")),
|
||||
}
|
||||
.map_err(|e| super::bad("photo.allMetadata", e))?;
|
||||
let exif: Vec<Value> = lightcraft_meta::file_tag_rows(&bytes)
|
||||
.into_iter()
|
||||
.map(|r| json!({"group": r.group, "tag": r.tag, "name": r.name, "value": r.value}))
|
||||
.collect();
|
||||
// XMP: the sidecar if there is one, else the file's own packet
|
||||
let packet = crate::sidecar::read_packet(path, ph.kind, s.sidecar_naming(ph.id)).map(|(x, _)| x).or_else(|| lightcraft_meta::embedded(&bytes).xmp);
|
||||
let xmp: Vec<Value> = packet
|
||||
.and_then(|x| lightcraft_meta::parse_xmp(&x).ok())
|
||||
.map(|d| d.properties.into_iter().filter(|(k, _)| !k.starts_with("lc:")).map(|(k, v)| json!({"name": k, "value": v.join("; ")})).collect())
|
||||
.unwrap_or_default();
|
||||
Ok(json!({"exif": exif, "xmp": xmp}))
|
||||
}),
|
||||
cmd!(query "albums.list", "List Albums", [], None, "{}", always, |s, _| {
|
||||
let all: Vec<Album> = s.catalog.albums().cloned().collect();
|
||||
Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, &s.catalog)).collect()))
|
||||
@@ -161,11 +186,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"versions": ph.versions.iter().map(|v| json!({"name": v.name, "created": v.created})).collect::<Vec<_>>(),
|
||||
}))
|
||||
}),
|
||||
cmd!(query "app.gpu", "GPU Rendering", [], None, "{enabled?: bool} — allow/forbid GPU rendering (CPU fallback; LIGHTCRAFT_GPU=0 forbids it for the process)", always, |_, p| {
|
||||
cmd!(query "app.gpu", "GPU Rendering", [], None, "{enabled?: bool} — allow/forbid GPU rendering (CPU fallback; LIGHTCRAFT_GPU=0 forbids it for the process); returns {enabled, available, adapter, reason (why the GPU is off), lastFallback (latest render redone on the CPU, and why)}", always, |_, p| {
|
||||
if let Some(on) = p.get("enabled").and_then(Value::as_bool) {
|
||||
lightcraft_gpu::set_enabled(on);
|
||||
}
|
||||
Ok(json!({"enabled": lightcraft_gpu::enabled(), "available": lightcraft_gpu::available(), "adapter": lightcraft_gpu::adapter_name()}))
|
||||
Ok(json!({
|
||||
"enabled": lightcraft_gpu::enabled(),
|
||||
"available": lightcraft_gpu::available(),
|
||||
"adapter": lightcraft_gpu::adapter_name(),
|
||||
"reason": lightcraft_gpu::unavailable_reason(),
|
||||
"lastFallback": lightcraft_gpu::last_fallback(),
|
||||
}))
|
||||
}),
|
||||
cmd!(query "library.memory", "Memory Usage", [], None, "{} — bytes held by each cache (decoded sources, rendered previews, GPU buffers; heap when instrumented)", always, |s, _| {
|
||||
Ok(serde_json::to_value(s.memory_report()).unwrap_or_default())
|
||||
|
||||
@@ -9,14 +9,26 @@ use crate::{Result, Session};
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut written = Vec::new();
|
||||
let mut merged = Vec::new();
|
||||
let mut backups = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let owners = crate::sidecar::StemOwners::of(&s.catalog);
|
||||
for id in s.targets(p) {
|
||||
match s.save_sidecar(id) {
|
||||
Ok(path) => written.push(path.display().to_string()),
|
||||
match s.save_sidecar_with(id, &owners) {
|
||||
Ok(r) => {
|
||||
let path = r.path.display().to_string();
|
||||
if r.merged {
|
||||
merged.push(path.clone());
|
||||
}
|
||||
if let Some(b) = r.backup {
|
||||
backups.push(json!({"path": path, "backup": b.display().to_string()}));
|
||||
}
|
||||
written.push(path);
|
||||
}
|
||||
Err(e) => failed.push(json!({"id": id.0, "error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
Ok(json!({"written": written, "failed": failed}))
|
||||
Ok(json!({"written": written, "merged": merged, "backups": backups, "failed": failed}))
|
||||
}
|
||||
|
||||
fn read(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -63,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save Metadata to File",
|
||||
["Photo"],
|
||||
Some("Cmd+S"),
|
||||
"{ids?} — writes each photo's XMP sidecar (metadata + develop settings) next to the original → {written: [path], failed}",
|
||||
"{ids?} — writes each photo's XMP sidecar (metadata + develop settings) next to the original, merged into an existing one (other apps' data kept; an unreadable one is backed up first) → {written: [path], merged: [path], backups: [{path, backup}], failed}",
|
||||
has_selection,
|
||||
save
|
||||
),
|
||||
|
||||
@@ -55,7 +55,21 @@ pub fn has_adjustments(props: &Props) -> bool {
|
||||
props.keys().any(|k| k.starts_with("crs:") && !NON_ADJUSTMENT.contains(&k.split('/').next().unwrap_or(k)))
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Keys read by [`to_partial`] while [`to_partial_report`] runs.
|
||||
static READ: std::cell::RefCell<Option<std::collections::BTreeSet<String>>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn note(k: &str) {
|
||||
READ.with(|r| {
|
||||
if let Some(s) = r.borrow_mut().as_mut() {
|
||||
s.insert(k.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn first<'a>(props: &'a Props, k: &str) -> Option<&'a str> {
|
||||
note(k);
|
||||
props.get(k).and_then(|v| v.first()).map(|s| s.trim()).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
@@ -95,6 +109,7 @@ pub fn rel_to_kelvin(r: f64) -> f64 {
|
||||
|
||||
/// Parse a point curve stored as an `rdf:Seq` of `"x, y"` strings in 0..255.
|
||||
fn curve(props: &Props, k: &str) -> Option<Value> {
|
||||
note(k);
|
||||
let items = props.get(k)?;
|
||||
let mut pts = Vec::new();
|
||||
for it in items {
|
||||
@@ -130,9 +145,30 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
||||
n(o, "Shadows2012", "light.shadows");
|
||||
n(o, "Whites2012", "light.whites");
|
||||
n(o, "Blacks2012", "light.blacks");
|
||||
// older process versions (most `.lrtemplate` presets): approximate the 2012 sliders from the
|
||||
// earlier ones, relative to their defaults (contrast 25, blacks 5, brightness 50)
|
||||
let has = |o: &Value, path: &str| path.split('.').try_fold(o, |v, k| v.get(k)).is_some();
|
||||
// presets written by newer versions keep the old fields at their defaults: ignore them there
|
||||
let pv2012 = props.keys().any(|k| k.ends_with("2012"));
|
||||
let old = |o: &mut Value, crs: &str, path: &str, f: &dyn Fn(f64) -> f64| {
|
||||
let v = num(props, &format!("crs:{crs}"));
|
||||
if !pv2012
|
||||
&& !has(o, path)
|
||||
&& let Some(v) = v
|
||||
{
|
||||
put(o, path, json!(f(v).clamp(-100.0, 100.0)));
|
||||
}
|
||||
};
|
||||
old(o, "Exposure", "light.exposure", &|v| v);
|
||||
old(o, "Contrast", "light.contrast", &|v| v - 25.0);
|
||||
old(o, "HighlightRecovery", "light.highlights", &|v| -v);
|
||||
old(o, "FillLight", "light.shadows", &|v| v);
|
||||
old(o, "Shadows", "light.blacks", &|v| -(v - 5.0) * 4.0);
|
||||
old(o, "Brightness", "light.whites", &|v| (v - 50.0) * 0.6);
|
||||
// ---- Presence
|
||||
n(o, "Texture", "effects.texture");
|
||||
n(o, "Clarity2012", "effects.clarity");
|
||||
old(o, "Clarity", "effects.clarity", &|v| v);
|
||||
n(o, "Dehaze", "effects.dehaze");
|
||||
n(o, "Vibrance", "color.vibrance");
|
||||
n(o, "Saturation", "color.saturation");
|
||||
@@ -191,6 +227,12 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
||||
n(o, "ParametricMidtoneSplit", "curve.split_mid");
|
||||
n(o, "ParametricHighlightSplit", "curve.split_highlights");
|
||||
n(o, "CurveRefineSaturation", "curve.refine_saturation");
|
||||
if let Some(c) = curve(props, "crs:ToneCurve").filter(|c| c.as_array().is_some_and(|a| !a.is_empty())) {
|
||||
// the older single curve, used when no 2012 curve is present
|
||||
if !props.contains_key("crs:ToneCurvePV2012") {
|
||||
put(o, "curve.master", c);
|
||||
}
|
||||
}
|
||||
for (crs, ch) in
|
||||
[("ToneCurvePV2012", "master"), ("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")]
|
||||
{
|
||||
@@ -310,6 +352,42 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
||||
out
|
||||
}
|
||||
|
||||
/// [`to_partial`], plus the adjustments in the packet it could not carry over (field names
|
||||
/// without the `crs:` prefix, grouped: `CameraProfile`, `Look`, `MaskGroupBasedCorrections`…).
|
||||
///
|
||||
/// With `values` (the packet's structured properties), local corrections become masks too
|
||||
/// ([`crate::crs_masks`]; radial masks fitted to `aspect` = width / height); components that
|
||||
/// can't be carried over are reported as `Mask: <kind>`.
|
||||
pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values>, raw: Option<bool>, aspect: f64) -> (Value, Vec<String>) {
|
||||
READ.with(|r| *r.borrow_mut() = Some(Default::default()));
|
||||
let mut out = to_partial(props, raw);
|
||||
let mut read = READ.with(|r| r.borrow_mut().take()).unwrap_or_default();
|
||||
let mut mask_skips = Vec::new();
|
||||
if let Some(values) = values {
|
||||
let (masks, skipped) = crate::crs_masks::masks(values, aspect);
|
||||
if !masks.is_empty() {
|
||||
put(&mut out, "masks", Value::Array(masks));
|
||||
}
|
||||
if values.keys().any(|k| crate::crs_masks::CONTAINERS.contains(&k.as_str())) {
|
||||
read.extend(crate::crs_masks::CONTAINERS.iter().map(|c| c.to_string()));
|
||||
mask_skips = skipped.into_iter().map(|k| format!("Mask: {k}")).collect();
|
||||
}
|
||||
}
|
||||
// fields that only switch a panel on/off or name things: not adjustments by themselves
|
||||
let quiet = |k: &str| k.starts_with("Enable") || k.starts_with("ToneCurveName") || k == "AutoTone" || k == "AutoGrayscaleMix";
|
||||
let mut unmapped: Vec<String> = props
|
||||
.keys()
|
||||
.filter(|k| k.starts_with("crs:"))
|
||||
.map(|k| k.split('/').next().unwrap_or(k))
|
||||
.filter(|k| !NON_ADJUSTMENT.contains(k) && !read.contains(*k))
|
||||
.map(|k| k.trim_start_matches("crs:").to_string())
|
||||
.filter(|k| !quiet(k))
|
||||
.collect();
|
||||
unmapped.dedup();
|
||||
unmapped.extend(mask_skips);
|
||||
(out, unmapped)
|
||||
}
|
||||
|
||||
/// Read an XMP preset (`crs:` fields + `crs:Name` / `crs:Group`) into one of our presets.
|
||||
/// Returns `None` when the packet has no adjustments we understand.
|
||||
pub fn preset_from_xmp(xmp: &str, fallback_name: &str) -> Option<Preset> {
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
//! Interchange: local corrections (masks) stored as `crs:` structures in XMP sidecars, XMP
|
||||
//! presets and `.lrtemplate` files → our [`Mask`](lightcraft_develop::Mask)s.
|
||||
//!
|
||||
//! Four containers hold them: `MaskGroupBasedCorrections` (current: each correction has a list
|
||||
//! of mask components) and the older `GradientBasedCorrections`, `CircularGradientBasedCorrections`
|
||||
//! and `PaintBasedCorrections`. Every correction carries its local sliders (`Local*`, stored as
|
||||
//! −1..1 fractions; exposure ×4 EV) and its components (`crs:What` = `Mask/Gradient`,
|
||||
//! `Mask/CircularGradient`, `Mask/Paint`, `Mask/Image`, `Mask/RangeMask`).
|
||||
//!
|
||||
//! Implemented from the public XMP data model and black-box observation; best effort — the
|
||||
//! pipelines differ, and the component combine modes and AI mask kinds are mapped from what the
|
||||
//! fields evidently mean. Unsupported components are reported, never guessed.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use lightcraft_meta::XmpValue;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Top-level properties keyed `prefix:name`, with structure.
|
||||
pub type Values = BTreeMap<String, XmpValue>;
|
||||
|
||||
/// The containers this module reads.
|
||||
pub const CONTAINERS: [&str; 4] =
|
||||
["crs:MaskGroupBasedCorrections", "crs:GradientBasedCorrections", "crs:CircularGradientBasedCorrections", "crs:PaintBasedCorrections"];
|
||||
|
||||
fn text<'a>(v: &'a XmpValue, k: &str) -> Option<&'a str> {
|
||||
v.field(k).and_then(XmpValue::text).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn num(v: &XmpValue, k: &str) -> Option<f64> {
|
||||
let s = text(v, k)?;
|
||||
let s = s.strip_prefix('+').unwrap_or(s);
|
||||
s.parse::<f64>().ok().filter(|x| x.is_finite())
|
||||
}
|
||||
|
||||
fn flag(v: &XmpValue, k: &str) -> Option<bool> {
|
||||
text(v, k).map(|s| s.eq_ignore_ascii_case("true") || s == "1")
|
||||
}
|
||||
|
||||
/// Local slider fields → (our `adjust` field, scale from the stored fraction).
|
||||
const LOCAL: &[(&str, &str, f64)] = &[
|
||||
("LocalExposure2012", "exposure", 4.0),
|
||||
("LocalContrast2012", "contrast", 100.0),
|
||||
("LocalHighlights2012", "highlights", 100.0),
|
||||
("LocalShadows2012", "shadows", 100.0),
|
||||
("LocalWhites2012", "whites", 100.0),
|
||||
("LocalBlacks2012", "blacks", 100.0),
|
||||
("LocalClarity2012", "clarity", 100.0),
|
||||
("LocalTexture", "texture", 100.0),
|
||||
("LocalDehaze", "dehaze", 100.0),
|
||||
("LocalTemperature", "temp", 100.0),
|
||||
("LocalTint", "tint", 100.0),
|
||||
("LocalSaturation", "saturation", 100.0),
|
||||
("LocalHue", "hue", 100.0),
|
||||
("LocalSharpness", "sharpness", 100.0),
|
||||
("LocalLuminanceNoise", "noise", 100.0),
|
||||
("LocalMoire", "moire", 100.0),
|
||||
("LocalDefringe", "defringe", 100.0),
|
||||
("LocalToningSaturation", "color_sat", 100.0),
|
||||
("LocalToningHue", "color_hue", 1.0),
|
||||
// older process versions
|
||||
("LocalExposure", "exposure", 4.0),
|
||||
("LocalContrast", "contrast", 100.0),
|
||||
("LocalClarity", "clarity", 100.0),
|
||||
];
|
||||
|
||||
fn adjust(c: &XmpValue) -> 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}")) {
|
||||
let lim = if *ours == "exposure" {
|
||||
4.0
|
||||
} else if *ours == "color_hue" {
|
||||
360.0
|
||||
} else {
|
||||
100.0
|
||||
};
|
||||
let v = (v * k).clamp(if *ours == "color_hue" { 0.0 } else { -lim }, lim);
|
||||
a.insert((*ours).into(), json!(v));
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
/// Perceptual lightness 0..1 (how range masks store luminance) → our range scale: log
|
||||
/// luminance on −8..+4 EV mapped to 0..1.
|
||||
fn lightness_to_range(l: f64) -> f64 {
|
||||
let y = l.clamp(1e-4, 1.0).powf(2.2);
|
||||
((y.log2() + 8.0) / 12.0).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
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> {
|
||||
let what = text(m, "crs:What").unwrap_or("");
|
||||
let op = match num(m, "crs:MaskBlendMode").unwrap_or(0.0) as i64 {
|
||||
1 => "subtract",
|
||||
2 => "intersect",
|
||||
_ => "add",
|
||||
};
|
||||
let invert = flag(m, "crs:MaskInverted").unwrap_or(false);
|
||||
// positions are fractions of the image's width / height; our radii are in long-edge units
|
||||
let (sx, sy) = if aspect >= 1.0 { (1.0, 1.0 / aspect) } else { (aspect, 1.0) };
|
||||
let shape = match what {
|
||||
"Mask/Gradient" => {
|
||||
let g = |k: &str, d: f64| num(m, &format!("crs:{k}")).unwrap_or(d);
|
||||
json!({"kind": "linear", "start": pt(g("FullX", 0.5), g("FullY", 0.0)), "end": pt(g("ZeroX", 0.5), g("ZeroY", 1.0))})
|
||||
}
|
||||
"Mask/CircularGradient" => {
|
||||
let g = |k: &str, d: f64| num(m, &format!("crs:{k}")).unwrap_or(d);
|
||||
let (l, r, t, b) = (g("Left", 0.25), g("Right", 0.75), g("Top", 0.25), g("Bottom", 0.75));
|
||||
json!({
|
||||
"kind": "radial",
|
||||
"center": pt((l + r) / 2.0, (t + b) / 2.0),
|
||||
"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),
|
||||
})
|
||||
}
|
||||
"Mask/Paint" => {
|
||||
let pts: Vec<Value> = m
|
||||
.field("crs:Dabs")
|
||||
.map(XmpValue::items)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(XmpValue::text)
|
||||
.filter_map(|d| {
|
||||
let mut it = d.split_whitespace().skip(1).filter_map(|v| v.parse::<f64>().ok());
|
||||
Some(pt(it.next()?, it.next()?))
|
||||
})
|
||||
.collect();
|
||||
if pts.is_empty() {
|
||||
return Err("empty brush".into());
|
||||
}
|
||||
let erase = num(m, "crs:MaskValue").is_some_and(|v| v <= 0.0);
|
||||
json!({"kind": "brush", "strokes": [{
|
||||
"points": pts,
|
||||
"size": num(m, "crs:Radius").unwrap_or(0.03).clamp(0.001, 1.0),
|
||||
"feather": (num(m, "crs:CenterWeight").unwrap_or(0.5) * 100.0).clamp(0.0, 100.0),
|
||||
"flow": (num(m, "crs:Flow").unwrap_or(1.0) * 100.0).clamp(0.0, 100.0),
|
||||
"density": (num(m, "crs:MaskValue").unwrap_or(1.0).abs() * 100.0).clamp(0.0, 100.0),
|
||||
"erase": erase,
|
||||
}]})
|
||||
}
|
||||
"Mask/Image" => match text(m, "crs:MaskSubType") {
|
||||
Some("1") => json!({"kind": "subject"}),
|
||||
Some("2") => json!({"kind": "sky"}),
|
||||
other => return Err(format!("AI mask ({})", other.unwrap_or("?"))),
|
||||
},
|
||||
"Mask/RangeMask" => {
|
||||
let r = m.field("crs:CorrectionRangeMask").unwrap_or(m);
|
||||
match text(r, "crs:Type") {
|
||||
Some("2") => {
|
||||
let q: Vec<f64> = text(r, "crs:LumRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
|
||||
let [a, b, c, d] = q[..] else { return Err("luminance range".into()) };
|
||||
let (a, b, c, d) = (lightness_to_range(a), lightness_to_range(b), lightness_to_range(c), lightness_to_range(d));
|
||||
json!({"kind": "luminanceRange", "lo": b, "hi": c, "lo_feather": (b - a).max(0.0), "hi_feather": (d - c).max(0.0)})
|
||||
}
|
||||
Some("3") => {
|
||||
let q: Vec<f64> = text(r, "crs:DepthRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
|
||||
let [a, b, c, _] = q[..] else { return Err("depth range".into()) };
|
||||
json!({"kind": "depthRange", "lo": b, "hi": c, "feather": (b - a).max(0.0)})
|
||||
}
|
||||
_ => return Err("colour range".into()),
|
||||
}
|
||||
}
|
||||
other => return Err(if other.is_empty() { "unknown mask".into() } else { other.trim_start_matches("Mask/").to_string() }),
|
||||
};
|
||||
Ok(json!({"op": op, "invert": invert, "shape": shape}))
|
||||
}
|
||||
|
||||
/// Masks in `values` (JSON for `DevelopSettings::masks`), plus the kinds of component that
|
||||
/// couldn't be carried over. `aspect` = width / height of the target (presets: assume 3:2).
|
||||
pub fn masks(values: &Values, aspect: f64) -> (Vec<Value>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
for container in CONTAINERS {
|
||||
let Some(list) = values.get(container) else { continue };
|
||||
for c in list.items() {
|
||||
if flag(c, "crs:CorrectionActive") == Some(false) {
|
||||
continue;
|
||||
}
|
||||
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) {
|
||||
Ok(v) => parts.push(v),
|
||||
Err(kind) => skipped.push(kind),
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// brush dabs of one correction are one brush component
|
||||
let mut merged: Vec<Value> = Vec::new();
|
||||
for p in parts {
|
||||
let is_brush = p["shape"]["kind"] == "brush";
|
||||
if is_brush
|
||||
&& let Some(prev) = merged.iter_mut().find(|q| q["shape"]["kind"] == "brush" && q["op"] == p["op"])
|
||||
&& let (Some(a), Some(b)) = (prev["shape"]["strokes"].as_array_mut(), p["shape"]["strokes"].as_array())
|
||||
{
|
||||
a.extend(b.iter().cloned());
|
||||
continue;
|
||||
}
|
||||
merged.push(p);
|
||||
}
|
||||
let n = out.len() + 1;
|
||||
let name = text(c, "crs:CorrectionName").map(str::to_string).unwrap_or_else(|| format!("Mask {n}"));
|
||||
out.push(json!({"id": n, "name": name, "visible": true, "invert": false, "components": merged, "adjust": adjust(c)}));
|
||||
}
|
||||
}
|
||||
skipped.sort();
|
||||
skipped.dedup();
|
||||
(out, skipped)
|
||||
}
|
||||
|
||||
/// A `.lrtemplate` Lua value as an XMP value (field names get the `crs:` prefix).
|
||||
pub fn from_lua(v: &crate::preset_import::Lua) -> XmpValue {
|
||||
use crate::preset_import::Lua;
|
||||
match v {
|
||||
Lua::Nil => XmpValue::Text(String::new()),
|
||||
Lua::Bool(b) => XmpValue::Text(if *b { "true" } else { "false" }.into()),
|
||||
Lua::Num(n) => XmpValue::Text(format!("{n}")),
|
||||
Lua::Str(s) => XmpValue::Text(s.clone()),
|
||||
Lua::Table(arr, map) if map.is_empty() => XmpValue::Array(arr.iter().map(from_lua).collect()),
|
||||
Lua::Table(_, map) => XmpValue::Struct(map.iter().map(|(k, v)| (format!("crs:{k}"), from_lua(v))).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The aspect radial masks are computed for when the target's shape isn't known yet.
|
||||
pub const DEFAULT_ASPECT: f64 = 1.5;
|
||||
|
||||
/// Re-fit radial masks in `partial` (computed for aspect `from`) to a target of aspect `to`.
|
||||
pub fn refit_radials(partial: &mut Value, from: f64, to: f64) {
|
||||
let scale = |a: f64| if a >= 1.0 { (1.0, 1.0 / a) } else { (a, 1.0) };
|
||||
let ((fx, fy), (tx, ty)) = (scale(from), scale(to));
|
||||
let Some(masks) = partial.get_mut("masks").and_then(Value::as_array_mut) else { return };
|
||||
for m in masks {
|
||||
for c in m["components"].as_array_mut().into_iter().flatten() {
|
||||
let s = &mut c["shape"];
|
||||
if s["kind"] == "radial" {
|
||||
if let Some(rx) = s["rx"].as_f64() {
|
||||
s["rx"] = json!(rx / fx * tx);
|
||||
}
|
||||
if let Some(ry) = s["ry"].as_f64() {
|
||||
s["ry"] = json!(ry / fy * ty);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_develop::{DevelopSettings, MaskOp, MaskShape};
|
||||
|
||||
/// A packet in the shape local corrections take (values written for this test).
|
||||
const PACKET: &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:Exposure2012="0.2">
|
||||
<crs:MaskGroupBasedCorrections><rdf:Seq>
|
||||
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="1" crs:CorrectionActive="true" crs:CorrectionName="Darker sky"
|
||||
crs:LocalExposure2012="-0.25" crs:LocalSaturation="0.3" crs:LocalTemperature="-0.1">
|
||||
<crs:CorrectionMasks><rdf:Seq>
|
||||
<rdf:li crs:What="Mask/Image" crs:MaskSubType="2" crs:MaskBlendMode="0" crs:MaskInverted="false"/>
|
||||
<rdf:li crs:What="Mask/Gradient" crs:MaskBlendMode="2" crs:FullX="0.5" crs:FullY="0" crs:ZeroX="0.5" crs:ZeroY="0.6"/>
|
||||
</rdf:Seq></crs:CorrectionMasks>
|
||||
</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/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>
|
||||
</rdf:Description></rdf:li>
|
||||
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionActive="false" crs:LocalExposure2012="1">
|
||||
<crs:CorrectionMasks><rdf:Seq><rdf:li crs:What="Mask/Image" crs:MaskSubType="1"/></rdf:Seq></crs:CorrectionMasks>
|
||||
</rdf:Description></rdf:li>
|
||||
</rdf:Seq></crs:MaskGroupBasedCorrections>
|
||||
<crs:PaintBasedCorrections><rdf:Seq>
|
||||
<rdf:li crs:What="Correction" crs:LocalClarity2012="0.4"><crs:CorrectionMasks><rdf:Seq>
|
||||
<rdf:li crs:What="Mask/Paint" crs:Radius="0.05" crs:Flow="0.8" crs:CenterWeight="0.3" crs:MaskValue="1">
|
||||
<crs:Dabs><rdf:Seq><rdf:li>d 0.1 0.2</rdf:li><rdf:li>d 0.15 0.22</rdf:li></rdf:Seq></crs:Dabs></rdf:li>
|
||||
<rdf:li crs:What="Mask/Paint" crs:Radius="0.02" crs:MaskValue="1"><crs:Dabs><rdf:Seq><rdf:li>d 0.5 0.5</rdf:li></rdf:Seq></crs:Dabs></rdf:li>
|
||||
</rdf:Seq></crs:CorrectionMasks></rdf:li>
|
||||
</rdf:Seq></crs:PaintBasedCorrections>
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
|
||||
|
||||
fn settings() -> (DevelopSettings, Vec<String>) {
|
||||
let d = lightcraft_meta::parse_xmp(PACKET).unwrap();
|
||||
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), None, 1.5);
|
||||
(DevelopSettings::default().merged(&partial).expect("valid settings"), unmapped)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrections_become_masks() {
|
||||
let (s, unmapped) = settings();
|
||||
assert_eq!(s.light.exposure, 0.2, "global settings still map");
|
||||
assert_eq!(s.masks.len(), 3, "inactive corrections are skipped: {:?}", s.masks);
|
||||
let sky = &s.masks[0];
|
||||
assert_eq!(sky.name, "Darker sky");
|
||||
assert_eq!((sky.adjust.exposure, sky.adjust.saturation, sky.adjust.temp, sky.adjust.amount), (-1.0, 30.0, -10.0, 100.0));
|
||||
assert_eq!(sky.components[0].shape, MaskShape::Sky);
|
||||
assert_eq!(sky.components[1].op, MaskOp::Intersect);
|
||||
let MaskShape::Linear { start, end } = &sky.components[1].shape else { panic!() };
|
||||
assert_eq!((start.y, end.y), (0.0, 0.6), "full effect at the Full point");
|
||||
let rad = &s.masks[1];
|
||||
assert_eq!((rad.name.as_str(), rad.adjust.contrast, rad.adjust.amount), ("Mask 2", 20.0, 50.0));
|
||||
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));
|
||||
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];
|
||||
assert_eq!(brush.adjust.clarity, 40.0);
|
||||
assert_eq!(brush.components.len(), 1, "the brush's dab runs are one component");
|
||||
let MaskShape::Brush { strokes } = &brush.components[0].shape else { panic!() };
|
||||
assert_eq!((strokes.len(), strokes[0].points.len(), strokes[0].flow, strokes[0].feather), (2, 2, 80.0, 30.0));
|
||||
assert_eq!(unmapped, vec!["Mask: AI mask (9)".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_render() {
|
||||
let (s, _) = settings();
|
||||
let img = lightcraft_scenes::demo_library()[0].render(96, 64);
|
||||
let render = |d: &DevelopSettings| {
|
||||
lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data
|
||||
};
|
||||
assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lrtemplate_corrections() {
|
||||
let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = {
|
||||
{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = -0.5, CorrectionMasks = {
|
||||
{ What = "Mask/Gradient", ZeroX = 0.5, ZeroY = 0.5, FullX = 0.5, FullY = 0, MaskValue = 1 } } } } } } }"#;
|
||||
let v = crate::preset_import::read_presets("Grad.lrtemplate", t.as_bytes(), None).unwrap();
|
||||
assert!(v[0].unmapped.is_empty(), "{:?}", v[0].unmapped);
|
||||
let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap();
|
||||
assert_eq!(s.masks.len(), 1);
|
||||
assert_eq!(s.masks[0].adjust.exposure, -2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_add_masks_once() {
|
||||
let (s, _) = settings();
|
||||
let partial = json!({"masks": serde_json::to_value(&s.masks).unwrap(), "light": {"exposure": 0.5}});
|
||||
let p =
|
||||
lightcraft_develop::Preset { id: "t".into(), name: "t".into(), group: "g".into(), settings: partial, favorite: false, builtin: false };
|
||||
let mut mine = DevelopSettings::default();
|
||||
mine.masks.push(lightcraft_develop::Mask { id: 7, name: "Mine".into(), ..Default::default() });
|
||||
let once = p.apply(&mine, 1.0);
|
||||
assert_eq!(once.masks.len(), 4, "added to the photo's own mask");
|
||||
assert_eq!(once.masks[1].id, 8, "fresh ids");
|
||||
assert_eq!(p.apply(&once, 1.0).masks.len(), 4, "applying again adds nothing");
|
||||
let half = p.apply(&mine, 0.5);
|
||||
assert_eq!(half.masks[1].adjust.amount, 50.0, "amount scales the masks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_radials_fit_the_photo() {
|
||||
let mut v = json!({"masks": [{"components": [{"shape": {"kind": "radial", "rx": 0.3, "ry": 0.2}}]}]});
|
||||
refit_radials(&mut v, 1.5, 1.0);
|
||||
let s = &v["masks"][0]["components"][0]["shape"];
|
||||
assert!((s["rx"].as_f64().unwrap() - 0.3).abs() < 1e-9);
|
||||
assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height");
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ pub fn load(s: &mut Session) {
|
||||
}
|
||||
let folder = s.catalog.alloc_album_id();
|
||||
ops.push(Op::AddAlbum {
|
||||
album: Album { id: folder, name: "Travel 2026".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None },
|
||||
album: Album { id: folder, name: "Travel 2026".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false },
|
||||
});
|
||||
let by_kw = |kw: &str| -> Vec<_> { scenes.iter().zip(&ids).filter(|(sc, _)| sc.meta.keywords.contains(&kw)).map(|(_, id)| *id).collect() };
|
||||
for (name, parent, photos) in [
|
||||
@@ -75,7 +75,7 @@ pub fn load(s: &mut Session) {
|
||||
] {
|
||||
let id = s.catalog.alloc_album_id();
|
||||
let cover = photos.first().copied();
|
||||
ops.push(Op::AddAlbum { album: Album { id, name: name.into(), parent, folder: false, photos, cover, smart: None } });
|
||||
ops.push(Op::AddAlbum { album: Album { id, name: name.into(), parent, folder: false, photos, cover, smart: None, quick: false } });
|
||||
}
|
||||
for op in ops {
|
||||
let _ = s.catalog.apply(op);
|
||||
|
||||
+205
-22
@@ -1,6 +1,15 @@
|
||||
//! Cameras and memory cards: mounted volumes with a `DCIM` folder (the DCF layout every camera
|
||||
//! writes). Listed by `library.devices`; the app offers them under File → Add from Device, and
|
||||
//! writes). Listed by `library.devices`; the app offers them under File → Import from Device, and
|
||||
//! the import review copies from them into the library.
|
||||
//!
|
||||
//! Menus are rebuilt every frame (even with their popups closed), so [`devices`] never touches the
|
||||
//! disk: it serves the last scan and, when that is older than [`MAX_AGE`], starts one background
|
||||
//! rescan (coalesced; no lock is held while it runs). A changed result calls the [`on_change`]
|
||||
//! hook, which the UI uses to repaint, so hot-plugged cards still appear. [`devices_now`] scans
|
||||
//! synchronously for explicit requests (`library.devices`, Add from Device without a path).
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -14,6 +23,9 @@ pub struct Device {
|
||||
pub root: String,
|
||||
}
|
||||
|
||||
/// How long a scan is served before [`devices`] starts another one.
|
||||
pub const MAX_AGE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Folders whose children are mounted volumes on this platform. `LIGHTCRAFT_DEVICE_ROOTS`
|
||||
/// (paths joined like `PATH`) replaces them (tests, unusual mounts).
|
||||
fn mount_parents() -> Vec<std::path::PathBuf> {
|
||||
@@ -23,7 +35,9 @@ fn mount_parents() -> Vec<std::path::PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
if cfg!(target_os = "macos") {
|
||||
out.push("/Volumes".into());
|
||||
} else if cfg!(target_os = "linux") {
|
||||
} else if cfg!(any(target_os = "linux", target_os = "freebsd")) {
|
||||
// Linux desktops mount under /media/$USER or /run/media/$USER; FreeBSD's automounter
|
||||
// (automount/autofs, or a desktop's) under /media. Missing folders are skipped.
|
||||
if let Ok(user) = std::env::var("USER") {
|
||||
out.push(format!("/media/{user}").into());
|
||||
out.push(format!("/run/media/{user}").into());
|
||||
@@ -34,21 +48,114 @@ fn mount_parents() -> Vec<std::path::PathBuf> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Mounted volumes that look like a camera or a memory card. Cached for a couple of seconds:
|
||||
/// menus ask often, and listing mounts can be slow (network volumes).
|
||||
pub fn devices() -> Vec<Device> {
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
static CACHE: Mutex<Option<(Instant, Vec<Device>)>> = Mutex::new(None);
|
||||
let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((t, d)) = c.as_ref()
|
||||
&& t.elapsed() < Duration::from_secs(2)
|
||||
{
|
||||
return d.clone();
|
||||
type Scanner = dyn Fn() -> Vec<Device> + Send + Sync;
|
||||
type Notify = dyn Fn() + Send + Sync;
|
||||
|
||||
#[derive(Default)]
|
||||
struct WatchState {
|
||||
list: Vec<Device>,
|
||||
/// When the served list was scanned (`None`: never). No clock (and no threads) on wasm, where
|
||||
/// there are no mounted volumes either.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
scanned: Option<std::time::Instant>,
|
||||
/// A background scan is running.
|
||||
refreshing: bool,
|
||||
}
|
||||
|
||||
/// A cached device list, refreshed off the calling thread.
|
||||
pub struct DeviceWatch {
|
||||
scanner: Box<Scanner>,
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
max_age: Duration,
|
||||
state: Mutex<WatchState>,
|
||||
notify: Mutex<Option<Arc<Notify>>>,
|
||||
}
|
||||
|
||||
impl DeviceWatch {
|
||||
pub fn new(scanner: impl Fn() -> Vec<Device> + Send + Sync + 'static, max_age: Duration) -> Arc<Self> {
|
||||
Arc::new(DeviceWatch { scanner: Box::new(scanner), max_age, state: Mutex::default(), notify: Mutex::new(None) })
|
||||
}
|
||||
let d = scan();
|
||||
*c = Some((Instant::now(), d.clone()));
|
||||
d
|
||||
|
||||
fn state(&self) -> std::sync::MutexGuard<'_, WatchState> {
|
||||
self.state.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Called (on the scanning thread) whenever a scan changes the list.
|
||||
pub fn on_change(&self, f: impl Fn() + Send + Sync + 'static) {
|
||||
*self.notify.lock().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(f));
|
||||
}
|
||||
|
||||
/// The last scan, immediately; starts a background rescan when it is stale and none is running.
|
||||
pub fn get(self: &Arc<Self>) -> Vec<Device> {
|
||||
let mut s = self.state();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if !s.refreshing && s.scanned.is_none_or(|t| t.elapsed() >= self.max_age) {
|
||||
s.refreshing = true;
|
||||
let me = self.clone();
|
||||
// the scan runs without the state lock; `get` only ever waits for a list clone
|
||||
let spawned = std::thread::Builder::new().name("lightcraft-devices".into()).spawn(move || {
|
||||
let list = (me.scanner)();
|
||||
me.store(list);
|
||||
});
|
||||
if spawned.is_err() {
|
||||
s.refreshing = false;
|
||||
}
|
||||
}
|
||||
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)();
|
||||
self.store(list.clone());
|
||||
list
|
||||
}
|
||||
|
||||
fn store(&self, list: Vec<Device>) {
|
||||
let changed = {
|
||||
let mut s = self.state();
|
||||
s.refreshing = false;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
s.scanned = Some(std::time::Instant::now());
|
||||
}
|
||||
let changed = s.list != list;
|
||||
s.list = list;
|
||||
changed
|
||||
};
|
||||
if changed {
|
||||
let f = self.notify.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
if let Some(f) = f {
|
||||
f();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A background scan is running.
|
||||
pub fn refreshing(&self) -> bool {
|
||||
self.state().refreshing
|
||||
}
|
||||
}
|
||||
|
||||
fn global() -> &'static Arc<DeviceWatch> {
|
||||
static WATCH: std::sync::OnceLock<Arc<DeviceWatch>> = std::sync::OnceLock::new();
|
||||
WATCH.get_or_init(|| DeviceWatch::new(scan, MAX_AGE))
|
||||
}
|
||||
|
||||
/// Mounted volumes that look like a camera or a memory card, without blocking: the last scan,
|
||||
/// refreshed in the background when older than [`MAX_AGE`] (empty until the first scan lands).
|
||||
pub fn devices() -> Vec<Device> {
|
||||
global().get()
|
||||
}
|
||||
|
||||
/// Scan the mounted volumes now (blocking), for explicit requests.
|
||||
pub fn devices_now() -> Vec<Device> {
|
||||
global().scan_now()
|
||||
}
|
||||
|
||||
/// Run `f` whenever a scan changes the device list (the UI requests a repaint).
|
||||
pub fn on_change(f: impl Fn() + Send + Sync + 'static) {
|
||||
global().on_change(f);
|
||||
}
|
||||
|
||||
fn scan() -> Vec<Device> {
|
||||
@@ -73,10 +180,7 @@ pub fn devices_in(parents: &[std::path::PathBuf], extra: Vec<std::path::PathBuf>
|
||||
roots.extend(extra);
|
||||
let mut out: Vec<Device> = Vec::new();
|
||||
for root in roots {
|
||||
let Ok(rd) = std::fs::read_dir(&root) else { continue };
|
||||
let Some(dcim) = rd.flatten().map(|e| e.path()).find(|p| p.is_dir() && p.file_name().is_some_and(|n| n.eq_ignore_ascii_case("DCIM"))) else {
|
||||
continue;
|
||||
};
|
||||
let Some(dcim) = dcim_in(&root) else { continue };
|
||||
let name = root.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| root.to_string_lossy().to_string());
|
||||
if !out.iter().any(|d| d.root == root.to_string_lossy()) {
|
||||
out.push(Device { name, path: dcim.to_string_lossy().to_string(), root: root.to_string_lossy().to_string() });
|
||||
@@ -85,9 +189,22 @@ pub fn devices_in(parents: &[std::path::PathBuf], extra: Vec<std::path::PathBuf>
|
||||
out
|
||||
}
|
||||
|
||||
/// The volume's `DCIM` folder: the usual spellings are checked directly, so a volume's root is
|
||||
/// only listed (other spellings on a case-sensitive volume) off Windows, whose drives aren't.
|
||||
fn dcim_in(root: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
if let Some(p) = ["DCIM", "dcim"].iter().map(|n| root.join(n)).find(|p| p.is_dir()) {
|
||||
return Some(p);
|
||||
}
|
||||
if cfg!(windows) {
|
||||
return None;
|
||||
}
|
||||
std::fs::read_dir(root).ok()?.flatten().map(|e| e.path()).find(|p| p.file_name().is_some_and(|n| n.eq_ignore_ascii_case("DCIM")) && p.is_dir())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn volumes_with_a_dcim_folder_are_devices() {
|
||||
@@ -96,10 +213,76 @@ mod tests {
|
||||
std::fs::create_dir_all(base.join("EOS_DIGITAL/DCIM/100CANON")).unwrap();
|
||||
std::fs::create_dir_all(base.join("Backup/Photos")).unwrap();
|
||||
std::fs::create_dir_all(base.join("SD/dcim")).unwrap();
|
||||
std::fs::create_dir_all(base.join("X/Dcim")).unwrap();
|
||||
let d = devices_in(std::slice::from_ref(&base), Vec::new());
|
||||
let names: Vec<&str> = d.iter().map(|x| x.name.as_str()).collect();
|
||||
assert_eq!(names, ["EOS_DIGITAL", "SD"], "{d:?}");
|
||||
assert!(d[0].path.ends_with("DCIM") && d[1].path.ends_with("dcim"));
|
||||
assert_eq!(names, ["EOS_DIGITAL", "SD", "X"], "{d:?}");
|
||||
// the DCIM folder itself, in a spelling that opens on that volume
|
||||
for x in &d {
|
||||
let p = std::path::Path::new(&x.path);
|
||||
assert!(p.is_dir() && p.file_name().is_some_and(|n| n.eq_ignore_ascii_case("DCIM")), "{x:?}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
fn card(name: &str) -> Device {
|
||||
Device { name: name.into(), path: format!("/v/{name}/DCIM"), root: format!("/v/{name}") }
|
||||
}
|
||||
|
||||
/// Wait (bounded) for `f`.
|
||||
fn until(f: impl Fn() -> bool) -> bool {
|
||||
let t = std::time::Instant::now();
|
||||
while t.elapsed() < Duration::from_secs(20) {
|
||||
if f() {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slow_scans_never_block_and_hot_plug_still_arrives() {
|
||||
// a scanner that takes 1.5 s (a sleeping network drive) and counts its runs
|
||||
const SLOW: Duration = Duration::from_millis(1500);
|
||||
let mounted = Arc::new(Mutex::new(Vec::<Device>::new()));
|
||||
let runs = Arc::new(AtomicUsize::new(0));
|
||||
let (m, r) = (mounted.clone(), runs.clone());
|
||||
let watch = DeviceWatch::new(
|
||||
move || {
|
||||
r.fetch_add(1, Ordering::SeqCst);
|
||||
std::thread::sleep(SLOW);
|
||||
m.lock().unwrap().clone()
|
||||
},
|
||||
Duration::from_millis(20),
|
||||
);
|
||||
let changes = Arc::new(AtomicUsize::new(0));
|
||||
let c = changes.clone();
|
||||
watch.on_change(move || {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
// cold: served at once (empty) while one scan runs in the background
|
||||
let t = std::time::Instant::now();
|
||||
for _ in 0..50 {
|
||||
assert!(watch.get().is_empty());
|
||||
}
|
||||
assert!(t.elapsed() < SLOW / 2, "get() waited for the scan: {:?}", t.elapsed());
|
||||
assert!(watch.refreshing());
|
||||
assert!(until(|| !watch.refreshing()));
|
||||
assert_eq!(runs.load(Ordering::SeqCst), 1, "requests while scanning are coalesced");
|
||||
assert_eq!(changes.load(Ordering::SeqCst), 0, "nothing changed");
|
||||
// a card is plugged in: the next stale request starts a rescan, still without waiting
|
||||
mounted.lock().unwrap().push(card("EOS_DIGITAL"));
|
||||
std::thread::sleep(Duration::from_millis(40));
|
||||
let t = std::time::Instant::now();
|
||||
assert!(watch.get().is_empty(), "the old list is served while rescanning");
|
||||
assert!(t.elapsed() < SLOW / 2, "warm get() waited: {:?}", t.elapsed());
|
||||
assert!(until(|| changes.load(Ordering::SeqCst) == 1), "a changed scan notifies");
|
||||
assert_eq!(watch.get(), [card("EOS_DIGITAL")]);
|
||||
// explicit requests scan synchronously
|
||||
assert!(until(|| !watch.refreshing()));
|
||||
mounted.lock().unwrap().clear();
|
||||
assert!(watch.scan_now().is_empty());
|
||||
assert_eq!(changes.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
+400
-87
@@ -202,6 +202,8 @@ pub enum Anchor {
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Watermark {
|
||||
pub text: String,
|
||||
/// Basic Japanese vertical lettering: upright glyphs in columns from right to left.
|
||||
pub vertical: bool,
|
||||
/// Text height as a fraction of the image's short edge.
|
||||
pub size: f32,
|
||||
/// 0..1.
|
||||
@@ -213,16 +215,38 @@ pub struct Watermark {
|
||||
pub color: [u8; 3],
|
||||
/// Soft dark drop shadow for legibility on bright areas.
|
||||
pub shadow: bool,
|
||||
/// A graphic (PNG with transparency, JPEG, …) drawn instead of the text (empty = text).
|
||||
pub image: String,
|
||||
/// The graphic's width as a fraction of the photo's width.
|
||||
pub image_width: f32,
|
||||
/// The colour space the photo is in (set by the encoder; the graphic is converted into it).
|
||||
#[serde(skip)]
|
||||
pub target: Option<OutputSpace>,
|
||||
}
|
||||
|
||||
impl Default for Watermark {
|
||||
fn default() -> Self {
|
||||
Self { text: String::new(), size: 0.035, opacity: 0.7, anchor: Anchor::BottomRight, inset: 0.025, color: [255; 3], shadow: true }
|
||||
Self {
|
||||
text: String::new(),
|
||||
vertical: false,
|
||||
size: 0.035,
|
||||
opacity: 0.7,
|
||||
anchor: Anchor::BottomRight,
|
||||
inset: 0.025,
|
||||
color: [255; 3],
|
||||
shadow: true,
|
||||
image: String::new(),
|
||||
image_width: 0.2,
|
||||
target: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inter SemiBold (OFL, see assets/ATTRIBUTION.md).
|
||||
static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
|
||||
/// BIZ UDMincho (OFL), Japanese fallback on both native and web.
|
||||
/// Bundled OFL Japanese font bytes, shared by watermarks and UI glyph fallback.
|
||||
pub static WATERMARK_JAPANESE_FONT: &[u8] = include_bytes!("../../../assets/fonts/BIZUDMincho-Regular.ttf");
|
||||
|
||||
/// Draw `wm` onto `img` (straight alpha blending of the encoded values).
|
||||
pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) {
|
||||
@@ -261,28 +285,76 @@ pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) {
|
||||
/// every covered pixel (shadow pass first).
|
||||
fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) {
|
||||
use ab_glyph::{Font, FontRef, PxScale, ScaleFont, point};
|
||||
if !wm.image.trim().is_empty() {
|
||||
logo_coverage(width, height, wm, blend_px);
|
||||
return;
|
||||
}
|
||||
let text = wm.text.trim();
|
||||
if text.is_empty() || width == 0 || height == 0 {
|
||||
return;
|
||||
}
|
||||
let Ok(font) = FontRef::try_from_slice(WATERMARK_FONT) else { return };
|
||||
let Ok(japanese) = FontRef::try_from_slice(WATERMARK_JAPANESE_FONT) else { return };
|
||||
let fonts = [font, japanese];
|
||||
let short = width.min(height) as f32;
|
||||
let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0);
|
||||
let sf = font.as_scaled(PxScale::from(px));
|
||||
// Lay out one line.
|
||||
let mut glyphs = Vec::new();
|
||||
let mut x = 0.0f32;
|
||||
let mut y = 0.0f32;
|
||||
let mut tw = 0.0f32;
|
||||
let mut prev = None;
|
||||
for ch in text.chars() {
|
||||
let columns = text.split('\n').count();
|
||||
let mut column = 0usize;
|
||||
for original in text.chars() {
|
||||
if original == '\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 vertical_form = match original {
|
||||
'、' => '︑',
|
||||
'。' => '︒',
|
||||
'(' => '︵',
|
||||
')' => '︶',
|
||||
'「' => '﹁',
|
||||
'」' => '﹂',
|
||||
'『' => '﹃',
|
||||
'』' => '﹄',
|
||||
'【' => '︻',
|
||||
'】' => '︼',
|
||||
'…' => '︙',
|
||||
other => other,
|
||||
};
|
||||
let ch = if wm.vertical && fonts[1].glyph_id(vertical_form).0 != 0 { vertical_form } else { original };
|
||||
let face = usize::from(fonts[0].glyph_id(ch).0 == 0);
|
||||
let sf = fonts[face].as_scaled(PxScale::from(px));
|
||||
let id = sf.glyph_id(ch);
|
||||
if let Some(p) = prev {
|
||||
x += sf.kern(p, id);
|
||||
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));
|
||||
}
|
||||
glyphs.push(id.with_scale_and_position(px, point(x, sf.ascent())));
|
||||
x += sf.h_advance(id);
|
||||
prev = Some(id);
|
||||
}
|
||||
let (tw, th) = (x, sf.ascent() - sf.descent());
|
||||
let (tw, th) = if wm.vertical { (columns as f32 * px, tw.max(y)) } else { (tw.max(x), y + px) };
|
||||
let inset = wm.inset.clamp(0.0, 0.4) * short;
|
||||
let (w, h) = (width as f32, height as f32);
|
||||
use Anchor::*;
|
||||
@@ -306,15 +378,90 @@ fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px:
|
||||
let passes: &[(f32, [u8; 3], f32)] =
|
||||
if wm.shadow { &[((px * 0.05).max(1.0), [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 g in &glyphs {
|
||||
if let Some(o) = font.outline_glyph(g.clone()) {
|
||||
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| blend((ox + off + b.min.x) as i32 + gx as i32, (oy + off + b.min.y) as i32 + gy as i32, cov, col, a * alpha));
|
||||
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),
|
||||
};
|
||||
blend((ox + off + sx) as i32, (oy + off + sy) as i32, cov, col, a * alpha);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A watermark graphic, decoded once per file (and again when the file changes).
|
||||
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);
|
||||
let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok()?;
|
||||
let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let map = c.get_or_insert_with(HashMap::new);
|
||||
if let Some((m, img)) = map.get(path)
|
||||
&& *m == modified
|
||||
{
|
||||
return Some(img.clone());
|
||||
}
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
let img = Arc::new(lightcraft_codecs::decode(&bytes, Default::default()).ok()?.to_srgb8());
|
||||
map.insert(path.to_string(), (modified, img.clone()));
|
||||
Some(img)
|
||||
}
|
||||
|
||||
/// 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])) {
|
||||
let Some(logo) = watermark_logo(wm.image.trim()) else {
|
||||
log::warn!("watermark: can't read {}", wm.image);
|
||||
return;
|
||||
};
|
||||
if width == 0 || height == 0 || logo.width == 0 || logo.height == 0 {
|
||||
return;
|
||||
}
|
||||
let lw = ((wm.image_width.clamp(0.01, 1.0) * width as f32).round() as usize).clamp(1, width);
|
||||
let lh = ((lw as f32 * logo.height as f32 / logo.width as f32).round() as usize).clamp(1, height);
|
||||
let pre: lightcraft_raster::Image<[f32; 4]> = lightcraft_raster::Image::from_fn(logo.width, logo.height, |x, y| {
|
||||
let p = logo.get(x, y);
|
||||
let a = p[3] as f32 / 255.0;
|
||||
[p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a]
|
||||
});
|
||||
let scaled = lightcraft_raster::resample::resize(&pre, lw, lh, lightcraft_raster::resample::Filter::Mitchell);
|
||||
let inset = wm.inset.clamp(0.0, 0.4) * width.min(height) as f32;
|
||||
let (w, h, lwf, lhf) = (width as f32, height as f32, lw as f32, lh as f32);
|
||||
use Anchor::*;
|
||||
let ox = match wm.anchor {
|
||||
TopLeft | Left | BottomLeft => inset,
|
||||
Top | Center | Bottom => (w - lwf) / 2.0,
|
||||
TopRight | Right | BottomRight => w - inset - lwf,
|
||||
}
|
||||
.max(0.0) as usize;
|
||||
let oy = match wm.anchor {
|
||||
TopLeft | Top | TopRight => inset,
|
||||
Left | Center | Right => (h - lhf) / 2.0,
|
||||
BottomLeft | Bottom | BottomRight => h - inset - lhf,
|
||||
}
|
||||
.max(0.0) as usize;
|
||||
let space = wm.target.unwrap_or(OutputSpace::Srgb);
|
||||
let op = wm.opacity.clamp(0.0, 1.0);
|
||||
for y in 0..lh {
|
||||
for x in 0..lw {
|
||||
let [r, g, b, a] = scaled.get(x, y);
|
||||
let a = a.clamp(0.0, 1.0);
|
||||
let (px, py) = (ox + x, oy + y);
|
||||
if a <= 1e-4 || px >= width || py >= height {
|
||||
continue;
|
||||
}
|
||||
let c = [r, g, b].map(|v| (v / a).round().clamp(0.0, 255.0) as u8);
|
||||
blend_px(px, py, a * op, srgb8_in(space, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which metadata is embedded in exported files.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -344,8 +491,11 @@ pub struct ExportOptions {
|
||||
pub limit_kb: Option<u32>,
|
||||
pub sharpen: SharpenFor,
|
||||
pub sharpen_amount: SharpenAmount,
|
||||
/// File name template: `{name}` (original stem), `{seq}` (zero-padded to 3, counting from
|
||||
/// `start_number`), `{date}` (capture date, YYYYMMDD); the extension is appended.
|
||||
/// File name template with the batch-rename tokens ([`crate::rename::expand_tokens`]): `{name}`
|
||||
/// (original stem), `{seq}` (zero-padded to 3, counting from `start_number`; `{seq:N}` for N
|
||||
/// digits), `{date}` (capture date, YYYYMMDD; `{date:%Y-%m-%d}`…), `{num}`, `{folder}`,
|
||||
/// `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; the output
|
||||
/// extension is appended.
|
||||
pub naming: String,
|
||||
/// First `{seq}` value.
|
||||
pub start_number: u32,
|
||||
@@ -355,6 +505,8 @@ pub struct ExportOptions {
|
||||
pub conflict: Conflict,
|
||||
/// TIFF compression.
|
||||
pub tiff_compression: TiffCompression,
|
||||
/// DNG export: how the raw data is stored.
|
||||
pub dng_compression: DngCompression,
|
||||
pub metadata: MetadataPolicy,
|
||||
/// Strip GPS / location even when the policy would include it.
|
||||
pub remove_location: bool,
|
||||
@@ -382,6 +534,7 @@ impl Default for ExportOptions {
|
||||
subfolder: String::new(),
|
||||
conflict: Conflict::Unique,
|
||||
tiff_compression: TiffCompression::Deflate,
|
||||
dng_compression: DngCompression::Lossless,
|
||||
metadata: MetadataPolicy::All,
|
||||
remove_location: false,
|
||||
watermark: None,
|
||||
@@ -423,6 +576,7 @@ impl ExportOptions {
|
||||
Some("zip" | "deflate") => TiffCompression::Deflate,
|
||||
_ => d.tiff_compression,
|
||||
},
|
||||
dng_compression: enm(p, "dngCompression").unwrap_or(d.dng_compression),
|
||||
metadata: enm(p, "metadata").unwrap_or(d.metadata),
|
||||
remove_location: p.get("removeLocation").and_then(Value::as_bool).unwrap_or(d.remove_location),
|
||||
watermark: match p.get("watermark") {
|
||||
@@ -430,7 +584,7 @@ impl ExportOptions {
|
||||
Some(v @ Value::Object(_)) => serde_json::from_value(v.clone()).ok(),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|w: &Watermark| !w.text.trim().is_empty()),
|
||||
.filter(|w: &Watermark| !w.text.trim().is_empty() || !w.image.trim().is_empty()),
|
||||
color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space),
|
||||
bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8),
|
||||
}
|
||||
@@ -456,10 +610,9 @@ impl ExportOptions {
|
||||
r(ResizeMode::ShortEdge, v)
|
||||
} else if let Some(v) = f("megapixels") {
|
||||
r(ResizeMode::Megapixels, v)
|
||||
} else if let Some(v) = f("percent") {
|
||||
r(ResizeMode::Percent, v)
|
||||
} else {
|
||||
return None;
|
||||
let v = f("percent")?;
|
||||
r(ResizeMode::Percent, v)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -524,36 +677,23 @@ impl ExportOptions {
|
||||
if self.format == ExportFormat::Avif { OutputSpace::Srgb } else { self.color_space }
|
||||
}
|
||||
|
||||
/// Output file name for photo `stem` at 1-based position `seq` in a batch.
|
||||
pub fn file_name(&self, stem: &str, seq: usize) -> String {
|
||||
self.file_name_dated(stem, seq, None)
|
||||
}
|
||||
|
||||
/// [`ExportOptions::file_name`] with the capture time (ISO 8601) for `{date}`.
|
||||
pub fn file_name_dated(&self, stem: &str, seq: usize, captured: Option<&str>) -> String {
|
||||
/// Output file name for photo `p` at 1-based position `seq` in a batch (the original's
|
||||
/// extension is kept for [`ExportFormat::Original`]).
|
||||
pub fn file_name_for(&self, p: &lightcraft_catalog::Photo, seq: usize) -> String {
|
||||
let base = if self.naming.trim().is_empty() { "{name}" } else { self.naming.as_str() };
|
||||
let n = seq + self.start_number.max(1) as usize - 1;
|
||||
let date: String = captured.map(|c| c.chars().take(10).filter(char::is_ascii_digit).collect()).unwrap_or_default();
|
||||
let name = base.replace("{name}", stem).replace("{seq}", &format!("{n:03}")).replace("{date}", &date);
|
||||
let name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect();
|
||||
format!("{name}.{}", self.format.extension())
|
||||
}
|
||||
|
||||
/// Output file name for photo file `file_name` (the original's extension is kept for
|
||||
/// [`ExportFormat::Original`]).
|
||||
pub fn file_name_for(&self, file_name: &str, seq: usize) -> String {
|
||||
self.file_name_for_dated(file_name, seq, None)
|
||||
}
|
||||
|
||||
/// [`ExportOptions::file_name_for`] with the capture time for `{date}`.
|
||||
pub fn file_name_for_dated(&self, file_name: &str, seq: usize, captured: Option<&str>) -> String {
|
||||
let (stem, ext) = file_name.rsplit_once('.').unwrap_or((file_name, ""));
|
||||
let name = self.file_name_dated(stem, seq, captured);
|
||||
let name = crate::rename::expand_tokens(base, p, n, 3);
|
||||
let mut name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect();
|
||||
let (stem, ext) = p.file_name.rsplit_once('.').unwrap_or((&p.file_name, ""));
|
||||
if name.trim().is_empty() {
|
||||
// every token came out empty (no title, no capture time…): keep the original name
|
||||
name = stem.to_string();
|
||||
}
|
||||
if self.format == ExportFormat::Original {
|
||||
let base = name.trim_end_matches('.');
|
||||
if ext.is_empty() { base.to_string() } else { format!("{base}.{ext}") }
|
||||
} else {
|
||||
name
|
||||
format!("{name}.{}", self.format.extension())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -575,7 +715,7 @@ pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: Shar
|
||||
let (w, h) = (img.width, img.height);
|
||||
match &mut img.samples {
|
||||
DeepSamples::U16(v) => {
|
||||
let src: Vec<[f32; 3]> = v.chunks_exact(3).map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]).collect();
|
||||
let src: Vec<[f32; 3]> = v.as_chunks::<3>().0.iter().map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]).collect();
|
||||
if let Some(out) = unsharp(w, h, &src, target, amount) {
|
||||
for (d, s) in v.iter_mut().zip(out.as_flattened()) {
|
||||
*d = s.round().clamp(0.0, 65535.0) as u16;
|
||||
@@ -583,7 +723,7 @@ pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: Shar
|
||||
}
|
||||
}
|
||||
DeepSamples::F32(v) => {
|
||||
let src: Vec<[f32; 3]> = v.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect();
|
||||
let src: Vec<[f32; 3]> = v.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect();
|
||||
if let Some(out) = unsharp(w, h, &src, target, amount) {
|
||||
for (d, s) in v.iter_mut().zip(out.as_flattened()) {
|
||||
*d = s.max(0.0);
|
||||
@@ -648,7 +788,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
|
||||
output_sharpen(&mut img, o.sharpen, o.sharpen_amount);
|
||||
let space = o.effective_space();
|
||||
if let Some(wm) = &o.watermark {
|
||||
let wm = Watermark { color: srgb8_in(space, wm.color), ..wm.clone() };
|
||||
let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() };
|
||||
draw_watermark(&mut img, &wm);
|
||||
}
|
||||
let profile = icc::write_named(named_space(space));
|
||||
@@ -732,7 +872,7 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>)
|
||||
let mut img = img.clone();
|
||||
output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount);
|
||||
if let Some(wm) = &o.watermark {
|
||||
let wm = Watermark { color: srgb8_in(img.space, wm.color), ..wm.clone() };
|
||||
let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() };
|
||||
draw_watermark_deep(&mut img, &wm);
|
||||
}
|
||||
let profile = match img.samples {
|
||||
@@ -770,7 +910,16 @@ fn parse_shutter(s: &str) -> Option<f64> {
|
||||
pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) -> Option<Metadata> {
|
||||
let m = &photo.meta;
|
||||
let text = |s: &str| (!s.trim().is_empty()).then(|| s.to_string());
|
||||
let mut out = Metadata { copyright: text(&m.copyright), artist: text(&m.creator), software: Some("LightCraft".into()), ..Default::default() };
|
||||
// copyright info (also under "copyright only"): notice, creator, status, usage terms, info URL
|
||||
let mut out = Metadata {
|
||||
copyright: text(&m.copyright),
|
||||
copyright_marked: m.copyright_status.marked(),
|
||||
usage_terms: text(&m.usage_terms),
|
||||
copyright_url: text(&m.copyright_url),
|
||||
artist: text(&m.creator),
|
||||
software: Some("LightCraft".into()),
|
||||
..Default::default()
|
||||
};
|
||||
match o.metadata {
|
||||
MetadataPolicy::None => return None,
|
||||
MetadataPolicy::Copyright => return Some(out),
|
||||
@@ -838,6 +987,8 @@ pub struct PreparedExport {
|
||||
pub photo: lightcraft_catalog::PhotoId,
|
||||
pub file_name: String,
|
||||
work: Work,
|
||||
/// The library's originals, which [`run_batch`] never writes over (shared by a batch).
|
||||
guard: std::sync::Arc<crate::originals::OriginalGuard>,
|
||||
}
|
||||
|
||||
struct RenderWork {
|
||||
@@ -854,21 +1005,39 @@ enum Work {
|
||||
path: String,
|
||||
read: Option<crate::merge::ByteReader>,
|
||||
packet: String,
|
||||
dng: bool,
|
||||
dng: Option<DngCompression>,
|
||||
label: String,
|
||||
size: (usize, usize),
|
||||
},
|
||||
}
|
||||
|
||||
/// Set up the export of photo `id` at 1-based position `seq` of a batch.
|
||||
/// Set up the export of photo `id` at 1-based position `seq` of a batch. (For a whole batch use
|
||||
/// [`prepare_batch`]: it looks at the library's originals once.)
|
||||
pub fn prepare_export(
|
||||
session: &mut crate::Session,
|
||||
id: lightcraft_catalog::PhotoId,
|
||||
o: &ExportOptions,
|
||||
seq: usize,
|
||||
) -> Result<PreparedExport, String> {
|
||||
let guard = std::sync::Arc::new(session.original_guard());
|
||||
prepare_guarded(session, id, o, seq, guard)
|
||||
}
|
||||
|
||||
/// [`prepare_export`] for each of `ids` in order (`{seq}` = position, from 1).
|
||||
pub fn prepare_batch(session: &mut crate::Session, ids: &[lightcraft_catalog::PhotoId], o: &ExportOptions) -> Result<Vec<PreparedExport>, String> {
|
||||
let guard = std::sync::Arc::new(session.original_guard());
|
||||
ids.iter().enumerate().map(|(i, id)| prepare_guarded(session, *id, o, i + 1, guard.clone())).collect()
|
||||
}
|
||||
|
||||
fn prepare_guarded(
|
||||
session: &mut crate::Session,
|
||||
id: lightcraft_catalog::PhotoId,
|
||||
o: &ExportOptions,
|
||||
seq: usize,
|
||||
guard: std::sync::Arc<crate::originals::OriginalGuard>,
|
||||
) -> Result<PreparedExport, String> {
|
||||
let p = session.catalog.photo(id).ok_or("no such photo")?;
|
||||
let file_name = o.file_name_for_dated(&p.file_name, seq, p.captured.as_deref());
|
||||
let file_name = o.file_name_for(p, seq);
|
||||
let work = if o.format.is_rendered() {
|
||||
let (w, h) = output_size(p, o);
|
||||
let meta = export_metadata(p, o);
|
||||
@@ -881,13 +1050,13 @@ pub fn prepare_export(
|
||||
Work::File {
|
||||
path: path.clone(),
|
||||
read: session.media.file_bytes.clone(),
|
||||
packet: crate::sidecar::sidecar_packet(p),
|
||||
dng: o.format == ExportFormat::Dng,
|
||||
packet: crate::sidecar::sidecar_packet(p, &session.catalog),
|
||||
dng: (o.format == ExportFormat::Dng).then_some(o.dng_compression),
|
||||
label: p.file_name.clone(),
|
||||
size: (p.width as usize, p.height as usize),
|
||||
}
|
||||
};
|
||||
Ok(PreparedExport { photo: id, file_name, work })
|
||||
Ok(PreparedExport { photo: id, file_name, work, guard })
|
||||
}
|
||||
|
||||
impl PreparedExport {
|
||||
@@ -905,15 +1074,20 @@ impl PreparedExport {
|
||||
Some(r) => r(&path)?,
|
||||
None => std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?,
|
||||
};
|
||||
if !dng {
|
||||
let Some(dng) = dng else {
|
||||
return Ok(Exported { file_name, bytes, width: size.0, height: size.1, sidecars: vec![("xmp", packet.into_bytes())] });
|
||||
}
|
||||
};
|
||||
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}"))?;
|
||||
drop(bytes);
|
||||
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), ..Default::default() })
|
||||
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,
|
||||
};
|
||||
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), compression, ..Default::default() })
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Exported { file_name, bytes: dng, width: raw.width, height: raw.height, sidecars: Vec::new() })
|
||||
}
|
||||
@@ -921,15 +1095,29 @@ impl PreparedExport {
|
||||
}
|
||||
}
|
||||
|
||||
/// How an exported DNG stores its raw data.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DngCompression {
|
||||
/// Lossless JPEG (the usual DNG compression).
|
||||
#[default]
|
||||
Lossless,
|
||||
/// Deflate (zip) tiles with prediction.
|
||||
Deflate,
|
||||
Uncompressed,
|
||||
}
|
||||
|
||||
/// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy
|
||||
/// applied), or one exact file path (single photo; overwritten).
|
||||
/// 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)
|
||||
/// is never written over: see [`crate::originals`].
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Destination {
|
||||
pub dir: String,
|
||||
pub exact: Option<String>,
|
||||
}
|
||||
|
||||
/// Export `ids` in order ([`prepare_export`] + [`run_batch`]), stopping at the first error.
|
||||
/// Export `ids` in order ([`prepare_batch`] + [`run_batch`]), stopping at the first error.
|
||||
pub fn export_batch(
|
||||
session: &mut crate::Session,
|
||||
ids: &[lightcraft_catalog::PhotoId],
|
||||
@@ -938,15 +1126,34 @@ pub fn export_batch(
|
||||
write: &mut dyn FnMut(&str, &[u8]) -> Result<(), String>,
|
||||
exists: &dyn Fn(&str) -> bool,
|
||||
) -> Result<Vec<serde_json::Value>, String> {
|
||||
let items = ids.iter().enumerate().map(|(i, id)| prepare_export(session, *id, o, i + 1)).collect::<Result<Vec<_>, _>>()?;
|
||||
let items = prepare_batch(session, ids, o)?;
|
||||
run_batch(items, o, to, write, exists, true, &mut |_, _| true)
|
||||
}
|
||||
|
||||
/// Write an exported or rendered file on disk: its folder is created if needed, and the file is
|
||||
/// replaced atomically ([`lightcraft_catalog::safe_file::write_atomic`]: a temp file, synced, then
|
||||
/// renamed), so a failure part-way leaves any previous file intact and no truncated one. The
|
||||
/// native writer behind exports, renders and screenshots (app, CLI, MCP).
|
||||
pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let p = std::path::Path::new(path);
|
||||
if let Some(dir) = p.parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
lightcraft_catalog::safe_file::write_atomic(p, bytes).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
/// The path of the sidecar with extension `ext` of the exported file `main`.
|
||||
fn sidecar_path(main: &str, ext: &str) -> String {
|
||||
std::path::Path::new(main).with_extension(ext).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// Run prepared exports in order: pick each one's path, and hand the bytes (and sidecars) to
|
||||
/// `write`. `exists` tells whether a path is taken (conflict policy). `progress(done, next file)`
|
||||
/// is called before each photo; returning false cancels the rest. Returns one JSON object per
|
||||
/// photo: `{path, width, height, bytes, sidecars}`, `{skipped: path}` or (unless
|
||||
/// `stop_on_error`) `{photo, file, error}`.
|
||||
/// `write`. `exists` tells whether a path is taken. The conflict policy applies to a file and its
|
||||
/// sidecars as one: with Unique both get the same free name, with Skip the photo is skipped when
|
||||
/// either is taken. A path that is a catalogued original (or its sidecar) is refused whatever the
|
||||
/// policy. `progress(done, next file)` is called before each photo; returning false cancels the
|
||||
/// rest. Returns one JSON object per photo: `{path, width, height, bytes, sidecars}`,
|
||||
/// `{skipped: path}` or (unless `stop_on_error`) `{photo, file, error}`.
|
||||
pub fn run_batch(
|
||||
items: Vec<PreparedExport>,
|
||||
o: &ExportOptions,
|
||||
@@ -966,7 +1173,7 @@ pub fn run_batch(
|
||||
if !progress(i, &item.file_name) {
|
||||
break;
|
||||
}
|
||||
let (photo, name) = (item.photo, item.file_name.clone());
|
||||
let (photo, name, guard) = (item.photo, item.file_name.clone(), item.guard.clone());
|
||||
let e = match item.run() {
|
||||
Ok(e) => e,
|
||||
Err(err) if stop_on_error => return Err(err),
|
||||
@@ -975,14 +1182,18 @@ pub fn run_batch(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// 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 to.exact.as_deref().filter(|_| single) {
|
||||
Some(p) => p.to_string(),
|
||||
Some(p) => Ok(p.to_string()),
|
||||
None => {
|
||||
let mut path = join(&dir, &e.file_name);
|
||||
let busy = |p: &str, taken: &std::collections::HashSet<String>| taken.contains(p) || exists(p);
|
||||
if busy(&path, &taken) {
|
||||
let path = join(&dir, &e.file_name);
|
||||
let busy = |main: &str| group(main).iter().any(|p| taken.contains(p) || exists(p));
|
||||
if !busy(&path) {
|
||||
Ok(path)
|
||||
} else {
|
||||
match o.conflict {
|
||||
Conflict::Overwrite => {}
|
||||
Conflict::Overwrite => Ok(path),
|
||||
Conflict::Skip => {
|
||||
out.push(json!({"skipped": path}));
|
||||
continue;
|
||||
@@ -990,29 +1201,34 @@ pub fn run_batch(
|
||||
Conflict::Unique => {
|
||||
let (stem, ext) = e.file_name.rsplit_once('.').map_or((e.file_name.as_str(), None), |(a, b)| (a, Some(b)));
|
||||
let name = |n: usize| join(&dir, &ext.map_or(format!("{stem}-{n}"), |x| format!("{stem}-{n}.{x}")));
|
||||
path = (2..).map(name).find(|p| !busy(p, &taken)).expect("a free name");
|
||||
(2..1_000_000).map(name).find(|p| !busy(p)).ok_or_else(|| format!("{path}: no free file name"))
|
||||
}
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
};
|
||||
let written = write(&path, &e.bytes).and_then(|()| {
|
||||
let mut sidecars = Vec::new();
|
||||
for (ext, bytes) in &e.sidecars {
|
||||
let sc = std::path::Path::new(&path).with_extension(ext).to_string_lossy().to_string();
|
||||
write(&sc, bytes)?;
|
||||
sidecars.push(sc);
|
||||
let file = path.clone().unwrap_or_else(|_| name.clone());
|
||||
let written = path.and_then(|path| {
|
||||
let files = group(&path);
|
||||
// never over an original, whatever the conflict policy or the exact path said
|
||||
for f in &files {
|
||||
guard.check(std::path::Path::new(f))?;
|
||||
}
|
||||
Ok(sidecars)
|
||||
write(&path, &e.bytes)?;
|
||||
let mut sidecars = Vec::new();
|
||||
for ((_, bytes), sc) in e.sidecars.iter().zip(files.iter().skip(1)) {
|
||||
write(sc, bytes)?;
|
||||
sidecars.push(sc.clone());
|
||||
}
|
||||
Ok((path, files, sidecars))
|
||||
});
|
||||
match written {
|
||||
Ok(sidecars) => {
|
||||
taken.insert(path.clone());
|
||||
Ok((path, files, sidecars)) => {
|
||||
taken.extend(files);
|
||||
out.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars}));
|
||||
}
|
||||
Err(err) if stop_on_error => return Err(err),
|
||||
Err(err) => out.push(json!({"photo": photo.0, "file": path, "error": err})),
|
||||
Err(err) => out.push(json!({"photo": photo.0, "file": file, "error": err})),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
@@ -1069,6 +1285,9 @@ mod tests {
|
||||
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00");
|
||||
p.meta.camera = "Synthetic X2".into();
|
||||
p.meta.copyright = "(c) Me".into();
|
||||
p.meta.copyright_status = lightcraft_catalog::CopyrightStatus::Copyrighted;
|
||||
p.meta.usage_terms = "Editorial use only".into();
|
||||
p.meta.copyright_url = "https://example.com/rights".into();
|
||||
p.meta.shutter = "1/250".into();
|
||||
p.meta.gps = Some((43.0, -110.0));
|
||||
let all = export_metadata(&p, &ExportOptions::default()).unwrap();
|
||||
@@ -1080,12 +1299,19 @@ mod tests {
|
||||
assert!(m.model.is_none() && m.gps.is_none() && m.copyright.is_some());
|
||||
let c = export_metadata(&p, &ExportOptions { metadata: MetadataPolicy::Copyright, ..Default::default() }).unwrap();
|
||||
assert!(c.model.is_none() && c.gps.is_none() && c.copyright.as_deref() == Some("(c) Me"));
|
||||
// "copyright only" keeps all the copyright info: status, usage terms, info URL
|
||||
assert_eq!(
|
||||
(c.copyright_marked, c.usage_terms.as_deref(), c.copyright_url.as_deref()),
|
||||
(Some(true), Some("Editorial use only"), Some("https://example.com/rights"))
|
||||
);
|
||||
assert!(export_metadata(&p, &ExportOptions { metadata: MetadataPolicy::None, ..Default::default() }).is_none());
|
||||
// embedded and readable back from the JPEG
|
||||
let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&all)).unwrap();
|
||||
let back = lightcraft_meta::extract(&jpg);
|
||||
assert_eq!(back.model.as_deref(), Some("Synthetic X2"));
|
||||
assert_eq!(back.copyright.as_deref(), Some("(c) Me"));
|
||||
assert_eq!((back.copyright_marked, back.usage_terms.as_deref()), (Some(true), Some("Editorial use only")));
|
||||
assert_eq!(back.copyright_url.as_deref(), Some("https://example.com/rights"));
|
||||
assert!(back.gps.is_some());
|
||||
}
|
||||
|
||||
@@ -1108,18 +1334,103 @@ mod tests {
|
||||
assert!(ExportOptions::from_json(&serde_json::json!({"watermark": ""})).watermark.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_watermarks_support_vertical_columns_and_legacy_defaults() {
|
||||
use ab_glyph::Font;
|
||||
let font = ab_glyph::FontRef::try_from_slice(WATERMARK_JAPANESE_FONT).unwrap();
|
||||
for c in "日本語".chars() {
|
||||
assert_ne!(font.glyph_id(c).0, 0);
|
||||
}
|
||||
let old = ExportOptions::from_json(&json!({"watermark": {"text": "日本語"}}));
|
||||
assert!(!old.watermark.unwrap().vertical);
|
||||
let options =
|
||||
ExportOptions::from_json(&json!({"watermark": {"text": "日本語", "vertical": true, "size": 0.1, "shadow": false, "opacity": 1.0}}));
|
||||
let wm = options.watermark.unwrap();
|
||||
assert!(wm.vertical);
|
||||
let round: Watermark = serde_json::from_value(serde_json::to_value(&wm).unwrap()).unwrap();
|
||||
assert!(round.vertical);
|
||||
let mut img = Rgba8::new(400, 300);
|
||||
img.data.fill([0, 0, 0, 255]);
|
||||
draw_watermark(&mut img, &wm);
|
||||
let lit: Vec<_> = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 64).collect();
|
||||
assert!(lit.len() > 100);
|
||||
let w = lit.iter().map(|p| p.0).max().unwrap() - lit.iter().map(|p| p.0).min().unwrap();
|
||||
let h = lit.iter().map(|p| p.1).max().unwrap() - lit.iter().map(|p| p.1).min().unwrap();
|
||||
assert!(h > w * 2, "vertical Japanese must extend down the column");
|
||||
let two = Watermark { text: "日\n本".into(), anchor: Anchor::TopLeft, inset: 0.0, ..wm };
|
||||
img.data.fill([0, 0, 0, 255]);
|
||||
draw_watermark(&mut img, &two);
|
||||
for (text, dx) in [("本", 0), ("日", 30)] {
|
||||
let mut single = Rgba8::new(400, 300);
|
||||
single.data.fill([0, 0, 0, 255]);
|
||||
draw_watermark(&mut single, &Watermark { text: text.into(), ..two.clone() });
|
||||
for y in 0..30 {
|
||||
for x in 0..30 {
|
||||
assert_eq!(img.get(x + dx, y), single.get(x, y), "newlines move columns left");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphic_watermark_is_placed_scaled_and_blended() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-wm-logo-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let logo_path = dir.join("logo.png");
|
||||
// 40×20: left half opaque red, right half fully transparent
|
||||
let logo = Rgba8::from_fn(40, 20, |x, _| if x < 20 { [255, 0, 0, 255] } else { [0, 255, 0, 0] });
|
||||
let png = lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap();
|
||||
std::fs::write(&logo_path, png).unwrap();
|
||||
let mut img = Rgba8::new(400, 300);
|
||||
img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]);
|
||||
let wm = Watermark { image: logo_path.to_string_lossy().into(), image_width: 0.25, opacity: 1.0, inset: 0.0, ..Default::default() };
|
||||
draw_watermark(&mut img, &wm);
|
||||
// 100 px wide × 50 px high in the bottom-right corner
|
||||
assert!(img.get(310, 280)[0] > 200, "opaque half: {:?}", img.get(310, 280));
|
||||
assert_eq!(img.get(390, 280), [0, 0, 0, 255], "transparent half leaves the photo");
|
||||
assert_eq!(img.get(310, 240), [0, 0, 0, 255], "above the logo");
|
||||
assert_eq!(img.get(10, 10), [0, 0, 0, 255]);
|
||||
// an image-only watermark survives the params (no text needed)
|
||||
let o = ExportOptions::from_json(&json!({"watermark": {"image": logo_path.to_string_lossy(), "imageWidth": 0.1}}));
|
||||
assert_eq!(o.watermark.as_ref().map(|w| w.image_width), Some(0.1));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn naming_and_params() {
|
||||
let o = ExportOptions::from_json(&serde_json::json!({"format": "jpg", "quality": 150, "longEdge": 2048, "naming": "{name}-{seq}"}));
|
||||
assert_eq!(o.format, ExportFormat::Jpeg);
|
||||
assert_eq!(o.quality, 100);
|
||||
assert_eq!(o.resize, Some(Resize { dont_enlarge: true, height: 0, ..Resize::long_edge(2048) }));
|
||||
assert_eq!(o.file_name("IMG/1", 7), "IMG_1-007.jpg");
|
||||
assert_eq!(o.file_name_for(&named("IMG/1.png"), 7), "IMG_1-007.jpg");
|
||||
assert_eq!(o.ppi, 240);
|
||||
let orig = ExportOptions { format: ExportFormat::Original, naming: "{name}-{seq}".into(), ..Default::default() };
|
||||
assert_eq!(orig.file_name_for("DSC_1.NEF", 2), "DSC_1-002.NEF");
|
||||
assert_eq!(orig.file_name_for("noext", 1), "noext-001");
|
||||
assert_eq!(ExportOptions { format: ExportFormat::Dng, ..Default::default() }.file_name_for("a.cr2", 1), "a.dng");
|
||||
assert_eq!(orig.file_name_for(&named("DSC_1.NEF"), 2), "DSC_1-002.NEF");
|
||||
assert_eq!(orig.file_name_for(&named("noext"), 1), "noext-001");
|
||||
assert_eq!(ExportOptions { format: ExportFormat::Dng, ..Default::default() }.file_name_for(&named("a.cr2"), 1), "a.dng");
|
||||
}
|
||||
|
||||
fn named(file_name: &str) -> lightcraft_catalog::Photo {
|
||||
use lightcraft_catalog::{Photo, PhotoId, Source};
|
||||
Photo::new(PhotoId(1), Source::Demo { scene: 0 }, file_name, "", 1, 1, "2026-01-01T00:00:00")
|
||||
}
|
||||
|
||||
/// Export naming takes the same tokens as Rename Photos and import renaming.
|
||||
#[test]
|
||||
fn naming_uses_the_rename_tokens() {
|
||||
let mut p = named("DSC_0815.NEF");
|
||||
p.captured = Some("2026-09-30T14:05:09".into());
|
||||
p.meta.camera = "Model X/2".into();
|
||||
p.meta.title = "Harbour".into();
|
||||
p.rating = 5;
|
||||
let o = |naming: &str| ExportOptions { naming: naming.into(), start_number: 9, ..Default::default() };
|
||||
assert_eq!(o("{date:%Y-%m-%d}_{title}_{seq:2}").file_name_for(&p, 1), "2026-09-30_Harbour_09.jpg");
|
||||
assert_eq!(o("{camera}-{num}-{rating}").file_name_for(&p, 1), "Model X_2-0815-5.jpg", "path separators become _");
|
||||
assert_eq!(o("{name}-{seq}").file_name_for(&p, 3), "DSC_0815-011.jpg", "a bare {{seq}} keeps its 3 digits");
|
||||
assert_eq!(o("{ext}_{name}").file_name_for(&p, 1), "NEF_DSC_0815.jpg");
|
||||
// nothing left after expanding: the original name
|
||||
p.meta.title.clear();
|
||||
assert_eq!(o("{title}").file_name_for(&p, 1), "DSC_0815.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1147,7 +1458,9 @@ mod tests {
|
||||
let full = ExportOptions::default();
|
||||
assert_eq!(ExportOptions::from_json(&full.to_json()), full);
|
||||
assert!(ExportOptions::has_size_param(&full.to_json()), "full size is explicit");
|
||||
assert_eq!(o.file_name_dated("IMG", 2, Some("2026-09-30T10:00:00")), "20260930-IMG-043.tif");
|
||||
let mut p = named("IMG.png");
|
||||
p.captured = Some("2026-09-30T10:00:00".into());
|
||||
assert_eq!(o.file_name_for(&p, 2), "20260930-IMG-043.tif");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -33,6 +33,9 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
|
||||
title: m.title.clone().unwrap_or_default(),
|
||||
caption: m.caption.clone().unwrap_or_default(),
|
||||
copyright: m.copyright.clone().unwrap_or_default(),
|
||||
copyright_status: lightcraft_catalog::CopyrightStatus::from_marked(m.copyright_marked),
|
||||
usage_terms: m.usage_terms.clone().unwrap_or_default(),
|
||||
copyright_url: m.copyright_url.clone().unwrap_or_default(),
|
||||
creator: m.artist.clone().unwrap_or_default(),
|
||||
keywords: m.keywords.clone(),
|
||||
};
|
||||
@@ -114,6 +117,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
as_shot_wb: None,
|
||||
content_hash,
|
||||
xmp: lightcraft_meta::embedded(bytes).xmp,
|
||||
preview_only: Some(why),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
@@ -141,6 +145,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
as_shot_wb,
|
||||
content_hash,
|
||||
xmp: lightcraft_meta::embedded(bytes).xmp,
|
||||
preview_only: None,
|
||||
});
|
||||
}
|
||||
let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?;
|
||||
@@ -170,6 +175,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
content_hash,
|
||||
embedded_lens: None,
|
||||
xmp: None,
|
||||
preview_only: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -396,6 +402,62 @@ mod tests {
|
||||
assert!(load_bytes(b"\0\0\0\x18ftypcrx \0\0\0\x01", 24).is_err());
|
||||
}
|
||||
|
||||
/// Issue #10: a raw variant we can't decode imports as "preview only" with the decoder's
|
||||
/// reason, is rendered as the rendered JPEG it is (not as raw), says so to agents, survives a
|
||||
/// library reopen, and Reload / Relink clear it once the file decodes (undoably).
|
||||
#[test]
|
||||
fn unsupported_raw_imports_as_preview_only_and_survives_reopen() {
|
||||
use serde_json::json;
|
||||
let dir = std::env::temp_dir().join(format!("lc-preview-only-{}", 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 path = files.join("DSC_0001.cr3");
|
||||
std::fs::write(&path, cr3_with_preview(96, 64)).unwrap();
|
||||
let mut s = crate::Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
// the import review already says so
|
||||
let scan = s.execute("library.importPreview", &json!({"paths": [path.to_string_lossy()]})).unwrap();
|
||||
assert!(scan["candidates"][0]["previewOnly"].as_str().is_some_and(|w| !w.is_empty()), "{scan}");
|
||||
let r = s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap();
|
||||
let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap());
|
||||
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}");
|
||||
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!(*p.develop, lightcraft_develop::DevelopSettings::default());
|
||||
assert!(s.render_job(id, 48, 48, false, true).unwrap().run().rendered.is_ok());
|
||||
// agents see it
|
||||
let q = s.execute("catalog.query", &json!({"filter": {}})).unwrap();
|
||||
assert_eq!(q["photos"][0]["previewOnly"], json!(why), "{q}");
|
||||
let i = s.execute("photo.inspect", &json!({"id": id.0})).unwrap();
|
||||
assert_eq!(i["preview_only"], json!(why), "{i}");
|
||||
// a library reopen keeps it
|
||||
s.persist().unwrap();
|
||||
drop(s);
|
||||
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()));
|
||||
// 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!(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()));
|
||||
// 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();
|
||||
s.execute("photo.relink", &json!({"id": id.0, "path": dng.to_string_lossy()})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().preview_only, None);
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_jobs_show_embedded_previews_then_cached_renders() {
|
||||
use crate::media::QuickSource;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Last-resort panic guard (never-crash standard, `AGENTS.md`): a panic that escapes a command
|
||||
//! becomes that command's error and the session (catalog, library, undo history) stays usable.
|
||||
//! It's a safety net, not a way to report errors: commands return `Err` themselves.
|
||||
|
||||
use std::any::Any;
|
||||
use std::io::Write;
|
||||
use std::panic::{self, AssertUnwindSafe};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The text of a panic payload (`panic!("…")` gives a `&str` or a `String`).
|
||||
pub fn panic_message(payload: &(dyn Any + Send)) -> String {
|
||||
match (payload.downcast_ref::<&str>(), payload.downcast_ref::<String>()) {
|
||||
(Some(s), _) => (*s).to_string(),
|
||||
(_, Some(s)) => s.clone(),
|
||||
_ => "unknown panic".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f`; a panic inside it becomes `Err("<what> failed unexpectedly: <message>")`. On wasm32
|
||||
/// (built with `panic = "abort"`) nothing can be caught and this just runs `f`.
|
||||
pub fn catch<T>(what: &str, f: impl FnOnce() -> T) -> Result<T, String> {
|
||||
panic::catch_unwind(AssertUnwindSafe(f)).map_err(|p| {
|
||||
let msg = format!("{what} failed unexpectedly: {}", panic_message(p.as_ref()));
|
||||
log::error!("{msg}");
|
||||
msg
|
||||
})
|
||||
}
|
||||
|
||||
/// Install a panic hook that keeps the default report (stderr) and also appends the panic's
|
||||
/// location and message to `log` (best effort), so a panic in a GUI session leaves a trace.
|
||||
pub fn install_hook(log: PathBuf) {
|
||||
let default = panic::take_hook();
|
||||
panic::set_hook(Box::new(move |info| {
|
||||
default(info);
|
||||
let at = info.location().map(|l| format!("{}:{}", l.file(), l.line())).unwrap_or_default();
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let thread = std::thread::current().name().unwrap_or("unnamed").to_string();
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log) {
|
||||
let _ = writeln!(f, "{secs} [{thread}] panic at {at}: {}", panic_message(info.payload()));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_panic_becomes_an_error() {
|
||||
assert_eq!(catch("ok", || 7), Ok(7));
|
||||
let e = catch("`demo.cmd`", || -> u32 { panic!("boom {}", 1) }).unwrap_err();
|
||||
assert_eq!(e, "`demo.cmd` failed unexpectedly: boom 1");
|
||||
assert!(catch("x", || std::panic::panic_any(5u8)).unwrap_err().ends_with("unknown panic"));
|
||||
}
|
||||
}
|
||||
+375
-82
@@ -6,12 +6,16 @@
|
||||
//! 3. Probe the rest — dimensions, metadata, and the **content hash** of the bytes — in parallel
|
||||
//! on native targets.
|
||||
//! 4. Skip **duplicates by content** (same bytes already in the library, or twice in this batch).
|
||||
//! 5. *Add* in place (the photo points at the original file) or *copy* into the library's
|
||||
//! `Originals/YYYY/YYYY-MM-DD/` folder (names made unique) and point at the copy.
|
||||
//! 6. Read each photo's XMP sidecar (or a raw/DNG file's embedded XMP): the sidecar wins for
|
||||
//! metadata and develop settings are restored (see [`crate::sidecar`]).
|
||||
//! 5. *Add* in place (the photo points at the original file), *copy* into the library's
|
||||
//! `Originals/YYYY/YYYY-MM-DD/` folder (names made unique) and point at the copy, or *move*
|
||||
//! there: placed like a copy (with its XMP sidecars), and the original removed only once its
|
||||
//! catalog record is committed and saved (see [`crate::import_move`] for the safety rules).
|
||||
//! 6. Apply each photo's XMP sidecar (or a raw/DNG file's embedded XMP): the sidecar wins for
|
||||
//! metadata and develop settings are restored (see [`crate::sidecar`]). Its capture time fills
|
||||
//! in when the file has none (read before step 5, so it also files and names the copy).
|
||||
//! 7. Optionally apply a preset and add keywords (the import dialog's options).
|
||||
//! 8. Commit all new photos as one undoable op.
|
||||
//! 8. Commit all new photos as one undoable op. (Undoing a move removes the catalog records;
|
||||
//! the files stay at the destination — undo never moves files back over a card.)
|
||||
//!
|
||||
//! The import dialog first calls [`scan`] (`library.importPreview`): the same expansion, probing
|
||||
//! and duplicate detection without adding anything, so the user can review the candidates. The
|
||||
@@ -39,6 +43,9 @@ pub enum ImportMode {
|
||||
Add,
|
||||
/// Copy the files into the library's `Originals/` folder.
|
||||
Copy,
|
||||
/// Move the files there: as Copy, then each original (and its sidecars) is removed from the
|
||||
/// source once the copy is verified and catalogued.
|
||||
Move,
|
||||
}
|
||||
|
||||
/// What an import does besides adding the photos.
|
||||
@@ -52,6 +59,61 @@ pub struct ImportOptions {
|
||||
/// Browsing a folder: photos come in as `local` (not in the library), and a file with the
|
||||
/// same content as one already known is still listed.
|
||||
pub local: bool,
|
||||
/// Copy: where the copies go (default: the library's `Originals/`).
|
||||
pub destination: Option<String>,
|
||||
/// Copy: the folders inside the destination.
|
||||
pub organize: Organize,
|
||||
/// Copy: a file-name template for the copies ([`crate::rename::expand`] tokens: `{name}`,
|
||||
/// `{seq:N}`, `{date:%Y%m%d}`, `{camera}`…), numbered from `rename_start`.
|
||||
pub rename: Option<String>,
|
||||
pub rename_start: usize,
|
||||
/// A metadata preset (by name) applied to every imported photo.
|
||||
pub metadata_preset: Option<String>,
|
||||
/// Copy: raws are copied as DNG (Copy as DNG).
|
||||
pub convert_dng: bool,
|
||||
}
|
||||
|
||||
/// How copies are filed in the destination. The date is the capture time, else the time of the
|
||||
/// import.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Organize {
|
||||
/// `YYYY/YYYY-MM-DD/` by capture date.
|
||||
#[default]
|
||||
ByDay,
|
||||
/// `YYYY/YYYY-MM/`.
|
||||
ByMonth,
|
||||
/// All in the destination itself.
|
||||
Flat,
|
||||
/// A folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → `2026/20260114/`: the template's own
|
||||
/// `/` make the levels, each expanded with the file-name tokens ([`crate::rename::expand_folder`];
|
||||
/// never outside the destination).
|
||||
Template(String),
|
||||
}
|
||||
|
||||
impl Organize {
|
||||
/// `date` | `month` | `flat`, or a folder template (anything with a `{`, `/` or `\`).
|
||||
pub fn parse(s: &str) -> Option<Organize> {
|
||||
match s {
|
||||
"date" | "day" | "byDay" => Some(Organize::ByDay),
|
||||
"month" | "byMonth" => Some(Organize::ByMonth),
|
||||
"flat" | "none" | "intoOneFolder" => Some(Organize::Flat),
|
||||
t if t.contains(['{', '/', '\\']) => Some(Organize::Template(t.trim().to_string())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The folders (inside the destination) a copy of `p` goes to.
|
||||
pub fn folders(&self, p: &Photo) -> Vec<String> {
|
||||
let date = p.date();
|
||||
let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated");
|
||||
let year = day.get(..4).unwrap_or("undated");
|
||||
match self {
|
||||
Organize::ByDay => vec![year.into(), day.into()],
|
||||
Organize::ByMonth => vec![year.into(), day.get(..7).unwrap_or("undated").into()],
|
||||
Organize::Flat => Vec::new(),
|
||||
Organize::Template(t) => crate::rename::expand_folder(t, p, 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file found by [`scan`], for the import review.
|
||||
@@ -73,6 +135,9 @@ pub struct ImportCandidate {
|
||||
pub existing: Option<u64>,
|
||||
/// Not readable.
|
||||
pub error: Option<String>,
|
||||
/// A raw variant that can't be decoded yet (why): it imports as preview only.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preview_only: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -88,6 +153,13 @@ pub struct Duplicate {
|
||||
pub struct ImportReport {
|
||||
pub imported: Vec<u64>,
|
||||
pub duplicates: Vec<Duplicate>,
|
||||
/// Move: the originals that were moved (and removed from the source).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub moved: Vec<Moved>,
|
||||
/// Move: originals (or sidecars) left at the source, and why — the photo may still have been
|
||||
/// imported (from its copy, or in place).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub kept: Vec<Kept>,
|
||||
/// (path, error)
|
||||
pub failed: Vec<(String, String)>,
|
||||
/// Files found after expanding folders.
|
||||
@@ -96,6 +168,23 @@ pub struct ImportReport {
|
||||
pub sidecars: usize,
|
||||
}
|
||||
|
||||
/// A file moved by an import.
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct Moved {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
/// Its sidecars' new paths.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub sidecars: Vec<String>,
|
||||
}
|
||||
|
||||
/// A source a Move left where it was.
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct Kept {
|
||||
pub path: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// The develop settings a photo gets on import: raws start from their as-shot white balance with
|
||||
/// default sharpening / colour noise reduction, and file-embedded lens corrections on (as the
|
||||
/// camera intended); a user default preset ([`ImportDefaults`]) goes on top.
|
||||
@@ -127,6 +216,14 @@ pub struct ImportDefaults {
|
||||
pub copyright: String,
|
||||
/// Creator given to imported photos that don't name one (empty = none).
|
||||
pub creator: String,
|
||||
/// Metadata preset applied to every imported photo (`metadata.*`; `None` = none).
|
||||
pub metadata_preset: Option<String>,
|
||||
/// Auto import: a watched folder whose new photos are added as they arrive (`None` = off),
|
||||
/// copied into the library's Originals instead of added in place when `auto_copy`, and an
|
||||
/// album they go to.
|
||||
pub auto_folder: Option<String>,
|
||||
pub auto_copy: bool,
|
||||
pub auto_album: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -164,8 +261,12 @@ pub fn apply_import_defaults(s: &Session, p: &mut Photo) {
|
||||
if p.meta.creator.trim().is_empty() && !d.creator.trim().is_empty() {
|
||||
p.meta.creator = d.creator.trim().to_string();
|
||||
}
|
||||
if let Some(mp) = d.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) {
|
||||
crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields);
|
||||
}
|
||||
let base = p.camera_defaults();
|
||||
let raw = p.kind == lightcraft_catalog::MediaKind::Raw;
|
||||
// a raw shown from its embedded JPEG gets the rendered-file default, not the raw one
|
||||
let raw = p.develops_raw();
|
||||
let preset = s.import_defaults.preset_for(raw, &p.meta.camera).and_then(|id| s.presets.iter().find(|x| x.id == id));
|
||||
match preset {
|
||||
Some(pr) => {
|
||||
@@ -213,8 +314,14 @@ pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
fn probe_all(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
|
||||
let Some(probe) = s.media.file_probe.clone() else {
|
||||
/// Probe files (headers + content hash) as an import would.
|
||||
pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
|
||||
probe_all(s.media.file_probe.as_ref(), paths, &ScanProgress::default())
|
||||
}
|
||||
|
||||
fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress: &ScanProgress) -> Vec<Result<ProbeInfo, String>> {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let Some(probe) = probe.cloned() else {
|
||||
return paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
@@ -240,7 +347,11 @@ fn probe_all(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
|
||||
if i >= paths.len() {
|
||||
break;
|
||||
}
|
||||
if progress.cancel.load(Relaxed) {
|
||||
break;
|
||||
}
|
||||
let r = probe(&paths[i]);
|
||||
progress.done.fetch_add(1, Relaxed);
|
||||
out.lock().unwrap_or_else(|e| e.into_inner())[i] = Some(r);
|
||||
}
|
||||
});
|
||||
@@ -249,81 +360,136 @@ fn probe_all(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
|
||||
return results.into_iter().map(|r| r.unwrap_or_else(|| Err("not probed".into()))).collect();
|
||||
}
|
||||
}
|
||||
paths.iter().map(|p| probe(p)).collect()
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
if progress.cancel.load(Relaxed) {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
let r = probe(p);
|
||||
progress.done.fetch_add(1, Relaxed);
|
||||
r
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `Originals/YYYY/YYYY-MM-DD/name`, made unique.
|
||||
fn copy_into_library(lib: &Path, src: &str, date: &str) -> Result<String, String> {
|
||||
let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated");
|
||||
let year = day.get(..4).unwrap_or("undated");
|
||||
let dir = lib.join("Originals").join(year).join(day);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let name = Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into());
|
||||
let (stem, ext) = match name.rsplit_once('.') {
|
||||
Some((s, e)) => (s.to_string(), format!(".{e}")),
|
||||
None => (name.clone(), String::new()),
|
||||
};
|
||||
let mut dst = dir.join(&name);
|
||||
let mut i = 1;
|
||||
while dst.exists() {
|
||||
dst = dir.join(format!("{stem}-{i}{ext}"));
|
||||
i += 1;
|
||||
/// Copy `src` into `root`/`folders` as `name` (default: its own name), made unique with -1, -2…;
|
||||
/// returns the new path. The copy is verified ([`crate::import_move::copy_new`]: a new file,
|
||||
/// synced and compared byte for byte); a bad one is removed and the photo reported as failed.
|
||||
fn copy_into(root: &Path, src: &str, folders: &[String], name: Option<&str>) -> Result<String, String> {
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
let name = name
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into()));
|
||||
crate::import_move::copy_new(Path::new(src), &dir, &name).map(|p| p.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// What a [`scan`] needs from the session, so it can run on another thread (a folder on a network
|
||||
/// share can take minutes to read).
|
||||
pub struct ScanInput {
|
||||
probe: Option<crate::media::FileProbe>,
|
||||
skip: Option<PathBuf>,
|
||||
/// path → (photo, its summary) for files already in the library.
|
||||
by_path: HashMap<String, (PhotoId, ImportCandidate)>,
|
||||
by_hash: HashMap<String, PhotoId>,
|
||||
cache: HashMap<String, ProbeInfo>,
|
||||
}
|
||||
|
||||
/// Progress and cancellation of a running [`scan_with`].
|
||||
#[derive(Default)]
|
||||
pub struct ScanProgress {
|
||||
/// Files to probe, set once the folders are expanded.
|
||||
pub total: std::sync::atomic::AtomicUsize,
|
||||
pub done: std::sync::atomic::AtomicUsize,
|
||||
pub cancel: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
/// The result of [`scan_with`]: the candidates, and the probes to keep for the import that follows.
|
||||
pub struct ScanOutput {
|
||||
pub candidates: Vec<ImportCandidate>,
|
||||
pub probes: HashMap<String, ProbeInfo>,
|
||||
}
|
||||
|
||||
impl ScanInput {
|
||||
pub fn new(s: &mut Session, paths: &[String]) -> (Self, Vec<String>) {
|
||||
let skip = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
|
||||
let mut by_path = HashMap::new();
|
||||
let mut by_hash = HashMap::new();
|
||||
for p in s.catalog.photos() {
|
||||
if let Source::File { path } = &p.source {
|
||||
let c = ImportCandidate {
|
||||
path: path.clone(),
|
||||
format: p.format.clone(),
|
||||
kind: p.kind,
|
||||
width: p.width,
|
||||
height: p.height,
|
||||
file_size: p.file_size,
|
||||
captured: p.captured.clone(),
|
||||
duplicate: Some("path".into()),
|
||||
existing: Some(p.id.0),
|
||||
..Default::default()
|
||||
};
|
||||
by_path.insert(path.clone(), (p.id, c));
|
||||
}
|
||||
if let Some(h) = &p.content_hash {
|
||||
by_hash.insert(h.clone(), p.id);
|
||||
}
|
||||
}
|
||||
let input = ScanInput { probe: s.media.file_probe.clone(), skip, by_path, by_hash, cache: std::mem::take(&mut s.import_probes) };
|
||||
(input, paths.to_vec())
|
||||
}
|
||||
std::fs::copy(src, &dst).map_err(|e| format!("copy {src}: {e}"))?;
|
||||
Ok(dst.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Find and probe what importing `paths` would add, marking duplicates (by path, or by content
|
||||
/// against the library and earlier candidates). Nothing is added; the probes are kept for the
|
||||
/// import that follows.
|
||||
pub fn scan(s: &mut Session, paths: &[String]) -> Vec<ImportCandidate> {
|
||||
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
|
||||
let files = expand(paths, lib_dir.as_deref());
|
||||
let mut by_path: HashMap<String, PhotoId> = HashMap::new();
|
||||
let mut by_hash: HashMap<String, PhotoId> = HashMap::new();
|
||||
for p in s.catalog.photos() {
|
||||
if let Source::File { path } = &p.source {
|
||||
by_path.insert(path.clone(), p.id);
|
||||
}
|
||||
if let Some(h) = &p.content_hash {
|
||||
by_hash.insert(h.clone(), p.id);
|
||||
}
|
||||
}
|
||||
let todo: Vec<String> = files.iter().filter(|f| !by_path.contains_key(f.as_str())).cloned().collect();
|
||||
let (input, paths) = ScanInput::new(s, paths);
|
||||
let out = scan_with(input, &paths, &ScanProgress::default());
|
||||
s.import_probes = out.probes;
|
||||
out.candidates
|
||||
}
|
||||
|
||||
/// [`scan`] without the session, so it can run on a worker thread. Stops early (returning what it
|
||||
/// has) when `progress.cancel` is set.
|
||||
pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let files = expand(paths, input.skip.as_deref());
|
||||
let todo: Vec<String> = files.iter().filter(|f| !input.by_path.contains_key(f.as_str())).cloned().collect();
|
||||
progress.total.store(todo.len(), Relaxed);
|
||||
// probes from a preceding `scan` are reused when the file is unchanged (same size)
|
||||
let cached: Vec<Option<ProbeInfo>> = todo
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let info = s.import_probes.remove(f)?;
|
||||
let info = input.cache.remove(f)?;
|
||||
let size = std::fs::metadata(f).map(|m| m.len()).ok();
|
||||
(size.is_none() || size == Some(info.file_size)).then_some(info)
|
||||
})
|
||||
.collect();
|
||||
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
|
||||
let mut fresh = probe_all(s, &missing).into_iter();
|
||||
progress.done.store(todo.len() - missing.len(), Relaxed);
|
||||
let mut fresh = probe_all(input.probe.as_ref(), &missing, progress).into_iter();
|
||||
let probed: Vec<Result<ProbeInfo, String>> =
|
||||
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
|
||||
crate::memory::release();
|
||||
let mut probes: HashMap<String, Result<ProbeInfo, String>> = todo.into_iter().zip(probed).collect();
|
||||
let mut seen_hash: HashMap<String, Option<u64>> = by_hash.into_iter().map(|(h, id)| (h, Some(id.0))).collect();
|
||||
let mut seen_hash: HashMap<String, Option<u64>> = input.by_hash.into_iter().map(|(h, id)| (h, Some(id.0))).collect();
|
||||
let mut out = Vec::with_capacity(files.len());
|
||||
let mut kept = HashMap::new();
|
||||
for f in files {
|
||||
let name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone());
|
||||
let mut c = ImportCandidate { path: f.clone(), name, ..Default::default() };
|
||||
if let Some(id) = by_path.get(&f) {
|
||||
c.duplicate = Some("path".into());
|
||||
c.existing = Some(id.0);
|
||||
if let Some(p) = s.catalog.photo(*id) {
|
||||
(c.format, c.kind, c.width, c.height, c.file_size, c.captured) =
|
||||
(p.format.clone(), p.kind, p.width, p.height, p.file_size, p.captured.clone());
|
||||
}
|
||||
if let Some((_, c)) = input.by_path.get(&f) {
|
||||
let mut c = c.clone();
|
||||
c.name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone());
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
let name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone());
|
||||
let mut c = ImportCandidate { path: f.clone(), name, ..Default::default() };
|
||||
match probes.remove(&f) {
|
||||
Some(Ok(info)) => {
|
||||
(c.format, c.kind, c.width, c.height, c.file_size, c.captured) =
|
||||
(info.format.clone(), info.kind, info.width, info.height, info.file_size, info.captured.clone());
|
||||
c.preview_only = info.preview_only.clone();
|
||||
if let Some(h) = &info.content_hash {
|
||||
match seen_hash.get(h) {
|
||||
Some(existing) => {
|
||||
@@ -335,14 +501,14 @@ pub fn scan(s: &mut Session, paths: &[String]) -> Vec<ImportCandidate> {
|
||||
}
|
||||
}
|
||||
}
|
||||
s.import_probes.insert(f, info);
|
||||
kept.insert(f, info);
|
||||
}
|
||||
Some(Err(e)) => c.error = Some(e),
|
||||
None => c.error = Some("not probed".into()),
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
ScanOutput { candidates: out, probes: kept }
|
||||
}
|
||||
|
||||
/// Import files/folders. See the module docs.
|
||||
@@ -355,11 +521,23 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
let mode = opts.mode;
|
||||
// Only a library on disk has an `Originals/` folder. The browser build keeps the bytes of every
|
||||
// added file in its own storage already, so "copy" there means "add".
|
||||
let mode = if mode == ImportMode::Copy && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode };
|
||||
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
|
||||
if mode == ImportMode::Copy && lib_dir.is_none() {
|
||||
return Err(crate::EngineError::Other("copying into the library needs an open library".into()));
|
||||
let transfer = matches!(mode, ImportMode::Copy | ImportMode::Move);
|
||||
let mode = if transfer && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode };
|
||||
if mode == ImportMode::Move && opts.local {
|
||||
return Err(crate::EngineError::Other("browsing a folder never moves its files".into()));
|
||||
}
|
||||
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
|
||||
// where copies go: the chosen folder, else the library's Originals/
|
||||
let copy_root = opts
|
||||
.destination
|
||||
.as_deref()
|
||||
.filter(|d| !d.trim().is_empty())
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| lib_dir.as_ref().map(|l| l.join("Originals")));
|
||||
if matches!(mode, ImportMode::Copy | ImportMode::Move) && copy_root.is_none() {
|
||||
return Err(crate::EngineError::Other("copying or moving needs a destination folder or an open library".into()));
|
||||
}
|
||||
let mut seq = opts.rename_start.max(1);
|
||||
let files = expand(paths, lib_dir.as_deref());
|
||||
let mut report = ImportReport { scanned: files.len(), ..Default::default() };
|
||||
|
||||
@@ -388,10 +566,17 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
}
|
||||
}
|
||||
|
||||
let probed = probe_all(s, &todo);
|
||||
// files a preceding scan already probed are not read again (a network share is slow to read)
|
||||
let cached: Vec<Option<ProbeInfo>> = todo.iter().map(|f| s.import_probes.remove(f)).collect();
|
||||
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
|
||||
let mut fresh = probe_all(s.media.file_probe.as_ref(), &missing, &ScanProgress::default()).into_iter();
|
||||
let probed: Vec<Result<ProbeInfo, String>> =
|
||||
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
|
||||
crate::memory::release();
|
||||
let now = (s.clock)();
|
||||
let mut ops = promote;
|
||||
// Move: placed at the destination, sources untouched until the records are committed
|
||||
let mut placed: Vec<crate::import_move::Placed> = Vec::new();
|
||||
for (path, info) in todo.into_iter().zip(probed) {
|
||||
let info = match info {
|
||||
Ok(i) => i,
|
||||
@@ -408,23 +593,92 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" });
|
||||
continue;
|
||||
}
|
||||
let stored = match (mode, &lib_dir) {
|
||||
(ImportMode::Copy, Some(lib)) if !Path::new(&path).starts_with(lib) => {
|
||||
match copy_into_library(lib, &path, info.captured.as_deref().unwrap_or(&now)) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
report.failed.push((path, e));
|
||||
continue;
|
||||
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
|
||||
// and names the copy when the file itself has none
|
||||
let raw = info.kind == MediaKind::Raw;
|
||||
let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming)
|
||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||
.or_else(|| info.xmp.clone().filter(|_| raw));
|
||||
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, raw).map(|sc| sc.resolve_label(&s.catalog)) {
|
||||
Ok(sc) => (sc != crate::sidecar::SidecarData::default()).then_some(sc),
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: XMP: {e}");
|
||||
None
|
||||
}
|
||||
});
|
||||
let mut info = info;
|
||||
if info.captured.is_none() {
|
||||
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
|
||||
}
|
||||
let stored = match (mode, ©_root) {
|
||||
(ImportMode::Copy | ImportMode::Move, Some(root))
|
||||
if !crate::import_move::inside(Path::new(&path), root)
|
||||
&& !lib_dir.as_ref().is_some_and(|l| crate::import_move::inside(Path::new(&path), l)) =>
|
||||
{
|
||||
// the templates see the photo as it will be catalogued (undated: the import time)
|
||||
let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &now);
|
||||
q.captured = info.captured.clone();
|
||||
q.meta = info.meta.clone();
|
||||
let name = opts.rename.as_deref().filter(|t| !t.trim().is_empty()).map(|t| {
|
||||
let n = crate::rename::expand(t, &q, seq);
|
||||
seq += 1;
|
||||
n
|
||||
});
|
||||
let folders = opts.organize.folders(&q);
|
||||
if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) {
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
let name = name.unwrap_or(own);
|
||||
match crate::import_move::place(Path::new(&path), &dir, &name) {
|
||||
Ok(pl) => {
|
||||
let dst = pl.dst.to_string_lossy().to_string();
|
||||
placed.push(pl);
|
||||
dst
|
||||
}
|
||||
Err(e) => {
|
||||
report.failed.push((path, e));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if mode == ImportMode::Move {
|
||||
let reason = "a symbolic link: the file it points to was copied, the link and its target stay";
|
||||
report.kept.push(Kept { path: path.clone(), reason: reason.into() });
|
||||
}
|
||||
match copy_into(root, &path, &folders, name.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
report.failed.push((path, e));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(ImportMode::Move, _) => {
|
||||
let reason = "already inside the destination or the library: added where it is";
|
||||
report.kept.push(Kept { path: path.clone(), reason: reason.into() });
|
||||
path.clone()
|
||||
}
|
||||
_ => path.clone(),
|
||||
};
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
if let Some(h) = &info.content_hash {
|
||||
by_hash.insert(h.clone(), id);
|
||||
}
|
||||
let name = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
|
||||
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
|
||||
let mut stored = stored;
|
||||
if opts.convert_dng && mode == ImportMode::Copy && stored != path && info.kind == MediaKind::Raw && !info.format.eq_ignore_ascii_case("DNG") {
|
||||
match crate::cmd::convert::write_dng_for(s, &stored, String::new()) {
|
||||
Ok(dng) => {
|
||||
let _ = std::fs::remove_file(&stored);
|
||||
stored = dng;
|
||||
info.format = "DNG".into();
|
||||
}
|
||||
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
|
||||
}
|
||||
}
|
||||
// a copy is catalogued under its new name
|
||||
let name = Path::new(&stored).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
|
||||
let mut p = Photo::new(id, Source::File { path: stored }, &name, &info.format, info.width, info.height, &now);
|
||||
p.kind = info.kind;
|
||||
p.file_size = info.file_size;
|
||||
@@ -433,20 +687,14 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
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();
|
||||
apply_import_defaults(s, &mut p);
|
||||
let raw = p.kind == lightcraft_catalog::MediaKind::Raw;
|
||||
let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming)
|
||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||
.or_else(|| info.xmp.clone().filter(|_| raw));
|
||||
if let Some(x) = packet {
|
||||
match crate::sidecar::parse_sidecar(&x, raw) {
|
||||
Ok(sc) if sc != crate::sidecar::SidecarData::default() => {
|
||||
crate::sidecar::merge_into(&mut p, &sc, &now);
|
||||
report.sidecars += 1;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("import {path}: XMP: {e}"),
|
||||
}
|
||||
if let Some(sc) = &sidecar {
|
||||
crate::sidecar::merge_into(&mut p, sc, &now);
|
||||
report.sidecars += 1;
|
||||
}
|
||||
if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) {
|
||||
crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields);
|
||||
}
|
||||
for k in &opts.keywords {
|
||||
let k = lightcraft_catalog::keywords::clean(k);
|
||||
@@ -463,14 +711,58 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
}
|
||||
report.imported.push(id.0);
|
||||
p.local = opts.local;
|
||||
if opts.local {
|
||||
// the state as browsed: a later change keeps the record from being forgotten
|
||||
p.set_local_baseline();
|
||||
}
|
||||
ops.push(Op::AddPhoto { photo: Box::new(p) });
|
||||
}
|
||||
if !ops.is_empty() {
|
||||
s.commit(&format!("Add {} Photo{}", ops.len(), if ops.len() == 1 { "" } else { "s" }), Op::Batch { ops })?;
|
||||
let log0 = s.pending_log.len();
|
||||
if !ops.is_empty()
|
||||
&& let Err(e) = s.commit(&format!("Add {} Photo{}", ops.len(), if ops.len() == 1 { "" } else { "s" }), Op::Batch { ops })
|
||||
{
|
||||
// nothing was catalogued: take the moves back (their sources were never touched)
|
||||
placed.iter().for_each(crate::import_move::rollback);
|
||||
return Err(e);
|
||||
}
|
||||
if !placed.is_empty() {
|
||||
finish_moves(s, placed, log0, &mut report);
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Move, after the commit: save the library, then remove each source whose destination is
|
||||
/// still intact. Nothing is removed when the library can't be saved.
|
||||
fn finish_moves(s: &mut Session, placed: Vec<crate::import_move::Placed>, log0: usize, report: &mut ImportReport) {
|
||||
// (saving here clears the pending ops, so the sidecars `execute` would auto-write are written here)
|
||||
let auto: Vec<Op> = if s.xmp.auto_write { s.pending_log.get(log0..).map(<[Op]>::to_vec).unwrap_or_default() } else { Vec::new() };
|
||||
let before = s.pending_log.len();
|
||||
if let Err(e) = s.persist() {
|
||||
for p in placed {
|
||||
let reason = format!("the library could not be saved ({e}): the original stays; the photo uses the copy at {}", p.dst.display());
|
||||
report.kept.push(Kept { path: p.src.to_string_lossy().to_string(), reason });
|
||||
}
|
||||
return;
|
||||
}
|
||||
for p in placed {
|
||||
let from = p.src.to_string_lossy().to_string();
|
||||
match crate::import_move::finish(&p) {
|
||||
Ok(kept) => {
|
||||
let sidecars = p.sidecars.iter().map(|sc| sc.dst.to_string_lossy().to_string()).collect();
|
||||
report.moved.push(Moved { from, to: p.dst.to_string_lossy().to_string(), sidecars });
|
||||
report.kept.extend(kept.into_iter().map(|(path, reason)| Kept { path, reason }));
|
||||
}
|
||||
Err(reason) => {
|
||||
log::warn!("import: move {from}: {reason}");
|
||||
report.kept.push(Kept { path: from, reason });
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.pending_log.len() < before && s.interaction.is_none() && !auto.is_empty() {
|
||||
s.auto_write_sidecars(&auto);
|
||||
}
|
||||
}
|
||||
|
||||
/// The current local time as ISO 8601 (`YYYY-MM-DDTHH:MM:SS`, UTC on targets without a clock
|
||||
/// offset); for [`Session::clock`] on native hosts.
|
||||
pub fn system_clock() -> String {
|
||||
@@ -503,6 +795,7 @@ impl Session {
|
||||
if let Some(info) = self.import_probes.get(&c.path) {
|
||||
p.as_shot_wb = info.as_shot_wb;
|
||||
p.embedded_lens = info.embedded_lens;
|
||||
p.preview_only = info.preview_only.clone();
|
||||
}
|
||||
p.develop = std::sync::Arc::new(p.import_defaults());
|
||||
let edge = edge.clamp(64, crate::media::SourceLevel::Thumb.max_edge());
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
//! Import → **Move**: the file transfer behind [`crate::import::ImportMode::Move`].
|
||||
//!
|
||||
//! Moving deletes the user's originals, so it is done in two phases and every step that could
|
||||
//! lose data comes last:
|
||||
//!
|
||||
//! 1. [`place`] puts the photo (and its XMP sidecars) at the destination **without touching the
|
||||
//! source**: a hard link when source and destination share a volume (instant, no extra space,
|
||||
//! never overwrites), else a copy that is written to a new file (never overwriting), synced to
|
||||
//! disk and compared byte for byte with the original. A name that is taken gets -1, -2…
|
||||
//! Anything that fails is undone ([`rollback`]): the partial destination is removed and the
|
||||
//! source stays as it was.
|
||||
//! 2. Only after the catalog records pointing at the destinations were committed and saved
|
||||
//! does [`finish`] remove each source — after checking once more that its destination is
|
||||
//! still there with the same size. A source that can't be removed (a read-only card, a
|
||||
//! locked file) stays and is reported; the photo is then imported from the copy.
|
||||
//!
|
||||
//! A `IMG_1.xmp` sidecar is shared by `IMG_1.CR3` and `IMG_1.JPG`: it is copied along with each,
|
||||
//! and removed from the source only once no photo with that name is left beside it.
|
||||
//!
|
||||
//! (Not `std::fs::rename`: it would remove the source before the catalog knows the new place,
|
||||
//! and on Unix it silently replaces an existing destination.)
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A photo put in place by [`place`]; its source is untouched.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Placed {
|
||||
pub src: PathBuf,
|
||||
pub dst: PathBuf,
|
||||
pub sidecars: Vec<PlacedSidecar>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct PlacedSidecar {
|
||||
pub src: PathBuf,
|
||||
pub dst: PathBuf,
|
||||
/// `false`: an identical sidecar was already there (the other half of a raw + JPEG pair).
|
||||
pub created: bool,
|
||||
/// Named after the photo's stem (`IMG_1.xmp`), so possibly shared with a sibling photo.
|
||||
pub stem: bool,
|
||||
}
|
||||
|
||||
/// Test-only fault injection (per thread), to exercise the failure paths.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) enum Fault {
|
||||
#[default]
|
||||
None,
|
||||
/// Hard links fail, as across volumes: the copy path runs.
|
||||
NoLink,
|
||||
/// Writing the copy fails half-way (implies `NoLink`).
|
||||
FailWrite,
|
||||
/// The copy comes out different from the original, caught by the verification (implies `NoLink`).
|
||||
Corrupt,
|
||||
/// Removing the source fails (as on a read-only card).
|
||||
FailRemove,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static FAULT: std::cell::Cell<Fault> = const { std::cell::Cell::new(Fault::None) };
|
||||
}
|
||||
|
||||
/// Inject a fault for the calling thread's next moves (tests only).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn inject(f: Fault) {
|
||||
FAULT.with(|c| c.set(f));
|
||||
}
|
||||
|
||||
fn injected(f: Fault) -> bool {
|
||||
#[cfg(test)]
|
||||
{
|
||||
FAULT.with(|c| c.get()) == f
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
let _ = f;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `path` inside `dir` (as written, or once symlinks and `..` are resolved)?
|
||||
pub(crate) fn inside(path: &Path, dir: &Path) -> bool {
|
||||
if path.starts_with(dir) {
|
||||
return true;
|
||||
}
|
||||
match (fs::canonicalize(path), fs::canonicalize(dir)) {
|
||||
(Ok(p), Ok(d)) => p.starts_with(d),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_symlink(path: &Path) -> bool {
|
||||
fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink())
|
||||
}
|
||||
|
||||
/// The XMP sidecars beside `src`, both naming conventions (`IMG_1.xmp`, `IMG_1.CR3.xmp`; the
|
||||
/// `.xmp` extension in any case): (path, named after the stem).
|
||||
pub(crate) fn sidecars_of(src: &Path) -> Vec<(PathBuf, bool)> {
|
||||
let (Some(dir), Some(name)) = (src.parent(), src.file_name().map(|n| n.to_string_lossy().to_string())) else { return Vec::new() };
|
||||
let stem = src.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let Ok(rd) = fs::read_dir(dir) else { return Vec::new() };
|
||||
let mut out: Vec<(PathBuf, bool)> = rd
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let n = e.file_name().to_string_lossy().to_string();
|
||||
let (base, ext) = n.rsplit_once('.')?;
|
||||
if !ext.eq_ignore_ascii_case("xmp") || !e.path().is_file() {
|
||||
return None;
|
||||
}
|
||||
if base == name {
|
||||
Some((e.path(), false))
|
||||
} else if base == stem {
|
||||
Some((e.path(), true))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// Put `src` into `dir` as `name` (-1, -2… when taken) with its sidecars, leaving the source
|
||||
/// untouched. On failure nothing is left at the destination.
|
||||
pub(crate) fn place(src: &Path, dir: &Path, name: &str) -> Result<Placed, String> {
|
||||
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let (stem, ext) = match name.rsplit_once('.') {
|
||||
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
|
||||
_ => (name.to_string(), String::new()),
|
||||
};
|
||||
let mut dst = None;
|
||||
for i in 0..100_000u32 {
|
||||
let d = if i == 0 { dir.join(name) } else { dir.join(format!("{stem}-{i}{ext}")) };
|
||||
match link_or_copy(src, &d) {
|
||||
Ok(()) => {
|
||||
dst = Some(d);
|
||||
break;
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(format!("move {}: {e} (the original is kept)", src.display())),
|
||||
}
|
||||
}
|
||||
let Some(dst) = dst else { return Err(format!("move {}: no free name in {}", src.display(), dir.display())) };
|
||||
let mut placed = Placed { src: src.to_path_buf(), dst, sidecars: Vec::new() };
|
||||
for (sc, stem_named) in sidecars_of(src) {
|
||||
let sc_ext = sc.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_else(|| "xmp".into());
|
||||
let sc_dst =
|
||||
if stem_named { placed.dst.with_extension(&sc_ext) } else { PathBuf::from(format!("{}.{sc_ext}", placed.dst.to_string_lossy())) };
|
||||
let r = if sc_dst.exists() {
|
||||
match same_bytes(&sc, &sc_dst) {
|
||||
Ok(true) => Ok(false),
|
||||
Ok(false) => Err(format!("a different sidecar is already at {} (the original is kept)", sc_dst.display())),
|
||||
Err(e) => Err(format!("{}: {e} (the original is kept)", sc_dst.display())),
|
||||
}
|
||||
} else {
|
||||
link_or_copy(&sc, &sc_dst).map(|()| true).map_err(|e| format!("move {}: {e} (the original is kept)", sc.display()))
|
||||
};
|
||||
match r {
|
||||
Ok(created) => placed.sidecars.push(PlacedSidecar { src: sc, dst: sc_dst, created, stem: stem_named }),
|
||||
Err(e) => {
|
||||
rollback(&placed);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(placed)
|
||||
}
|
||||
|
||||
/// Import → **Copy**: copy `src` into `dir` as `name` (-1, -2… when taken) with the same checks as
|
||||
/// a move's copy — a new file (never overwriting one, even one that appears meanwhile), synced to
|
||||
/// disk and compared byte for byte with the source; a bad copy is removed and reported, so a card
|
||||
/// is never wiped on the strength of it. Returns the new path.
|
||||
pub(crate) fn copy_new(src: &Path, dir: &Path, name: &str) -> Result<PathBuf, String> {
|
||||
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let (stem, ext) = match name.rsplit_once('.') {
|
||||
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
|
||||
_ => (name.to_string(), String::new()),
|
||||
};
|
||||
for i in 0..100_000u32 {
|
||||
let d = if i == 0 { dir.join(name) } else { dir.join(format!("{stem}-{i}{ext}")) };
|
||||
match copy_verified(src, &d) {
|
||||
Ok(()) => return Ok(d),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(format!("copy {}: {e} (nothing was imported from it; the original is untouched)", src.display())),
|
||||
}
|
||||
}
|
||||
Err(format!("copy {}: no free name in {}", src.display(), dir.display()))
|
||||
}
|
||||
|
||||
/// Undo a [`place`]: remove what it created at the destination (the sources were never touched).
|
||||
pub(crate) fn rollback(p: &Placed) {
|
||||
for sc in p.sidecars.iter().filter(|s| s.created) {
|
||||
let _ = fs::remove_file(&sc.dst);
|
||||
}
|
||||
let _ = fs::remove_file(&p.dst);
|
||||
}
|
||||
|
||||
/// Remove a placed photo's source (its catalog record is committed and saved): only when the
|
||||
/// destination is still there with the same size. Then its sidecars, unless another photo still
|
||||
/// uses a shared one. `Err` = the source was kept (why); `Ok` lists sidecars kept (path, why).
|
||||
pub(crate) fn finish(p: &Placed) -> Result<Vec<(String, String)>, String> {
|
||||
let same = match (fs::metadata(&p.src), fs::metadata(&p.dst)) {
|
||||
(Ok(s), Ok(d)) => s.is_file() && d.is_file() && s.len() == d.len(),
|
||||
_ => false,
|
||||
};
|
||||
if !same {
|
||||
return Err(format!("{} is missing or changed: the original is kept", p.dst.display()));
|
||||
}
|
||||
if injected(Fault::FailRemove) {
|
||||
return Err("could not remove the original (injected): it stays where it was".into());
|
||||
}
|
||||
fs::remove_file(&p.src).map_err(|e| format!("could not remove the original ({e}): it stays where it was"))?;
|
||||
let mut kept = Vec::new();
|
||||
for sc in &p.sidecars {
|
||||
let path = sc.src.to_string_lossy().to_string();
|
||||
if !same_bytes(&sc.src, &sc.dst).unwrap_or(false) {
|
||||
kept.push((path, format!("{} is missing or changed: the sidecar is kept", sc.dst.display())));
|
||||
continue;
|
||||
}
|
||||
if sc.stem
|
||||
&& let Some(other) = sibling_photo(&sc.src)
|
||||
{
|
||||
kept.push((path, format!("also the sidecar of {other}: kept beside it")));
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = fs::remove_file(&sc.src) {
|
||||
kept.push((path, format!("could not remove the sidecar ({e})")));
|
||||
}
|
||||
}
|
||||
Ok(kept)
|
||||
}
|
||||
|
||||
/// A photo (by extension) still beside a stem-named sidecar, sharing its stem.
|
||||
pub(crate) fn sibling_photo(sidecar: &Path) -> Option<String> {
|
||||
let dir = sidecar.parent()?;
|
||||
let stem = sidecar.file_stem()?.to_string_lossy().to_string();
|
||||
fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).find_map(|f| {
|
||||
let ok = f.file_stem().is_some_and(|s| s.to_string_lossy() == stem) && crate::import::is_supported(&f);
|
||||
ok.then(|| f.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default())
|
||||
})
|
||||
}
|
||||
|
||||
/// `dst` = `src` without touching `src`: a hard link, else a verified copy. Never overwrites
|
||||
/// (`AlreadyExists` when `dst` is taken).
|
||||
fn link_or_copy(src: &Path, dst: &Path) -> io::Result<()> {
|
||||
let force_copy = injected(Fault::NoLink) || injected(Fault::FailWrite) || injected(Fault::Corrupt);
|
||||
if !force_copy && !is_symlink(src) {
|
||||
match fs::hard_link(src, dst) {
|
||||
Ok(()) => {
|
||||
let (a, b) = (fs::metadata(src)?, fs::metadata(dst)?);
|
||||
if a.len() == b.len() {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = fs::remove_file(dst);
|
||||
return Err(io::Error::other("the link doesn't match the original"));
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => return Err(e),
|
||||
// another volume, or no hard links there (FAT/exFAT cards, some shares): copy
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
copy_verified(src, dst)
|
||||
}
|
||||
|
||||
/// Copy into a new file, sync it to disk and compare it with the original; on any failure the
|
||||
/// partial copy is removed.
|
||||
fn copy_verified(src: &Path, dst: &Path) -> io::Result<()> {
|
||||
let mut out = OpenOptions::new().write(true).create_new(true).open(dst)?;
|
||||
let r = (|| {
|
||||
let mut inp = File::open(src)?;
|
||||
if injected(Fault::FailWrite) {
|
||||
out.write_all(b"partial")?;
|
||||
return Err(io::Error::other("write failed (injected)"));
|
||||
}
|
||||
io::copy(&mut inp, &mut out)?;
|
||||
if injected(Fault::Corrupt) {
|
||||
out.write_all(b"!")?;
|
||||
}
|
||||
out.sync_all()?;
|
||||
if let Ok(m) = fs::metadata(src).and_then(|m| m.modified()) {
|
||||
let _ = out.set_modified(m);
|
||||
}
|
||||
if !same_bytes(src, dst)? {
|
||||
return Err(io::Error::other("the copy differs from the original"));
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
drop(out);
|
||||
if r.is_err() {
|
||||
let _ = fs::remove_file(dst);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Do two files hold the same bytes?
|
||||
pub(crate) fn same_bytes(a: &Path, b: &Path) -> io::Result<bool> {
|
||||
if fs::metadata(a)?.len() != fs::metadata(b)?.len() {
|
||||
return Ok(false);
|
||||
}
|
||||
let (mut fa, mut fb) = (File::open(a)?, File::open(b)?);
|
||||
let (mut ba, mut bb) = (vec![0u8; 1 << 20], vec![0u8; 1 << 20]);
|
||||
loop {
|
||||
let (na, nb) = (fill(&mut fa, &mut ba)?, fill(&mut fb, &mut bb)?);
|
||||
if na != nb || ba.get(..na) != bb.get(..nb) {
|
||||
return Ok(false);
|
||||
}
|
||||
if na == 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read until `buf` is full or the file ends.
|
||||
fn fill(f: &mut File, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let mut n = 0;
|
||||
while let Some(rest) = buf.get_mut(n..).filter(|r| !r.is_empty()) {
|
||||
match f.read(rest) {
|
||||
Ok(0) => break,
|
||||
Ok(k) => n += k,
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
+236
-16
@@ -12,18 +12,25 @@
|
||||
|
||||
pub mod cmd;
|
||||
pub mod crs;
|
||||
pub mod crs_masks;
|
||||
pub mod demo;
|
||||
pub mod devices;
|
||||
pub mod export;
|
||||
pub mod files;
|
||||
pub mod guard;
|
||||
pub mod import;
|
||||
mod import_move;
|
||||
pub mod library;
|
||||
pub mod media;
|
||||
pub mod memory;
|
||||
pub mod merge;
|
||||
pub mod originals;
|
||||
pub mod preset_import;
|
||||
pub mod preset_luminar;
|
||||
pub mod presets;
|
||||
pub mod rename;
|
||||
pub mod sidecar;
|
||||
pub mod smart;
|
||||
mod view;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -33,7 +40,7 @@ use lightcraft_catalog::{Catalog, Filter, Op, PhotoId, Sort};
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
pub use media::{RenderJob, SourceLevel};
|
||||
use serde_json::Value;
|
||||
pub use view::{Browse, LibrarySource, Selection};
|
||||
pub use view::{Browse, FilterChip, LibrarySource, Selection, filter_chips};
|
||||
pub use {lightcraft_catalog as catalog, lightcraft_develop as develop, lightcraft_gpu as gpu, lightcraft_pipeline as pipeline};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -46,6 +53,13 @@ pub enum EngineError {
|
||||
BadParams { cmd: String, msg: String },
|
||||
#[error("{0}")]
|
||||
Catalog(#[from] lightcraft_catalog::CatalogError),
|
||||
/// The command's change is applied (in memory, undoable) but its journal records could not
|
||||
/// be written. They stay queued and are written by the next successful save.
|
||||
#[error("saved in memory but not written to disk: {0}; LightCraft will retry")]
|
||||
NotSaved(String),
|
||||
/// Another process (the app, `lightcraft-cli`, another computer) has the library open.
|
||||
#[error("{0}")]
|
||||
LibraryInUse(String),
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
@@ -67,15 +81,27 @@ pub struct Interaction {
|
||||
pub original: Arc<DevelopSettings>,
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
pub struct Session {
|
||||
/// Auto Sync: edits to the active photo also change the other selected photos (the settings
|
||||
/// that changed, nothing else).
|
||||
pub auto_sync: bool,
|
||||
pub catalog: Catalog,
|
||||
pub source: LibrarySource,
|
||||
pub filter: Filter,
|
||||
pub sort: Sort,
|
||||
pub selection: Selection,
|
||||
/// The grid order for the current source/filter/sort (cached by catalog revision).
|
||||
visible: Vec<PhotoId>,
|
||||
/// Shared, so frontends can hold it across frames without copying ([`Session::visible_shared`]).
|
||||
visible: Arc<[PhotoId]>,
|
||||
visible_key: Option<(u64, String)>,
|
||||
/// 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,
|
||||
/// Photos in the current source with no filter on (cached like `visible`).
|
||||
total: Option<((u64, String), usize)>,
|
||||
pub undo: Vec<UndoEntry>,
|
||||
pub redo: Vec<UndoEntry>,
|
||||
pub interaction: Option<Interaction>,
|
||||
@@ -114,15 +140,45 @@ pub struct Session {
|
||||
pub last_export: Option<serde_json::Value>,
|
||||
/// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json.
|
||||
pub export_presets: Vec<export::ExportPreset>,
|
||||
/// Metadata presets (`metadata.*`), persisted in prefs.json.
|
||||
pub metadata_presets: Vec<cmd::metadata::MetadataPreset>,
|
||||
/// Saved filter-bar settings (`filter.*`), persisted in prefs.json.
|
||||
pub filter_presets: Vec<cmd::filters::FilterPreset>,
|
||||
/// The user's point-curve presets (`curve.*`; built-ins: [`cmd::curves::builtin_presets`]),
|
||||
/// persisted in prefs.json.
|
||||
pub curve_presets: Vec<cmd::curves::CurvePreset>,
|
||||
/// Saved colour-label name sets.
|
||||
pub label_sets: Vec<cmd::manage::LabelSet>,
|
||||
/// Look-alike signatures by content key (Find Similar).
|
||||
pub signatures: std::collections::HashMap<String, [f32; 64]>,
|
||||
/// Imported `.cube` LUT profiles.
|
||||
pub lut_profiles: Vec<cmd::lut_profiles::LutProfile>,
|
||||
/// The target album B adds to (`None` = the Quick Collection).
|
||||
pub target_album: Option<lightcraft_catalog::AlbumId>,
|
||||
/// Auto import: files seen in the watched folder and their size then (a file is imported once
|
||||
/// its size held between two scans).
|
||||
pub auto_import_seen: std::collections::HashMap<String, u64>,
|
||||
/// Keyword sets (⌥1–⌥9 apply the current set's keywords), the one in use (`None` = Recent
|
||||
/// Keywords) and the recently added keywords, newest first.
|
||||
pub keyword_sets: Vec<cmd::keywords::KeywordSet>,
|
||||
pub keyword_set: Option<String>,
|
||||
pub recent_keywords: Vec<String>,
|
||||
/// Before/After: the "before" settings chosen per photo (this session; default: the photo's
|
||||
/// import state). See `cmd/before.rs`.
|
||||
pub before: std::collections::HashMap<PhotoId, Arc<DevelopSettings>>,
|
||||
/// File probes from the last import review (`library.importPreview`), reused by the import.
|
||||
pub import_probes: std::collections::HashMap<String, media::ProbeInfo>,
|
||||
/// 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,
|
||||
/// 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
|
||||
/// library opens (0 = never; persisted in prefs.json). See `cmd/browse.rs`.
|
||||
pub forget_local_days: u32,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -134,13 +190,16 @@ impl Default for Session {
|
||||
impl Session {
|
||||
pub fn new() -> Session {
|
||||
Session {
|
||||
auto_sync: false,
|
||||
catalog: Catalog::new(),
|
||||
source: LibrarySource::All,
|
||||
filter: Filter::default(),
|
||||
sort: Sort::default(),
|
||||
selection: Selection::default(),
|
||||
visible: Vec::new(),
|
||||
visible: Vec::new().into(),
|
||||
visible_key: None,
|
||||
visible_gen: 0,
|
||||
total: None,
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
interaction: None,
|
||||
@@ -163,10 +222,24 @@ impl Session {
|
||||
xmp: sidecar::XmpPrefs::default(),
|
||||
last_export: None,
|
||||
export_presets: Vec::new(),
|
||||
metadata_presets: Vec::new(),
|
||||
filter_presets: Vec::new(),
|
||||
curve_presets: Vec::new(),
|
||||
label_sets: Vec::new(),
|
||||
signatures: Default::default(),
|
||||
lut_profiles: Vec::new(),
|
||||
target_album: None,
|
||||
auto_import_seen: Default::default(),
|
||||
keyword_sets: Vec::new(),
|
||||
keyword_set: None,
|
||||
recent_keywords: Vec::new(),
|
||||
before: Default::default(),
|
||||
import_probes: Default::default(),
|
||||
preview_build: None,
|
||||
import_defaults: import::ImportDefaults::default(),
|
||||
cache_mb: 0,
|
||||
smart_previews_dir: None,
|
||||
forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,10 +259,14 @@ impl Session {
|
||||
let log_start = self.pending_log.len();
|
||||
let was_active = self.active();
|
||||
self.depth += 1;
|
||||
let r = (spec.run)(self, params);
|
||||
// last-resort guard: a panic in a command is that command's error, not a crash
|
||||
let r = guard::catch(&format!("`{id}`"), || (spec.run)(self, params)).unwrap_or_else(|e| Err(EngineError::Other(e)));
|
||||
self.depth -= 1;
|
||||
if self.depth == 0 && was_active.is_some() && self.active() != was_active {
|
||||
self.previous_active = was_active;
|
||||
if let Some(left) = was_active {
|
||||
self.auto_version(left);
|
||||
}
|
||||
}
|
||||
if r.is_ok() && spec.journal && self.depth == 0 {
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
@@ -201,9 +278,18 @@ impl Session {
|
||||
self.auto_write_sidecars(&self.pending_log[log_start..]);
|
||||
}
|
||||
if self.depth == 0 && self.library.is_some() {
|
||||
// Make the command durable before reporting success (on failure the ops stay pending,
|
||||
// are retried after the next command, and `library.info` reports the error).
|
||||
let _ = self.persist();
|
||||
// Make the command durable before reporting success. When the command's own records
|
||||
// can't be appended, it fails with `NotSaved`: the change stays applied in memory and
|
||||
// queued, and the next save (any later command, or the frame loop) retries it. Other
|
||||
// persistence trouble (a failed compaction, an older queued change still unwritten by
|
||||
// a command that changed nothing) doesn't fail the command; `library.info` reports it.
|
||||
let produced = self.pending_log.len() > log_start;
|
||||
if let Err(e @ EngineError::NotSaved(_)) = self.persist()
|
||||
&& produced
|
||||
&& r.is_ok()
|
||||
{
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
@@ -227,6 +313,30 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Leaving photo `id` after editing it: keep its settings as an automatic version (when they
|
||||
/// differ from its latest version; at most [`AUTO_VERSIONS`] auto versions, oldest dropped).
|
||||
/// Saved with the library but not an undo step.
|
||||
pub fn auto_version(&mut self, id: PhotoId) {
|
||||
let Some(p) = self.catalog.photo(id) else { return };
|
||||
if !p.is_edited() || p.versions.last().is_some_and(|v| *v.settings == *p.develop) || self.interaction.is_some() {
|
||||
return;
|
||||
}
|
||||
let mut versions = p.versions.clone();
|
||||
let created = (self.clock)();
|
||||
let name = lightcraft_catalog::dates::display_time(&created);
|
||||
versions.push(lightcraft_catalog::Version { name, created, settings: p.develop.clone(), auto: true });
|
||||
let autos = versions.iter().filter(|v| v.auto).count();
|
||||
if autos > AUTO_VERSIONS
|
||||
&& let Some(i) = versions.iter().position(|v| v.auto)
|
||||
{
|
||||
versions.remove(i);
|
||||
}
|
||||
let op = Op::SetVersions { id, versions };
|
||||
if self.catalog.apply(op.clone()).is_ok() {
|
||||
self.pending_log.push(op);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold the last `n` undo steps into one (commands that commit step by step because each op
|
||||
/// depends on the state the previous one left).
|
||||
pub fn merge_undo(&mut self, n: usize, label: &str) {
|
||||
@@ -272,15 +382,22 @@ impl Session {
|
||||
Ok(e.label)
|
||||
}
|
||||
|
||||
/// Apply an undo/redo op, first moving the files its renames imply (all or nothing).
|
||||
/// Apply an undo/redo op, first moving the files its renames imply (all or nothing). Files
|
||||
/// that can't be moved back after a failure are reported and the library follows them (a
|
||||
/// logged change outside the undo history), so none goes missing.
|
||||
fn apply_with_files(&mut self, op: &Op) -> Result<Op> {
|
||||
let fs = rename::RealFs;
|
||||
let moves = self.file_moves(op);
|
||||
Session::move_files(&moves)?;
|
||||
if let Err(e) = rename::move_all(&fs, &moves) {
|
||||
return Err(EngineError::Other(format!("can't move the files back: {}", self.follow_stuck(op, e, false))));
|
||||
}
|
||||
match self.catalog.apply(op.clone()) {
|
||||
Ok(inv) => Ok(inv),
|
||||
Err(e) => {
|
||||
let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect();
|
||||
let _ = Session::move_files(&back);
|
||||
if let Err(be) = rename::move_all(&fs, &back) {
|
||||
return Err(EngineError::Other(format!("{e}; the files could not all be moved back: {}", be.message)));
|
||||
}
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
@@ -312,10 +429,46 @@ impl Session {
|
||||
{
|
||||
return self.apply_silent(Op::SetDevelop { id, settings, label: label.into(), edited: Some(now) });
|
||||
}
|
||||
let op = self.develop_op(id, (*settings).clone(), label).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
||||
let mut ops = vec![self.develop_op(id, (*settings).clone(), label).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?];
|
||||
ops.extend(self.auto_sync_ops(id, &settings, label));
|
||||
let op = if ops.len() == 1 { ops.remove(0) } else { Op::Batch { ops } };
|
||||
self.commit(label, op)
|
||||
}
|
||||
|
||||
/// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`) over to the
|
||||
/// other selected photos: only the settings that changed; never spot removal or red eye (they
|
||||
/// belong to one photo's pixels), nor history / snapshot restores.
|
||||
fn auto_sync_ops(&self, id: PhotoId, new: &DevelopSettings, label: &str) -> Vec<Op> {
|
||||
if !self.auto_sync
|
||||
|| self.active() != Some(id)
|
||||
|| self.selection.ids.len() < 2
|
||||
|| label.starts_with("History:")
|
||||
|| label.starts_with("Restore ")
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(old) = self.develop_of(id) else { return Vec::new() };
|
||||
let Some(mut delta) = json_delta(&old.to_json(), &new.to_json()) else { return Vec::new() };
|
||||
if let Some(o) = delta.as_object_mut() {
|
||||
for k in ["spots", "red_eye", "version"] {
|
||||
o.remove(k);
|
||||
}
|
||||
if o.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
self.selection
|
||||
.ids
|
||||
.iter()
|
||||
.filter(|x| **x != id)
|
||||
.filter_map(|x| self.develop_of(*x).map(|d| (*x, d)))
|
||||
.filter_map(|(x, d)| {
|
||||
let synced = lightcraft_develop::apply_partial(&d, &delta, 1.0);
|
||||
(synced != *d).then(|| self.develop_op(x, synced, label)).flatten()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The op that sets a photo's develop settings and appends a History entry (for batches).
|
||||
pub fn develop_op(&self, id: PhotoId, settings: DevelopSettings, label: &str) -> Option<Op> {
|
||||
self.catalog.photo(id)?;
|
||||
@@ -361,6 +514,8 @@ impl Session {
|
||||
pub fn visible(&mut self) -> &[PhotoId] {
|
||||
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse));
|
||||
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)
|
||||
@@ -368,7 +523,7 @@ impl Session {
|
||||
f.folder = Some(b.path);
|
||||
f.subfolders = b.subfolders;
|
||||
}
|
||||
self.visible = self.catalog.query(&f, &self.sort);
|
||||
let mut visible = self.catalog.query(&f, &self.sort);
|
||||
if matches!(self.source, LibrarySource::Album(_))
|
||||
&& self.sort.key == lightcraft_catalog::SortKey::CaptureDate
|
||||
&& let LibrarySource::Album(a) = self.source
|
||||
@@ -377,23 +532,66 @@ impl Session {
|
||||
&& self.filter == Filter::default()
|
||||
{
|
||||
let order = al.photos.clone();
|
||||
self.visible.sort_by_key(|id| order.iter().position(|x| x == id).unwrap_or(usize::MAX));
|
||||
visible.sort_by_key(|id| order.iter().position(|x| x == id).unwrap_or(usize::MAX));
|
||||
if !self.sort.ascending {
|
||||
self.visible.reverse();
|
||||
visible.reverse();
|
||||
}
|
||||
}
|
||||
if self.source != LibrarySource::RecentlyDeleted {
|
||||
self.visible = self.catalog.arrange_stacks(&self.visible);
|
||||
if self.source == LibrarySource::RecentlyAdded {
|
||||
// newest import first, whatever the sort (the grid groups by import day)
|
||||
let cat = &self.catalog;
|
||||
visible.sort_by(|a, b| {
|
||||
let key = |id: &PhotoId| cat.photo(*id).map(|p| p.imported.clone()).unwrap_or_default();
|
||||
key(b).cmp(&key(a)).then(a.cmp(b))
|
||||
});
|
||||
}
|
||||
if self.source == LibrarySource::Missing {
|
||||
// only the photos the query kept (library photos, not Local browse records) are checked
|
||||
let cat = &self.catalog;
|
||||
visible.retain(|id| cmd::missing::is_missing(cat, *id));
|
||||
}
|
||||
if self.source != LibrarySource::RecentlyDeleted {
|
||||
visible = self.catalog.arrange_stacks(&visible);
|
||||
}
|
||||
self.visible = visible.into();
|
||||
self.visible_gen = VISIBLE_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
self.visible_key = Some(key);
|
||||
}
|
||||
&self.visible
|
||||
}
|
||||
|
||||
/// Photos in the current source (folder, album, …) before the filter bar and search narrow
|
||||
/// them; `None` where that is not a separate number (Missing Photos).
|
||||
pub fn source_total(&mut self) -> Option<usize> {
|
||||
if self.source == LibrarySource::Missing {
|
||||
return None;
|
||||
}
|
||||
let key = (self.catalog.revision, format!("{:?}|{:?}", self.source, self.browse));
|
||||
if self.total.as_ref().map(|t| &t.0) != Some(&key) {
|
||||
let mut f = self.source.to_filter(&Filter::default(), &self.catalog);
|
||||
if self.source == LibrarySource::Folder {
|
||||
let b = self.browse.clone().unwrap_or_default();
|
||||
f.folder = Some(b.path);
|
||||
f.subfolders = b.subfolders;
|
||||
}
|
||||
let n = self.catalog.query(&f, &self.sort).len();
|
||||
self.total = Some((key, n));
|
||||
}
|
||||
self.total.as_ref().map(|t| t.1)
|
||||
}
|
||||
|
||||
pub fn visible_cloned(&mut self) -> Vec<PhotoId> {
|
||||
self.visible().to_vec()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn visible_shared(&mut self) -> (u64, Arc<[PhotoId]>) {
|
||||
self.visible();
|
||||
(self.visible_gen, self.visible.clone())
|
||||
}
|
||||
|
||||
/// Targets of photo commands: explicit `ids`/`id` param, else the selection.
|
||||
pub fn targets(&self, p: &Value) -> Vec<PhotoId> {
|
||||
if let Some(a) = p.get("ids").and_then(Value::as_array) {
|
||||
@@ -406,6 +604,22 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Automatic versions kept per photo.
|
||||
pub const AUTO_VERSIONS: usize = 20;
|
||||
|
||||
/// The parts of `new` that differ from `old` (objects recurse; anything else is taken whole).
|
||||
pub fn json_delta(old: &Value, new: &Value) -> Option<Value> {
|
||||
match (old, new) {
|
||||
(Value::Object(a), Value::Object(b)) => {
|
||||
let m: serde_json::Map<String, Value> =
|
||||
b.iter().filter_map(|(k, nv)| json_delta(a.get(k).unwrap_or(&Value::Null), nv).map(|d| (k.clone(), d))).collect();
|
||||
(!m.is_empty()).then_some(Value::Object(m))
|
||||
}
|
||||
(a, b) if a == b => None,
|
||||
(_, b) => Some(b.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
@@ -413,8 +627,12 @@ mod tests_color;
|
||||
#[cfg(test)]
|
||||
mod tests_export;
|
||||
#[cfg(test)]
|
||||
mod tests_forget_local;
|
||||
#[cfg(test)]
|
||||
mod tests_import;
|
||||
#[cfg(test)]
|
||||
mod tests_import_move;
|
||||
#[cfg(test)]
|
||||
mod tests_libops;
|
||||
#[cfg(test)]
|
||||
mod tests_library;
|
||||
@@ -423,6 +641,8 @@ mod tests_merge;
|
||||
#[cfg(test)]
|
||||
mod tests_organize;
|
||||
#[cfg(test)]
|
||||
mod tests_persist;
|
||||
#[cfg(test)]
|
||||
mod tests_prefs;
|
||||
#[cfg(test)]
|
||||
mod tests_spots;
|
||||
|
||||
+235
-17
@@ -14,12 +14,16 @@
|
||||
//! via [`Session::open_library_in`] (the browser build keeps them in OPFS or IndexedDB).
|
||||
//!
|
||||
//! Every top-level [`Session::execute`] persists the ops it produced (fsynced) before returning,
|
||||
//! so a crash loses at most the command in flight. The log is compacted into a snapshot when it
|
||||
//! grows (see [`lightcraft_catalog::SnapshotPolicy`]) and on [`Session::close_library`].
|
||||
//! so a crash loses at most the command in flight. When that write fails the command returns
|
||||
//! [`EngineError::NotSaved`]: its change stays applied in memory and queued, and every later save
|
||||
//! retries the queue ([`Session::unsaved`] reports it meanwhile), so nothing is lost once the
|
||||
//! disk is writable again. The log is compacted into a snapshot when it
|
||||
//! grows (see [`lightcraft_catalog::SnapshotPolicy`]; written by a worker thread on native, see
|
||||
//! [`lightcraft_catalog::journal`]) and on [`Session::close_library`].
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use lightcraft_catalog::{FsStore, Journal, LoadReport, Store};
|
||||
use lightcraft_catalog::{FsStore, Journal, LibraryLock, LoadReport, Store};
|
||||
use lightcraft_develop::Preset;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -51,8 +55,18 @@ pub struct Library {
|
||||
pub report: LoadReport,
|
||||
/// Last persistence error (shown by the UI; ops stay pending and are retried).
|
||||
pub last_error: Option<String>,
|
||||
/// Why the last append of queued ops failed; `None` once a save succeeds. While set, changes
|
||||
/// exist only in memory ([`Session::unsaved`]).
|
||||
pub unsaved_error: Option<String>,
|
||||
/// When the frame loop may retry a failed append ([`Session::persist_if_dirty`] backs off).
|
||||
retry_at: Option<web_time::Instant>,
|
||||
/// What forgetting untouched Local records did when the library opened.
|
||||
pub forgot_local: Option<lightcraft_catalog::ForgetPlan>,
|
||||
presets_written: String,
|
||||
view_written: Vec<u8>,
|
||||
/// Keeps other processes out of this library while it's open (last: released after the
|
||||
/// journal's background snapshot has landed).
|
||||
lock: Option<LibraryLock>,
|
||||
}
|
||||
|
||||
/// Where a library's files live (see [`Session::open_library_in`]).
|
||||
@@ -91,6 +105,10 @@ impl Library {
|
||||
pub fn journal(&self) -> &Journal {
|
||||
&self.journal
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn journal_mut(&mut self) -> &mut Journal {
|
||||
&mut self.journal
|
||||
}
|
||||
pub fn thumbs_dir(&self) -> PathBuf {
|
||||
self.dir.join("thumbs")
|
||||
}
|
||||
@@ -108,10 +126,29 @@ struct PrefsFile {
|
||||
last_export: Option<serde_json::Value>,
|
||||
/// The user's export presets.
|
||||
export_presets: Vec<crate::export::ExportPreset>,
|
||||
/// Metadata presets.
|
||||
metadata_presets: Vec<crate::cmd::metadata::MetadataPreset>,
|
||||
/// Filter presets.
|
||||
filter_presets: Vec<crate::cmd::filters::FilterPreset>,
|
||||
/// Point-curve presets.
|
||||
curve_presets: Vec<crate::cmd::curves::CurvePreset>,
|
||||
/// Colour-label name sets.
|
||||
label_sets: Vec<crate::cmd::manage::LabelSet>,
|
||||
/// Imported LUT profiles.
|
||||
lut_profiles: Vec<crate::cmd::lut_profiles::LutProfile>,
|
||||
/// Keyword sets, the one in use, recent keywords.
|
||||
keyword_sets: Vec<crate::cmd::keywords::KeywordSet>,
|
||||
keyword_set: Option<String>,
|
||||
recent_keywords: Vec<String>,
|
||||
/// Develop defaults for imported photos.
|
||||
import: crate::import::ImportDefaults,
|
||||
/// Thumbnail disk cache budget (MB, 0 = default).
|
||||
cache_mb: u32,
|
||||
/// Folder for smart previews (default: `Smart Previews` in the library).
|
||||
smart_previews_dir: Option<String>,
|
||||
/// Days after which untouched Local records of unbrowsed folders are forgotten (missing =
|
||||
/// the default, 0 = never).
|
||||
forget_local_days: Option<u32>,
|
||||
}
|
||||
|
||||
fn presets_json(s: &Session) -> String {
|
||||
@@ -125,6 +162,35 @@ fn presets_json(s: &Session) -> String {
|
||||
serde_json::to_string_pretty(&f).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Finish a background compaction that is done (cheap otherwise). A failed one lost nothing (the
|
||||
/// log is kept whole) and is retried later; it is reported like other persistence errors.
|
||||
fn poll_compaction(lib: &mut Library) {
|
||||
if let Err(e) = lib.journal.poll() {
|
||||
log::error!("library: compaction: {e}");
|
||||
lib.last_error = Some(format!("compaction: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the frame loop waits before retrying a failed append (commands retry at once).
|
||||
const RETRY_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
/// `a` and `b` name the same directory.
|
||||
fn same_dir(a: &Path, b: &Path) -> bool {
|
||||
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
|
||||
(Ok(a), Ok(b)) => a == b,
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
/// This program, for the lock owner note ("LightCraft", "lightcraft-cli").
|
||||
fn program_name() -> String {
|
||||
let exe = std::env::current_exe().ok().and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()));
|
||||
match exe.as_deref() {
|
||||
Some("lightcraft") | None => "LightCraft".into(),
|
||||
Some(other) => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_json<T: serde::de::DeserializeOwned>(store: &mut dyn Store, name: &str) -> Option<T> {
|
||||
store.read(name).ok().flatten().and_then(|b| serde_json::from_slice(&b).ok())
|
||||
}
|
||||
@@ -132,17 +198,55 @@ fn read_json<T: serde::de::DeserializeOwned>(store: &mut dyn Store, name: &str)
|
||||
impl Session {
|
||||
/// Open (or create) the library at `dir` into this session, replacing its catalog. With
|
||||
/// `seed_demo`, a newly created library starts with the procedural demo photos.
|
||||
///
|
||||
/// The library is locked for this session ([`lightcraft_catalog::lock`]): if another process
|
||||
/// has it open, this fails with [`EngineError::LibraryInUse`] and nothing is read or changed.
|
||||
/// Reopening the library this session already has open keeps its lock.
|
||||
pub fn open_library(&mut self, dir: impl AsRef<Path>, seed_demo: bool) -> Result<&LoadReport> {
|
||||
let dir = dir.as_ref().to_path_buf();
|
||||
let open = || FsStore::open(&dir).map_err(|e| EngineError::Other(format!("can't open library {}: {e}", dir.display())));
|
||||
let stores = LibraryStores { catalog: Box::new(open()?), files: Box::new(open()?), on_disk: true, dir: dir.clone() };
|
||||
self.open_library_in(stores, seed_demo)
|
||||
// the library open in this session (same directory): hand its lock over
|
||||
let reused = self.library.as_mut().filter(|l| same_dir(&l.dir, &dir)).and_then(|l| l.lock.take());
|
||||
let reusing = reused.is_some();
|
||||
let mut lock = match reused {
|
||||
Some(l) => Some(l),
|
||||
None => Some(LibraryLock::acquire(&dir, &program_name()).map_err(|e| EngineError::LibraryInUse(e.to_string()))?),
|
||||
};
|
||||
if let Err(e) = self.open_stores(stores, seed_demo, &mut lock) {
|
||||
if reusing && let Some(lib) = self.library.as_mut() {
|
||||
lib.lock = lock.take();
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
self.loaded_report()
|
||||
}
|
||||
|
||||
/// Open (or create) a library whose files live in `stores` (e.g. browser storage), replacing
|
||||
/// this session's catalog.
|
||||
/// this session's catalog. Not locked: the host guards against a second opener (see
|
||||
/// [`Session::open_library`] for directories).
|
||||
pub fn open_library_in(&mut self, stores: LibraryStores, seed_demo: bool) -> Result<&LoadReport> {
|
||||
self.open_stores(stores, seed_demo, &mut None)?;
|
||||
self.loaded_report()
|
||||
}
|
||||
|
||||
fn loaded_report(&self) -> Result<&LoadReport> {
|
||||
match &self.library {
|
||||
Some(lib) => Ok(&lib.report),
|
||||
None => Err(EngineError::Other("library closed while opening".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open `stores`; the new library takes `lock` once nothing can fail any more.
|
||||
fn open_stores(&mut self, stores: LibraryStores, seed_demo: bool, lock: &mut Option<LibraryLock>) -> Result<()> {
|
||||
let LibraryStores { dir, catalog, mut files, on_disk } = stores;
|
||||
self.media.smart_dir = on_disk.then(|| crate::smart::dir(&dir));
|
||||
// the current library may be these same files: let its background snapshot land first
|
||||
if let Some(old) = self.library.as_mut()
|
||||
&& let Err(e) = old.journal.wait()
|
||||
{
|
||||
log::error!("library: {e}");
|
||||
}
|
||||
let (mut journal, catalog, report) = Journal::open(catalog)?;
|
||||
self.catalog = catalog;
|
||||
self.undo.clear();
|
||||
@@ -174,8 +278,22 @@ impl Session {
|
||||
self.xmp = prefs.xmp;
|
||||
self.last_export = prefs.last_export;
|
||||
self.export_presets = prefs.export_presets;
|
||||
self.metadata_presets = prefs.metadata_presets;
|
||||
self.filter_presets = prefs.filter_presets;
|
||||
self.curve_presets = prefs.curve_presets;
|
||||
self.label_sets = prefs.label_sets;
|
||||
self.lut_profiles = prefs.lut_profiles;
|
||||
crate::cmd::lut_profiles::register_all(self);
|
||||
self.keyword_sets = prefs.keyword_sets;
|
||||
self.keyword_set = prefs.keyword_set;
|
||||
self.recent_keywords = prefs.recent_keywords;
|
||||
self.import_defaults = prefs.import;
|
||||
self.cache_mb = prefs.cache_mb;
|
||||
self.forget_local_days = prefs.forget_local_days.unwrap_or(lightcraft_catalog::DEFAULT_FORGET_DAYS);
|
||||
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());
|
||||
}
|
||||
// view state
|
||||
if let Some(v) = read_json::<ViewFile>(files.as_mut(), "view.json") {
|
||||
self.source = v.source;
|
||||
@@ -198,26 +316,68 @@ impl Session {
|
||||
self.media.attach_disk_cache(&dir.join("thumbs"), self.cache_bytes());
|
||||
}
|
||||
let view_written = self.view_json();
|
||||
self.library = Some(Library { dir, on_disk, journal, files, report, last_error: None, presets_written, view_written });
|
||||
Ok(&self.library.as_ref().expect("just set").report)
|
||||
self.library = Some(Library {
|
||||
dir,
|
||||
on_disk,
|
||||
journal,
|
||||
files,
|
||||
report,
|
||||
last_error: None,
|
||||
unsaved_error: None,
|
||||
retry_at: None,
|
||||
forgot_local: None,
|
||||
presets_written,
|
||||
view_written,
|
||||
lock: lock.take(),
|
||||
});
|
||||
// forget untouched Local records of folders not browsed for a while (journaled at once)
|
||||
if self.forget_local_days > 0 {
|
||||
let plan = self.forget_local(false, None);
|
||||
if !plan.evict.is_empty() {
|
||||
self.compact_soon();
|
||||
} else if let Err(e) = self.persist() {
|
||||
log::error!("library: {e}");
|
||||
}
|
||||
if let Some(lib) = self.library.as_mut() {
|
||||
lib.forgot_local = Some(plan);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write pending ops to the log (fsynced), compact when due, and save changed presets.
|
||||
/// Called after every top-level command; cheap when nothing changed.
|
||||
/// Called after every top-level command; cheap when nothing changed. Fails (with
|
||||
/// [`EngineError::NotSaved`]) only when the queued ops couldn't be written; they stay queued.
|
||||
pub fn persist(&mut self) -> Result<()> {
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
if !self.pending_log.is_empty() {
|
||||
if let Err(e) = lib.journal.append(&self.pending_log) {
|
||||
lib.last_error = Some(e.to_string());
|
||||
log::error!("library: {e}");
|
||||
return Err(e.into());
|
||||
// the ops stay queued (and applied in memory): the next persist retries them
|
||||
let reason = e.to_string();
|
||||
log::error!("library: {} change(s) not written to disk: {reason}", self.pending_log.len());
|
||||
lib.last_error = Some(reason.clone());
|
||||
lib.unsaved_error = Some(reason.clone());
|
||||
lib.retry_at = Some(web_time::Instant::now() + RETRY_BACKOFF);
|
||||
return Err(EngineError::NotSaved(reason));
|
||||
}
|
||||
if lib.unsaved_error.take().is_some() {
|
||||
log::info!("library: queued changes written to disk");
|
||||
}
|
||||
lib.retry_at = None;
|
||||
self.pending_log.clear();
|
||||
lib.last_error = None;
|
||||
}
|
||||
poll_compaction(lib);
|
||||
// Never snapshot mid-interaction: the catalog then holds an uncommitted preview value.
|
||||
if lib.journal.wants_snapshot() && self.interaction.is_none() {
|
||||
lib.journal.snapshot(&self.catalog)?;
|
||||
// The snapshot is written by a worker thread (natively); appends go on meanwhile.
|
||||
// A failed compaction loses nothing (the log is kept whole and compacted later): it is
|
||||
// reported (`library.info` → `lastError`), not returned.
|
||||
if lib.journal.wants_snapshot()
|
||||
&& self.interaction.is_none()
|
||||
&& let Err(e) = lib.journal.snapshot_in_background(&self.catalog)
|
||||
{
|
||||
log::error!("library: compaction: {e}");
|
||||
lib.last_error = Some(format!("compaction: {e}"));
|
||||
}
|
||||
let presets = presets_json(self);
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
@@ -234,22 +394,52 @@ impl Session {
|
||||
/// Persist if commands left ops pending (cheap; frontends call it once per frame for state
|
||||
/// changed outside [`Session::execute`]).
|
||||
pub fn persist_if_dirty(&mut self) {
|
||||
if self.library.is_some() && !self.pending_log.is_empty() {
|
||||
let backing_off = self.library.as_ref().and_then(|l| l.retry_at).is_some_and(|t| web_time::Instant::now() < t);
|
||||
if self.library.is_some() && !self.pending_log.is_empty() && !backing_off {
|
||||
let _ = self.persist();
|
||||
} else if let Some(lib) = self.library.as_mut() {
|
||||
poll_compaction(lib);
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes applied in memory but not yet written to disk because saving failed: the number of
|
||||
/// queued ops and why (`None` when everything is saved, or no library is open). Retried by
|
||||
/// every command and by [`Session::persist_if_dirty`].
|
||||
pub fn unsaved(&self) -> Option<(usize, &str)> {
|
||||
let lib = self.library.as_ref()?;
|
||||
lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty()).map(|e| (self.pending_log.len(), e))
|
||||
}
|
||||
|
||||
/// Flush everything and write a snapshot (on quit). Ends an open interaction first.
|
||||
///
|
||||
/// The snapshot is written even when appending the queued ops fails: it is a fresh file
|
||||
/// holding everything in memory, so it saves those ops too (the log handle may be the only
|
||||
/// thing that's broken). Fails only if nothing could be saved; the ops then stay queued.
|
||||
pub fn close_library(&mut self) -> Result<()> {
|
||||
if self.library.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = self.end_interaction();
|
||||
self.persist()?;
|
||||
let persisted = self.persist();
|
||||
self.save_view();
|
||||
let unlogged = if persisted.is_err() { self.pending_log.len() as u64 } else { 0 };
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
lib.journal.snapshot(&self.catalog)?;
|
||||
Ok(())
|
||||
let before = lib.journal.seq();
|
||||
let snapshot = lib.journal.snapshot_with_unlogged(&self.catalog, unlogged);
|
||||
if unlogged > 0 && lib.journal.seq() == before + unlogged {
|
||||
// the snapshot holds the queued ops: they are saved, never append them again
|
||||
log::info!("library: {unlogged} queued change(s) saved in the closing snapshot");
|
||||
self.pending_log.clear();
|
||||
lib.unsaved_error = None;
|
||||
lib.retry_at = None;
|
||||
lib.last_error = None;
|
||||
}
|
||||
match (persisted, snapshot) {
|
||||
(_, Ok(())) => Ok(()),
|
||||
// neither the log nor the snapshot took the queued ops
|
||||
(Err(EngineError::NotSaved(e)), Err(s)) if lib.journal.seq() == before => Err(EngineError::NotSaved(format!("{e}; snapshot: {s}"))),
|
||||
(_, Err(s)) => Err(s.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn view_json(&self) -> Vec<u8> {
|
||||
@@ -286,8 +476,18 @@ impl Session {
|
||||
xmp: self.xmp,
|
||||
last_export: self.last_export.clone(),
|
||||
export_presets: self.export_presets.clone(),
|
||||
metadata_presets: self.metadata_presets.clone(),
|
||||
filter_presets: self.filter_presets.clone(),
|
||||
curve_presets: self.curve_presets.clone(),
|
||||
label_sets: self.label_sets.clone(),
|
||||
lut_profiles: self.lut_profiles.clone(),
|
||||
keyword_sets: self.keyword_sets.clone(),
|
||||
keyword_set: self.keyword_set.clone(),
|
||||
recent_keywords: self.recent_keywords.clone(),
|
||||
import: self.import_defaults.clone(),
|
||||
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),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
@@ -310,6 +510,24 @@ impl Session {
|
||||
self.save_prefs()
|
||||
}
|
||||
|
||||
/// Save, then start compacting in the background (natively; synchronously elsewhere): after
|
||||
/// a change that shrinks the catalog a lot, so the smaller snapshot replaces the old one.
|
||||
/// Errors are logged and reported like other persistence errors (nothing is lost).
|
||||
pub fn compact_soon(&mut self) {
|
||||
if let Err(e) = self.persist() {
|
||||
log::error!("library: {e}");
|
||||
return;
|
||||
}
|
||||
if self.interaction.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(lib) = self.library.as_mut() else { return };
|
||||
if let Err(e) = lib.journal.snapshot_in_background(&self.catalog) {
|
||||
log::error!("library: compaction: {e}");
|
||||
lib.last_error = Some(format!("compaction: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact the log into a snapshot now.
|
||||
pub fn compact_library(&mut self) -> Result<()> {
|
||||
self.persist()?;
|
||||
|
||||
+62
-10
@@ -26,7 +26,7 @@ use lightcraft_raster::{Histogram, Rgb32f, Rgba8};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 3;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 4;
|
||||
|
||||
/// 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];
|
||||
@@ -74,8 +74,19 @@ pub type PreviewLoader = Arc<dyn Fn(&str, usize) -> Option<Rgba8> + Send + Sync>
|
||||
#[derive(Clone)]
|
||||
pub enum SourceRef {
|
||||
Loaded(Arc<Rgb32f>),
|
||||
Demo { scene: Box<lightcraft_scenes::Scene>, max_edge: usize },
|
||||
File { path: String, max_edge: usize, loader: Option<FileLoader> },
|
||||
Demo {
|
||||
scene: Box<lightcraft_scenes::Scene>,
|
||||
max_edge: usize,
|
||||
},
|
||||
File {
|
||||
path: String,
|
||||
max_edge: usize,
|
||||
loader: Option<FileLoader>,
|
||||
},
|
||||
/// A smart preview standing in for a missing original.
|
||||
Smart {
|
||||
path: std::path::PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
impl SourceRef {
|
||||
@@ -87,6 +98,10 @@ impl SourceRef {
|
||||
Some(l) => l(path, *max_edge).map(|(img, _)| Arc::new(img)),
|
||||
None => Err(format!("no decoder available for {path}")),
|
||||
},
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
SourceRef::Smart { path } => crate::smart::load(path),
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
SourceRef::Smart { .. } => Err("smart previews are not available here".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,6 +123,8 @@ pub struct MediaCache {
|
||||
pub preview_loader: Option<PreviewLoader>,
|
||||
/// Reads a photo file's bytes (Photo Merge); `None` = the local file system.
|
||||
pub file_bytes: Option<crate::merge::ByteReader>,
|
||||
/// The library's smart previews folder (originals offline: render from the proxy).
|
||||
pub smart_dir: Option<std::path::PathBuf>,
|
||||
scenes: Vec<lightcraft_scenes::Scene>,
|
||||
/// Rendered thumbnails (memory, plus disk once a library is attached).
|
||||
pub rendered: Arc<PreviewCache>,
|
||||
@@ -126,6 +143,7 @@ impl Default for MediaCache {
|
||||
file_probe: None,
|
||||
preview_loader: None,
|
||||
file_bytes: None,
|
||||
smart_dir: None,
|
||||
scenes: Vec::new(),
|
||||
rendered: Arc::new(PreviewCache::memory(rendered_budget(budget))),
|
||||
}
|
||||
@@ -266,6 +284,16 @@ impl MediaCache {
|
||||
(Source::Demo { .. }, SourceLevel::Full) => p.width.max(p.height).max(1) as usize,
|
||||
_ => usize::MAX,
|
||||
});
|
||||
// the original is offline: its smart preview, when there is one
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir)
|
||||
&& !std::path::Path::new(path).exists()
|
||||
{
|
||||
let sp = dir.join(crate::smart::file_name(p));
|
||||
if crate::smart::is_valid(&sp) {
|
||||
return SourceRef::Smart { path: sp };
|
||||
}
|
||||
}
|
||||
self.origin_ref(&p.source, max_edge)
|
||||
}
|
||||
|
||||
@@ -346,6 +374,20 @@ impl RenderJob {
|
||||
self
|
||||
}
|
||||
|
||||
/// Soft-proof the result (see [`lightcraft_pipeline::Proof`]). Gets its own result key and is
|
||||
/// never kept as the photo's cached preview.
|
||||
pub fn with_proof(mut self, proof: Option<lightcraft_pipeline::Proof>) -> Self {
|
||||
if self.request.proof != proof {
|
||||
let k = |p: Option<lightcraft_pipeline::Proof>| p.map_or(0, |p| p.key()).wrapping_mul(0xc2b2_ae3d_27d4_eb4f);
|
||||
self.key ^= k(self.request.proof) ^ k(proof);
|
||||
self.request.proof = proof;
|
||||
}
|
||||
if proof.is_some() {
|
||||
self.view_cache = None;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Reuse `stages` across this view's renders.
|
||||
pub fn with_stages(mut self, stages: Arc<StageCache>) -> Self {
|
||||
self.stages = Some(stages);
|
||||
@@ -400,7 +442,13 @@ impl RenderJob {
|
||||
/// the CPU, reusing `stages` either way. Both produce the same image within 1–3 LSB (see
|
||||
/// `docs/gpu-pipeline.md`); `LIGHTCRAFT_GPU=0` or [`lightcraft_gpu::set_enabled`] forces the CPU.
|
||||
pub fn develop(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>, gpu: bool) -> Rendered {
|
||||
if gpu && let Some(r) = lightcraft_gpu::render(src, info, s, req, stages) {
|
||||
// LUT profiles have no GPU stage: they render on the CPU
|
||||
if gpu
|
||||
&& !lightcraft_pipeline::lut::is_lut_profile(&s.profile.id)
|
||||
&& !s.masks.iter().any(|m| m.visible && m.refine > 0.0)
|
||||
&& req.proof.is_none()
|
||||
&& let Some(r) = lightcraft_gpu::render(src, info, s, req, stages)
|
||||
{
|
||||
return r;
|
||||
}
|
||||
match stages {
|
||||
@@ -475,11 +523,9 @@ pub fn source_info(p: &Photo) -> SourceInfo {
|
||||
if matches!(p.source, Source::Demo { .. }) {
|
||||
return SourceInfo { raw: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None };
|
||||
}
|
||||
if p.kind == MediaKind::Raw {
|
||||
SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0, lens: p.embedded_lens }
|
||||
} else {
|
||||
SourceInfo::default()
|
||||
}
|
||||
// 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() { SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0, lens: p.embedded_lens } } else { SourceInfo::default() }
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
@@ -511,12 +557,15 @@ impl crate::Session {
|
||||
let request = RenderRequest { apply_crop, ..RenderRequest::fit(max_w, max_h) };
|
||||
// the photo id is part of the key: two photos with the same settings and size must not
|
||||
// share a result (a view slot showing photo A would otherwise look current for photo B)
|
||||
// …and so is the file's content: a file changed on disk (Reload) renders afresh
|
||||
let content = Hasher128::new().str(&content_key(&p)).finish().0 as u64;
|
||||
let key = settings.hash64()
|
||||
^ ((max_w as u64) << 40)
|
||||
^ ((max_h as u64) << 20)
|
||||
^ (apply_crop as u64)
|
||||
^ (level as u64) << 60
|
||||
^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15);
|
||||
^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15)
|
||||
^ content.rotate_left(17);
|
||||
let cache = thumb_bucket.map(|b| {
|
||||
let k = Hasher128::new().str(&content_key(&p)).u64(settings.hash64()).u64(b as u64).u64(RENDER_CACHE_VERSION).finish();
|
||||
(self.media.rendered.clone(), k)
|
||||
@@ -700,6 +749,9 @@ pub struct ProbeInfo {
|
||||
pub embedded_lens: Option<lightcraft_develop::EmbeddedLens>,
|
||||
/// The file's embedded XMP packet (raw/DNG files), for develop settings stored inside the file.
|
||||
pub xmp: Option<String>,
|
||||
/// A raw variant that can't be decoded yet: why. The file is described (and will be shown and
|
||||
/// edited) from its embedded preview; see [`lightcraft_catalog::Photo::preview_only`].
|
||||
pub preview_only: Option<String>,
|
||||
}
|
||||
|
||||
pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync>;
|
||||
|
||||
@@ -44,7 +44,12 @@ fn total_ram() -> Option<usize> {
|
||||
let out = std::process::Command::new("/usr/sbin/sysctl").args(["-n", "hw.memsize"]).output().ok()?;
|
||||
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
#[cfg(target_os = "freebsd")]
|
||||
{
|
||||
let out = std::process::Command::new("/sbin/sysctl").args(["-n", "hw.physmem"]).output().ok()?;
|
||||
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "freebsd")))]
|
||||
{
|
||||
None
|
||||
}
|
||||
|
||||
@@ -304,19 +304,13 @@ fn apply_auto(s: &mut DevelopSettings, src: &Rgb32f, info: &SourceInfo) {
|
||||
s.color.saturation = a.saturation;
|
||||
}
|
||||
|
||||
/// A free file name `dir/<stem>-<suffix>.dng` (`-2`, `-3`… when taken).
|
||||
fn output_path(first: &str, suffix: &str) -> String {
|
||||
/// `dir/<stem>-<suffix>.dng`, then `-2`, `-3`… (the candidates for a merge's output).
|
||||
fn output_names(first: &str, suffix: &str) -> impl Iterator<Item = std::path::PathBuf> {
|
||||
let p = std::path::Path::new(first);
|
||||
let dir = p.parent().map(|d| d.to_path_buf()).unwrap_or_default();
|
||||
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Merge".into());
|
||||
for k in 1.. {
|
||||
let name = if k == 1 { format!("{stem}-{suffix}.dng") } else { format!("{stem}-{suffix}-{k}.dng") };
|
||||
let cand = dir.join(name);
|
||||
if !cand.exists() {
|
||||
return cand.to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
let suffix = suffix.to_string();
|
||||
(1u64..).map(move |k| dir.join(if k == 1 { format!("{stem}-{suffix}.dng") } else { format!("{stem}-{suffix}-{k}.dng") }))
|
||||
}
|
||||
|
||||
/// Parse `merge.*` command parameters into a kind and finishing options.
|
||||
@@ -371,8 +365,11 @@ impl Session {
|
||||
/// apply Auto Settings / the auto crop. Returns `{id, path, …info}`.
|
||||
pub fn finish_merge(&mut self, job: &MergeJob, out: MergeOutput) -> Result<Value> {
|
||||
let first = &job.sources.first().ok_or_else(|| EngineError::Other("nothing merged".into()))?.1;
|
||||
let path = output_path(first, job.kind.suffix());
|
||||
std::fs::write(&path, &out.dng).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
// a new file under a free name (never replacing one), complete and synced before it appears
|
||||
let path = lightcraft_catalog::safe_file::write_new_unique(&mut output_names(first, job.kind.suffix()), &out.dng)
|
||||
.map_err(|e| EngineError::Other(format!("could not write the merged DNG next to {first}: {e}")))?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let report = crate::import::import(self, std::slice::from_ref(&path), crate::import::ImportMode::Add)?;
|
||||
let id = report
|
||||
.imported
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Never write over an original: exports, renders and other files LightCraft writes for the user
|
||||
//! must not land on a catalogued photo's own file (or its XMP sidecar), whatever the conflict
|
||||
//! setting or exact path asked for. [`OriginalGuard`] is a snapshot of the library's originals
|
||||
//! that answers "would writing here replace one?" by file identity (the same file under another
|
||||
//! spelling, through `..`, a link or a case-insensitive volume counts), not by comparing strings.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use lightcraft_catalog::safe_file::same_file;
|
||||
use lightcraft_catalog::{Catalog, Source};
|
||||
|
||||
/// The library's originals and their sidecars, looked up by file name.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OriginalGuard {
|
||||
/// lower-case file name → (path on disk, what it is: "the original of IMG_1.CR3", …)
|
||||
by_name: HashMap<String, Vec<(PathBuf, String)>>,
|
||||
}
|
||||
|
||||
impl OriginalGuard {
|
||||
/// The originals of every photo in `catalog`.
|
||||
pub fn new(catalog: &Catalog) -> Self {
|
||||
let mut g = Self::default();
|
||||
for p in catalog.photos() {
|
||||
if let Source::File { path } = &p.source {
|
||||
g.add(Path::new(path), &p.file_name);
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// Protect `path` (an original named `label`) and its sidecars (`IMG_1.xmp`, `IMG_1.CR3.xmp`).
|
||||
pub fn add(&mut self, path: &Path, label: &str) {
|
||||
let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return };
|
||||
let mut put = |file: String, p: PathBuf, what: String| self.by_name.entry(file.to_lowercase()).or_default().push((p, what));
|
||||
put(name.clone(), path.to_path_buf(), format!("the original of {label}"));
|
||||
let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
|
||||
put(format!("{stem}.xmp"), path.with_extension("xmp"), format!("the XMP sidecar of {label}"));
|
||||
put(format!("{name}.xmp"), PathBuf::from(format!("{}.xmp", path.to_string_lossy())), format!("the XMP sidecar of {label}"));
|
||||
}
|
||||
|
||||
/// `Err` (a message for the user) when writing `target` would replace a protected file.
|
||||
pub fn check(&self, target: &Path) -> Result<(), String> {
|
||||
if std::fs::symlink_metadata(target).is_err() {
|
||||
return Ok(()); // nothing there to replace
|
||||
}
|
||||
let mut names: Vec<String> = target.file_name().map(|n| n.to_string_lossy().to_lowercase()).into_iter().collect();
|
||||
if let Some(n) = std::fs::canonicalize(target).ok().and_then(|c| c.file_name().map(|n| n.to_string_lossy().to_lowercase())) {
|
||||
names.push(n);
|
||||
}
|
||||
for n in &names {
|
||||
for (p, what) in self.by_name.get(n).into_iter().flatten() {
|
||||
if same_file(p, target) {
|
||||
return Err(format!(
|
||||
"{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
/// A guard over this library's originals (see [`OriginalGuard`]).
|
||||
pub fn original_guard(&self) -> OriginalGuard {
|
||||
OriginalGuard::new(&self.catalog)
|
||||
}
|
||||
|
||||
/// `Err` when writing `path` would replace a catalogued photo's original or sidecar.
|
||||
pub fn check_write_target(&self, path: &str) -> Result<(), String> {
|
||||
self.original_guard().check(Path::new(path))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId};
|
||||
|
||||
#[test]
|
||||
fn originals_and_their_sidecars_are_protected_by_identity() {
|
||||
let d = std::env::temp_dir().join(format!("lc-originals-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(d.join("sub")).unwrap();
|
||||
let orig = d.join("IMG_1.jpg");
|
||||
std::fs::write(&orig, b"original").unwrap();
|
||||
std::fs::write(d.join("IMG_1.xmp"), b"<x/>").unwrap();
|
||||
std::fs::write(d.join("other.jpg"), b"other").unwrap();
|
||||
let mut c = Catalog::default();
|
||||
let p = Photo::new(PhotoId(1), Source::File { path: orig.to_string_lossy().to_string() }, "IMG_1.jpg", "JPEG", 10, 10, "2026-01-01");
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
let g = OriginalGuard::new(&c);
|
||||
assert!(g.check(&orig).unwrap_err().contains("original of IMG_1.jpg"));
|
||||
assert!(g.check(&d.join("sub/../IMG_1.jpg")).is_err(), "another spelling of the same file");
|
||||
assert!(g.check(&d.join("IMG_1.xmp")).unwrap_err().contains("sidecar"));
|
||||
assert!(g.check(&d.join("other.jpg")).is_ok(), "an ordinary existing file");
|
||||
assert!(g.check(&d.join("new.jpg")).is_ok(), "a free name");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
//! Importing other editors' presets: XMP preset files, classic `.lrtemplate` files (a Lua table
|
||||
//! literal), photos that carry their edits as an XMP packet ("DNG presets" from mobile apps,
|
||||
//! edited JPEG/TIFF), and zip bundles of any of these. Everything ends up as `crs:` properties
|
||||
//! mapped by [`crate::crs`]. Luminar looks (`.lmp`, `.mplumpack` collections) are read by
|
||||
//! [`crate::preset_luminar`].
|
||||
//!
|
||||
//! Written from the formats' public structure (Lua's table syntax, the ZIP application note,
|
||||
//! ISO 16684-1 XMP) and black-box observation; no third-party code or preset content was used.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use lightcraft_develop::Preset;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::crs::Props;
|
||||
|
||||
/// One preset read from a file, with the settings we could not carry over.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Imported {
|
||||
pub preset: Preset,
|
||||
/// Names of the other editor's settings that have no counterpart here (local masks, its own
|
||||
/// profiles…).
|
||||
pub unmapped: Vec<String>,
|
||||
}
|
||||
|
||||
/// Extensions of files read as presets (beyond our own `.lcpreset`).
|
||||
pub const PRESET_EXTS: &[&str] = &["xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack"];
|
||||
|
||||
// ------------------------------------------------------------------------------------- zip
|
||||
|
||||
/// The files of a zip archive: (path inside the archive, bytes). Stored and deflated entries;
|
||||
/// directories, macOS resource forks and hidden files are skipped.
|
||||
pub fn read_zip(bytes: &[u8]) -> Result<Vec<(String, Vec<u8>)>, String> {
|
||||
let u16_at = |o: usize| bytes.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize);
|
||||
let u32_at = |o: usize| bytes.get(o..o + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize);
|
||||
// end of central directory: the last "PK\x05\x06" within the trailing 64 KiB + 22 bytes
|
||||
let tail = bytes.len().saturating_sub(65_557);
|
||||
let eocd = (tail..bytes.len().saturating_sub(21)).rev().find(|&i| bytes[i..].starts_with(b"PK\x05\x06")).ok_or("not a zip file")?;
|
||||
let count = u16_at(eocd + 10).ok_or("bad zip")?;
|
||||
let mut at = u32_at(eocd + 16).ok_or("bad zip")?;
|
||||
let mut out = Vec::new();
|
||||
for _ in 0..count {
|
||||
if !bytes.get(at..).is_some_and(|b| b.starts_with(b"PK\x01\x02")) {
|
||||
return Err("bad zip central directory".into());
|
||||
}
|
||||
let method = u16_at(at + 10).ok_or("bad zip")?;
|
||||
let csize = u32_at(at + 20).ok_or("bad zip")?;
|
||||
let usize_ = u32_at(at + 24).ok_or("bad zip")?;
|
||||
let (nlen, xlen, clen) = (u16_at(at + 28).ok_or("bad zip")?, u16_at(at + 30).ok_or("bad zip")?, u16_at(at + 32).ok_or("bad zip")?);
|
||||
let local = u32_at(at + 42).ok_or("bad zip")?;
|
||||
let name = String::from_utf8_lossy(bytes.get(at + 46..at + 46 + nlen).ok_or("bad zip")?).replace('\\', "/");
|
||||
at += 46 + nlen + xlen + clen;
|
||||
let skip = name.ends_with('/') || name.split('/').any(|p| p.starts_with('.') || p == "__MACOSX");
|
||||
if skip {
|
||||
continue;
|
||||
}
|
||||
if !bytes.get(local..).is_some_and(|b| b.starts_with(b"PK\x03\x04")) {
|
||||
return Err(format!("{name}: bad local header"));
|
||||
}
|
||||
let data_at = local + 30 + u16_at(local + 26).ok_or("bad zip")? + u16_at(local + 28).ok_or("bad zip")?;
|
||||
let data = bytes.get(data_at..data_at + csize).ok_or_else(|| format!("{name}: truncated"))?;
|
||||
let content = match method {
|
||||
0 => data.to_vec(),
|
||||
8 => miniz_oxide::inflate::decompress_to_vec_with_limit(data, usize_.max(1) * 2 + 1024).map_err(|e| format!("{name}: {e:?}"))?,
|
||||
m => return Err(format!("{name}: unsupported compression method {m}")),
|
||||
};
|
||||
out.push((name, content));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------- Lua tables
|
||||
|
||||
/// A value of a Lua table literal (what `.lrtemplate` files contain).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Lua {
|
||||
Nil,
|
||||
Bool(bool),
|
||||
Num(f64),
|
||||
Str(String),
|
||||
/// Positional items and named fields, in file order.
|
||||
Table(Vec<Lua>, Vec<(String, Lua)>),
|
||||
}
|
||||
|
||||
impl Lua {
|
||||
pub fn get(&self, key: &str) -> Option<&Lua> {
|
||||
match self {
|
||||
Lua::Table(_, m) => m.iter().find(|(k, _)| k == key).map(|(_, v)| v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn str(&self) -> Option<&str> {
|
||||
match self {
|
||||
Lua::Str(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LuaParser<'a> {
|
||||
s: &'a [u8],
|
||||
i: usize,
|
||||
}
|
||||
|
||||
impl LuaParser<'_> {
|
||||
fn err<T>(&self, what: &str) -> Result<T, String> {
|
||||
Err(format!("{what} at byte {}", self.i))
|
||||
}
|
||||
fn skip_ws(&mut self) {
|
||||
loop {
|
||||
while self.i < self.s.len() && self.s[self.i].is_ascii_whitespace() {
|
||||
self.i += 1;
|
||||
}
|
||||
if self.s[self.i..].starts_with(b"--") {
|
||||
self.i += 2;
|
||||
if let Some(level) = self.long_bracket_level() {
|
||||
let _ = self.long_string(level);
|
||||
} else {
|
||||
while self.i < self.s.len() && self.s[self.i] != b'\n' {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
/// `[[` / `[==[` at the cursor: its level (number of `=`).
|
||||
fn long_bracket_level(&self) -> Option<usize> {
|
||||
let r = &self.s[self.i..];
|
||||
if r.first() != Some(&b'[') {
|
||||
return None;
|
||||
}
|
||||
let eq = r[1..].iter().take_while(|c| **c == b'=').count();
|
||||
(r.get(1 + eq) == Some(&b'[')).then_some(eq)
|
||||
}
|
||||
fn long_string(&mut self, level: usize) -> Result<String, String> {
|
||||
self.i += level + 2;
|
||||
let close = format!("]{}]", "=".repeat(level));
|
||||
let rest = &self.s[self.i..];
|
||||
let end = rest.windows(close.len()).position(|w| w == close.as_bytes()).ok_or("unterminated long string")?;
|
||||
let mut text = String::from_utf8_lossy(&rest[..end]).to_string();
|
||||
if text.starts_with('\n') {
|
||||
text.remove(0);
|
||||
}
|
||||
self.i += end + close.len();
|
||||
Ok(text)
|
||||
}
|
||||
fn string(&mut self) -> Result<String, String> {
|
||||
let q = self.s[self.i];
|
||||
self.i += 1;
|
||||
let mut out = Vec::new();
|
||||
while self.i < self.s.len() {
|
||||
let c = self.s[self.i];
|
||||
self.i += 1;
|
||||
match c {
|
||||
c if c == q => return Ok(String::from_utf8_lossy(&out).to_string()),
|
||||
b'\\' => {
|
||||
let e = *self.s.get(self.i).ok_or("bad escape")?;
|
||||
self.i += 1;
|
||||
match e {
|
||||
b'n' => out.push(b'\n'),
|
||||
b't' => out.push(b'\t'),
|
||||
b'r' => out.push(b'\r'),
|
||||
b'0'..=b'9' => {
|
||||
let mut v = (e - b'0') as u32;
|
||||
for _ in 0..2 {
|
||||
match self.s.get(self.i) {
|
||||
Some(d @ b'0'..=b'9') => {
|
||||
v = v * 10 + (d - b'0') as u32;
|
||||
self.i += 1;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
out.push(v.min(255) as u8);
|
||||
}
|
||||
b'\n' => out.push(b'\n'),
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
self.err("unterminated string")
|
||||
}
|
||||
fn ident(&mut self) -> Option<String> {
|
||||
let start = self.i;
|
||||
while self.i < self.s.len() && (self.s[self.i].is_ascii_alphanumeric() || self.s[self.i] == b'_') {
|
||||
self.i += 1;
|
||||
}
|
||||
(self.i > start && !self.s[start].is_ascii_digit()).then(|| String::from_utf8_lossy(&self.s[start..self.i]).to_string())
|
||||
}
|
||||
fn number(&mut self) -> Result<f64, String> {
|
||||
let start = self.i;
|
||||
if self.s[self.i..].starts_with(b"0x") || self.s[self.i..].starts_with(b"0X") {
|
||||
self.i += 2;
|
||||
let h = self.i;
|
||||
while self.i < self.s.len() && self.s[self.i].is_ascii_hexdigit() {
|
||||
self.i += 1;
|
||||
}
|
||||
return i64::from_str_radix(std::str::from_utf8(&self.s[h..self.i]).unwrap_or(""), 16).map(|v| v as f64).map_err(|e| e.to_string());
|
||||
}
|
||||
while self.i < self.s.len() && (self.s[self.i].is_ascii_digit() || matches!(self.s[self.i], b'.' | b'e' | b'E' | b'+' | b'-')) {
|
||||
if matches!(self.s[self.i], b'+' | b'-') && self.i > start && !matches!(self.s[self.i - 1], b'e' | b'E') {
|
||||
break;
|
||||
}
|
||||
self.i += 1;
|
||||
}
|
||||
std::str::from_utf8(&self.s[start..self.i]).ok().and_then(|t| t.parse().ok()).map_or_else(|| self.err("bad number"), Ok)
|
||||
}
|
||||
fn value(&mut self) -> Result<Lua, String> {
|
||||
self.skip_ws();
|
||||
let Some(&c) = self.s.get(self.i) else { return self.err("unexpected end") };
|
||||
match c {
|
||||
b'{' => self.table(),
|
||||
b'"' | b'\'' => self.string().map(Lua::Str),
|
||||
b'[' if self.long_bracket_level().is_some() => {
|
||||
let level = self.long_bracket_level().unwrap_or(0);
|
||||
self.long_string(level).map(Lua::Str)
|
||||
}
|
||||
b'-' => {
|
||||
self.i += 1;
|
||||
self.skip_ws();
|
||||
match self.value()? {
|
||||
Lua::Num(n) => Ok(Lua::Num(-n)),
|
||||
_ => self.err("bad negation"),
|
||||
}
|
||||
}
|
||||
b'0'..=b'9' | b'.' => self.number().map(Lua::Num),
|
||||
_ => match self.ident().as_deref() {
|
||||
Some("true") => Ok(Lua::Bool(true)),
|
||||
Some("false") => Ok(Lua::Bool(false)),
|
||||
Some("nil") => Ok(Lua::Nil),
|
||||
// a call with one string / table argument, e.g. ZSTR "…" (localisation helpers)
|
||||
Some(_) => {
|
||||
self.skip_ws();
|
||||
match self.s.get(self.i) {
|
||||
Some(b'"' | b'\'' | b'{') => self.value(),
|
||||
Some(b'(') => {
|
||||
self.i += 1;
|
||||
let v = self.value()?;
|
||||
self.skip_ws();
|
||||
if self.s.get(self.i) == Some(&b')') {
|
||||
self.i += 1;
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
_ => Ok(Lua::Nil),
|
||||
}
|
||||
}
|
||||
None => self.err("unexpected character"),
|
||||
},
|
||||
}
|
||||
}
|
||||
fn table(&mut self) -> Result<Lua, String> {
|
||||
self.i += 1; // {
|
||||
let (mut arr, mut map) = (Vec::new(), Vec::new());
|
||||
loop {
|
||||
self.skip_ws();
|
||||
match self.s.get(self.i) {
|
||||
None => return self.err("unterminated table"),
|
||||
Some(b'}') => {
|
||||
self.i += 1;
|
||||
return Ok(Lua::Table(arr, map));
|
||||
}
|
||||
Some(b',' | b';') => {
|
||||
self.i += 1;
|
||||
continue;
|
||||
}
|
||||
Some(b'[') if self.long_bracket_level().is_none() => {
|
||||
self.i += 1;
|
||||
let k = self.value()?;
|
||||
self.skip_ws();
|
||||
if self.s.get(self.i) != Some(&b']') {
|
||||
return self.err("expected ]");
|
||||
}
|
||||
self.i += 1;
|
||||
self.expect_eq()?;
|
||||
let v = self.value()?;
|
||||
match k {
|
||||
Lua::Str(s) => map.push((s, v)),
|
||||
Lua::Num(n) => map.push((format!("{n}"), v)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// `name = value`, or a positional value
|
||||
let save = self.i;
|
||||
if let Some(name) = self.ident() {
|
||||
self.skip_ws();
|
||||
if self.s.get(self.i) == Some(&b'=') && self.s.get(self.i + 1) != Some(&b'=') {
|
||||
self.i += 1;
|
||||
let v = self.value()?;
|
||||
map.push((name, v));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
self.i = save;
|
||||
arr.push(self.value()?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn expect_eq(&mut self) -> Result<(), String> {
|
||||
self.skip_ws();
|
||||
if self.s.get(self.i) == Some(&b'=') {
|
||||
self.i += 1;
|
||||
Ok(())
|
||||
} else {
|
||||
self.err("expected =")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a Lua table literal, optionally preceded by `name =` or `return`.
|
||||
pub fn parse_lua(text: &str) -> Result<Lua, String> {
|
||||
let mut p = LuaParser { s: text.as_bytes(), i: 0 };
|
||||
p.skip_ws();
|
||||
let save = p.i;
|
||||
match p.ident().as_deref() {
|
||||
Some("return") => {}
|
||||
Some(_) => {
|
||||
p.skip_ws();
|
||||
if p.s.get(p.i) == Some(&b'=') {
|
||||
p.i += 1;
|
||||
} else {
|
||||
p.i = save;
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
p.value()
|
||||
}
|
||||
|
||||
/// A localisable string `"$$$/Key/Path=Default text"` → its default text.
|
||||
fn delocalize(s: &str) -> String {
|
||||
match s.strip_prefix("$$$/") {
|
||||
Some(rest) => rest.split_once('=').map_or(rest, |(_, t)| t).to_string(),
|
||||
None => s.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The develop settings of an `.lrtemplate` as `crs:` properties (flat, and structured for local
|
||||
/// corrections), plus its title.
|
||||
pub fn lrtemplate_props(text: &str) -> Result<(Option<String>, Props, crate::crs_masks::Values), String> {
|
||||
let root = parse_lua(text)?;
|
||||
let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(delocalize).filter(|t| !t.trim().is_empty());
|
||||
let settings = root.get("value").and_then(|v| v.get("settings")).ok_or("no develop settings in this template")?;
|
||||
let Lua::Table(_, fields) = settings else { return Err("no develop settings in this template".into()) };
|
||||
let mut props = Props::new();
|
||||
let mut values = crate::crs_masks::Values::new();
|
||||
for (k, v) in fields {
|
||||
let key = format!("crs:{k}");
|
||||
if crate::crs_masks::CONTAINERS.contains(&key.as_str()) {
|
||||
values.insert(key.clone(), crate::crs_masks::from_lua(v));
|
||||
}
|
||||
let vals: Vec<String> = match v {
|
||||
Lua::Num(n) => vec![format!("{n}")],
|
||||
Lua::Bool(b) => vec![if *b { "True" } else { "False" }.to_string()],
|
||||
Lua::Str(s) => vec![delocalize(s)],
|
||||
// point curves are flat lists x1, y1, x2, y2, …
|
||||
Lua::Table(arr, map) if map.is_empty() && !arr.is_empty() && arr.iter().all(|a| matches!(a, Lua::Num(_))) => arr
|
||||
.chunks(2)
|
||||
.filter_map(|c| match c {
|
||||
[Lua::Num(x), Lua::Num(y)] => Some(format!("{x}, {y}")),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
Lua::Table(a, m) if a.is_empty() && m.is_empty() => continue,
|
||||
// anything else (local corrections, retouch…) is only reported
|
||||
Lua::Table(..) => vec!["(table)".into()],
|
||||
Lua::Nil => continue,
|
||||
};
|
||||
props.insert(key, vals);
|
||||
}
|
||||
Ok((title, props, values))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------- XMP in files
|
||||
|
||||
/// The XMP packet embedded in a file of any container (DNG, JPEG, TIFF…): the text from
|
||||
/// `<x:xmpmeta` to `</x:xmpmeta>`.
|
||||
pub fn embedded_xmp(bytes: &[u8]) -> Option<String> {
|
||||
let start = bytes.windows(10).position(|w| w == b"<x:xmpmeta")?;
|
||||
let end = bytes[start..].windows(12).position(|w| w == b"</x:xmpmeta>")? + start + 12;
|
||||
Some(String::from_utf8_lossy(&bytes[start..end]).to_string())
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------- files
|
||||
|
||||
/// A preset group named after the innermost folder of `dir` (a path inside the imported folder or
|
||||
/// bundle) that isn't a container named for an app or a format (`Lightroom Classic`, `XMP`,
|
||||
/// `Mobile (DNG)`, `Develop Presets`…).
|
||||
pub fn group_from_dir(dir: &str) -> Option<String> {
|
||||
const FILLER: &[&str] = &[
|
||||
"lightroom",
|
||||
"lr",
|
||||
"lrc",
|
||||
"classic",
|
||||
"cc",
|
||||
"camera",
|
||||
"raw",
|
||||
"acr",
|
||||
"photoshop",
|
||||
"desktop",
|
||||
"mobile",
|
||||
"xmp",
|
||||
"lrtemplate",
|
||||
"lrtemplates",
|
||||
"dng",
|
||||
"dngs",
|
||||
"preset",
|
||||
"presets",
|
||||
"settings",
|
||||
"lcpreset",
|
||||
"lightcraft",
|
||||
"user",
|
||||
"develop",
|
||||
"files",
|
||||
"for",
|
||||
"and",
|
||||
"version",
|
||||
"versions",
|
||||
"format",
|
||||
"formats",
|
||||
"new",
|
||||
"old",
|
||||
"adobe",
|
||||
"luminar",
|
||||
"skylum",
|
||||
"looks",
|
||||
"lmp",
|
||||
"mplumpack",
|
||||
"contents",
|
||||
"resources",
|
||||
];
|
||||
let generic = |name: &str| {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).all(|w| FILLER.contains(&w) || w.chars().all(|c| c.is_ascii_digit()))
|
||||
};
|
||||
// a macOS bundle (`Look.lmp/Contents/…`) is a file, not a group
|
||||
let bundle = |p: &str| p.to_ascii_lowercase().ends_with(".lmp");
|
||||
dir.split(['/', '\\']).rev().find(|p| !p.is_empty() && !generic(p) && !bundle(p)).map(str::to_string)
|
||||
}
|
||||
|
||||
pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: String, group: Option<String>, from_photo: bool) -> Option<Imported> {
|
||||
let (mut settings, unmapped) = crate::crs::to_partial_report(props, Some(values), None, crate::crs_masks::DEFAULT_ASPECT);
|
||||
if from_photo && let Some(o) = settings.as_object_mut() {
|
||||
// a photo's own framing and absolute white balance don't belong in a look
|
||||
o.remove("crop");
|
||||
o.remove("geometry");
|
||||
if o.get("wb").and_then(|w| w.get("mode")).and_then(Value::as_str).is_none_or(|m| m == "custom" || m == "asShot") {
|
||||
o.remove("wb");
|
||||
}
|
||||
}
|
||||
if settings.as_object().is_none_or(Map::is_empty) {
|
||||
return None;
|
||||
}
|
||||
let group = props
|
||||
.get("crs:Group")
|
||||
.and_then(|v| v.first())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.or(group)
|
||||
.unwrap_or_else(|| "Imported Presets".into());
|
||||
let uuid = props.get("crs:UUID").and_then(|v| v.first()).map(|u| u.to_ascii_lowercase());
|
||||
let id = format!("user.xmp.{}", uuid.unwrap_or_else(|| crate::presets::slug(&format!("{group}-{name}"))));
|
||||
Some(Imported { preset: Preset { id, name, group, settings, favorite: false, builtin: false }, unmapped })
|
||||
}
|
||||
|
||||
/// Read the presets in file `name` (`bytes`); `group` names the group when the file doesn't
|
||||
/// (its folder). Zip bundles are read recursively.
|
||||
pub fn read_presets(name: &str, bytes: &[u8], group: Option<String>) -> Result<Vec<Imported>, String> {
|
||||
let path = Path::new(name);
|
||||
let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Preset".into());
|
||||
let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
match ext.as_str() {
|
||||
"zip" => {
|
||||
let mut out = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
for (inner, data) in read_zip(bytes)? {
|
||||
let iext = Path::new(&inner).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
if !(PRESET_EXTS.contains(&iext.as_str()) || iext == crate::presets::LCPRESET_EXT) || iext == "zip" {
|
||||
continue;
|
||||
}
|
||||
let dir_group =
|
||||
inner.rsplit_once('/').and_then(|(d, _)| group_from_dir(d)).or_else(|| group_from_dir(&stem)).or_else(|| group.clone());
|
||||
match read_presets(&inner, &data, dir_group) {
|
||||
Ok(v) => out.extend(v),
|
||||
Err(e) => errors.push(format!("{inner}: {e}")),
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(if errors.is_empty() { "no presets in this archive".into() } else { errors.join("; ") });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
"lmp" => crate::preset_luminar::read_lmp(name, bytes, group).map(|i| vec![i]),
|
||||
"mplumpack" => crate::preset_luminar::read_mplumpack(name, bytes),
|
||||
"lrtemplate" => {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let (title, props, values) = lrtemplate_props(text.trim_start_matches('\u{feff}'))?;
|
||||
build(&props, &values, title.unwrap_or(stem), group, false)
|
||||
.map(|i| vec![i])
|
||||
.ok_or_else(|| "no develop settings we can use in this template".into())
|
||||
}
|
||||
"xmp" => {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let d = lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).map_err(|e| e.to_string())?;
|
||||
let props = &d.properties;
|
||||
let name = props.get("crs:Name").and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).unwrap_or(stem);
|
||||
build(props, &d.values, name, group, false).map(|i| vec![i]).ok_or_else(|| "no develop settings in this XMP file".into())
|
||||
}
|
||||
// a photo carrying its edits (mobile "DNG presets", edited JPEG / TIFF)
|
||||
"dng" | "jpg" | "jpeg" | "tif" | "tiff" => {
|
||||
let xmp = embedded_xmp(bytes).ok_or("this photo carries no edits (no XMP)")?;
|
||||
let d = lightcraft_meta::parse_xmp(&xmp).map_err(|e| e.to_string())?;
|
||||
build(&d.properties, &d.values, stem, group, true).map(|i| vec![i]).ok_or_else(|| "this photo carries no edits we can use".into())
|
||||
}
|
||||
_ => crate::presets::parse_preset_file(name, bytes).map(|v| {
|
||||
v.into_iter()
|
||||
.map(|mut p| {
|
||||
if p.group == "Imported Presets"
|
||||
&& let Some(g) = &group
|
||||
{
|
||||
p.group = g.clone();
|
||||
}
|
||||
Imported { preset: p, unmapped: Vec::new() }
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// A zip archive (`deflate`: compress entries) — enough of a writer for the tests.
|
||||
pub(crate) fn zip(entries: &[(&str, &[u8])], deflate: bool) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut central = Vec::new();
|
||||
for (name, data) in entries {
|
||||
let (method, body) = if deflate { (8u16, miniz_oxide::deflate::compress_to_vec(data, 6)) } else { (0, data.to_vec()) };
|
||||
let off = out.len() as u32;
|
||||
let head = |sig: &[u8], out: &mut Vec<u8>, central: bool| {
|
||||
out.extend_from_slice(sig);
|
||||
if central {
|
||||
out.extend_from_slice(&20u16.to_le_bytes());
|
||||
}
|
||||
out.extend_from_slice(&20u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&method.to_le_bytes());
|
||||
out.extend_from_slice(&[0; 8]); // time, date, crc (not checked)
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
};
|
||||
head(b"PK\x03\x04", &mut out, false);
|
||||
out.extend_from_slice(name.as_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
head(b"PK\x01\x02", &mut central, true);
|
||||
central.extend_from_slice(&[0; 10]); // comment length, disk, internal + external attributes
|
||||
central.extend_from_slice(&off.to_le_bytes());
|
||||
central.extend_from_slice(name.as_bytes());
|
||||
}
|
||||
let cd = out.len() as u32;
|
||||
out.extend_from_slice(¢ral);
|
||||
out.extend_from_slice(b"PK\x05\x06\0\0\0\0");
|
||||
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&(central.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&cd.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// A template in the classic Lua-table form (written for this test).
|
||||
pub(crate) const TEMPLATE: &str = r#"s = {
|
||||
id = "0D2F9C1E-TEST",
|
||||
internalName = "Warm Fade",
|
||||
title = "$$$/Test/Warm=Warm Fade",
|
||||
type = "Develop",
|
||||
value = {
|
||||
settings = {
|
||||
Exposure2012 = 0.35,
|
||||
Contrast2012 = -20,
|
||||
ConvertToGrayscale = false,
|
||||
ToneCurvePV2012 = { 0, 20, 128, 128, 255, 240, },
|
||||
SplitToningShadowHue = 210,
|
||||
SplitToningShadowSaturation = 15,
|
||||
HueAdjustmentOrange = -8,
|
||||
CameraProfile = "Some Profile",
|
||||
RetouchInfo = {},
|
||||
ProcessVersion = "11.0",
|
||||
-- a comment
|
||||
EnableColorAdjustments = true,
|
||||
},
|
||||
uuid = "0D2F9C1E-TEST",
|
||||
},
|
||||
version = 0,
|
||||
}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn lua_tables() {
|
||||
let v = parse_lua(
|
||||
r#"return { a = 1, ["b c"] = 'x\'y', -2.5e1, 0x10, t = { true, nil }, [[long
|
||||
text]], z = ZSTR "loc" }"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(v.get("a"), Some(&Lua::Num(1.0)));
|
||||
assert_eq!(v.get("b c"), Some(&Lua::Str("x'y".into())));
|
||||
assert_eq!(v.get("z"), Some(&Lua::Str("loc".into())));
|
||||
let Lua::Table(arr, _) = &v else { panic!() };
|
||||
assert_eq!(arr[..2], [Lua::Num(-25.0), Lua::Num(16.0)]);
|
||||
assert_eq!(arr[2], Lua::Str("long\ntext".into()));
|
||||
assert!(parse_lua("s = { a = ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lrtemplate_maps_settings_and_reports_the_rest() {
|
||||
let v = read_presets("Warm Fade.lrtemplate", TEMPLATE.as_bytes(), None).unwrap();
|
||||
assert_eq!(v.len(), 1);
|
||||
let p = &v[0].preset;
|
||||
assert_eq!((p.name.as_str(), p.group.as_str()), ("Warm Fade", "Imported Presets"));
|
||||
assert_eq!(p.settings["light"]["exposure"], json!(0.35));
|
||||
assert_eq!(p.settings["light"]["contrast"], json!(-20.0));
|
||||
assert_eq!(p.settings["treatment"], json!("color"));
|
||||
assert_eq!(p.settings["grading"]["shadows"]["hue"], json!(210.0));
|
||||
assert_eq!(p.settings["mixer"]["orange"]["hue"], json!(-8.0));
|
||||
let c = p.settings["curve"]["master"].as_array().unwrap();
|
||||
assert_eq!(c.len(), 3);
|
||||
assert!((c[0]["y"].as_f64().unwrap() - 20.0 / 255.0).abs() < 1e-9);
|
||||
assert_eq!(v[0].unmapped, vec!["CameraProfile".to_string()], "profiles are ours; the rest is mapped or bookkeeping");
|
||||
// it parses into real develop settings
|
||||
let d = lightcraft_develop::DevelopSettings::default().merged(&p.settings).expect("valid develop settings");
|
||||
assert_eq!(d.light.exposure, 0.35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_process_version_fields_are_approximated() {
|
||||
let t = "s = { title = \"Old\", value = { settings = { Exposure = 0.5, Contrast = 50, FillLight = 20, HighlightRecovery = 30, Clarity = 10, ToneCurve = { 0, 0, 64, 50, 255, 255 } } } }";
|
||||
let p = &read_presets("Old.lrtemplate", t.as_bytes(), None).unwrap()[0].preset;
|
||||
assert_eq!(p.settings["light"]["exposure"], json!(0.5));
|
||||
assert_eq!(p.settings["light"]["contrast"], json!(25.0), "relative to the old default of 25");
|
||||
assert_eq!(p.settings["light"]["shadows"], json!(20.0));
|
||||
assert_eq!(p.settings["light"]["highlights"], json!(-30.0));
|
||||
assert_eq!(p.settings["effects"]["clarity"], json!(10.0));
|
||||
assert_eq!(p.settings["curve"]["master"].as_array().unwrap().len(), 3);
|
||||
// newer presets carry the old fields at their defaults: those must not shift anything
|
||||
let t2 = "s = { value = { settings = { Contrast2012 = 10, Contrast = 25, Shadows = 5, Brightness = 50 } } }";
|
||||
let p2 = &read_presets("New.lrtemplate", t2.as_bytes(), None).unwrap()[0];
|
||||
assert_eq!(p2.preset.settings, json!({"light": {"contrast": 10.0}}));
|
||||
assert!(p2.unmapped.is_empty(), "{:?}", p2.unmapped);
|
||||
}
|
||||
|
||||
const XMP: &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:Exposure2012="+0.50" crs:Vibrance="20" crs:WhiteBalance="As Shot" crs:Temperature="5600" crs:HasCrop="True" crs:CropLeft="0.1" crs:CropRight="0.9" crs:CropTop="0" crs:CropBottom="1"/>
|
||||
</rdf:RDF></x:xmpmeta>"#;
|
||||
|
||||
#[test]
|
||||
fn photos_carrying_edits_become_looks() {
|
||||
// a "DNG preset": any container with an XMP packet; framing and white balance stay behind
|
||||
let mut file = b"II*\0 binary junk ".to_vec();
|
||||
file.extend_from_slice(XMP.as_bytes());
|
||||
file.extend_from_slice(b" more junk");
|
||||
let v = read_presets("/x/Moody.dng", &file, None).unwrap();
|
||||
let p = &v[0].preset;
|
||||
assert_eq!(p.name, "Moody");
|
||||
assert_eq!(p.settings, json!({"light": {"exposure": 0.5}, "color": {"vibrance": 20.0}}));
|
||||
assert!(read_presets("plain.jpg", b"\xff\xd8 no xmp", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_bundles_with_folders_as_groups() {
|
||||
for deflate in [false, true] {
|
||||
let z = zip(
|
||||
&[
|
||||
("Pack/Film/Warm Fade.lrtemplate", TEMPLATE.as_bytes()),
|
||||
("Pack/Film/Bright.xmp", XMP.as_bytes()),
|
||||
("__MACOSX/Pack/._Bright.xmp", b"junk"),
|
||||
("Pack/readme.txt", b"thanks for downloading"),
|
||||
],
|
||||
deflate,
|
||||
);
|
||||
let v = read_presets("pack.zip", &z, None).unwrap();
|
||||
let names: Vec<_> = v.iter().map(|i| (i.preset.name.as_str(), i.preset.group.as_str())).collect();
|
||||
assert_eq!(names, [("Warm Fade", "Film"), ("Bright", "Film")], "deflate={deflate}");
|
||||
}
|
||||
assert!(read_presets("empty.zip", &zip(&[("a.txt", b"x")], true), None).is_err());
|
||||
assert!(read_presets("bad.zip", b"not a zip", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_folder_names_are_not_groups() {
|
||||
assert_eq!(group_from_dir("Settings"), None);
|
||||
assert_eq!(group_from_dir("Develop Presets/"), None);
|
||||
assert_eq!(group_from_dir("Looks/My Film Looks"), Some("My Film Looks".into()));
|
||||
// commercial packs nest by format: the pack's name wins
|
||||
assert_eq!(group_from_dir("Film Pack/Lightroom Classic"), Some("Film Pack".into()));
|
||||
assert_eq!(group_from_dir("Film Pack/Mobile (DNG)/"), Some("Film Pack".into()));
|
||||
assert_eq!(group_from_dir("Film Pack/XMP Presets for Lightroom CC 7.3+"), Some("Film Pack".into()));
|
||||
assert_eq!(group_from_dir("Sacred Light"), Some("Sacred Light".into()), "whole words only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_command_reads_folders_bundles_and_applies() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-preset-import-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("Looks/Film")).unwrap();
|
||||
std::fs::write(dir.join("Looks/Film/Warm Fade.lrtemplate"), TEMPLATE).unwrap();
|
||||
std::fs::write(dir.join("Looks/bundle.zip"), zip(&[("Street/Bright.xmp", XMP.as_bytes())], true)).unwrap();
|
||||
std::fs::write(dir.join("Looks/notes.txt"), "not a preset").unwrap();
|
||||
let mut s = crate::Session::with_demo();
|
||||
let before = s.presets.len();
|
||||
let paths = json!([dir.join("Looks").to_string_lossy()]);
|
||||
// a dry run reports without adding
|
||||
let r = s.execute("preset.import", &json!({"paths": paths, "dryRun": true})).unwrap();
|
||||
assert_eq!(r["imported"].as_array().unwrap().len(), 2, "{r}");
|
||||
assert_eq!(s.presets.len(), before);
|
||||
let r = s.execute("preset.import", &json!({"paths": paths})).unwrap();
|
||||
let got: Vec<(String, String)> =
|
||||
r["imported"].as_array().unwrap().iter().map(|i| (i["name"].as_str().unwrap().into(), i["group"].as_str().unwrap().into())).collect();
|
||||
assert_eq!(got, [("Warm Fade".to_string(), "Film".to_string()), ("Bright".into(), "Street".into())]);
|
||||
assert_eq!(r["imported"][0]["unmapped"], json!(["CameraProfile"]));
|
||||
assert_eq!(s.presets.len(), before + 2);
|
||||
// importing again adds nothing
|
||||
let r = s.execute("preset.import", &json!({"paths": paths})).unwrap();
|
||||
assert_eq!((r["imported"].as_array().unwrap().len(), r["skipped"].as_u64()), (0, Some(2)));
|
||||
// and the imported look applies
|
||||
let id = s.catalog.photos().next().unwrap().id;
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
let pid = s.presets.iter().find(|p| p.name == "Warm Fade").unwrap().id.clone();
|
||||
s.execute("preset.apply", &json!({"id": pid})).unwrap();
|
||||
let d = s.develop_of(id).unwrap();
|
||||
assert_eq!((d.light.exposure, d.light.contrast), (0.35, -20.0));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
//! Luminar looks: `.lmp` files and `.mplumpack` collections.
|
||||
//!
|
||||
//! An `.lmp` look is an Apple XML property list: a dict whose `AdjustmentLayers` array holds layers
|
||||
//! (`Identifier`, `Enabled`, `Amount` = opacity 0..1, `BlendModeIdentifier`, optional nested
|
||||
//! `Sublayers.AdjustmentLayers`), each with `Effects` (`Identifier` such as
|
||||
//! `MIPLDevelopCommonEffectID`, and `Parameters`: name → `{Value: number}`; parameters at their
|
||||
//! default are left out). Newer looks may be a macOS bundle directory `Name.lmp/Contents/preset.lmp`
|
||||
//! (plus `Info.plist` and LUT resources). An `.mplumpack` is a zip of `.lmp` files with a
|
||||
//! `PresetsInfo.plist` (`GroupName`) and icons.
|
||||
//!
|
||||
//! The look's sliders with a clear counterpart become `crs:` fields (mapped by [`crate::crs`]);
|
||||
//! everything else (AI tools, Orton, glow, LUTs…) is reported as unmapped. Sliders are on a
|
||||
//! −100..100 scale; Exposure is read as ±100 ↔ ±4 EV. Layer opacity scales additive sliders.
|
||||
//!
|
||||
//! Written from black-box inspection of freely distributed looks and the public property-list
|
||||
//! format; no Skylum code or look content was used.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::crs::Props;
|
||||
use crate::preset_import::{Imported, build, group_from_dir, read_zip};
|
||||
|
||||
// ----------------------------------------------------------------------------- property lists
|
||||
|
||||
/// A value of an XML property list.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Plist {
|
||||
Dict(Vec<(String, Plist)>),
|
||||
Array(Vec<Plist>),
|
||||
Str(String),
|
||||
Num(f64),
|
||||
Bool(bool),
|
||||
/// `<data>` (base64; not decoded)
|
||||
Data,
|
||||
}
|
||||
|
||||
impl Plist {
|
||||
pub fn get(&self, key: &str) -> Option<&Plist> {
|
||||
match self {
|
||||
Plist::Dict(m) => m.iter().find(|(k, _)| k == key).map(|(_, v)| v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn str(&self) -> Option<&str> {
|
||||
match self {
|
||||
Plist::Str(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn num(&self) -> Option<f64> {
|
||||
match self {
|
||||
Plist::Num(n) => Some(*n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn array(&self) -> &[Plist] {
|
||||
match self {
|
||||
Plist::Array(a) => a,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
s: &'a str,
|
||||
i: usize,
|
||||
/// Nesting of the element being read (bounded: a hostile file must not overflow the stack).
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
/// Deepest `<dict>` / `<array>` nesting read (real looks nest a handful of levels).
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
fn unescape(t: &str) -> String {
|
||||
let mut out = String::with_capacity(t.len());
|
||||
let mut rest = t;
|
||||
while let Some(p) = rest.find('&') {
|
||||
out.push_str(&rest[..p]);
|
||||
rest = &rest[p..];
|
||||
let Some(end) = rest.find(';') else { break };
|
||||
let ent = &rest[1..end];
|
||||
let c = match ent {
|
||||
"amp" => Some('&'),
|
||||
"lt" => Some('<'),
|
||||
"gt" => Some('>'),
|
||||
"quot" => Some('"'),
|
||||
"apos" => Some('\''),
|
||||
_ => ent
|
||||
.strip_prefix("#x")
|
||||
.or_else(|| ent.strip_prefix("#X"))
|
||||
.and_then(|h| u32::from_str_radix(h, 16).ok())
|
||||
.or_else(|| ent.strip_prefix('#').and_then(|d| d.parse().ok()))
|
||||
.and_then(char::from_u32),
|
||||
};
|
||||
match c {
|
||||
Some(c) => {
|
||||
out.push(c);
|
||||
rest = &rest[end + 1..];
|
||||
}
|
||||
None => {
|
||||
out.push('&');
|
||||
rest = &rest[1..];
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
impl Parser<'_> {
|
||||
fn err<T>(&self, what: &str) -> Result<T, String> {
|
||||
Err(format!("property list: {what} at byte {}", self.i))
|
||||
}
|
||||
/// Skip whitespace, the XML declaration, the doctype and comments.
|
||||
fn skip(&mut self) {
|
||||
loop {
|
||||
let r = &self.s[self.i..];
|
||||
let t = r.trim_start();
|
||||
self.i += r.len() - t.len();
|
||||
let close = if t.starts_with("<?") {
|
||||
"?>"
|
||||
} else if t.starts_with("<!--") {
|
||||
"-->"
|
||||
} else if t.starts_with("<!") {
|
||||
">"
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
match t.find(close) {
|
||||
Some(e) => self.i += e + close.len(),
|
||||
None => {
|
||||
self.i = self.s.len();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The next tag: (name, closing, self-closing).
|
||||
fn tag(&mut self) -> Result<(String, bool, bool), String> {
|
||||
self.skip();
|
||||
let r = &self.s[self.i..];
|
||||
if !r.starts_with('<') {
|
||||
return self.err("expected a tag");
|
||||
}
|
||||
let Some(end) = r.find('>') else { return self.err("unterminated tag") };
|
||||
let inner = &r[1..end];
|
||||
self.i += end + 1;
|
||||
let closing = inner.starts_with('/');
|
||||
let selfc = inner.ends_with('/');
|
||||
let name = inner.trim_start_matches('/').trim_end_matches('/').split_whitespace().next().unwrap_or("").to_string();
|
||||
Ok((name, closing, selfc))
|
||||
}
|
||||
fn peek_close(&mut self, name: &str) -> bool {
|
||||
self.skip();
|
||||
let r = &self.s[self.i..];
|
||||
let t = format!("</{name}>");
|
||||
if r.starts_with(&t) {
|
||||
self.i += t.len();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
fn text(&mut self, name: &str) -> Result<String, String> {
|
||||
let r = &self.s[self.i..];
|
||||
let t = format!("</{name}>");
|
||||
let Some(end) = r.find(&t) else { return self.err(&format!("missing {t}")) };
|
||||
self.i += end + t.len();
|
||||
Ok(unescape(&r[..end]))
|
||||
}
|
||||
fn value(&mut self) -> Result<Plist, String> {
|
||||
if self.depth >= MAX_DEPTH {
|
||||
return self.err("nested too deeply");
|
||||
}
|
||||
self.depth += 1;
|
||||
let v = self.value_inner();
|
||||
self.depth -= 1;
|
||||
v
|
||||
}
|
||||
fn value_inner(&mut self) -> Result<Plist, String> {
|
||||
let (name, closing, selfc) = self.tag()?;
|
||||
if closing {
|
||||
return self.err(&format!("unexpected </{name}>"));
|
||||
}
|
||||
let leaf = |p: &mut Self, f: &dyn Fn(String) -> Result<Plist, String>| if selfc { f(String::new()) } else { p.text(&name).and_then(f) };
|
||||
match name.as_str() {
|
||||
"plist" => {
|
||||
if selfc {
|
||||
return Ok(Plist::Dict(Vec::new()));
|
||||
}
|
||||
let v = self.value()?;
|
||||
self.peek_close("plist");
|
||||
Ok(v)
|
||||
}
|
||||
"dict" => {
|
||||
let mut m = Vec::new();
|
||||
if !selfc {
|
||||
while !self.peek_close("dict") {
|
||||
let (k, c, sc) = self.tag()?;
|
||||
if k != "key" || c {
|
||||
return self.err("expected <key>");
|
||||
}
|
||||
let key = if sc { String::new() } else { self.text("key")? };
|
||||
m.push((key, self.value()?));
|
||||
}
|
||||
}
|
||||
Ok(Plist::Dict(m))
|
||||
}
|
||||
"array" => {
|
||||
let mut a = Vec::new();
|
||||
if !selfc {
|
||||
while !self.peek_close("array") {
|
||||
a.push(self.value()?);
|
||||
}
|
||||
}
|
||||
Ok(Plist::Array(a))
|
||||
}
|
||||
"string" | "date" => leaf(self, &|t| Ok(Plist::Str(t))),
|
||||
"real" | "integer" => {
|
||||
leaf(self, &|t| t.trim().parse::<f64>().map(Plist::Num).map_err(|_| format!("property list: bad number {:?}", t.trim())))
|
||||
}
|
||||
"data" => leaf(self, &|_| Ok(Plist::Data)),
|
||||
"true" | "false" => {
|
||||
if !selfc {
|
||||
self.text(&name)?;
|
||||
}
|
||||
Ok(Plist::Bool(name == "true"))
|
||||
}
|
||||
_ => self.err(&format!("unknown element <{name}>")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an XML property list.
|
||||
pub fn parse_plist(bytes: &[u8]) -> Result<Plist, String> {
|
||||
if bytes.starts_with(b"bplist") {
|
||||
return Err("binary property lists are not supported".into());
|
||||
}
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let mut p = Parser { s: text.trim_start_matches('\u{feff}'), i: 0, depth: 0 };
|
||||
p.value()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------ mapping
|
||||
|
||||
/// Luminar's colour bands (`h`/`s`/`l` + band in the HSL tool) — the same eight as `crs:`.
|
||||
const BANDS: [&str; 8] = ["Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta"];
|
||||
|
||||
/// Effects whose parameters carry the basic tone / colour sliders under their own names.
|
||||
const BASIC: &[&str] = &[
|
||||
"MIPLDevelopCommonEffectID",
|
||||
"MIPLExposureEffect",
|
||||
"MIPLContrastEffect",
|
||||
"MIPLHighlightsEffect",
|
||||
"MIPLBlackWhiteEffect",
|
||||
"MIPLWhiteBalanceEffect",
|
||||
"MIPLSaturationEffect",
|
||||
"MIPLVibranceEffect",
|
||||
"MIPLClarityEffect",
|
||||
];
|
||||
|
||||
/// How one Luminar slider maps: the `crs:` field, a scale, whether it adds up across layers
|
||||
/// (and fades with layer opacity), and its clamp range.
|
||||
struct Target {
|
||||
crs: String,
|
||||
scale: f64,
|
||||
additive: bool,
|
||||
range: (f64, f64),
|
||||
}
|
||||
|
||||
fn target(effect: &str, param: &str, value: f64) -> Option<Target> {
|
||||
let t = |crs: &str, scale: f64, additive: bool, range: (f64, f64)| Some(Target { crs: crs.to_string(), scale, additive, range });
|
||||
let pm = (-100.0, 100.0);
|
||||
if BASIC.contains(&effect) {
|
||||
return match param {
|
||||
"Exposure" => t("Exposure2012", 4.0 / 100.0, true, (-5.0, 5.0)),
|
||||
"Contrast" => t("Contrast2012", 1.0, true, pm),
|
||||
"Highlights" => t("Highlights2012", 1.0, true, pm),
|
||||
"Shadows" => t("Shadows2012", 1.0, true, pm),
|
||||
"Whites" => t("Whites2012", 1.0, true, pm),
|
||||
"Blacks" => t("Blacks2012", 1.0, true, pm),
|
||||
// an absolute colour temperature (raw develop) vs. a relative shift
|
||||
"Temperature" if value.abs() > 1000.0 => t("Temperature", 1.0, false, (2000.0, 50000.0)),
|
||||
"Temperature" => t("IncrementalTemperature", 1.0, true, pm),
|
||||
"Tint" => t("IncrementalTint", 1.0, true, pm),
|
||||
"Saturation" => t("Saturation", 1.0, true, pm),
|
||||
"Vibrance" => t("Vibrance", 1.0, true, pm),
|
||||
"Clarity" => t("Clarity2012", 1.0, true, pm),
|
||||
"Dehaze" => t("Dehaze", 1.0, true, pm),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
match (effect, param) {
|
||||
("MIPLAIStructureEffect" | "MIPLStructureEffect", "Amount") => t("Clarity2012", 1.0, true, pm),
|
||||
("MIPLDehazeEffect", "Amount") => t("Dehaze", 1.0, true, pm),
|
||||
("MIPLVignetteEffect", "Amount") => t("PostCropVignetteAmount", 1.0, true, pm),
|
||||
("MIPLVignetteEffect", "Vignette Size" | "Size") => t("PostCropVignetteMidpoint", 1.0, false, (0.0, 100.0)),
|
||||
("MIPLGrainNewEffect" | "MIPLGrainEffect", "Amount") => t("GrainAmount", 1.0, true, (0.0, 100.0)),
|
||||
("MIPLChannelsEffect" | "MIPLHSLEffect", _) => {
|
||||
let (kind, band) = param.split_at(param.char_indices().nth(1).map_or(param.len(), |(i, _)| i));
|
||||
let field = match kind {
|
||||
"h" => "HueAdjustment",
|
||||
"s" => "SaturationAdjustment",
|
||||
"l" => "LuminanceAdjustment",
|
||||
_ => return None,
|
||||
};
|
||||
BANDS.contains(&band).then(|| Target { crs: format!("{field}{band}"), scale: 1.0, additive: true, range: pm })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A point curve stored as `OptionalData`: a leading value, then x, y pairs in 0..1 with x
|
||||
/// increasing. Anything else isn't read.
|
||||
fn curve_points(param: &Plist) -> Option<Vec<(f64, f64)>> {
|
||||
let data: Vec<f64> = param.get("OptionalData")?.array().iter().map(Plist::num).collect::<Option<_>>()?;
|
||||
if data.len() < 5 || data.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
let pts: Vec<(f64, f64)> = data[1..].chunks(2).map(|c| (c[0], c[1])).collect();
|
||||
let ok = pts.iter().all(|&(x, y)| (0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y)) && pts.windows(2).all(|w| w[1].0 > w[0].0);
|
||||
ok.then_some(pts)
|
||||
}
|
||||
|
||||
/// `MIPLSmartContrastEffect` → `SmartContrast`.
|
||||
fn short(effect: &str) -> &str {
|
||||
let e = effect.strip_prefix("MIPL").unwrap_or(effect);
|
||||
["_v1_Effect", "EffectID", "Effect"].iter().find_map(|s| e.strip_suffix(s)).unwrap_or(e)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Acc {
|
||||
sums: BTreeMap<String, (f64, (f64, f64))>,
|
||||
curves: BTreeMap<String, Vec<(f64, f64)>>,
|
||||
unmapped: Vec<String>,
|
||||
}
|
||||
|
||||
impl Acc {
|
||||
fn skip(&mut self, what: String) {
|
||||
if !self.unmapped.contains(&what) {
|
||||
self.unmapped.push(what);
|
||||
}
|
||||
}
|
||||
|
||||
fn layers(&mut self, layers: &[Plist], weight: f64) {
|
||||
for layer in layers {
|
||||
if matches!(layer.get("Enabled"), Some(Plist::Bool(false))) {
|
||||
continue;
|
||||
}
|
||||
let opacity = weight * layer.get("Amount").and_then(Plist::num).unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
if opacity <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let blend = layer.get("BlendModeIdentifier").and_then(Plist::str).unwrap_or("Normal");
|
||||
// a layer with a mask applies locally: not a global look
|
||||
let empty = |v: &Plist| {
|
||||
matches!(v, Plist::Bool(false)) || matches!(v, Plist::Dict(d) if d.is_empty()) || matches!(v, Plist::Array(a) if a.is_empty())
|
||||
};
|
||||
let masked = matches!(layer, Plist::Dict(m) if m.iter().any(|(k, v)| k.to_ascii_lowercase().contains("mask") && !empty(v)));
|
||||
let literal = blend.eq_ignore_ascii_case("normal") && !masked;
|
||||
let mut reported = false;
|
||||
for effect in layer.get("Effects").map(Plist::array).unwrap_or_default() {
|
||||
let id = effect.get("Identifier").and_then(Plist::str).unwrap_or("?");
|
||||
let Some(Plist::Dict(params)) = effect.get("Parameters") else { continue };
|
||||
for (name, pv) in params {
|
||||
let value = pv.get("Value").and_then(Plist::num).or(pv.num());
|
||||
if literal && id == "MIPLCurveEffect" {
|
||||
let crs = match name.as_str() {
|
||||
"RGB" => Some("ToneCurvePV2012"),
|
||||
"Red" => Some("ToneCurvePV2012Red"),
|
||||
"Green" => Some("ToneCurvePV2012Green"),
|
||||
"Blue" => Some("ToneCurvePV2012Blue"),
|
||||
_ => None,
|
||||
};
|
||||
if let (Some(crs), Some(pts)) = (crs, curve_points(pv))
|
||||
&& !self.curves.contains_key(crs)
|
||||
{
|
||||
// a curve at partial opacity: the points fade toward the identity
|
||||
self.curves.insert(crs.to_string(), pts.into_iter().map(|(x, y)| (x, x + (y - x) * opacity)).collect());
|
||||
continue;
|
||||
}
|
||||
if pv.get("OptionalData").is_some() {
|
||||
self.skip(format!("{}.{name}", short(id)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(v) = value.filter(|v| *v != 0.0) else { continue };
|
||||
match target(id, name, v).filter(|_| literal) {
|
||||
Some(t) => {
|
||||
let x = v * t.scale * if t.additive { opacity } else { 1.0 };
|
||||
let e = self.sums.entry(t.crs).or_insert((0.0, t.range));
|
||||
e.0 = if t.additive { e.0 + x } else { x };
|
||||
}
|
||||
None => {
|
||||
reported = true;
|
||||
self.skip(format!("{}.{name}", short(id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !literal && reported {
|
||||
let lid = layer.get("Identifier").and_then(Plist::str).unwrap_or("layer");
|
||||
self.skip(if masked { format!("{lid}: mask") } else { format!("{lid}: blend mode {blend}") });
|
||||
}
|
||||
if let Some(sub) = layer.get("Sublayers").and_then(|s| s.get("AdjustmentLayers")) {
|
||||
self.layers(sub.array(), opacity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of a look stored at `path`: its file name, or the bundle's for
|
||||
/// `Name.lmp/Contents/preset.lmp`.
|
||||
fn look_name(path: &str) -> String {
|
||||
let p = Path::new(path);
|
||||
let stem = |p: &Path| p.file_stem().map(|s| s.to_string_lossy().to_string());
|
||||
let bundle = p.parent().filter(|d| d.file_name().is_some_and(|n| n.eq_ignore_ascii_case("Contents"))).and_then(Path::parent);
|
||||
match bundle {
|
||||
Some(b) if b.extension().is_some_and(|e| e.eq_ignore_ascii_case("lmp")) => stem(b),
|
||||
_ => stem(p),
|
||||
}
|
||||
.unwrap_or_else(|| "Look".into())
|
||||
}
|
||||
|
||||
/// Read one Luminar look (`.lmp`) into a preset in `group` (default "Imported Presets").
|
||||
pub fn read_lmp(path: &str, bytes: &[u8], group: Option<String>) -> Result<Imported, String> {
|
||||
let root = parse_plist(bytes)?;
|
||||
let layers = root.get("AdjustmentLayers").ok_or("not a Luminar look (no adjustment layers)")?;
|
||||
let mut acc = Acc::default();
|
||||
acc.layers(layers.array(), 1.0);
|
||||
let mut props = Props::new();
|
||||
for (k, (v, (lo, hi))) in &acc.sums {
|
||||
props.insert(format!("crs:{k}"), vec![format!("{}", v.clamp(*lo, *hi))]);
|
||||
}
|
||||
for (k, pts) in &acc.curves {
|
||||
props.insert(format!("crs:{k}"), pts.iter().map(|(x, y)| format!("{}, {}", x * 255.0, y * 255.0)).collect());
|
||||
}
|
||||
if let Some(u) = root.get("uuid").and_then(Plist::str).filter(|u| !u.trim().is_empty()) {
|
||||
props.insert("crs:UUID".into(), vec![u.trim().to_string()]);
|
||||
}
|
||||
let name = root.get("Name").and_then(Plist::str).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| look_name(path));
|
||||
match build(&props, &Default::default(), name, group, false) {
|
||||
Some(mut i) => {
|
||||
i.preset.id = i.preset.id.replacen("user.xmp.", "user.lmp.", 1);
|
||||
i.unmapped.extend(acc.unmapped);
|
||||
Ok(i)
|
||||
}
|
||||
None if acc.unmapped.is_empty() => Err("this look has no adjustments".into()),
|
||||
None => Err(format!("none of this look's settings have a counterpart here ({})", acc.unmapped.join(", "))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a Luminar looks collection (`.mplumpack`, a zip): every look, grouped by the pack's
|
||||
/// `GroupName` (else the pack's file name).
|
||||
pub fn read_mplumpack(path: &str, bytes: &[u8]) -> Result<Vec<Imported>, String> {
|
||||
let entries = read_zip(bytes)?;
|
||||
let info = entries
|
||||
.iter()
|
||||
.find(|(n, _)| n.rsplit('/').next().is_some_and(|b| b.eq_ignore_ascii_case("PresetsInfo.plist")))
|
||||
.and_then(|(_, b)| parse_plist(b).ok())
|
||||
.and_then(|p| p.get("GroupName").and_then(Plist::str).map(str::trim).filter(|g| !g.is_empty()).map(str::to_string));
|
||||
let stem = Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string());
|
||||
let pack = info.or_else(|| stem.as_deref().and_then(group_from_dir)).unwrap_or_else(|| "Luminar Looks".into());
|
||||
let mut out = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
for (inner, data) in &entries {
|
||||
if !Path::new(inner).extension().is_some_and(|e| e.eq_ignore_ascii_case("lmp")) {
|
||||
continue;
|
||||
}
|
||||
match read_lmp(inner, data, Some(pack.clone())) {
|
||||
Ok(i) => out.push(i),
|
||||
Err(e) => errors.push(format!("{inner}: {e}")),
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(if errors.is_empty() { "no looks in this collection".into() } else { errors.join("; ") });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::preset_import::read_presets;
|
||||
use serde_json::json;
|
||||
|
||||
/// One effect's parameter as Luminar writes it.
|
||||
fn param(name: &str, v: f64) -> String {
|
||||
format!("<key>{name}</key><dict><key>OptionalDataType</key><integer>0</integer><key>Value</key><real>{v}</real></dict>")
|
||||
}
|
||||
|
||||
fn layer(id: &str, amount: f64, blend: &str, effects: &[(&str, String)], sub: &str) -> String {
|
||||
let fx: String = effects
|
||||
.iter()
|
||||
.map(|(e, p)| format!("<dict><key>Identifier</key><string>{e}</string><key>Parameters</key><dict>{p}</dict></dict>"))
|
||||
.collect();
|
||||
let sub =
|
||||
if sub.is_empty() { String::new() } else { format!("<key>Sublayers</key><dict><key>AdjustmentLayers</key><array>{sub}</array></dict>") };
|
||||
format!(
|
||||
"<dict><key>Amount</key><real>{amount}</real><key>BlendModeIdentifier</key><string>{blend}</string><key>Effects</key><array>{fx}</array>\
|
||||
<key>Enabled</key><true/><key>Identifier</key><string>{id}</string>{sub}</dict>"
|
||||
)
|
||||
}
|
||||
|
||||
fn look(layers: &str, extra: &str) -> String {
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
|
||||
<plist version=\"1.0\"><dict><key>AdjustmentLayers</key><array>{layers}</array>{extra}<key>group_identifier</key><string>Custom</string></dict></plist>"
|
||||
)
|
||||
}
|
||||
|
||||
/// A newer-style look (develop sliders nested in sublayers) written for this test.
|
||||
fn modern() -> String {
|
||||
let curve = "<key>RGB</key><dict><key>OptionalData</key><array><real>0.5</real><real>0</real><real>0.1</real><real>0.5</real><real>0.5</real><real>1</real><real>0.9</real></array><key>OptionalDataType</key><integer>1</integer><key>Value</key><real>50</real></dict>";
|
||||
let sub = [
|
||||
layer(
|
||||
"DevelopAdjustmentSubLayer",
|
||||
1.0,
|
||||
"Normal",
|
||||
&[(
|
||||
"MIPLDevelopCommonEffectID",
|
||||
param("Exposure", 25.0) + ¶m("Contrast", 12.0) + ¶m("Highlights", -30.0) + ¶m("Temperature", 15.0),
|
||||
)],
|
||||
&layer("CurveLayer", 1.0, "Normal", &[("MIPLCurveEffect", curve.to_string())], ""),
|
||||
),
|
||||
layer("Disabled", 1.0, "Normal", &[("MIPLDevelopCommonEffectID", param("Shadows", 50.0))], "").replace("<true/>", "<false/>"),
|
||||
]
|
||||
.concat();
|
||||
let layers = [
|
||||
layer("DevelopAdjustmentLayer", 1.0, "Normal", &[], &sub),
|
||||
layer("AIStructureEffect", 1.0, "Normal", &[("MIPLAIStructureEffect", param("Amount", 20.0) + ¶m("Boost", 10.0))], ""),
|
||||
layer(
|
||||
"Half",
|
||||
0.5,
|
||||
"Normal",
|
||||
&[("MIPLVibranceEffect", param("Vibrance", 30.0)), ("MIPLSaturationEffect", param("Saturation", -10.0))],
|
||||
"",
|
||||
),
|
||||
layer("Hsl", 1.0, "Normal", &[("MIPLChannelsEffect", param("hOrange", -8.0) + ¶m("sBlue", 12.0) + ¶m("lGreen", 4.0))], ""),
|
||||
layer("Vignette", 1.0, "Normal", &[("MIPLVignetteEffect", param("Amount", -20.0) + ¶m("Vignette Size", 40.0))], ""),
|
||||
layer("Orton", 1.0, "Normal", &[("MIPLOrtonFilterEffect", param("Amount", 15.0))], ""),
|
||||
layer("Screened", 1.0, "Screen", &[("MIPLContrastEffect", param("Contrast", 40.0))], ""),
|
||||
layer("Untouched", 1.0, "Normal", &[("MIPLSkyEnhancerEffect", String::new())], ""),
|
||||
]
|
||||
.concat();
|
||||
look(&layers, "<key>uuid</key><string>AB12-CD34</string><key>kMPPresetIdentifierKey</key><string>x 2.lmp</string>")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plists_parse() {
|
||||
let p = parse_plist(b"<?xml version=\"1.0\"?><!-- c --><plist><dict><key>a&b</key><string>x <yA</string><key>n</key><integer>-3</integer><key>e</key><dict/><key>t</key><true/><key>d</key><data>AAEC</data><key>s</key><string/></dict></plist>").unwrap();
|
||||
assert_eq!(p.get("a&b").and_then(Plist::str), Some("x <yA"));
|
||||
assert_eq!(p.get("n").and_then(Plist::num), Some(-3.0));
|
||||
assert_eq!(p.get("e"), Some(&Plist::Dict(vec![])));
|
||||
assert_eq!(p.get("t"), Some(&Plist::Bool(true)));
|
||||
assert_eq!(p.get("d"), Some(&Plist::Data));
|
||||
assert_eq!(p.get("s").and_then(Plist::str), Some(""));
|
||||
assert!(parse_plist(b"<plist><dict><key>a</key>").is_err());
|
||||
assert!(parse_plist(b"bplist00\x01\x02").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_nesting_is_an_error_not_a_crash() {
|
||||
let deep = format!("<plist>{}{}</plist>", "<array>".repeat(200_000), "</array>".repeat(200_000));
|
||||
assert!(parse_plist(deep.as_bytes()).unwrap_err().contains("nested too deeply"));
|
||||
let ok = format!("<plist>{}<true/>{}</plist>", "<array>".repeat(20), "</array>".repeat(20));
|
||||
assert!(parse_plist(ok.as_bytes()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn look_sliders_map_and_the_rest_is_reported() {
|
||||
let i = read_presets("/x/Soft Contrast.lmp", modern().as_bytes(), None).unwrap().remove(0);
|
||||
let p = &i.preset;
|
||||
assert_eq!((p.name.as_str(), p.group.as_str(), p.id.as_str()), ("Soft Contrast", "Imported Presets", "user.lmp.ab12-cd34"));
|
||||
let s = &p.settings;
|
||||
assert_eq!(s["light"]["exposure"], json!(1.0), "±100 ↔ ±4 EV");
|
||||
assert_eq!(s["light"]["contrast"], json!(12.0));
|
||||
assert_eq!(s["light"]["highlights"], json!(-30.0));
|
||||
assert!(s["light"].get("shadows").is_none(), "disabled layers are ignored");
|
||||
assert_eq!(s["effects"]["clarity"], json!(20.0), "Structure → clarity");
|
||||
assert_eq!(s["color"]["vibrance"], json!(15.0), "layer opacity scales the slider");
|
||||
assert_eq!(s["color"]["saturation"], json!(-5.0));
|
||||
assert_eq!(s["mixer"]["orange"]["hue"], json!(-8.0));
|
||||
assert_eq!(s["mixer"]["blue"]["sat"], json!(12.0));
|
||||
assert_eq!(s["mixer"]["green"]["lum"], json!(4.0));
|
||||
assert_eq!(s["vignette"]["amount"], json!(-20.0));
|
||||
assert_eq!(s["vignette"]["midpoint"], json!(40.0));
|
||||
assert!(s["wb"]["temp"].as_f64().unwrap() > 6500.0, "a warm shift: {}", s["wb"]);
|
||||
let c = s["curve"]["master"].as_array().unwrap();
|
||||
assert_eq!(c.len(), 3);
|
||||
assert!((c[0]["y"].as_f64().unwrap() - 0.1).abs() < 1e-9 && (c[2]["y"].as_f64().unwrap() - 0.9).abs() < 1e-9);
|
||||
assert_eq!(i.unmapped, ["AIStructure.Boost", "OrtonFilter.Amount", "Contrast.Contrast", "Screened: blend mode Screen"]);
|
||||
assert!(s["light"]["contrast"] == json!(12.0), "the screen-blended layer isn't folded in");
|
||||
lightcraft_develop::DevelopSettings::default().merged(s).expect("valid develop settings");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_looks_and_bundles() {
|
||||
// the older flat layout: one layer per tool, the look's name inside
|
||||
let layers = [
|
||||
layer(
|
||||
"ToneLayer",
|
||||
1.0,
|
||||
"Normal",
|
||||
&[("MIPLExposureEffect", param("Exposure", 0.0)), ("MIPLHighlightsEffect", param("Shadows", 10.0))],
|
||||
"",
|
||||
),
|
||||
layer("ClarityLayer", 1.0, "Normal", &[("MIPLClarityEffect", param("Clarity", 20.0))], ""),
|
||||
layer("Wb", 1.0, "Normal", &[("MIPLWhiteBalanceEffect", param("Temperature", -15.0) + ¶m("Tint", 10.0))], ""),
|
||||
]
|
||||
.concat();
|
||||
let lmp = look(&layers, "<key>Name</key><string>Blue Hour</string>");
|
||||
let i = read_presets("whatever.lmp", lmp.as_bytes(), Some("Mine".into())).unwrap().remove(0);
|
||||
assert_eq!((i.preset.name.as_str(), i.preset.group.as_str()), ("Blue Hour", "Mine"));
|
||||
assert_eq!(i.preset.settings["light"], json!({"shadows": 10.0}));
|
||||
assert_eq!(i.preset.settings["effects"]["clarity"], json!(20.0));
|
||||
assert_eq!(i.preset.settings["wb"]["tint"], json!(10.0));
|
||||
assert!(i.preset.settings["wb"]["temp"].as_f64().unwrap() < 6500.0);
|
||||
assert!(i.unmapped.is_empty(), "{:?}", i.unmapped);
|
||||
// a bundle's inner file is named after the bundle
|
||||
let b = read_presets("/p/Vintage.lmp/Contents/preset.lmp", modern().as_bytes(), None).unwrap();
|
||||
assert_eq!(b[0].preset.name, "Vintage");
|
||||
// nothing we can carry over: an error that says what was there
|
||||
let only = look(&layer("Orton", 1.0, "Normal", &[("MIPLOrtonFilterEffect", param("Amount", 15.0))], ""), "");
|
||||
let e = read_presets("Glow.lmp", only.as_bytes(), None).unwrap_err();
|
||||
assert!(e.contains("OrtonFilter.Amount"), "{e}");
|
||||
assert!(read_presets("x.lmp", b"<plist><dict/></plist>", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packs_group_by_their_name() {
|
||||
use crate::preset_import::tests::zip;
|
||||
let a = look(&layer("C", 1.0, "Normal", &[("MIPLClarityEffect", param("Clarity", 20.0))], ""), "<key>Name</key><string>Pop 1</string>");
|
||||
let b = look(&layer("S", 1.0, "Normal", &[("MIPLSaturationEffect", param("Saturation", 25.0))], ""), "");
|
||||
let info = b"<?xml version=\"1.0\"?><plist version=\"1.0\"><dict><key>GroupName</key><string>Magic Light</string></dict></plist>";
|
||||
let pack = zip(
|
||||
&[
|
||||
("Pop 1.lmp", a.as_bytes()),
|
||||
("__MACOSX/._Pop 1.lmp", b"junk"),
|
||||
("Pop 2.lmp", b.as_bytes()),
|
||||
("icon@2x.png", b"\x89PNG"),
|
||||
("PresetsInfo.plist", info),
|
||||
],
|
||||
true,
|
||||
);
|
||||
let v = read_presets("/d/Magic Light-2.mplumpack", &pack, None).unwrap();
|
||||
let got: Vec<_> = v.iter().map(|i| (i.preset.name.as_str(), i.preset.group.as_str())).collect();
|
||||
assert_eq!(got, [("Pop 1", "Magic Light"), ("Pop 2", "Magic Light")]);
|
||||
// without PresetsInfo.plist the pack's file name is the group
|
||||
let v = read_presets("Seaside Looks.mplumpack", &zip(&[("Pop 2.lmp", b.as_bytes())], false), None).unwrap();
|
||||
assert_eq!(v[0].preset.group, "Seaside Looks");
|
||||
// a pack inside a downloaded zip, and bundle-style looks in a zip folder
|
||||
let outer = zip(&[("Archive/Magic Light-2.mplumpack", &pack), ("Archive/Magic Light.plist", b"<plist/>")], false);
|
||||
assert_eq!(read_presets("download.zip", &outer, None).unwrap().len(), 2);
|
||||
let bundles = zip(
|
||||
&[
|
||||
("Wild Pack/Vintage.lmp/Contents/preset.lmp", modern().as_bytes()),
|
||||
("Wild Pack/Vintage.lmp/Contents/Info.plist", b"<plist><dict/></plist>"),
|
||||
("Wild Pack/Flat.lmp", a.as_bytes()),
|
||||
],
|
||||
true,
|
||||
);
|
||||
let v = read_presets("wild.zip", &bundles, None).unwrap();
|
||||
let got: Vec<_> = v.iter().map(|i| (i.preset.name.as_str(), i.preset.group.as_str())).collect();
|
||||
assert_eq!(got, [("Vintage", "Wild Pack"), ("Pop 1", "Wild Pack")]);
|
||||
assert!(read_presets("empty.mplumpack", &zip(&[("icon.png", b"x")], false), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_command_reads_looks_and_bundle_folders() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-luminar-import-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("Wild/Vintage.lmp/Contents/resources")).unwrap();
|
||||
std::fs::write(dir.join("Wild/Vintage.lmp/Contents/preset.lmp"), modern()).unwrap();
|
||||
std::fs::write(dir.join("Wild/Vintage.lmp/Contents/Info.plist"), "<plist><dict/></plist>").unwrap();
|
||||
std::fs::write(dir.join("Wild/Vintage.lmp/Contents/PkgInfo"), "LMP?????").unwrap();
|
||||
let mut s = crate::Session::with_demo();
|
||||
let r = s.execute("preset.import", &json!({"paths": [dir.join("Wild").to_string_lossy()]})).unwrap();
|
||||
let im = &r["imported"][0];
|
||||
assert_eq!((im["name"].as_str(), im["group"].as_str()), (Some("Vintage"), Some("Wild")), "{r}");
|
||||
assert!(im["unmapped"].as_array().unwrap().iter().any(|u| u == "OrtonFilter.Amount"));
|
||||
// a dropped bundle folder on its own
|
||||
let r = s.execute("preset.import", &json!({"paths": [dir.join("Wild/Vintage.lmp").to_string_lossy()], "dryRun": true})).unwrap();
|
||||
assert_eq!(r["imported"][0]["group"], json!("Imported Presets"), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user