Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc3fa1a71b | ||
|
|
d0612b0b21 | ||
|
|
66dc737ff5 | ||
|
|
fec3b15234 | ||
|
|
11ebb3bdb6 | ||
|
|
65da36675f | ||
|
|
cbb6c3b8f7 | ||
|
|
5b414ffb8e | ||
|
|
f7a7831380 | ||
|
|
2b2ade7a23 | ||
|
|
d6c7228dd4 | ||
|
|
4b3048c48f | ||
|
|
bc4799ca42 | ||
|
|
586479a309 | ||
|
|
87f275b58e | ||
|
|
894759e6d8 | ||
|
|
1952f7c960 | ||
|
|
7bbdbc03ef | ||
|
|
0f34a65a8c | ||
|
|
6c066b66f2 | ||
|
|
7b59081d22 | ||
|
|
e11f77d9ca | ||
|
|
f383d1e736 | ||
|
|
edf05789ed | ||
|
|
bccccfb92a | ||
|
|
4939d9b994 | ||
|
|
e0ab41ede0 | ||
|
|
8690b51fcf | ||
|
|
e1d3b56bb6 | ||
|
|
6100237cdb | ||
|
|
4579599745 | ||
|
|
8506fecf5f | ||
|
|
0f49ebf8f0 | ||
|
|
ab86862011 | ||
|
|
577330a08b | ||
|
|
30697cfc26 | ||
|
|
ddbf139e5e | ||
|
|
ee77490dd2 | ||
|
|
f31a55c8a7 | ||
|
|
7218b990f5 | ||
|
|
d16d9aadeb | ||
|
|
1caa04acbc | ||
|
|
bef2060b24 | ||
|
|
40303b999c | ||
|
|
ce5488ad99 | ||
|
|
f6bc1ae5ae | ||
|
|
6c8a65c337 | ||
|
|
fe386f4f15 | ||
|
|
a6f421aafb | ||
|
|
50cd8dc6dc | ||
|
|
cc8b3eb3bb | ||
|
|
a6b402981b | ||
|
|
4deb5eff04 | ||
|
|
6e2408ff7d | ||
|
|
66be31e3d8 | ||
|
|
332f37a5c7 | ||
|
|
aa1af6d5e9 | ||
|
|
963912b231 | ||
|
|
4174ce8c57 | ||
|
|
f28e1c362e | ||
|
|
70d800ba68 | ||
|
|
33ef91a123 | ||
|
|
f1b29cfe4a | ||
|
|
130b807e1b | ||
|
|
43f4e24355 | ||
|
|
5e618c553a | ||
|
|
cbebabccad | ||
|
|
08c2acda26 | ||
|
|
1a05c3eed2 | ||
|
|
ee4b78f646 | ||
|
|
d592b29d12 | ||
|
|
cddcb905e0 | ||
|
|
ebc73bb7b4 | ||
|
|
e96a2750a1 | ||
|
|
509d5843dd | ||
|
|
771b0b98d7 | ||
|
|
f4df914731 | ||
|
|
882a059fac | ||
|
|
46732cb0fe | ||
|
|
05f242c445 | ||
|
|
572b55a44a | ||
|
|
bd10f89d02 | ||
|
|
fb5b18843f | ||
|
|
dfbb1ad935 | ||
|
|
87e0181690 | ||
|
|
0fd0ffb783 | ||
|
|
e99aa61fc4 | ||
|
|
58626b982d | ||
|
|
7c799a9acf | ||
|
|
e00025aaf6 | ||
|
|
294012742e | ||
|
|
4d13061580 | ||
|
|
63380829b1 | ||
|
|
62e16d7adc | ||
|
|
d47e941a0c | ||
|
|
83fb206a29 | ||
|
|
3567980442 | ||
|
|
8cd791100b | ||
|
|
ff1e51e4d4 | ||
|
|
c05f2de251 | ||
|
|
e87ee8e36d | ||
|
|
efef3a5df1 | ||
|
|
b50fbf1285 | ||
|
|
92bd3aa688 | ||
|
|
6592e62912 | ||
|
|
8532d4ea56 | ||
|
|
2218ca5a4c | ||
|
|
14053b2e40 | ||
|
|
1f2f25356a | ||
|
|
2ed6af155d | ||
|
|
b14cf58630 | ||
|
|
104444edcb | ||
|
|
35e38661a7 |
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
|
||||
@@ -47,6 +47,10 @@ jobs:
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/lightcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
|
||||
if grep -oE '<Icon Id="[^"]*"' packaging/windows/lightcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
desktop-file-validate packaging/linux/ai.storyteller.lightcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
|
||||
|
||||
@@ -221,8 +221,9 @@ jobs:
|
||||
- name: Install wasm-bindgen CLI
|
||||
run: |
|
||||
# Must match the wasm-bindgen version in Cargo.lock exactly (cargo xtask web checks it).
|
||||
command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION" \
|
||||
|| cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
|
||||
if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION"; }; then
|
||||
cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
|
||||
fi
|
||||
wasm-bindgen --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -86,6 +91,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
- 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).
|
||||
|
||||
@@ -86,6 +91,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
- 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
+24
-22
@@ -2073,7 +2073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -2091,7 +2091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-catalog"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"lightcraft-develop",
|
||||
@@ -2105,7 +2105,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"dhat",
|
||||
"lightcraft-codecs",
|
||||
@@ -2120,7 +2120,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-codecs"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"image",
|
||||
@@ -2145,7 +2145,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-color"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"proptest",
|
||||
@@ -2154,7 +2154,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-develop"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
@@ -2165,7 +2165,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-engine"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"libc",
|
||||
@@ -2188,11 +2188,12 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-geom"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -2200,7 +2201,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-gpu"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"lightcraft-color",
|
||||
@@ -2216,7 +2217,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-mcp"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-engine",
|
||||
@@ -2227,7 +2228,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-merge"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-color",
|
||||
@@ -2243,7 +2244,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-meta"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"lightcraft-tiff",
|
||||
@@ -2255,7 +2256,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-pipeline"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -2271,7 +2272,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-preview"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"lightcraft-raster",
|
||||
@@ -2283,7 +2284,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raster"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"lightcraft-color",
|
||||
@@ -2295,7 +2296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raw"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"lightcraft-color",
|
||||
@@ -2314,7 +2315,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-scenes"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -2325,11 +2326,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-sysmem"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-tiff"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -2338,7 +2339,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-ui-egui"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"lightcraft-catalog",
|
||||
@@ -2358,8 +2359,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-web"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
@@ -5126,7 +5128,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"flate2",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
||||
@@ -14,3 +14,7 @@ list (author, source, license) is in assets/ATTRIBUTION.md.
|
||||
|
||||
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>
|
||||
|
||||
+101
-20
@@ -4,6 +4,69 @@ 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 measured camera calibration database**: ARW has a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. 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; Japanese/English UI (see docs/localization-ja.md); remaining technical errors and other languages; 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 fidelity and coverage (ARW preview estimates are only a starting point) |
|
||||
| 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. Sony ARW's file-local fit (matrix + tone curve from its own
|
||||
JPEG) is the first step; generalise it to the other makes' raws (NEF, RW2, PEF, ORF…), then validate fidelity.
|
||||
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 +74,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,24 +82,27 @@ 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** 🚧 (guarded ARW preview fitting; measured database still missing), 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 ⬜) |
|
||||
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; Japanese/English localisation 🟡; accessibility and other locales ⬜) |
|
||||
|
||||
## Parity estimate (2026-10-02, evening update)
|
||||
## 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 | Morning |
|
||||
| Scope | Weighted completion | Rows | 2026-10-02 |
|
||||
|---|---:|---:|---:|
|
||||
| P0 (core) | **98.7%** | 198 | 98.5% |
|
||||
| P1 (important parity) | **94.4%** | 144 | 79.9% |
|
||||
| P2 (later / AI / niche, incl. Classic modules) | **29.4%** | 158 | 11.7% |
|
||||
| **All in-scope rows** | **75.6%** | 500 | 65.7% |
|
||||
| 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.
|
||||
|
||||
@@ -50,7 +116,7 @@ trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel
|
||||
| Work package | Tracker rows | Agent-hours | Risk |
|
||||
|---|---|---:|---|
|
||||
| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low |
|
||||
| Raw codecs: CR3 (CRX), compressed NEF / ORF / RAF, RW2 v4, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
|
||||
| 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 |
|
||||
@@ -86,30 +152,31 @@ 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, lossy JPEG / Smart Previews, 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.
|
||||
- **Olympus compressed ORF**, **Fujifilm compressed RAF**, **Canon CR3/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
|
||||
|
||||
**Camera colour matrices:** non-DNG raws use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
**Camera colour matrices:** ARW files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
|
||||
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax/Panasonic equivalents) and our
|
||||
own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
@@ -119,3 +186,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.
|
||||
@@ -91,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).
|
||||
@@ -182,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
|
||||
}
|
||||
@@ -419,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(),
|
||||
@@ -533,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(())
|
||||
@@ -580,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(),
|
||||
@@ -593,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);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ lightcraft-preview = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
crc32fast = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
eframe = { workspace = true, features = ["default_fonts", "glow"] }
|
||||
|
||||
@@ -232,6 +232,18 @@ impl Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is there a file at `path`?
|
||||
pub async fn exists(&self, path: &str) -> Result<bool, String> {
|
||||
match self {
|
||||
Backend::Opfs(root) => Ok(Self::opfs_file(root, path, false).await?.is_some()),
|
||||
Backend::Idb(db) => {
|
||||
let (_tx, store) = Self::idb_store(db, false)?;
|
||||
let n = idb_req(&store.count_with_key(&path.into()).map_err(err)?).await?;
|
||||
Ok(n.as_f64().is_some_and(|n| n > 0.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove(&self, path: &str) -> Result<(), String> {
|
||||
match self {
|
||||
Backend::Opfs(root) => {
|
||||
@@ -309,14 +321,25 @@ async fn dir_keys(dir: &FileSystemDirectoryHandle) -> Result<Vec<String>, String
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Ask the browser not to evict our storage under pressure (best effort; fire and forget).
|
||||
pub fn request_persistence() {
|
||||
if let Some(sm) = storage_manager()
|
||||
&& let Ok(p) = sm.persist()
|
||||
{
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let granted = JsFuture::from(p).await.ok().is_some_and(|v| v.is_truthy());
|
||||
log::info!("lightcraft: persistent storage {}", if granted { "granted" } else { "not granted (best effort)" });
|
||||
});
|
||||
}
|
||||
/// Ask the browser not to evict our storage under pressure; `on_result(granted)` once known
|
||||
/// (`false` also when the browser has no storage manager).
|
||||
pub fn request_persistence(on_result: impl FnOnce(bool) + 'static) {
|
||||
let Some(sm) = storage_manager() else {
|
||||
on_result(false);
|
||||
return;
|
||||
};
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// already persistent: nothing to ask
|
||||
let already = match sm.persisted() {
|
||||
Ok(p) => JsFuture::from(p).await.ok().is_some_and(|v| v.is_truthy()),
|
||||
Err(_) => false,
|
||||
};
|
||||
let granted = already
|
||||
|| match sm.persist() {
|
||||
Ok(p) => JsFuture::from(p).await.ok().is_some_and(|v| v.is_truthy()),
|
||||
Err(_) => false,
|
||||
};
|
||||
log::info!("lightcraft: persistent storage {}", if granted { "granted" } else { "not granted" });
|
||||
on_result(granted);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
//! Library backups for the browser build: a plain zip (entries stored, not compressed — photos
|
||||
//! don't shrink) of the library files and every stored original, built one entry at a time so
|
||||
//! only one file is in wasm memory at once, and read back the same way for a restore.
|
||||
//!
|
||||
//! Layout of a backup:
|
||||
//! - `library/catalog.snap`, `library/catalog.log`, `library/presets.json`, … (see
|
||||
//! [`crate::files::LIBRARY_FILES`]);
|
||||
//! - `originals/<content hash>/<file name>`: each imported photo's original bytes, under its own
|
||||
//! name so an unzipped backup is browsable;
|
||||
//! - `README.txt`.
|
||||
//!
|
||||
//! Restoring never deletes anything: the library files go to a new library folder in browser
|
||||
//! storage, originals that aren't stored yet are added (they're named by content, so existing
|
||||
//! ones are identical), and only then does a small pointer file ([`ACTIVE_LIBRARY`]) switch to
|
||||
//! the restored library. The library that was there before stays in storage.
|
||||
//!
|
||||
//! No zip64: a backup holds at most [`MAX_BYTES`] and 65 535 entries.
|
||||
|
||||
/// Storage key of the file naming the library folder in use (absent: `library`).
|
||||
pub const ACTIVE_LIBRARY: &str = "active-library";
|
||||
|
||||
/// The default library folder.
|
||||
pub const DEFAULT_LIBRARY_DIR: &str = "library";
|
||||
|
||||
/// Largest backup (offsets are 32-bit without zip64).
|
||||
pub const MAX_BYTES: u64 = 0xFFFF_FFFF - (64 << 20);
|
||||
|
||||
const LOCAL_SIG: u32 = 0x0403_4b50;
|
||||
const CENTRAL_SIG: u32 = 0x0201_4b50;
|
||||
const END_SIG: u32 = 0x0605_4b50;
|
||||
/// General-purpose flag bit 11: names are UTF-8.
|
||||
const UTF8: u16 = 0x0800;
|
||||
/// 1980-01-01 00:00 in MS-DOS format (date, time).
|
||||
const DOS_DATE: u16 = (1 << 5) | 1;
|
||||
|
||||
/// The readme inside every backup.
|
||||
pub const README: &str = "LightCraft library backup (browser version)\n\n\
|
||||
library/ the catalog (catalog.snap + catalog.log), presets, view state and preferences\n\
|
||||
originals/ every imported photo, as originals/<content hash>/<file name>\n\n\
|
||||
Restore it in LightCraft for the web with File > Restore Library from Backup...\n\
|
||||
The photos in originals/ are the untouched files you imported; edits live in the catalog.\n";
|
||||
|
||||
/// Is `name` a safe library folder name (`library` or `library-<letters, digits, ->`)?
|
||||
pub fn valid_library_dir(name: &str) -> bool {
|
||||
name == DEFAULT_LIBRARY_DIR
|
||||
|| name.strip_prefix("library-").is_some_and(|s| !s.is_empty() && s.len() <= 64 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
|
||||
}
|
||||
|
||||
/// Is `h` a plausible content hash (used as a storage key segment)?
|
||||
pub fn valid_hash(h: &str) -> bool {
|
||||
!h.is_empty() && h.len() <= 128 && h.chars().all(|c| c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
/// Zip entry name for an original.
|
||||
pub fn original_entry(hash: &str, file_name: &str) -> String {
|
||||
let name: String = file_name.chars().map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c }).collect();
|
||||
let name = if name.is_empty() || name == "." || name == ".." { "original".to_string() } else { name };
|
||||
format!("originals/{hash}/{name}")
|
||||
}
|
||||
|
||||
/// Where a backup entry goes when restoring into library folder `dir`: `Some(storage key)`, or
|
||||
/// `None` to skip it (README, thumbnails, anything unknown or unsafe).
|
||||
pub fn restore_key(entry: &str, dir: &str) -> Option<String> {
|
||||
if let Some(name) = entry.strip_prefix("library/") {
|
||||
return crate::files::LIBRARY_FILES.contains(&name).then(|| format!("{dir}/{name}"));
|
||||
}
|
||||
let rest = entry.strip_prefix("originals/")?;
|
||||
let (hash, _) = rest.split_once('/').unwrap_or((rest, ""));
|
||||
valid_hash(hash).then(|| format!("originals/{hash}"))
|
||||
}
|
||||
|
||||
fn u16le(v: &mut Vec<u8>, x: u16) {
|
||||
v.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
fn u32le(v: &mut Vec<u8>, x: u32) {
|
||||
v.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
|
||||
struct Central {
|
||||
name: String,
|
||||
crc: u32,
|
||||
size: u32,
|
||||
offset: u32,
|
||||
}
|
||||
|
||||
/// Writes a stored (uncompressed) zip piece by piece: [`ZipWriter::entry`] returns the local
|
||||
/// header to emit before the entry's bytes, [`ZipWriter::finish`] the central directory.
|
||||
#[derive(Default)]
|
||||
pub struct ZipWriter {
|
||||
entries: Vec<Central>,
|
||||
offset: u64,
|
||||
}
|
||||
|
||||
impl ZipWriter {
|
||||
/// The local header for `name` holding `data` (emit it, then `data`).
|
||||
pub fn entry(&mut self, name: &str, data: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let size = u32::try_from(data.len()).map_err(|_| format!("{name}: larger than 4 GB"))?;
|
||||
let name_len = u16::try_from(name.len()).map_err(|_| format!("{name}: name too long"))?;
|
||||
if self.entries.len() >= usize::from(u16::MAX) {
|
||||
return Err("too many files for one backup".into());
|
||||
}
|
||||
let header_len = 30 + u64::from(name_len);
|
||||
if self.offset + header_len + u64::from(size) > MAX_BYTES {
|
||||
return Err("the library is too large for one backup file (4 GB)".into());
|
||||
}
|
||||
let offset = u32::try_from(self.offset).map_err(|_| "backup too large".to_string())?;
|
||||
let crc = crc32fast::hash(data);
|
||||
let mut h = Vec::with_capacity(header_len as usize);
|
||||
u32le(&mut h, LOCAL_SIG);
|
||||
u16le(&mut h, 20); // version needed
|
||||
u16le(&mut h, UTF8);
|
||||
u16le(&mut h, 0); // stored
|
||||
u16le(&mut h, 0); // time
|
||||
u16le(&mut h, DOS_DATE);
|
||||
u32le(&mut h, crc);
|
||||
u32le(&mut h, size);
|
||||
u32le(&mut h, size);
|
||||
u16le(&mut h, name_len);
|
||||
u16le(&mut h, 0); // extra
|
||||
h.extend_from_slice(name.as_bytes());
|
||||
self.offset += header_len + u64::from(size);
|
||||
self.entries.push(Central { name: name.to_string(), crc, size, offset });
|
||||
Ok(h)
|
||||
}
|
||||
|
||||
/// The central directory and end record (emit last).
|
||||
pub fn finish(self) -> Result<Vec<u8>, String> {
|
||||
let mut cd = Vec::new();
|
||||
for e in &self.entries {
|
||||
u32le(&mut cd, CENTRAL_SIG);
|
||||
u16le(&mut cd, 20); // made by
|
||||
u16le(&mut cd, 20); // needed
|
||||
u16le(&mut cd, UTF8);
|
||||
u16le(&mut cd, 0);
|
||||
u16le(&mut cd, 0);
|
||||
u16le(&mut cd, DOS_DATE);
|
||||
u32le(&mut cd, e.crc);
|
||||
u32le(&mut cd, e.size);
|
||||
u32le(&mut cd, e.size);
|
||||
u16le(&mut cd, e.name.len() as u16);
|
||||
u16le(&mut cd, 0); // extra
|
||||
u16le(&mut cd, 0); // comment
|
||||
u16le(&mut cd, 0); // disk
|
||||
u16le(&mut cd, 0); // internal attrs
|
||||
u32le(&mut cd, 0); // external attrs
|
||||
u32le(&mut cd, e.offset);
|
||||
cd.extend_from_slice(e.name.as_bytes());
|
||||
}
|
||||
let cd_offset = u32::try_from(self.offset).map_err(|_| "backup too large".to_string())?;
|
||||
let cd_size = u32::try_from(cd.len()).map_err(|_| "backup too large".to_string())?;
|
||||
let n = self.entries.len() as u16;
|
||||
let mut out = cd;
|
||||
u32le(&mut out, END_SIG);
|
||||
u16le(&mut out, 0);
|
||||
u16le(&mut out, 0);
|
||||
u16le(&mut out, n);
|
||||
u16le(&mut out, n);
|
||||
u32le(&mut out, cd_size);
|
||||
u32le(&mut out, cd_offset);
|
||||
u16le(&mut out, 0); // comment
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of a zip's central directory.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ZipEntry {
|
||||
pub name: String,
|
||||
pub crc: u32,
|
||||
pub size: u64,
|
||||
/// Offset of the entry's local header.
|
||||
pub header: u64,
|
||||
/// 0 = stored (the only method a restore reads).
|
||||
pub method: u16,
|
||||
}
|
||||
|
||||
fn rd16(b: &[u8], at: usize) -> Option<u16> {
|
||||
Some(u16::from_le_bytes(b.get(at..at + 2)?.try_into().ok()?))
|
||||
}
|
||||
fn rd32(b: &[u8], at: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(b.get(at..at + 4)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
/// How many bytes from the end of a zip to read to find its end record.
|
||||
pub fn tail_len(file_len: u64) -> u64 {
|
||||
file_len.min(22 + 65_535)
|
||||
}
|
||||
|
||||
/// The central directory's (offset, size) from the last [`tail_len`] bytes of a zip.
|
||||
pub fn find_central(tail: &[u8]) -> Result<(u64, u64), String> {
|
||||
let at = (0..tail.len().saturating_sub(21)).rev().find(|&i| rd32(tail, i) == Some(END_SIG)).ok_or("not a zip file (no end record)")?;
|
||||
let size = rd32(tail, at + 12).ok_or("truncated zip")?;
|
||||
let offset = rd32(tail, at + 16).ok_or("truncated zip")?;
|
||||
if size == u32::MAX || offset == u32::MAX {
|
||||
return Err("zip64 backups are not supported".into());
|
||||
}
|
||||
Ok((u64::from(offset), u64::from(size)))
|
||||
}
|
||||
|
||||
/// Parse a central directory.
|
||||
pub fn parse_central(cd: &[u8]) -> Result<Vec<ZipEntry>, String> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i + 46 <= cd.len() {
|
||||
if rd32(cd, i) != Some(CENTRAL_SIG) {
|
||||
return Err("damaged zip directory".into());
|
||||
}
|
||||
let bad = || "damaged zip directory".to_string();
|
||||
let method = rd16(cd, i + 10).ok_or_else(bad)?;
|
||||
let crc = rd32(cd, i + 16).ok_or_else(bad)?;
|
||||
let size = rd32(cd, i + 24).ok_or_else(bad)?;
|
||||
let n = usize::from(rd16(cd, i + 28).ok_or_else(bad)?);
|
||||
let extra = usize::from(rd16(cd, i + 30).ok_or_else(bad)?);
|
||||
let comment = usize::from(rd16(cd, i + 32).ok_or_else(bad)?);
|
||||
let header = rd32(cd, i + 42).ok_or_else(bad)?;
|
||||
let name = cd.get(i + 46..i + 46 + n).ok_or_else(bad)?;
|
||||
out.push(ZipEntry { name: String::from_utf8_lossy(name).into_owned(), crc, size: u64::from(size), header: u64::from(header), method });
|
||||
i += 46 + n + extra + comment;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Offset of an entry's data from its local header's first 30 bytes.
|
||||
pub fn data_offset(entry: &ZipEntry, local: &[u8]) -> Result<u64, String> {
|
||||
if rd32(local, 0) != Some(LOCAL_SIG) {
|
||||
return Err(format!("{}: damaged entry", entry.name));
|
||||
}
|
||||
let n = rd16(local, 26).ok_or("truncated entry")?;
|
||||
let extra = rd16(local, 28).ok_or("truncated entry")?;
|
||||
Ok(entry.header + 30 + u64::from(n) + u64::from(extra))
|
||||
}
|
||||
|
||||
/// Check an entry's bytes.
|
||||
pub fn verify(entry: &ZipEntry, data: &[u8]) -> Result<(), String> {
|
||||
if entry.method != 0 {
|
||||
return Err(format!("{}: compressed entries are not supported (not a LightCraft backup?)", entry.name));
|
||||
}
|
||||
if data.len() as u64 != entry.size || crc32fast::hash(data) != entry.crc {
|
||||
return Err(format!("{}: damaged (checksum mismatch)", entry.name));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A name for the restored library's folder.
|
||||
pub fn restored_dir_name(now_ms: f64) -> String {
|
||||
format!("library-restored-{}", now_ms.max(0.0) as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn build(files: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut w = ZipWriter::default();
|
||||
let mut out = Vec::new();
|
||||
for (n, d) in files {
|
||||
out.extend(w.entry(n, d).unwrap());
|
||||
out.extend_from_slice(d);
|
||||
}
|
||||
out.extend(w.finish().unwrap());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn written_backups_read_back() {
|
||||
let files: [(&str, &[u8]); 3] =
|
||||
[("README.txt", README.as_bytes()), ("library/catalog.log", b"{\"seq\":1}\n"), ("originals/abc123/IMG 1.jpg", &[0, 1, 2, 3, 255])];
|
||||
let zip = build(&files);
|
||||
let tail = &zip[zip.len() - tail_len(zip.len() as u64) as usize..];
|
||||
let (off, size) = find_central(tail).unwrap();
|
||||
let entries = parse_central(&zip[off as usize..(off + size) as usize]).unwrap();
|
||||
assert_eq!(entries.len(), 3);
|
||||
for (e, (name, data)) in entries.iter().zip(files) {
|
||||
assert_eq!(e.name, name);
|
||||
let start = data_offset(e, &zip[e.header as usize..e.header as usize + 30]).unwrap() as usize;
|
||||
let got = &zip[start..start + e.size as usize];
|
||||
assert_eq!(got, data);
|
||||
verify(e, got).unwrap();
|
||||
}
|
||||
// damage is caught
|
||||
let e = &entries[2];
|
||||
assert!(verify(e, &[0, 1, 2, 3, 254]).is_err());
|
||||
assert!(find_central(b"not a zip at all, just some text").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_keys_are_safe() {
|
||||
assert_eq!(restore_key("library/catalog.snap", "library-restored-1").as_deref(), Some("library-restored-1/catalog.snap"));
|
||||
assert_eq!(restore_key("library/../../x", "library-r"), None);
|
||||
assert_eq!(restore_key("library/evil.bin", "library-r"), None);
|
||||
assert_eq!(restore_key("originals/abc123/IMG.jpg", "d").as_deref(), Some("originals/abc123"));
|
||||
assert_eq!(restore_key("originals/../library/catalog.log", "d"), None);
|
||||
assert_eq!(restore_key("README.txt", "d"), None);
|
||||
assert_eq!(restore_key("thumbs/x.jpg", "d"), None);
|
||||
assert_eq!(original_entry("abc", "a/b\\c.jpg"), "originals/abc/a_b_c.jpg");
|
||||
assert_eq!(original_entry("abc", ".."), "originals/abc/original");
|
||||
assert!(valid_library_dir("library") && valid_library_dir("library-restored-17"));
|
||||
assert!(!valid_library_dir("library-../x") && !valid_library_dir("originals") && !valid_library_dir("library-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_limits_are_errors_not_panics() {
|
||||
let mut w = ZipWriter { offset: MAX_BYTES - 10, ..Default::default() };
|
||||
assert!(w.entry("x", &[0; 64]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,8 @@ struct Mirror {
|
||||
files: BTreeMap<String, Vec<u8>>,
|
||||
/// Dirty files, least recently modified first.
|
||||
dirty: Vec<(String, Dirty)>,
|
||||
/// Why the last flush to browser storage failed (quota, eviction…), until one succeeds.
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl Mirror {
|
||||
@@ -138,6 +140,18 @@ impl Files {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the outcome of a flush to browser storage: `Some(why)` when it failed, `None` once
|
||||
/// one succeeds. While a flush is failing, the catalog [`Store`] refuses new writes, so the
|
||||
/// engine keeps the changes queued and reports them unsaved (`library.info` → `unsavedOps`,
|
||||
/// the top bar's warning) instead of pretending they were saved.
|
||||
pub fn set_error(&self, e: Option<String>) {
|
||||
self.lock().error = e;
|
||||
}
|
||||
|
||||
pub fn error(&self) -> Option<String> {
|
||||
self.lock().error.clone()
|
||||
}
|
||||
|
||||
/// A catalog [`Store`] over these files.
|
||||
pub fn store(&self) -> FilesStore {
|
||||
FilesStore(self.clone())
|
||||
@@ -147,19 +161,32 @@ impl Files {
|
||||
/// [`Store`] implementation over [`Files`].
|
||||
pub struct FilesStore(Files);
|
||||
|
||||
impl FilesStore {
|
||||
/// Writes fail while browser storage does (see [`Files::set_error`]).
|
||||
fn writable(&self) -> io::Result<()> {
|
||||
match self.0.error() {
|
||||
Some(e) => Err(io::Error::other(format!("browser storage: {e}"))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for FilesStore {
|
||||
fn read(&mut self, name: &str) -> io::Result<Option<Vec<u8>>> {
|
||||
Ok(self.0.get(name))
|
||||
}
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
|
||||
self.writable()?;
|
||||
self.0.write(name, data);
|
||||
Ok(())
|
||||
}
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
|
||||
self.writable()?;
|
||||
self.0.append(name, data);
|
||||
Ok(())
|
||||
}
|
||||
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()> {
|
||||
self.writable()?;
|
||||
self.0.truncate(name, len as usize);
|
||||
Ok(())
|
||||
}
|
||||
@@ -224,6 +251,37 @@ mod tests {
|
||||
assert_eq!(names, ["catalog.snap", "catalog.log"]);
|
||||
}
|
||||
|
||||
/// A failing flush (quota exceeded, storage evicted) is not silent: commands report the change
|
||||
/// as unsaved and the engine keeps it queued; once storage works again it is written.
|
||||
#[test]
|
||||
fn flush_failures_show_as_unsaved_and_are_retried() {
|
||||
let mut disk = BTreeMap::new();
|
||||
let files = Files::default();
|
||||
let mut s = Session::new();
|
||||
let stores = LibraryStores { dir: "browser".into(), catalog: Box::new(files.store()), files: Box::new(files.store()), on_disk: false };
|
||||
s.open_library_in(stores, true).unwrap();
|
||||
flush(&files, &mut disk);
|
||||
files.set_error(Some("QuotaExceededError".into()));
|
||||
let r = s.execute("photo.rate", &json!({"rating": 5}));
|
||||
assert!(matches!(r, Err(lightcraft_engine::EngineError::NotSaved(_))), "{r:?}");
|
||||
let (ops, why) = s.unsaved().unwrap();
|
||||
assert!(ops > 0 && why.contains("QuotaExceeded"), "{why}");
|
||||
files.set_error(None);
|
||||
s.persist_if_dirty();
|
||||
s.persist().unwrap();
|
||||
assert!(s.unsaved().is_none(), "written once storage works again");
|
||||
flush(&files, &mut disk);
|
||||
let id = s.selection.active.unwrap();
|
||||
let files2 = Files::default();
|
||||
for (k, v) in &disk {
|
||||
files2.preload(k, v.clone());
|
||||
}
|
||||
let mut s2 = Session::new();
|
||||
let stores = LibraryStores { dir: "browser".into(), catalog: Box::new(files2.store()), files: Box::new(files2.store()), on_disk: false };
|
||||
s2.open_library_in(stores, true).unwrap();
|
||||
assert_eq!(s2.catalog.photo(id).unwrap().rating, 5, "the rating survived");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_survives_reload_through_flushes() {
|
||||
let mut disk = BTreeMap::new();
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod backup;
|
||||
pub mod bench;
|
||||
pub mod files;
|
||||
pub mod store;
|
||||
@@ -24,6 +25,8 @@ pub mod wire;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod backend;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod safety;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod workers;
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Keeping a library in the browser safe: notices the user must see (a failed save, storage the
|
||||
//! browser may evict, a file that couldn't be stored), one tab at a time per library, a message
|
||||
//! instead of a dead page after a panic, and backup / restore of the whole library
|
||||
//! ([`crate::backup`]).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use js_sys::{Reflect, Uint8Array};
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::backup::{
|
||||
ACTIVE_LIBRARY, README, ZipWriter, data_offset, find_central, original_entry, parse_central, restore_key, restored_dir_name, tail_len,
|
||||
valid_hash, verify,
|
||||
};
|
||||
use crate::files::{Files, LIBRARY_FILES};
|
||||
use crate::store::storage_key;
|
||||
|
||||
thread_local! {
|
||||
/// Messages for the user, shown by the app on its next frame.
|
||||
static NOTICES: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// Queue a message for the user (shown for several seconds; also logged).
|
||||
pub fn notice(text: impl Into<String>) {
|
||||
let text = text.into();
|
||||
log::warn!("{text}");
|
||||
NOTICES.with(|n| n.borrow_mut().push(text));
|
||||
crate::web::request_repaint();
|
||||
}
|
||||
|
||||
/// Messages queued since the last call.
|
||||
pub fn take_notices() -> Vec<String> {
|
||||
NOTICES.with(|n| std::mem::take(&mut *n.borrow_mut()))
|
||||
}
|
||||
|
||||
fn document() -> Option<web_sys::Document> {
|
||||
web_sys::window()?.document()
|
||||
}
|
||||
|
||||
/// Ask the user (the browser's own confirm box). `false` when it can't be shown.
|
||||
pub fn confirm(text: &str) -> bool {
|
||||
web_sys::window().and_then(|w| w.confirm_with_message(text).ok()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Cover the page with a message (the app can't continue, or must not start).
|
||||
pub fn show_blocking(text: &str) {
|
||||
let Some(doc) = document() else { return };
|
||||
let el = match doc.get_element_by_id("lightcraft_blocking") {
|
||||
Some(el) => el,
|
||||
None => {
|
||||
let Ok(el) = doc.create_element("div") else { return };
|
||||
el.set_id("lightcraft_blocking");
|
||||
let _ = el.set_attribute(
|
||||
"style",
|
||||
"position:fixed;inset:0;z-index:10;display:flex;align-items:center;justify-content:center;padding:32px;\
|
||||
background:#1c1c1c;color:#e0e0e0;font:15px/1.5 system-ui,-apple-system,'Segoe UI',sans-serif;text-align:center;white-space:pre-line",
|
||||
);
|
||||
if let Some(body) = doc.body() {
|
||||
let _ = body.append_child(&el);
|
||||
}
|
||||
el
|
||||
}
|
||||
};
|
||||
el.set_text_content(Some(text));
|
||||
if let Some(loading) = doc.get_element_by_id("lightcraft_loading") {
|
||||
loading.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/// `panic = "abort"` on wasm: a panic ends the module. Say so on the page instead of leaving a
|
||||
/// frozen canvas, and say that the stored library is intact.
|
||||
pub fn install_panic_hook() {
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
let msg = match (info.payload().downcast_ref::<&str>(), info.payload().downcast_ref::<String>()) {
|
||||
(Some(s), _) => (*s).to_string(),
|
||||
(_, Some(s)) => s.clone(),
|
||||
_ => "unknown error".into(),
|
||||
};
|
||||
let at = info.location().map(|l| format!(" at {}:{}", l.file(), l.line())).unwrap_or_default();
|
||||
log::error!("LightCraft panicked{at}: {msg}");
|
||||
show_blocking(&format!(
|
||||
"LightCraft stopped after an internal error:\n{msg}\n\nThe library saved in this browser is kept. Reload the page to continue.\n\
|
||||
If it happens again right away, a photo may be the cause: reload with ?workers=0 or report the error."
|
||||
));
|
||||
}));
|
||||
}
|
||||
|
||||
/// The name of the lock that keeps a library to one tab.
|
||||
const TAB_LOCK: &str = "lightcraft-library";
|
||||
|
||||
/// Hold the library lock for as long as this page lives (Web Locks API). `Some(true)`: ours;
|
||||
/// `Some(false)`: another tab of this site has it; `None`: the browser can't tell (no Web Locks).
|
||||
pub async fn acquire_tab_lock() -> Option<bool> {
|
||||
let nav = Reflect::get(&js_sys::global(), &"navigator".into()).ok()?;
|
||||
let locks = Reflect::get(&nav, &"locks".into()).ok().filter(|l| !l.is_undefined() && !l.is_null())?;
|
||||
let request: js_sys::Function = Reflect::get(&locks, &"request".into()).ok()?.dyn_into().ok()?;
|
||||
let opts = js_sys::Object::new();
|
||||
Reflect::set(&opts, &"ifAvailable".into(), &true.into()).ok()?;
|
||||
let granted = js_sys::Promise::new(&mut |resolve, _reject| {
|
||||
let cb = Closure::once_into_js(move |lock: JsValue| -> JsValue {
|
||||
let ours = !lock.is_null() && !lock.is_undefined();
|
||||
let _ = resolve.call1(&JsValue::NULL, &ours.into());
|
||||
// keep the lock until the page goes away: a promise that never settles
|
||||
if ours { js_sys::Promise::new(&mut |_, _| {}).into() } else { JsValue::NULL }
|
||||
});
|
||||
if let Err(e) = request.call3(&locks, &TAB_LOCK.into(), &opts, &cb) {
|
||||
log::warn!("Web Locks request failed: {e:?}");
|
||||
}
|
||||
});
|
||||
JsFuture::from(granted).await.ok().map(|v| v.is_truthy())
|
||||
}
|
||||
|
||||
/// Offer a blob as a download named `name`.
|
||||
pub fn download_blob(name: &str, blob: &web_sys::Blob) -> Result<(), String> {
|
||||
let e = |e: JsValue| format!("download failed: {e:?}");
|
||||
let url = web_sys::Url::create_object_url_with_blob(blob).map_err(e)?;
|
||||
let doc = 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);
|
||||
a.click();
|
||||
// revoke once the download has started
|
||||
let revoke = Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
if let Some(w) = web_sys::window() {
|
||||
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 60_000);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// File ▸ Back Up Library…: a zip of the library files (as the app has them, saved or not) and
|
||||
/// every original in storage, built entry by entry into the browser's memory (outside the wasm
|
||||
/// heap) and downloaded. `names`: content hash → the photo's file name.
|
||||
pub async fn backup(files: Files, backend: Backend, names: HashMap<String, String>) -> Result<(usize, f64), String> {
|
||||
let parts = js_sys::Array::new();
|
||||
let mut zip = ZipWriter::default();
|
||||
let mut bytes = 0f64;
|
||||
let mut add = |zip: &mut ZipWriter, name: &str, data: &[u8]| -> Result<(), String> {
|
||||
let header = zip.entry(name, data)?;
|
||||
parts.push(&Uint8Array::from(&header[..]));
|
||||
parts.push(&Uint8Array::from(data));
|
||||
bytes += (header.len() + data.len()) as f64;
|
||||
Ok(())
|
||||
};
|
||||
add(&mut zip, "README.txt", README.as_bytes())?;
|
||||
for name in LIBRARY_FILES {
|
||||
if let Some(d) = files.get(name) {
|
||||
add(&mut zip, &format!("library/{name}"), &d)?;
|
||||
}
|
||||
}
|
||||
let mut originals = 0;
|
||||
for hash in backend.list("originals").await? {
|
||||
if !valid_hash(&hash) {
|
||||
continue;
|
||||
}
|
||||
match backend.read(&storage_key(&hash)).await {
|
||||
Ok(Some(data)) => {
|
||||
let name = names.get(&hash).map_or("original", String::as_str);
|
||||
add(&mut zip, &original_entry(&hash, name), &data)?;
|
||||
originals += 1;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => return Err(format!("reading an original from browser storage: {e}")),
|
||||
}
|
||||
}
|
||||
let tail = zip.finish()?;
|
||||
parts.push(&Uint8Array::from(&tail[..]));
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type("application/zip");
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(|e| format!("{e:?}"))?;
|
||||
let day: String = String::from(js_sys::Date::new_0().to_iso_string()).chars().take(10).collect();
|
||||
download_blob(&format!("lightcraft-library-backup-{day}.zip"), &blob)?;
|
||||
Ok((originals, bytes + tail.len() as f64))
|
||||
}
|
||||
|
||||
/// Bytes `start..end` of a picked file.
|
||||
async fn read_range(file: &web_sys::File, start: u64, end: u64) -> Result<Vec<u8>, String> {
|
||||
let blob = file.slice_with_f64_and_f64(start as f64, end as f64).map_err(|e| format!("{e:?}"))?;
|
||||
let buf = JsFuture::from(blob.array_buffer()).await.map_err(|e| format!("{e:?}"))?;
|
||||
Ok(Uint8Array::new(&buf).to_vec())
|
||||
}
|
||||
|
||||
/// Restore a backup into a new library folder (nothing in storage is deleted or overwritten),
|
||||
/// then point [`ACTIVE_LIBRARY`] at it. Returns the folder's name; the caller reloads the page.
|
||||
pub async fn restore(file: web_sys::File, backend: Backend) -> Result<String, String> {
|
||||
let len = file.size() as u64;
|
||||
let tail = read_range(&file, len - tail_len(len), len).await?;
|
||||
let (cd_offset, cd_size) = find_central(&tail)?;
|
||||
let cd = read_range(&file, cd_offset, cd_offset + cd_size).await?;
|
||||
let entries = parse_central(&cd)?;
|
||||
if !entries.iter().any(|e| e.name == "library/catalog.snap" || e.name == "library/catalog.log") {
|
||||
return Err("this isn't a LightCraft library backup (no library/catalog files)".into());
|
||||
}
|
||||
let dir = restored_dir_name(js_sys::Date::now());
|
||||
let mut written = 0usize;
|
||||
for e in &entries {
|
||||
let Some(key) = restore_key(&e.name, &dir) else { continue };
|
||||
// originals are named by their content: one already stored is the same file
|
||||
if key.starts_with("originals/") && backend.exists(&key).await? {
|
||||
continue;
|
||||
}
|
||||
let local = read_range(&file, e.header, e.header + 30).await?;
|
||||
let start = data_offset(e, &local)?;
|
||||
let data = read_range(&file, start, start + e.size).await?;
|
||||
verify(e, &data)?;
|
||||
backend.write(&key, &data).await.map_err(|err| format!("storing {}: {err}", e.name))?;
|
||||
written += 1;
|
||||
}
|
||||
backend.write(ACTIVE_LIBRARY, dir.as_bytes()).await?;
|
||||
log::info!("lightcraft: restored {written} files into {dir}");
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// File ▸ Restore Library from Backup…: confirm, pick the zip, restore, reload.
|
||||
pub fn pick_and_restore(backend: Backend, before_reload: impl FnOnce() + 'static) {
|
||||
let ok = confirm(
|
||||
"Restore a LightCraft library backup?\n\nThe backup becomes the library in this browser and the page reloads. \
|
||||
The current library is not deleted: it stays in browser storage.",
|
||||
);
|
||||
if !ok {
|
||||
return;
|
||||
}
|
||||
let Some(doc) = 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_accept(".zip,application/zip");
|
||||
let inp = input.clone();
|
||||
let mut before_reload = Some(before_reload);
|
||||
let on_change = Closure::<dyn FnMut()>::new(move || {
|
||||
let Some(file) = inp.files().and_then(|f| f.get(0)) else { return };
|
||||
let backend = backend.clone();
|
||||
let before = before_reload.take();
|
||||
notice(format!("Restoring {}…", file.name()));
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match restore(file, backend).await {
|
||||
Ok(_) => {
|
||||
if let Some(f) = before {
|
||||
f();
|
||||
}
|
||||
if let Some(w) = web_sys::window() {
|
||||
let _ = w.location().reload();
|
||||
}
|
||||
}
|
||||
Err(e) => notice(format!("Restore failed: {e}. Nothing was changed: the current library is still in use.")),
|
||||
}
|
||||
});
|
||||
});
|
||||
input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
|
||||
on_change.forget();
|
||||
input.click();
|
||||
}
|
||||
+154
-23
@@ -15,16 +15,21 @@ use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::backup::{ACTIVE_LIBRARY, DEFAULT_LIBRARY_DIR, valid_library_dir};
|
||||
use crate::bench::Bench;
|
||||
use crate::files::{Files, FlushOp, LIBRARY_FILES};
|
||||
use crate::safety::{self, notice};
|
||||
use crate::store::{Originals, content_hash, download_name, storage_key};
|
||||
use crate::wire::ThumbIndex;
|
||||
use crate::workers::{THUMB_INDEX, Workers};
|
||||
|
||||
const ACCEPT: &str = ".jpg,.jpeg,.png,.tif,.tiff,.webp,.dng,.cr2,.nef,.arw,.psd,.jxl,.gif,.bmp";
|
||||
|
||||
/// Where the library files live in browser storage.
|
||||
const LIBRARY_DIR: &str = "library";
|
||||
/// After a failed save, wait this long before trying again (ms).
|
||||
const SAVE_RETRY_MS: f64 = 2000.0;
|
||||
|
||||
/// How long a notice stays on screen (s).
|
||||
const NOTICE_SECS: f64 = 8.0;
|
||||
|
||||
/// How often view state, UI prefs and the thumbnail index are saved (ms).
|
||||
const SAVE_EVERY_MS: f64 = 1000.0;
|
||||
@@ -45,8 +50,18 @@ fn set_status(text: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn library_key(name: &str) -> String {
|
||||
format!("{LIBRARY_DIR}/{name}")
|
||||
/// Storage key of library file `name` in library folder `dir`.
|
||||
fn library_key(dir: &str, name: &str) -> String {
|
||||
format!("{dir}/{name}")
|
||||
}
|
||||
|
||||
/// Repaint the app (notices from async tasks).
|
||||
pub(crate) fn request_repaint() {
|
||||
CTX.with(|c| {
|
||||
if let Some(c) = c.borrow().as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// URL options (`?bench&store=idb&workers=2&reset`).
|
||||
@@ -86,8 +101,9 @@ async fn store_file(originals: Originals, backend: Option<Backend>, name: String
|
||||
if let Some(b) = &backend
|
||||
&& let Err(e) = b.write(&storage_key(&hash), &bytes).await
|
||||
{
|
||||
// still importable for this session; it won't survive a reload
|
||||
log::error!("storing {name} in browser storage failed: {e}");
|
||||
// a photo whose bytes aren't stored would be gone after a reload: don't add it
|
||||
notice(format!("{name} was not added: browser storage refused it ({e}). Free up space or back up and clear old photos."));
|
||||
return;
|
||||
}
|
||||
originals.added(&name, &hash, Arc::from(bytes));
|
||||
ctx.request_repaint();
|
||||
@@ -148,8 +164,42 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context) -> Services {
|
||||
fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen: Rc<Cell<bool>>, ctx: egui::Context) -> Services {
|
||||
let (backup_backend, backup_files) = (backend.clone(), files);
|
||||
let restore_backend = backend.clone();
|
||||
Services {
|
||||
backup_library: Some(Box::new(move |session: &mut Session| {
|
||||
let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
|
||||
// the photos' own names for the originals in the zip
|
||||
let names: std::collections::HashMap<String, String> = session
|
||||
.catalog
|
||||
.photos()
|
||||
.filter_map(|p| match &p.source {
|
||||
Source::File { path } => crate::store::hash_of_path(path).map(|h| (h.to_string(), p.file_name.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let files = backup_files.clone();
|
||||
notice("Backing up the library… (the download starts when it's ready)");
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match safety::backup(files, b, names).await {
|
||||
Ok((n, bytes)) => notice(format!(
|
||||
"Library backed up: {n} original{} ({:.1} MB). Keep the file somewhere safe.",
|
||||
if n == 1 { "" } else { "s" },
|
||||
bytes / 1e6
|
||||
)),
|
||||
Err(e) => notice(format!("Backup failed: {e}")),
|
||||
}
|
||||
});
|
||||
Ok(json!({"started": true}))
|
||||
})),
|
||||
restore_library: Some(Box::new(move |_session: &mut Session| {
|
||||
let Some(b) = restore_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
|
||||
let frozen = frozen.clone();
|
||||
// once the backup is in place, stop saving the old library (the page reloads)
|
||||
safety::pick_and_restore(b, move || frozen.set(true));
|
||||
Ok(json!({"started": true}))
|
||||
})),
|
||||
pick_files: Some(Box::new(move || {
|
||||
open_picker(originals.clone(), backend.clone(), ctx.clone());
|
||||
Vec::new() // files arrive asynchronously and are imported on a later frame
|
||||
@@ -176,21 +226,29 @@ fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write dirty library files to storage, in order, until none are left.
|
||||
async fn flush(files: Files, backend: Backend, flushing: Rc<Cell<bool>>) {
|
||||
/// Write dirty library files to storage, in order, until none are left. A failure (quota,
|
||||
/// storage cleared) is recorded in `files` — the catalog then reports changes as unsaved instead
|
||||
/// of accepting them silently — and retried after [`SAVE_RETRY_MS`].
|
||||
async fn flush(files: Files, backend: Backend, dir: String, flushing: Rc<Cell<bool>>, failed_at: Rc<Cell<f64>>) {
|
||||
'outer: loop {
|
||||
let ops = files.take_dirty();
|
||||
if ops.is_empty() {
|
||||
if files.error().is_some() {
|
||||
log::info!("lightcraft: saving works again");
|
||||
}
|
||||
files.set_error(None);
|
||||
break;
|
||||
}
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
let r = match op {
|
||||
FlushOp::Write { name, data } => backend.write(&library_key(name), data).await,
|
||||
FlushOp::Append { name, offset, data } => backend.write_at(&library_key(name), Some(*offset), data).await,
|
||||
FlushOp::Write { name, data } => backend.write(&library_key(&dir, name), data).await,
|
||||
FlushOp::Append { name, offset, data } => backend.write_at(&library_key(&dir, name), Some(*offset), data).await,
|
||||
};
|
||||
if let Err(e) = r {
|
||||
log::error!("saving {} failed: {e}", op.name());
|
||||
files.failed(&ops[i..]);
|
||||
files.set_error(Some(e));
|
||||
failed_at.set(perf_now());
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
@@ -204,6 +262,8 @@ struct Boot {
|
||||
backend: Option<Backend>,
|
||||
files: Files,
|
||||
index: ThumbIndex,
|
||||
/// The library folder in storage (`library`, or a restored backup's).
|
||||
lib_dir: String,
|
||||
}
|
||||
|
||||
async fn boot(opts: Options) -> Boot {
|
||||
@@ -221,15 +281,32 @@ async fn boot(opts: Options) -> Boot {
|
||||
};
|
||||
let files = Files::default();
|
||||
let mut index = ThumbIndex::default();
|
||||
let mut lib_dir = DEFAULT_LIBRARY_DIR.to_string();
|
||||
if let Some(b) = &backend {
|
||||
if opts.reset {
|
||||
match b.clear().await {
|
||||
Ok(()) => log::info!("lightcraft: storage cleared (?reset)"),
|
||||
Err(e) => log::error!("clearing storage: {e}"),
|
||||
// everything this site keeps in the browser, photos included: never without asking
|
||||
let ok = safety::confirm(
|
||||
"?reset deletes the LightCraft library stored in this browser, including every imported photo. \
|
||||
This can't be undone. Delete it?",
|
||||
);
|
||||
if ok {
|
||||
match b.clear().await {
|
||||
Ok(()) => log::info!("lightcraft: storage cleared (?reset)"),
|
||||
Err(e) => log::error!("clearing storage: {e}"),
|
||||
}
|
||||
} else {
|
||||
log::info!("lightcraft: ?reset declined; storage kept");
|
||||
}
|
||||
}
|
||||
// a restored backup lives in its own folder (see `backup`)
|
||||
if let Ok(Some(d)) = b.read(ACTIVE_LIBRARY).await {
|
||||
let d = String::from_utf8_lossy(&d).trim().to_string();
|
||||
if valid_library_dir(&d) {
|
||||
lib_dir = d;
|
||||
}
|
||||
}
|
||||
for name in LIBRARY_FILES {
|
||||
match b.read(&library_key(name)).await {
|
||||
match b.read(&library_key(&lib_dir, name)).await {
|
||||
Ok(Some(data)) => files.preload(name, data),
|
||||
Ok(None) => {}
|
||||
Err(e) => log::error!("reading {name}: {e}"),
|
||||
@@ -238,7 +315,14 @@ async fn boot(opts: Options) -> Boot {
|
||||
if let Ok(Some(j)) = b.read(THUMB_INDEX).await {
|
||||
index = ThumbIndex::from_json(&j);
|
||||
}
|
||||
crate::backend::request_persistence();
|
||||
crate::backend::request_persistence(|granted| {
|
||||
if !granted {
|
||||
notice(
|
||||
"This browser may delete LightCraft's library when it runs short of space (persistent storage wasn't granted). \
|
||||
Keep your original photos elsewhere and use File ▸ Back Up Library… now and then.",
|
||||
);
|
||||
}
|
||||
});
|
||||
// thumbnails stored without an index entry (index saved late, or lost): delete them
|
||||
let b2 = b.clone();
|
||||
let known: std::collections::HashSet<String> = b
|
||||
@@ -266,7 +350,7 @@ async fn boot(opts: Options) -> Boot {
|
||||
index.len(),
|
||||
index.total() as f64 / 1e6
|
||||
);
|
||||
Boot { opts, backend, files, index }
|
||||
Boot { opts, backend, files, index, lib_dir }
|
||||
}
|
||||
|
||||
struct WebApp {
|
||||
@@ -276,6 +360,11 @@ struct WebApp {
|
||||
backend: Option<Backend>,
|
||||
workers: Option<Workers>,
|
||||
flushing: Rc<Cell<bool>>,
|
||||
/// When the last save to storage failed (ms; 0 = never).
|
||||
flush_failed_at: Rc<Cell<f64>>,
|
||||
/// Saving stopped (a restored backup is about to replace this library: the page reloads).
|
||||
frozen: Rc<Cell<bool>>,
|
||||
lib_dir: String,
|
||||
bench: Option<Bench>,
|
||||
first_frame_logged: bool,
|
||||
last_save: f64,
|
||||
@@ -284,17 +373,18 @@ struct WebApp {
|
||||
|
||||
impl WebApp {
|
||||
fn new(cc: &eframe::CreationContext<'_>, boot: Boot) -> Self {
|
||||
let Boot { opts, backend, files, index } = boot;
|
||||
let Boot { opts, backend, files, index, lib_dir } = boot;
|
||||
let originals = Originals::default();
|
||||
let t = perf_now();
|
||||
let mut session = Session::new();
|
||||
originals.install(&mut session);
|
||||
let stores = LibraryStores {
|
||||
dir: format!("browser:{}/{LIBRARY_DIR}", backend.as_ref().map_or("memory", |b| b.kind())).into(),
|
||||
dir: format!("browser:{}/{lib_dir}", backend.as_ref().map_or("memory", |b| b.kind())).into(),
|
||||
catalog: Box::new(files.store()),
|
||||
files: Box::new(files.store()),
|
||||
on_disk: false,
|
||||
};
|
||||
let mut problem = None;
|
||||
match session.open_library_in(stores, true).cloned() {
|
||||
Ok(r) => log::info!(
|
||||
"lightcraft: library {} in {:.0} ms ({} photos; snapshot seq {}, {} ops replayed)",
|
||||
@@ -305,19 +395,35 @@ impl WebApp {
|
||||
r.replayed
|
||||
),
|
||||
Err(e) => {
|
||||
log::error!("opening the library failed: {e}; starting a temporary one");
|
||||
session = Session::with_demo();
|
||||
// never a silent demo session (issue #100): an empty one, and the window says why
|
||||
log::error!("opening the library failed: {e}");
|
||||
session = Session::new();
|
||||
originals.install(&mut session);
|
||||
problem = Some(lightcraft_ui_egui::panels::library_problem::LibraryProblem {
|
||||
can_retry: false,
|
||||
..lightcraft_ui_egui::panels::library_problem::LibraryProblem::new(
|
||||
"the browser's storage for this page",
|
||||
format!("{e}. Reload the page to try again."),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
if backend.is_some() {
|
||||
notice(
|
||||
"LightCraft in the browser is experimental. Your library is kept in this browser's storage: keep your original photos \
|
||||
elsewhere and back up with File ▸ Back Up Library….",
|
||||
);
|
||||
}
|
||||
// previews are large (≤ 2560 px, f32): keep few in a 32-bit address space
|
||||
session.media.preview_capacity = 3;
|
||||
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), cc.egui_ctx.clone()));
|
||||
let frozen = Rc::new(Cell::new(false));
|
||||
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone()));
|
||||
let ui_written = files.get("ui.json").unwrap_or_default();
|
||||
if let Ok(ui) = serde_json::from_slice::<UiState>(&ui_written) {
|
||||
app.ui = ui;
|
||||
}
|
||||
app.ui = app.ui.sanitized();
|
||||
app.library_problem = problem;
|
||||
let n = opts.workers.unwrap_or_else(|| {
|
||||
let cores = window().map_or(1, |w| w.navigator().hardware_concurrency() as usize);
|
||||
cores.saturating_sub(1).clamp(1, 4)
|
||||
@@ -337,6 +443,9 @@ impl WebApp {
|
||||
backend,
|
||||
workers,
|
||||
flushing: Rc::new(Cell::new(false)),
|
||||
flush_failed_at: Rc::new(Cell::new(0.0)),
|
||||
frozen,
|
||||
lib_dir,
|
||||
bench: opts.bench.then(|| Bench::new(origin)),
|
||||
first_frame_logged: false,
|
||||
last_save: 0.0,
|
||||
@@ -398,6 +507,9 @@ impl WebApp {
|
||||
|
||||
/// Save view state, UI prefs and the thumbnail index now and then; flush dirty files.
|
||||
fn save(&mut self) {
|
||||
if self.frozen.get() {
|
||||
return;
|
||||
}
|
||||
let now = perf_now();
|
||||
if now - self.last_save >= SAVE_EVERY_MS {
|
||||
self.last_save = now;
|
||||
@@ -419,12 +531,16 @@ impl WebApp {
|
||||
});
|
||||
}
|
||||
}
|
||||
// after a failure, retry every couple of seconds rather than every frame
|
||||
let backing_off = self.files.error().is_some() && now - self.flush_failed_at.get() < SAVE_RETRY_MS;
|
||||
if let Some(b) = &self.backend
|
||||
&& self.files.is_dirty()
|
||||
&& !self.flushing.get()
|
||||
&& !backing_off
|
||||
{
|
||||
self.flushing.set(true);
|
||||
wasm_bindgen_futures::spawn_local(flush(self.files.clone(), b.clone(), self.flushing.clone()));
|
||||
let (files, dir, flushing, failed_at) = (self.files.clone(), self.lib_dir.clone(), self.flushing.clone(), self.flush_failed_at.clone());
|
||||
wasm_bindgen_futures::spawn_local(flush(files, b.clone(), dir, flushing, failed_at));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -454,8 +570,10 @@ impl WebApp {
|
||||
let (orig_n, orig_bytes) = self.originals.usage();
|
||||
json!({
|
||||
"storage": self.backend.as_ref().map_or("memory", |b| b.kind()),
|
||||
"library": self.lib_dir,
|
||||
"dirty": self.files.is_dirty(),
|
||||
"flushing": self.flushing.get(),
|
||||
"saveError": self.files.error(),
|
||||
"workers": {"alive": alive, "ready": ready, "remoteJobs": remote, "inlineJobs": inline},
|
||||
"originalsInMemory": orig_n,
|
||||
"originalBytesInMemory": orig_bytes,
|
||||
@@ -480,6 +598,10 @@ impl WebApp {
|
||||
|
||||
impl eframe::App for WebApp {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
for text in safety::take_notices() {
|
||||
let now = ctx.input(|i| i.time);
|
||||
self.app.ui.toast = Some((text, now + NOTICE_SECS));
|
||||
}
|
||||
self.run_inbox();
|
||||
self.import_dropped(ctx);
|
||||
self.load_originals(ctx);
|
||||
@@ -517,9 +639,18 @@ impl eframe::App for WebApp {
|
||||
#[wasm_bindgen]
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
safety::install_panic_hook();
|
||||
log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now());
|
||||
let opts = Options::from_url();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// one tab per library: two would each keep their own copy and overwrite each other's saves
|
||||
if opts.store != "memory" && safety::acquire_tab_lock().await == Some(false) {
|
||||
safety::show_blocking(
|
||||
"LightCraft is already open in another tab or window of this browser.\n\n\
|
||||
Switch to that tab, or close it and reload this page. (Two tabs would overwrite each other's changes.)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Some(canvas) = window()
|
||||
.and_then(|w| w.document())
|
||||
.and_then(|d| d.get_element_by_id("lightcraft_canvas"))
|
||||
|
||||
@@ -15,10 +15,9 @@ use std::sync::Arc;
|
||||
|
||||
use lightcraft_engine::catalog::Source;
|
||||
use lightcraft_engine::develop::{DevelopSettings, EmbeddedLens};
|
||||
use lightcraft_engine::media::{MediaCache, RenderJob, SourceLevel, SourceRef};
|
||||
use lightcraft_engine::media::{DecodedSource, MediaCache, RenderJob, SourceLevel, SourceRef};
|
||||
use lightcraft_engine::pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache};
|
||||
use lightcraft_preview::Lru;
|
||||
use lightcraft_raster::Rgb32f;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::store::hash_of_path;
|
||||
@@ -31,6 +30,8 @@ pub struct WireJob {
|
||||
/// Decode the source at most this long.
|
||||
pub max_edge: usize,
|
||||
pub raw: bool,
|
||||
#[serde(default)]
|
||||
pub relative_wb: bool,
|
||||
pub as_shot_temp: f64,
|
||||
pub as_shot_tint: f64,
|
||||
pub lens: Option<EmbeddedLens>,
|
||||
@@ -55,7 +56,7 @@ impl WireJob {
|
||||
pub fn from_job(job: &RenderJob, thumb_cached: bool, view: &str) -> 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::Loaded(img) => img.image.width.max(img.image.height),
|
||||
SourceRef::Smart { .. } => 2560,
|
||||
};
|
||||
WireJob {
|
||||
@@ -63,6 +64,7 @@ impl WireJob {
|
||||
origin: job.origin.clone(),
|
||||
max_edge,
|
||||
raw: job.info.raw,
|
||||
relative_wb: job.info.relative_wb,
|
||||
as_shot_temp: job.info.as_shot_temp,
|
||||
as_shot_tint: job.info.as_shot_tint,
|
||||
lens: job.info.lens,
|
||||
@@ -79,7 +81,14 @@ impl WireJob {
|
||||
}
|
||||
|
||||
pub fn info(&self) -> SourceInfo {
|
||||
SourceInfo { raw: self.raw, as_shot_temp: self.as_shot_temp, as_shot_tint: self.as_shot_tint, lens: self.lens }
|
||||
SourceInfo {
|
||||
raw: self.raw,
|
||||
as_shot_temp: self.as_shot_temp,
|
||||
as_shot_tint: self.as_shot_tint,
|
||||
lens: self.lens,
|
||||
relative_wb: self.relative_wb,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request(&self) -> RenderRequest {
|
||||
@@ -119,7 +128,7 @@ const WORKER_SOURCE_BYTES: usize = 192 << 20;
|
||||
|
||||
/// The synchronous part of a worker: decoded-source cache + rendering.
|
||||
pub struct WorkerCore {
|
||||
sources: Lru<String, Arc<Rgb32f>>,
|
||||
sources: Lru<String, DecodedSource>,
|
||||
media: MediaCache,
|
||||
stages: HashMap<String, Arc<StageCache>>,
|
||||
}
|
||||
@@ -150,21 +159,25 @@ impl WorkerCore {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let src = match (&job.origin, original) {
|
||||
(Source::File { .. }, Some(bytes)) => Arc::new(lightcraft_engine::files::load_bytes(bytes, job.max_edge)?.0),
|
||||
(Source::File { .. }, Some(bytes)) => {
|
||||
let (image, info) = lightcraft_engine::files::load_bytes(bytes, job.max_edge)?;
|
||||
DecodedSource::new(Arc::new(image), Some(info))
|
||||
}
|
||||
(Source::File { path }, None) => return Err(format!("{path}: original not found in browser storage")),
|
||||
(origin @ Source::Demo { .. }, _) => self.media.origin_ref(origin, job.max_edge).load()?,
|
||||
(origin @ Source::Demo { .. }, _) => self.media.origin_ref(origin, job.max_edge).load_source()?,
|
||||
};
|
||||
let cost = src.width * src.height * 12 + 64;
|
||||
let cost = src.image.width * src.image.height * 12 + 64;
|
||||
self.sources.insert(key, src.clone(), cost);
|
||||
src
|
||||
}
|
||||
};
|
||||
let info = src.info_or(job.info());
|
||||
Ok(match &job.stages {
|
||||
Some(view) => {
|
||||
let st = self.stages.entry(view.clone()).or_default().clone();
|
||||
lightcraft_engine::pipeline::render_cached(&src, &job.info(), &job.settings, &job.request(), &st)
|
||||
lightcraft_engine::pipeline::render_cached(&src.image, &info, &job.settings, &job.request(), &st)
|
||||
}
|
||||
None => lightcraft_engine::pipeline::render(&src, &job.info(), &job.settings, &job.request()),
|
||||
None => lightcraft_engine::pipeline::render(&src.image, &info, &job.settings, &job.request()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -295,6 +308,7 @@ mod tests {
|
||||
origin,
|
||||
max_edge: 512,
|
||||
raw: false,
|
||||
relative_wb: false,
|
||||
as_shot_temp: 6500.0,
|
||||
as_shot_tint: 0.0,
|
||||
lens: None,
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! This is the transport the MCP server (`lightcraft-cli mcp --connect`) wraps.
|
||||
//!
|
||||
//! The server only ever reads requests: a line that is not a JSON object with a string `method`
|
||||
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
|
||||
//! one error reply and the connection is closed, so nothing after it is executed. Lines are
|
||||
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
|
||||
//! never reaches the UI thread. See `docs/control-protocol.md`.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use lightcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
|
||||
/// anything longer is refused and the connection closed.
|
||||
pub const MAX_LINE: usize = 4 * 1024 * 1024;
|
||||
/// Connections served at once; further ones get an error line and are closed.
|
||||
pub const MAX_CONNECTIONS: usize = 16;
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
@@ -19,44 +33,235 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
}
|
||||
};
|
||||
eprintln!("lightcraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
rx
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let open = Arc::clone(&open);
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, &tx, &ctx);
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
serve_lines(read, stream, |method, params| {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
tx.send(req).ok()?;
|
||||
ctx.request_repaint();
|
||||
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
|
||||
});
|
||||
}
|
||||
|
||||
/// One parsed line.
|
||||
enum Line {
|
||||
Request {
|
||||
id: Value,
|
||||
method: String,
|
||||
params: Value,
|
||||
},
|
||||
Blank,
|
||||
/// Not a request: reply with this error and close the connection.
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
fn parse_line(bytes: &[u8]) -> Line {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Line::Blank;
|
||||
}
|
||||
let msg = match serde_json::from_str::<Value>(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
Line::Request {
|
||||
id: obj.get("id").cloned().unwrap_or(Value::Null),
|
||||
method: method.to_string(),
|
||||
params: obj.get("params").cloned().unwrap_or(json!({})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
buf.clear();
|
||||
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
buf.pop();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request line longer than {max} bytes; closing the connection"));
|
||||
}
|
||||
// Last line without a newline, then end of stream.
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
|
||||
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
|
||||
/// returns the reply object (the request's `id` is added here).
|
||||
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
Ok(false) => return,
|
||||
Err(e) => e,
|
||||
Ok(true) => match parse_line(&buf) {
|
||||
Line::Blank => continue,
|
||||
Line::Reject(e) => e,
|
||||
Line::Request { id, method, params } => {
|
||||
let Some(mut r) = handle(method, params) else { return };
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
if writeln!(out, "{r}").and_then(|()| out.flush()).is_err() {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
},
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut calls = Vec::new();
|
||||
serve_lines(input, &mut out, |m, _p| {
|
||||
calls.push(m);
|
||||
Some(json!({"ok": true, "result": null}))
|
||||
});
|
||||
let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()).collect();
|
||||
(replies, calls)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_are_answered_in_order_with_ids() {
|
||||
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
|
||||
assert_eq!(calls, ["a", "b", "c"]);
|
||||
assert_eq!(r.len(), 3);
|
||||
assert_eq!(r[0]["id"], 1);
|
||||
assert_eq!(r[1]["id"], 2);
|
||||
assert_eq!(r[2]["id"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_request_closes_before_the_body_runs() {
|
||||
// A browser's cross-origin `fetch` POST: the request line is rejected and the connection
|
||||
// closed, so the JSON body is never executed.
|
||||
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7980\r\nContent-Type: text/plain\r\n\r\n\
|
||||
{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"folder.move\"}}\n";
|
||||
let (r, calls) = run(http);
|
||||
assert!(calls.is_empty(), "nothing may run: {calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
assert!(r[0]["error"].as_str().unwrap().contains("closing the connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_request_json_closes_the_connection() {
|
||||
for junk in [
|
||||
&b"[1,2]\n{\"method\":\"a\"}\n"[..],
|
||||
b"{\"id\":1}\n{\"method\":\"a\"}\n",
|
||||
b"{\"method\":5}\n{\"method\":\"a\"}\n",
|
||||
b"\xff\xfe\n{\"method\":\"a\"}\n",
|
||||
] {
|
||||
let (r, calls) = run(junk);
|
||||
assert!(calls.is_empty());
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_before_junk_still_runs() {
|
||||
let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n");
|
||||
assert_eq!(calls, ["a"]);
|
||||
assert_eq!(r.len(), 2);
|
||||
assert_eq!(r[1]["ok"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlong_line_is_refused_without_buffering_it_all() {
|
||||
// An endless line without a newline: reading stops after MAX_LINE + 1 bytes.
|
||||
let endless = std::io::repeat(b'x');
|
||||
let mut out = Vec::new();
|
||||
let mut ran = false;
|
||||
serve_lines(endless, &mut out, |_, _| {
|
||||
ran = true;
|
||||
None
|
||||
});
|
||||
assert!(!ran);
|
||||
let reply: Value = serde_json::from_slice(out.strip_suffix(b"\n").unwrap()).unwrap();
|
||||
assert!(reply["error"].as_str().unwrap().contains("longer than"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_at_the_limit_is_accepted() {
|
||||
let mut buf = Vec::new();
|
||||
let line = vec![b'a'; 10];
|
||||
let mut input = line.clone();
|
||||
input.push(b'\n');
|
||||
assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true));
|
||||
assert_eq!(buf, line);
|
||||
assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_connection_is_closed_after_junk() {
|
||||
// End to end over a real loopback socket (the transport the app uses).
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
let read = s.try_clone().unwrap();
|
||||
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
|
||||
let mut text = String::new();
|
||||
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
|
||||
server.join().unwrap();
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines[0].contains("\"id\":7"));
|
||||
assert!(lines[1].contains("closing the connection"));
|
||||
}
|
||||
}
|
||||
+221
-52
@@ -7,7 +7,9 @@
|
||||
//! `~/Pictures/LightCraft Library`; a new library starts with the
|
||||
//! procedural demo photos unless `--no-demo` or files are given. Files and folders on the command
|
||||
//! line are imported (duplicates are skipped). `--memory` runs an in-memory session that writes
|
||||
//! nothing (demo photos unless files are given; used by the README showcase scripts).
|
||||
//! nothing (demo photos unless files are given; used by the README showcase scripts). A library
|
||||
//! that can't be opened is never replaced silently: the window says why and asks what to do
|
||||
//! (`lightcraft_ui_egui::panels::library_problem`).
|
||||
//!
|
||||
//! `--control <port>` (or `LIGHTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
||||
@@ -26,6 +28,7 @@ mod control_server;
|
||||
mod native_menu;
|
||||
|
||||
use lightcraft_engine::Session;
|
||||
use lightcraft_ui_egui::panels::library_problem::LibraryProblem;
|
||||
use lightcraft_ui_egui::{LightcraftApp, Services, UiState};
|
||||
|
||||
/// Reverse-DNS app id: Wayland app id, `.desktop` file and hicolor icon name.
|
||||
@@ -41,15 +44,16 @@ fn app_icon() -> egui::IconData {
|
||||
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
|
||||
}
|
||||
|
||||
struct App(LightcraftApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
struct App(LightcraftApp, PrefsWriter, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(m) = self.1.as_mut() {
|
||||
if let Some(m) = self.2.as_mut() {
|
||||
m.update(&mut self.0, ctx);
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
self.1.tick(&mut self.0, ctx);
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
@@ -58,7 +62,9 @@ impl eframe::App for App {
|
||||
self.0.ui(ui);
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.0);
|
||||
if let Err(e) = self.1.save(&self.0) {
|
||||
eprintln!("lightcraft: {e}");
|
||||
}
|
||||
if let Err(e) = self.0.session.close_library() {
|
||||
eprintln!("lightcraft: saving the library failed: {e}");
|
||||
}
|
||||
@@ -78,30 +84,127 @@ fn config_dir() -> Option<std::path::PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The saved UI state and app settings (`<config>/ui.json`), if any.
|
||||
fn load_prefs() -> Option<UiState> {
|
||||
/// The saved UI state and app settings (`<config>/ui.json`), if any, and a warning for the user
|
||||
/// when the file exists but can't be used (issue #103): a damaged file is kept as
|
||||
/// `ui.json.corrupt-<unix time>` first, so the next save can't lose the library location in it;
|
||||
/// one that can't be read at all is not written this session (`keep_file`).
|
||||
fn load_prefs() -> (Option<UiState>, Option<String>, bool) {
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return None;
|
||||
return (None, None, false);
|
||||
}
|
||||
let bytes = std::fs::read(config_dir()?.join("ui.json")).ok()?;
|
||||
serde_json::from_slice::<UiState>(&bytes).ok().map(UiState::sanitized)
|
||||
let Some(path) = config_dir().map(|d| d.join("ui.json")) else { return (None, None, false) };
|
||||
load_prefs_at(&path)
|
||||
}
|
||||
|
||||
fn save_prefs(app: &LightcraftApp) {
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return;
|
||||
fn load_prefs_at(path: &std::path::Path) -> (Option<UiState>, Option<String>, bool) {
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (None, None, false),
|
||||
Err(e) => {
|
||||
let msg = format!("The app settings ({}) couldn't be read: {e}. Defaults are used, and the file isn't overwritten.", path.display());
|
||||
return (None, Some(msg), true);
|
||||
}
|
||||
};
|
||||
match serde_json::from_slice::<UiState>(&bytes) {
|
||||
Ok(ui) => (Some(ui.sanitized()), None, false),
|
||||
Err(e) => {
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let keep = path.with_file_name(format!("ui.json.corrupt-{secs}"));
|
||||
let kept = std::fs::rename(path, &keep);
|
||||
let what = match &kept {
|
||||
Ok(()) => format!("it was kept as {}", keep.display()),
|
||||
Err(r) => format!("it couldn't be set aside ({r})"),
|
||||
};
|
||||
let msg = format!(
|
||||
"The app settings ({}) are damaged ({e}); {what}. Defaults are used — reopen your library with Settings → Open Library… if it isn't shown.",
|
||||
path.display()
|
||||
);
|
||||
(None, Some(msg), kept.is_err())
|
||||
}
|
||||
}
|
||||
if let Some(d) = config_dir() {
|
||||
let _ = std::fs::create_dir_all(&d);
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) {
|
||||
let _ = std::fs::write(d.join("ui.json"), bytes);
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` atomically: a temp file, fsynced, renamed over it (a crash or a full
|
||||
/// disk leaves the old file or the new one, never a truncated one).
|
||||
fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let written = (|| {
|
||||
let mut f = std::fs::File::create(&tmp)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()
|
||||
})();
|
||||
if let Err(e) = written {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
std::fs::rename(&tmp, path)?;
|
||||
if let Some(dir) = path.parent()
|
||||
&& let Ok(d) = std::fs::File::open(dir)
|
||||
{
|
||||
let _ = d.sync_all();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Saves `ui.json` (app settings, incl. the library to open at launch): as soon as the library
|
||||
/// changes, every few seconds when anything else changed, and at exit.
|
||||
#[derive(Default)]
|
||||
struct PrefsWriter {
|
||||
written: Vec<u8>,
|
||||
library: String,
|
||||
checked: f64,
|
||||
/// The last write failed (reported once until a write works).
|
||||
failing: bool,
|
||||
/// `ui.json` couldn't be read at launch: never overwrite it this session.
|
||||
keep_file: bool,
|
||||
}
|
||||
|
||||
impl PrefsWriter {
|
||||
fn save(&mut self, app: &LightcraftApp) -> Result<(), String> {
|
||||
if self.keep_file || std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(d) = config_dir() else { return Ok(()) };
|
||||
let bytes = serde_json::to_vec_pretty(&app.ui).map_err(|e| e.to_string())?;
|
||||
if bytes == self.written {
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::create_dir_all(&d)
|
||||
.and_then(|()| write_atomic(&d.join("ui.json"), &bytes))
|
||||
.map_err(|e| format!("saving the app settings failed: {e}"))?;
|
||||
self.written = bytes;
|
||||
self.library = app.ui.settings.library_path.clone();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tick(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
let moved = app.ui.settings.library_path != self.library;
|
||||
if !moved && now - self.checked < 3.0 {
|
||||
return;
|
||||
}
|
||||
self.checked = now;
|
||||
match self.save(app) {
|
||||
Ok(()) => self.failing = false,
|
||||
Err(e) => {
|
||||
eprintln!("lightcraft: {e}");
|
||||
if !self.failing {
|
||||
app.notices.push(format!("{e}. LightCraft keeps trying."));
|
||||
}
|
||||
self.failing = true;
|
||||
// don't retry every frame
|
||||
self.library = app.ui.settings.library_path.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()))),
|
||||
pick_folder: Some(Box::new(|| {
|
||||
rfd::FileDialog::new().set_title(lightcraft_ui_egui::i18n::tr("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();
|
||||
@@ -153,7 +256,7 @@ fn services() -> Services {
|
||||
pick_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(
|
||||
"Photos",
|
||||
lightcraft_ui_egui::i18n::tr("Photos"),
|
||||
&[
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "pef", "psd",
|
||||
"jxl", "gif", "bmp",
|
||||
@@ -167,8 +270,11 @@ fn services() -> Services {
|
||||
})),
|
||||
pick_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Import Presets")
|
||||
.add_filter("Presets & Profiles", &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"])
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Presets"))
|
||||
.add_filter(
|
||||
lightcraft_ui_egui::i18n::tr("Presets & Profiles"),
|
||||
&["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"],
|
||||
)
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
@@ -177,24 +283,24 @@ fn services() -> Services {
|
||||
})),
|
||||
pick_tracklog: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Auto-Tag from Tracklog")
|
||||
.add_filter("GPS Track Log", &["gpx"])
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog"))
|
||||
.add_filter(lightcraft_ui_egui::i18n::tr("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")
|
||||
.add_filter("LightCraft Preset", &["lcpreset"])
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Export Presets"))
|
||||
.add_filter(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"])
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().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"])
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets"))
|
||||
.add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"])
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
@@ -203,39 +309,37 @@ fn services() -> Services {
|
||||
})),
|
||||
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_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets"))
|
||||
.add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"])
|
||||
.set_file_name(name)
|
||||
.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())
|
||||
})),
|
||||
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())
|
||||
})),
|
||||
// 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()
|
||||
})),
|
||||
// the library is a folder on disk: backed up with the user's other files
|
||||
backup_library: None,
|
||||
restore_library: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The persistent library session (or an in-memory one with `--memory` / if the library can't open).
|
||||
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> Session {
|
||||
let fallback = || if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock();
|
||||
/// The persistent library session, or with `--memory` an in-memory one (demo photos).
|
||||
///
|
||||
/// If the library can't be opened the session is empty and in memory — never seeded with the
|
||||
/// demo photos, never written anywhere — and the problem is returned: the window then says so
|
||||
/// and offers Try Again / Choose Another Library… / Continue Without Saving / Quit (issue #100).
|
||||
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
|
||||
if in_memory {
|
||||
return fallback();
|
||||
return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock(), None);
|
||||
}
|
||||
let unopened = || Session::new().with_fs().with_system_clock();
|
||||
let Some(dir) = dir else {
|
||||
eprintln!("lightcraft: no library location (set --library or LIGHTCRAFT_LIBRARY); running in memory");
|
||||
return fallback();
|
||||
eprintln!("lightcraft: no library location (set --library or LIGHTCRAFT_LIBRARY)");
|
||||
return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it.")));
|
||||
};
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut s = Session::new().with_fs().with_system_clock();
|
||||
@@ -249,11 +353,11 @@ fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: boo
|
||||
if torn > 0 { ", torn tail repaired" } else { "" },
|
||||
t0.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
s
|
||||
(s, None)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("lightcraft: can't open library {}: {e}; running in memory", dir.display());
|
||||
fallback()
|
||||
eprintln!("lightcraft: can't open library {}: {e}", dir.display());
|
||||
(unopened(), Some(LibraryProblem::new(dir.to_string_lossy(), e.to_string())))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,7 +406,7 @@ fn main() -> eframe::Result {
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let prefs = load_prefs();
|
||||
let (prefs, prefs_warning, keep_prefs_file) = load_prefs();
|
||||
// --library, else the library last opened from Settings, else the default location
|
||||
let library_dir = library_dir.or_else(|| {
|
||||
prefs
|
||||
@@ -330,17 +434,30 @@ fn main() -> eframe::Result {
|
||||
"LightCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let session = open_session(in_memory, library_dir, seed_demo && files.is_empty());
|
||||
let (session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
|
||||
let mut app = LightcraftApp::new(session, services());
|
||||
if let Some(ui) = prefs {
|
||||
app.ui = ui;
|
||||
}
|
||||
lightcraft_ui_egui::i18n::set_language(app.ui.language);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.notices.extend(prefs_warning);
|
||||
// what's on disk now: only changes are written
|
||||
let writer = PrefsWriter {
|
||||
written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(),
|
||||
library: app.ui.settings.library_path.clone(),
|
||||
keep_file: keep_prefs_file,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
if !files.is_empty() {
|
||||
if let Some(mut p) = problem {
|
||||
// imported once the user has chosen where (into the library, or the temporary session)
|
||||
p.pending_import = files;
|
||||
app.library_problem = Some(p);
|
||||
} else if !files.is_empty() {
|
||||
let _ = app.run("library.import", serde_json::json!({"paths": files}));
|
||||
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
|
||||
}
|
||||
@@ -350,9 +467,61 @@ fn main() -> eframe::Result {
|
||||
let menu = (std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx));
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
writer,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu,
|
||||
)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-app-prefs-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and
|
||||
/// reported, not silently replaced by the defaults.
|
||||
#[test]
|
||||
fn damaged_ui_json_is_kept_and_reported() {
|
||||
let d = dir("damaged");
|
||||
let path = d.join("ui.json");
|
||||
std::fs::write(&path, br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#).unwrap();
|
||||
let (ui, warning, keep) = load_prefs_at(&path);
|
||||
assert!(ui.is_none() && !keep);
|
||||
let warning = warning.unwrap();
|
||||
assert!(warning.contains("damaged") && warning.contains("ui.json.corrupt-"), "{warning}");
|
||||
assert!(!path.exists());
|
||||
let kept: Vec<_> = std::fs::read_dir(&d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect();
|
||||
assert!(kept.len() == 1 && kept[0].starts_with("ui.json.corrupt-"), "{kept:?}");
|
||||
// a missing file is just "no settings yet"
|
||||
assert_eq!(load_prefs_at(&path).1, None);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_json_is_written_atomically() {
|
||||
let d = dir("atomic");
|
||||
let path = d.join("ui.json");
|
||||
std::fs::write(&path, b"old").unwrap();
|
||||
write_atomic(&path, b"{\"new\": true}").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}");
|
||||
assert!(!d.join("ui.json.tmp").exists());
|
||||
// a failed write leaves the old file
|
||||
let missing = d.join("gone").join("ui.json");
|
||||
assert!(write_atomic(&missing, b"x").is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}");
|
||||
let ui = UiState::default();
|
||||
write_atomic(&path, &serde_json::to_vec_pretty(&ui).unwrap()).unwrap();
|
||||
let (loaded, warning, _) = load_prefs_at(&path);
|
||||
assert!(loaded.is_some() && warning.is_none());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
@@ -148,7 +148,7 @@ fn yields_to_text(sc: &str) -> bool {
|
||||
|
||||
/// `&` marks a mnemonic in muda labels.
|
||||
fn label_text(s: &str) -> String {
|
||||
s.replace('&', "&&")
|
||||
lightcraft_ui_egui::i18n::tr(s).replace('&', "&&")
|
||||
}
|
||||
|
||||
fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> String {
|
||||
@@ -171,7 +171,7 @@ fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> String {
|
||||
out.push(';');
|
||||
}
|
||||
}
|
||||
let mut s = String::new();
|
||||
let mut s = format!("{};", lightcraft_ui_egui::i18n::is_japanese());
|
||||
for (t, items) in bar {
|
||||
s.push_str(t);
|
||||
walk(items, &mut s);
|
||||
@@ -210,30 +210,30 @@ 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 quit = MenuItem::with_id(QUIT, "Quit LightCraft", true, accelerator("Cmd+Q"));
|
||||
let about = MenuItem::with_id("app.about", lightcraft_ui_egui::i18n::tr("About LightCraft"), true, None);
|
||||
let settings = MenuItem::with_id(SETTINGS, lightcraft_ui_egui::i18n::tr("Settings…"), true, accelerator(SETTINGS_KEY));
|
||||
let quit = MenuItem::with_id(QUIT, lightcraft_ui_egui::i18n::tr("Quit LightCraft"), true, accelerator("Cmd+Q"));
|
||||
let _ = app_menu.append_items(&[
|
||||
&about,
|
||||
&PredefinedMenuItem::separator(),
|
||||
&settings,
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::services(None),
|
||||
&PredefinedMenuItem::services(Some(lightcraft_ui_egui::i18n::tr("Services"))),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::hide(Some("Hide LightCraft")),
|
||||
&PredefinedMenuItem::hide_others(None),
|
||||
&PredefinedMenuItem::show_all(None),
|
||||
&PredefinedMenuItem::hide(Some(lightcraft_ui_egui::i18n::tr("Hide LightCraft"))),
|
||||
&PredefinedMenuItem::hide_others(Some(lightcraft_ui_egui::i18n::tr("Hide Others"))),
|
||||
&PredefinedMenuItem::show_all(Some(lightcraft_ui_egui::i18n::tr("Show All"))),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&quit,
|
||||
]);
|
||||
let _ = self.menu.append(&app_menu);
|
||||
|
||||
for (title, nodes) in &bar {
|
||||
let sub = Submenu::new(title, true);
|
||||
let sub = Submenu::new(label_text(title), true);
|
||||
if title == "Window" {
|
||||
let _ = sub.append_items(&[
|
||||
&PredefinedMenuItem::minimize(None),
|
||||
&PredefinedMenuItem::maximize(Some("Zoom")),
|
||||
&PredefinedMenuItem::minimize(Some(lightcraft_ui_egui::i18n::tr("Minimize"))),
|
||||
&PredefinedMenuItem::maximize(Some(lightcraft_ui_egui::i18n::tr("Zoom"))),
|
||||
&PredefinedMenuItem::separator(),
|
||||
]);
|
||||
}
|
||||
@@ -247,10 +247,16 @@ impl NativeMenu {
|
||||
self.append_nodes(&sub, &nodes);
|
||||
if title == "File" {
|
||||
// ⌘W, the system's own item
|
||||
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::close_window(None)]);
|
||||
let _ = sub.append_items(&[
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::close_window(Some(lightcraft_ui_egui::i18n::tr("Close Window"))),
|
||||
]);
|
||||
}
|
||||
if title == "Window" {
|
||||
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]);
|
||||
let _ = sub.append_items(&[
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::bring_all_to_front(Some(lightcraft_ui_egui::i18n::tr("Bring All to Front"))),
|
||||
]);
|
||||
sub.set_as_windows_menu_for_nsapp();
|
||||
}
|
||||
if title == "Help" {
|
||||
@@ -282,12 +288,23 @@ impl NativeMenu {
|
||||
let accel = shortcut.as_deref().filter(|s| !CONTEXTUAL.contains(s)).and_then(accelerator);
|
||||
let handle = match checked {
|
||||
Some(c) => {
|
||||
let it = CheckMenuItem::with_id(key.clone(), label_text(label), *enabled, *c, accel);
|
||||
let it = CheckMenuItem::with_id(
|
||||
key.clone(),
|
||||
lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&"),
|
||||
*enabled,
|
||||
*c,
|
||||
accel,
|
||||
);
|
||||
let _ = sub.append(&it);
|
||||
Handle::Check(it)
|
||||
}
|
||||
None => {
|
||||
let it = MenuItem::with_id(key.clone(), label_text(label), *enabled, accel);
|
||||
let it = MenuItem::with_id(
|
||||
key.clone(),
|
||||
lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&"),
|
||||
*enabled,
|
||||
accel,
|
||||
);
|
||||
let _ = sub.append(&it);
|
||||
Handle::Plain(it)
|
||||
}
|
||||
@@ -325,6 +342,7 @@ impl NativeMenu {
|
||||
/// Per frame: run chosen items, then sync labels / enabled / checked and the text-focus
|
||||
/// accelerators with the app state.
|
||||
pub fn update(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
lightcraft_ui_egui::i18n::set_language(app.ui.language);
|
||||
while let Ok(key) = self.rx.try_recv() {
|
||||
if key == QUIT {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
@@ -352,8 +370,8 @@ impl NativeMenu {
|
||||
let Some(it) = items.get_mut(&MenuNode::key(id, params)) else { continue };
|
||||
if it.label != *label {
|
||||
match &it.handle {
|
||||
Handle::Plain(h) => h.set_text(label_text(label)),
|
||||
Handle::Check(h) => h.set_text(label_text(label)),
|
||||
Handle::Plain(h) => h.set_text(lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&")),
|
||||
Handle::Check(h) => h.set_text(lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&")),
|
||||
}
|
||||
it.label = label.clone();
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
@@ -19,6 +21,8 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `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 |
|
||||
|
||||
| `assets/fonts/BIZUDPGothic-*.ttf` | BIZ UDPGothic (Regular, Bold) | Morisawa / BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic/tree/18934af56b9c003ca58c54bffbf226848cb11032 | SIL Open Font License 1.1 (`assets/fonts/OFL-BIZUDGothic.txt`) | 2026-10-05 | none |
|
||||
|
||||
Notes
|
||||
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
|
||||
OFL-licensed: the asset rule excludes anything from Adobe. Source Sans 3 was removed on 2026-09-30 and replaced by Inter.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The BIZ UDGothic 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,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.
|
||||
@@ -44,6 +44,11 @@ fn photo(c: &mut Catalog, i: u64) -> Photo {
|
||||
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;
|
||||
@@ -153,6 +158,47 @@ fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
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
|
||||
|
||||
+204
-26
@@ -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,6 +11,12 @@
|
||||
//! 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
|
||||
@@ -27,6 +34,32 @@
|
||||
//!
|
||||
//! 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};
|
||||
@@ -34,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)]
|
||||
@@ -62,10 +97,16 @@ 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
|
||||
@@ -140,6 +181,9 @@ pub struct Journal {
|
||||
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.
|
||||
@@ -168,18 +212,58 @@ 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
|
||||
@@ -205,10 +289,16 @@ fn write_snapshot(store: &mut dyn Store, seq: u64, catalog: &Catalog) -> std::io
|
||||
Ok(SnapshotTiming { serialize_ms, write_sync_ms: (ms_since(t0) - serialize_ms).max(0.0), bytes, ..Default::default() })
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
@@ -221,15 +311,20 @@ 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 {
|
||||
@@ -242,6 +337,7 @@ impl Journal {
|
||||
stats: PersistStats::default(),
|
||||
pending: None,
|
||||
retry_at_records: 0,
|
||||
tail_dirty: false,
|
||||
};
|
||||
let log = log.unwrap_or_default();
|
||||
|
||||
@@ -261,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;
|
||||
@@ -277,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);
|
||||
}
|
||||
@@ -301,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))
|
||||
@@ -323,7 +466,20 @@ 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;
|
||||
@@ -343,12 +499,33 @@ impl Journal {
|
||||
|
||||
/// Write a snapshot of `catalog` (which must reflect every appended op) and reset the log.
|
||||
pub fn snapshot(&mut self, catalog: &Catalog) -> Result<()> {
|
||||
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 mut timing = write_snapshot(self.store.as_mut(), self.seq, catalog).map_err(io)?;
|
||||
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)?;
|
||||
@@ -357,10 +534,10 @@ impl Journal {
|
||||
timing.blocking_ms = timing.total_ms;
|
||||
timing.records = self.log_records;
|
||||
self.record_snapshot(timing);
|
||||
self.snapshot_seq = self.seq;
|
||||
self.log_records = 0;
|
||||
self.log_bytes = 0;
|
||||
self.retry_at_records = 0;
|
||||
self.tail_dirty = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -451,6 +628,7 @@ impl Journal {
|
||||
self.log_records = p.tail_records;
|
||||
self.log_bytes = p.tail.len() as u64;
|
||||
self.retry_at_records = 0;
|
||||
self.tail_dirty = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -5,15 +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;
|
||||
|
||||
@@ -24,6 +32,8 @@ pub use dates::{DateRun, GroupBy};
|
||||
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};
|
||||
@@ -44,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>;
|
||||
@@ -190,6 +204,12 @@ pub enum Op {
|
||||
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>,
|
||||
@@ -209,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,
|
||||
@@ -547,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 {
|
||||
@@ -624,4 +655,12 @@ 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,10 @@ pub struct Photo {
|
||||
/// picture style baked in — so it is treated as a rendered (non-raw) source.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub preview_only: Option<String>,
|
||||
/// 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.
|
||||
@@ -319,6 +323,7 @@ impl Photo {
|
||||
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
|
||||
@@ -326,6 +331,11 @@ impl Photo {
|
||||
pub fn develops_raw(&self) -> bool {
|
||||
self.kind == MediaKind::Raw && self.preview_only.is_none()
|
||||
}
|
||||
/// The current ARW reader has vendor WB multipliers but no measured camera illuminant.
|
||||
/// Use adjustments relative to the camera's as-shot look, as for rendered photographs.
|
||||
pub fn relative_wb(&self) -> bool {
|
||||
self.develops_raw() && self.format.eq_ignore_ascii_case("ARW")
|
||||
}
|
||||
/// 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 {
|
||||
@@ -337,7 +347,8 @@ impl Photo {
|
||||
/// The built-in defaults for this photo, before any user default preset: raws start from
|
||||
/// their as-shot white balance; embedded lens corrections on when the file has them.
|
||||
pub fn camera_defaults(&self) -> DevelopSettings {
|
||||
let mut d = match self.as_shot_wb {
|
||||
let wb = if self.relative_wb() { Some((6500.0, 0.0)) } else { self.as_shot_wb };
|
||||
let mut d = match wb {
|
||||
Some((t, tint)) => DevelopSettings::for_raw(t, tint),
|
||||
None => DevelopSettings::default(),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,12 @@ pub trait Store: Send {
|
||||
self.write_atomic(name, &buf)?;
|
||||
Ok(buf.len() as u64)
|
||||
}
|
||||
/// Append and make durable (fsync) before returning.
|
||||
/// 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;
|
||||
@@ -132,15 +135,26 @@ impl Store for FsStore {
|
||||
self.appender = Some((name.to_string(), f));
|
||||
}
|
||||
let Some((_, f)) = self.appender.as_mut() else { return Err(io::Error::other("appender not open")) };
|
||||
f.write_all(data)?;
|
||||
f.sync_data()
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -180,7 +180,8 @@ fn failed_background_snapshot_loses_nothing_and_retries_later() {
|
||||
wait_written(&j);
|
||||
assert!(j.poll().is_err());
|
||||
assert_eq!((j.snapshot_seq(), j.stats().failed_snapshots), (0, 1));
|
||||
assert!(g.files.get(SNAPSHOT).is_none());
|
||||
// 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());
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -283,7 +283,12 @@ fn streamed_snapshot_keeps_the_on_disk_format() {
|
||||
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\":1,\"seq\":{},\"catalog\":{}}}\n", j.seq(), c.to_snapshot());
|
||||
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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ 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 }
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Is a file there? — answered without blocking the caller.
|
||||
//!
|
||||
//! On a sleeping NAS, a dropped share or a USB drive spinning up, one `stat` can take as long as
|
||||
//! the OS or SMB timeout. The UI asks about files every frame (grid cells, the Info panel, the
|
||||
//! Missing Photos view), so in the app the answers come from a cache that a worker thread fills
|
||||
//! ([`Availability::run_in_background`]): an unknown file is reported as `None` ("assume it is
|
||||
//! there"; the render worker finds out if it isn't), known answers are rechecked after
|
||||
//! [`RECHECK`]. Everywhere else (CLI, MCP, tests) the default is synchronous: every question is a
|
||||
//! fresh check, as before.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Checks whether a path exists (injectable: tests use a slow one).
|
||||
pub type ExistsProbe = Arc<dyn Fn(&str) -> bool + Send + Sync>;
|
||||
/// Called (from the worker) when an answer changed, e.g. to repaint the UI.
|
||||
pub type Waker = Arc<dyn Fn() + Send + Sync>;
|
||||
|
||||
/// How long a cached answer is used before it is checked again (it is still returned meanwhile).
|
||||
pub const RECHECK: Duration = Duration::from_secs(15);
|
||||
|
||||
fn fs_exists(path: &str) -> bool {
|
||||
// the browser build has no file system: its originals are always "there"
|
||||
cfg!(target_arch = "wasm32") || std::path::Path::new(path).exists()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
/// path → (exists, when checked)
|
||||
known: HashMap<String, (bool, std::time::Instant)>,
|
||||
queue: VecDeque<String>,
|
||||
queued: HashSet<String>,
|
||||
worker: bool,
|
||||
/// Bumped whenever an answer changes (callers recompute what depends on it).
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
fn lock(m: &Mutex<State>) -> MutexGuard<'_, State> {
|
||||
m.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// File availability, synchronous or cached + background-checked. Cloning shares the cache.
|
||||
#[derive(Clone)]
|
||||
pub struct Availability {
|
||||
probe: ExistsProbe,
|
||||
background: Option<Waker>,
|
||||
state: Arc<Mutex<State>>,
|
||||
}
|
||||
|
||||
impl Default for Availability {
|
||||
fn default() -> Self {
|
||||
Self { probe: Arc::new(fs_exists), background: None, state: Arc::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Availability {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Availability").field("background", &self.background.is_some()).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Availability {
|
||||
/// Replace the existence check (tests: a slow or counting probe). Forgets cached answers.
|
||||
pub fn set_probe(&mut self, probe: ExistsProbe) {
|
||||
self.probe = probe;
|
||||
let mut st = lock(&self.state);
|
||||
st.known.clear();
|
||||
st.generation += 1;
|
||||
}
|
||||
|
||||
/// Answer from the cache and check on a worker thread from now on; `wake` runs when an
|
||||
/// answer changed. No effect in the browser build (no threads, no file system).
|
||||
pub fn run_in_background(&mut self, wake: Waker) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
}
|
||||
self.background = Some(wake);
|
||||
}
|
||||
|
||||
/// The existence check itself (blocking: for worker threads, e.g. the Rename preview's).
|
||||
pub fn probe(&self) -> ExistsProbe {
|
||||
self.probe.clone()
|
||||
}
|
||||
|
||||
pub fn is_background(&self) -> bool {
|
||||
self.background.is_some()
|
||||
}
|
||||
|
||||
/// Does `path` exist? Synchronous mode: checked now. Background mode: the cached answer
|
||||
/// (`None` while unknown); unknown or stale answers are queued for the worker.
|
||||
pub fn exists(&self, path: &str) -> Option<bool> {
|
||||
let Some(wake) = &self.background else {
|
||||
return Some((self.probe)(path));
|
||||
};
|
||||
let mut st = lock(&self.state);
|
||||
let now = std::time::Instant::now();
|
||||
let cached = st.known.get(path).copied();
|
||||
if cached.is_none_or(|(_, at)| now.saturating_duration_since(at) >= RECHECK) && !st.queued.contains(path) {
|
||||
st.queued.insert(path.to_string());
|
||||
st.queue.push_back(path.to_string());
|
||||
if !st.worker {
|
||||
st.worker = true;
|
||||
drop(st);
|
||||
self.spawn_worker(wake.clone());
|
||||
return cached.map(|c| c.0);
|
||||
}
|
||||
}
|
||||
cached.map(|c| c.0)
|
||||
}
|
||||
|
||||
/// Known to be missing (unknown counts as present).
|
||||
pub fn is_offline(&self, path: &str) -> bool {
|
||||
self.exists(path) == Some(false)
|
||||
}
|
||||
|
||||
/// Changes whenever a cached answer changes.
|
||||
pub fn generation(&self) -> u64 {
|
||||
lock(&self.state).generation
|
||||
}
|
||||
|
||||
/// Paths waiting for the worker.
|
||||
pub fn pending(&self) -> usize {
|
||||
lock(&self.state).queue.len()
|
||||
}
|
||||
|
||||
fn spawn_worker(&self, wake: Waker) {
|
||||
let (state, probe) = (self.state.clone(), self.probe.clone());
|
||||
let started = std::thread::Builder::new().name("lc-availability".into()).spawn(move || {
|
||||
loop {
|
||||
let path = {
|
||||
let mut st = lock(&state);
|
||||
let Some(p) = st.queue.pop_front() else {
|
||||
st.worker = false;
|
||||
return;
|
||||
};
|
||||
p
|
||||
};
|
||||
let ok = probe(&path);
|
||||
let changed = {
|
||||
let mut st = lock(&state);
|
||||
st.queued.remove(&path);
|
||||
let prev = st.known.insert(path, (ok, std::time::Instant::now()));
|
||||
let changed = prev.map(|p| p.0) != Some(ok);
|
||||
if changed {
|
||||
st.generation += 1;
|
||||
}
|
||||
changed
|
||||
};
|
||||
if changed {
|
||||
wake();
|
||||
}
|
||||
}
|
||||
});
|
||||
if started.is_err() {
|
||||
// try again on the next question
|
||||
lock(&self.state).worker = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn synchronous_by_default() {
|
||||
let mut a = Availability::default();
|
||||
let n = Arc::new(AtomicUsize::new(0));
|
||||
let c = n.clone();
|
||||
a.set_probe(Arc::new(move |p| {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
p == "here"
|
||||
}));
|
||||
assert_eq!(a.exists("here"), Some(true));
|
||||
assert_eq!(a.exists("gone"), Some(false));
|
||||
assert_eq!(a.exists("gone"), Some(false));
|
||||
assert_eq!(n.load(Ordering::SeqCst), 3, "every question is a fresh check");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_answers_from_the_cache_without_blocking() {
|
||||
let mut a = Availability::default();
|
||||
let caller = std::thread::current().id();
|
||||
let on_caller = Arc::new(AtomicUsize::new(0));
|
||||
let c = on_caller.clone();
|
||||
a.set_probe(Arc::new(move |p| {
|
||||
if std::thread::current().id() == caller {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200)); // a sleeping NAS
|
||||
p == "here"
|
||||
}));
|
||||
let woke = Arc::new(AtomicUsize::new(0));
|
||||
let w = woke.clone();
|
||||
a.run_in_background(Arc::new(move || {
|
||||
w.fetch_add(1, Ordering::SeqCst);
|
||||
}));
|
||||
let t0 = std::time::Instant::now();
|
||||
assert_eq!(a.exists("here"), None, "unknown at first");
|
||||
assert_eq!(a.exists("gone"), None);
|
||||
assert!(t0.elapsed() < Duration::from_millis(150), "the caller never waits for the probe");
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(20);
|
||||
while (a.exists("here").is_none() || a.exists("gone").is_none()) && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert_eq!(a.exists("here"), Some(true));
|
||||
assert_eq!(a.exists("gone"), Some(false));
|
||||
assert!(a.is_offline("gone") && !a.is_offline("here") && !a.is_offline("unknown"));
|
||||
assert_eq!(on_caller.load(Ordering::SeqCst), 0, "never probed on the calling thread");
|
||||
assert!(woke.load(Ordering::SeqCst) >= 2);
|
||||
assert!(a.generation() >= 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Estimate an ARW starting look from its own JPEG. Colour and luminance are fitted separately;
|
||||
//! the JPEG supplies correspondences only, never output pixels or a replacement for RAW editing.
|
||||
use lightcraft_color::{Mat3, luminance_2020};
|
||||
use lightcraft_pipeline::tone::{CameraTone, ToneMap};
|
||||
use lightcraft_raster::{
|
||||
Rgb32f,
|
||||
resample::{Filter, fit},
|
||||
};
|
||||
use lightcraft_raw::{RawImage, color::CameraTransform};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct CameraLook {
|
||||
pub matrix: Mat3,
|
||||
pub tone: CameraTone,
|
||||
}
|
||||
|
||||
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
|
||||
if !transform.matrix_is_fallback || raw.format != lightcraft_raw::RawFormat::Arw {
|
||||
return None;
|
||||
}
|
||||
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||||
let decoded = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions { max_size: Some((384, 384)), max_pixels: 64_000_000 }).ok()?;
|
||||
let reference = decoded.to_working();
|
||||
let crop = raw.crop.clipped(raw.active_area.width, raw.active_area.height);
|
||||
if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 {
|
||||
return None;
|
||||
}
|
||||
let aspect = crop.width as f64 / crop.height as f64;
|
||||
if (reference.width as f64 / reference.height as f64 / aspect - 1.0).abs() > 0.02 {
|
||||
return None;
|
||||
}
|
||||
// Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution.
|
||||
let k = (crop.width.max(crop.height).div_ceil(384).max(2)).div_ceil(2) * 2;
|
||||
let sensor = raw.develop_binned(k, 0.99).ok()??;
|
||||
let mut sensor = fit(&sensor, 96, 96, Filter::Box);
|
||||
let reference = fit(&reference, sensor.width, sensor.height, Filter::Box);
|
||||
let gain = 2f32.powf(transform.baseline_exposure as f32);
|
||||
sensor.map_in_place(|p| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain)));
|
||||
let look = fit_pairs(&sensor, &reference)?;
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!("[profile] ARW camera look: {:?}, {:?}", look.matrix.0, look.tone);
|
||||
}
|
||||
Some(look)
|
||||
}
|
||||
|
||||
/// A fit must cut the held-out squared error to below this share of the fallback's.
|
||||
const MIN_IMPROVEMENT: f64 = 0.7;
|
||||
/// ...and stay within this per-channel RMS of the camera JPEG (linear display values). On public
|
||||
/// raw.pixls.us samples (eight Sony bodies) good fits that still beat the fallback 1.5–3× landed
|
||||
/// at 0.065–0.093 (camera local tone, vignetting and lens processing that a global matrix + curve
|
||||
/// cannot follow): visibly better renders that 0.055 rejected.
|
||||
const MAX_HOLDOUT_RMS: f64 = 0.10;
|
||||
|
||||
fn luma(p: [f64; 3]) -> f64 {
|
||||
p[0] * 0.2627 + p[1] * 0.6780 + p[2] * 0.0593
|
||||
}
|
||||
|
||||
fn displayed(scene: [f64; 3], tone: &ToneMap) -> [f64; 3] {
|
||||
let scene = scene.map(|v| v.max(0.0));
|
||||
let y = luma(scene);
|
||||
if y <= 0.0 {
|
||||
return [0.0; 3];
|
||||
}
|
||||
scene.map(|v| v * f64::from(tone.apply(y as f32)) / y)
|
||||
}
|
||||
|
||||
fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
|
||||
if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() {
|
||||
return None;
|
||||
}
|
||||
let mut pairs = Vec::new();
|
||||
let mut colour = 0;
|
||||
for (input, output) in sensor.data.iter().zip(&reference.data) {
|
||||
let y = luminance_2020(*output);
|
||||
if !input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5)
|
||||
|| !output.iter().all(|v| v.is_finite() && *v > 0.004 && *v < 0.98)
|
||||
|| !(0.015..0.85).contains(&y)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let min = output.iter().copied().fold(f32::INFINITY, f32::min);
|
||||
let max = output.iter().copied().fold(0.0, f32::max);
|
||||
colour += usize::from(max - min > 0.05);
|
||||
pairs.push((input.map(f64::from), output.map(f64::from)));
|
||||
}
|
||||
if pairs.len() < 256 || colour < pairs.len() / 20 {
|
||||
return None;
|
||||
}
|
||||
let mut gram = [[0.0; 3]; 3];
|
||||
let mut cross = [[0.0; 3]; 3];
|
||||
for (i, (x, y)) in pairs.iter().enumerate() {
|
||||
if i % 3 == 0 {
|
||||
continue;
|
||||
}
|
||||
let (lx, ly) = (luma(*x), luma(*y));
|
||||
for row in 0..3 {
|
||||
for col in 0..3 {
|
||||
// Normalising by luminance prevents a camera S-curve from corrupting colour.
|
||||
gram[row][col] += x[row] * x[col] / (lx * lx);
|
||||
cross[row][col] += y[row] * x[col] / (ly * lx);
|
||||
}
|
||||
}
|
||||
}
|
||||
let trace: f64 = (0..3).map(|i| gram[i][i]).sum();
|
||||
let inverse = Mat3(gram).inverse()?;
|
||||
let condition = trace * (0..3).map(|i| inverse.0[i][i].abs()).sum::<f64>();
|
||||
if trace <= 0.0 || !condition.is_finite() || condition > 1e6 {
|
||||
return None;
|
||||
}
|
||||
let regularization = trace * 1e-4;
|
||||
for i in 0..3 {
|
||||
gram[i][i] += regularization;
|
||||
cross[i][i] += regularization;
|
||||
}
|
||||
let matrix = Mat3(cross).mul(&Mat3(gram).inverse()?);
|
||||
if !matrix.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0) {
|
||||
return None;
|
||||
}
|
||||
let tone_pairs: Vec<_> =
|
||||
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(matrix.apply(*x).map(|v| v.max(0.0))), luma(*y))).collect();
|
||||
let curve = fit_tone(tone_pairs)?;
|
||||
let tone = ToneMap::camera(&curve, 0.0, 0.0, 0.0);
|
||||
let original_tone = ToneMap::new(0.0, 0.0, 0.0);
|
||||
let mut before = 0.0;
|
||||
let mut after = 0.0;
|
||||
let mut samples = 0;
|
||||
for (i, (x, target)) in pairs.iter().enumerate() {
|
||||
if i % 3 != 0 {
|
||||
continue;
|
||||
}
|
||||
let corrected = displayed(matrix.apply(*x), &tone);
|
||||
let original = displayed(*x, &original_tone);
|
||||
for c in 0..3 {
|
||||
before += (original[c] - target[c]).powi(2);
|
||||
after += (corrected[c] - target[c]).powi(2);
|
||||
samples += 1;
|
||||
}
|
||||
}
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!("[profile] ARW holdout RMS {:.5} -> {:.5} ({samples} channels)", (before / samples as f64).sqrt(), (after / samples as f64).sqrt());
|
||||
}
|
||||
if !after.is_finite() || samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) {
|
||||
return None;
|
||||
}
|
||||
Some(CameraLook { matrix, tone: curve })
|
||||
}
|
||||
|
||||
fn median(values: &mut [f64]) -> Option<f64> {
|
||||
values.sort_by(f64::total_cmp);
|
||||
values.get(values.len() / 2).copied()
|
||||
}
|
||||
|
||||
fn fit_tone(mut pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
|
||||
if pairs.len() < 128 || !pairs.iter().all(|(x, y)| x.is_finite() && *x > 0.0 && y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut knots = [[0.0; 2]; 32];
|
||||
for (i, knot) in knots.iter_mut().enumerate() {
|
||||
let bin = pairs.get(i * pairs.len() / 32..(i + 1) * pairs.len() / 32)?;
|
||||
let mut xs: Vec<_> = bin.iter().map(|p| p.0).collect();
|
||||
let mut ys: Vec<_> = bin.iter().map(|p| p.1).collect();
|
||||
*knot = [median(&mut xs)? as f32, median(&mut ys)? as f32];
|
||||
}
|
||||
if knots[31][0] < knots[0][0] * 1.5 {
|
||||
return None;
|
||||
}
|
||||
// Pool adjacent violating bins (isotonic regression): no reversals or arbitrary polynomial.
|
||||
let mut blocks: Vec<(f32, usize)> = Vec::new();
|
||||
for knot in knots {
|
||||
blocks.push((knot[1], 1));
|
||||
while blocks.len() >= 2 {
|
||||
let (a, an) = *blocks.get(blocks.len() - 2)?;
|
||||
let (b, bn) = *blocks.last()?;
|
||||
if a <= b {
|
||||
break;
|
||||
}
|
||||
blocks.truncate(blocks.len() - 2);
|
||||
blocks.push(((a * an as f32 + b * bn as f32) / (an + bn) as f32, an + bn));
|
||||
}
|
||||
}
|
||||
let mut i = 0;
|
||||
for (y, n) in blocks {
|
||||
for knot in knots.get_mut(i..i + n)? {
|
||||
knot[1] = y;
|
||||
}
|
||||
i += n;
|
||||
}
|
||||
CameraTone::new(knots)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn separates_nonlinear_tone_from_colour_and_keeps_sensor_headroom() {
|
||||
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
|
||||
let mut sensor = Rgb32f::new(64, 64);
|
||||
let mut reference = sensor.clone();
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
let ev = 0.05 + (i % 31) as f32 * 0.017;
|
||||
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
|
||||
let p = known.apply_f32(*src);
|
||||
let y = luminance_2020(p);
|
||||
*dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y);
|
||||
}
|
||||
let original = sensor.clone();
|
||||
let fit = fit_pairs(&sensor, &reference).unwrap();
|
||||
assert_eq!(sensor.data, original.data);
|
||||
let tone = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0);
|
||||
let error: f64 = sensor
|
||||
.data
|
||||
.iter()
|
||||
.zip(&reference.data)
|
||||
.map(|(x, y)| {
|
||||
let p = displayed(fit.matrix.apply(x.map(f64::from)), &tone);
|
||||
(0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::<f64>() / 3.0
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ sensor.data.len() as f64;
|
||||
assert!(error.sqrt() < 0.025, "{error}");
|
||||
// The colour transform is homogeneous; tone mapping happens only after exposure.
|
||||
let p = fit.matrix.apply([2.0, 2.0, 2.0]);
|
||||
assert!(luma(p) > 1.0);
|
||||
assert!(tone.apply(0.2) < tone.apply(0.4));
|
||||
}
|
||||
#[test]
|
||||
fn accepts_a_much_better_fit_despite_local_camera_processing() {
|
||||
// The camera JPEG departs from any global matrix + curve (local tone, vignetting): ±0.12
|
||||
// per-pixel deviations, ~0.07 RMS. The fit is still far closer than the fallback.
|
||||
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
|
||||
let mut sensor = Rgb32f::new(64, 64);
|
||||
let mut reference = sensor.clone();
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
let ev = 0.05 + (i % 31) as f32 * 0.017;
|
||||
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
|
||||
let p = known.apply_f32(*src);
|
||||
let y = luminance_2020(p);
|
||||
*dst = p.map(|v| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
let noise = (seed % 2001) as f32 / 1000.0 - 1.0;
|
||||
(v * (1.0 - (-2.5 * y).exp()) / y + 0.12 * noise).clamp(0.005, 0.97)
|
||||
});
|
||||
}
|
||||
assert!(fit_pairs(&sensor, &reference).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_monochrome_invalid_and_unrelated_previews() {
|
||||
let mut sensor = Rgb32f::new(32, 32);
|
||||
let mut reference = sensor.clone();
|
||||
for (i, p) in sensor.data.iter_mut().enumerate() {
|
||||
*p = [0.1 + (i % 13) as f32 * 0.02, 0.15, 0.1];
|
||||
}
|
||||
reference.data.fill([0.2; 3]);
|
||||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||||
reference.data.fill([f32::NAN; 3]);
|
||||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
*src = [0.04 + (i % 11) as f32 * 0.02, 0.05 + (i % 17) as f32 * 0.01, 0.03 + (i % 23) as f32 * 0.01];
|
||||
*dst = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01];
|
||||
}
|
||||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||||
sensor.data.fill([0.1, 0.15, 0.12]);
|
||||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||||
reference.data.truncate(8);
|
||||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||||
}
|
||||
}
|
||||
+177
-35
@@ -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();
|
||||
@@ -60,7 +166,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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}",
|
||||
"{path, name} — rename a folder on disk (its files and sidecars go along) and relink the photos in it; one undo step (undo renames it back, refused if the old name is taken) → {path, relinked}",
|
||||
always,
|
||||
|s, p| {
|
||||
let from = str_param(p, "path").ok_or_else(|| bad("folder.rename", "missing path"))?.trim_end_matches(['/', '\\']).to_string();
|
||||
@@ -78,7 +184,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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}",
|
||||
"{path, into: destination folder} — move a folder on disk into another and relink the photos in it; one undo step (undo moves it back, refused if the old place is taken) → {path, relinked}",
|
||||
always,
|
||||
|s, p| {
|
||||
let from = str_param(p, "path").ok_or_else(|| bad("folder.move", "missing path"))?.trim_end_matches(['/', '\\']).to_string();
|
||||
@@ -98,62 +204,98 @@ 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;
|
||||
/// Rename `from` to `to` on disk (a folder; everything in it goes along). Never overwrites:
|
||||
/// refused when `to` exists (a case-only rename on a case-insensitive file system goes through
|
||||
/// a temporary name). Missing parents of `to` are created.
|
||||
pub(crate) fn rename_folder_on_disk(from: &str, to: &str) -> std::result::Result<(), String> {
|
||||
let (src, dst) = (Path::new(from), Path::new(to));
|
||||
if !src.is_dir() {
|
||||
return Err(format!("{from} is not a folder"));
|
||||
return Err(format!("{from} is not a folder (moved or deleted?)"));
|
||||
}
|
||||
if dst.exists() {
|
||||
let case_only = from != to && from.to_lowercase() == to.to_lowercase();
|
||||
if dst.exists() && !case_only {
|
||||
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
|
||||
if case_only {
|
||||
let tmp = src.with_file_name(format!(".lc-rename-{}", std::process::id()));
|
||||
if tmp.exists() {
|
||||
return Err(format!("{} already exists", tmp.display()));
|
||||
}
|
||||
std::fs::rename(src, &tmp).map_err(|e| format!("{from} → {to}: {e}"))?;
|
||||
return std::fs::rename(&tmp, dst).map_err(|e| {
|
||||
let _ = std::fs::rename(&tmp, src);
|
||||
format!("{from} → {to}: {e}")
|
||||
});
|
||||
}
|
||||
std::fs::rename(src, dst).map_err(|e| format!("{from} → {to}: {e}"))
|
||||
}
|
||||
|
||||
/// After a folder moved from `from` to `to`: a browsed folder at or below it follows.
|
||||
pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) {
|
||||
if let Some(b) = &mut s.browse
|
||||
&& let Ok(rel) = Path::new(&b.path).strip_prefix(from)
|
||||
{
|
||||
b.path = Path::new(to).join(rel).to_string_lossy().to_string();
|
||||
let f = b.path.clone();
|
||||
s.filter.folder = Some(f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename or move a folder on disk (everything in it goes along, sidecars included) and relink
|
||||
/// the photos inside, as one undo step: undo renames the folder back and restores the photos'
|
||||
/// paths, redo repeats the move — each refused (and reported) when the destination is taken.
|
||||
pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result::Result<usize, String> {
|
||||
let (src, dst) = (Path::new(from), Path::new(to));
|
||||
if !src.is_dir() {
|
||||
return Err(format!("{from} is not a folder"));
|
||||
}
|
||||
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());
|
||||
}
|
||||
rename_folder_on_disk(from, to)?;
|
||||
let ops: Vec<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,
|
||||
}
|
||||
.map(|(id, file_name, np)| 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);
|
||||
let n = ops.len();
|
||||
let name = Path::new(to).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let renamed = src.parent() == dst.parent();
|
||||
let label = if renamed { format!("Rename Folder to “{name}”") } else { format!("Move Folder “{name}”") };
|
||||
let folder = crate::FolderMove { from: to.to_string(), to: from.to_string() };
|
||||
if let Err(e) = s.commit_with_folder(&label, Op::Batch { ops }, folder) {
|
||||
// the catalog didn't take it: put the folder back where the photos still point
|
||||
let back = rename_folder_on_disk(to, from).err().map(|b| format!("; moving it back failed too: {b}")).unwrap_or_default();
|
||||
return Err(format!("{e}{back}"));
|
||||
}
|
||||
follow_folder(s, from, to);
|
||||
Ok(n)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ const PROBE_EDGE: usize = 384;
|
||||
pub(crate) fn probe(s: &mut Session, c: &str, x: f64, y: f64, neutral: impl FnOnce(&mut DevelopSettings)) -> Result<[f32; 3]> {
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let src = s.source_now(id, 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 info = s.source_info(id);
|
||||
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
neutral(&mut d);
|
||||
let req = lightcraft_pipeline::RenderRequest::fit(PROBE_EDGE, PROBE_EDGE);
|
||||
|
||||
@@ -10,24 +10,28 @@ use serde_json::{Value, json};
|
||||
use super::{CommandSpec, bad, cmd, has_active, has_selection};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// A free `<stem>.dng` (then `<stem>-2.dng`…) next to `path`.
|
||||
fn dng_path(path: &str) -> String {
|
||||
/// `<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(""));
|
||||
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());
|
||||
let mut out = dir.join(format!("{stem}.dng"));
|
||||
let mut n = 2;
|
||||
while out.exists() {
|
||||
out = dir.join(format!("{stem}-{n}.dng"));
|
||||
n += 1;
|
||||
}
|
||||
out.to_string_lossy().to_string()
|
||||
(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`); returns the new path.
|
||||
/// An empty `packet` writes no XMP.
|
||||
/// 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 {
|
||||
write_dng_with(s.media.file_bytes.as_ref(), path, packet)
|
||||
}
|
||||
|
||||
/// [`write_dng_for`] without the session (imports convert on a worker thread).
|
||||
pub(crate) fn write_dng_with(file_bytes: Option<&crate::merge::ByteReader>, path: &str, packet: String) -> std::result::Result<String, String> {
|
||||
let bytes = match file_bytes {
|
||||
Some(r) => r(path)?,
|
||||
None => std::fs::read(path).map_err(|e| format!("{path}: {e}"))?,
|
||||
};
|
||||
@@ -35,9 +39,21 @@ pub(crate) fn write_dng_for(s: &Session, path: &str, packet: String) -> std::res
|
||||
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 out = dng_path(path);
|
||||
std::fs::write(&out, dng).map_err(|e| format!("{out}: {e}"))?;
|
||||
Ok(out)
|
||||
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> {
|
||||
@@ -133,7 +149,7 @@ fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let mut written = Vec::new();
|
||||
let mut write = |path: &str, bytes: &[u8]| -> std::result::Result<(), String> {
|
||||
std::fs::write(path, bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
crate::export::write_file(path, bytes)?;
|
||||
written.push(path.to_string());
|
||||
Ok(())
|
||||
};
|
||||
|
||||
@@ -98,7 +98,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
}
|
||||
let label = if vals.len() == 1 { controls::find(&vals[0].0).map(|c| c.label).unwrap_or("Edit").to_string() } else { "Edit".into() };
|
||||
let apply = |d: &mut DevelopSettings| {
|
||||
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo| {
|
||||
if d.wb.mode == WbMode::AsShot && vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") {
|
||||
d.wb.temp = info.as_shot_temp;
|
||||
d.wb.tint = info.as_shot_tint;
|
||||
}
|
||||
for (k, v) in &vals {
|
||||
controls::set(d, k, *v);
|
||||
if k == "wb.temp" || k == "wb.tint" {
|
||||
@@ -113,15 +117,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
|
||||
.filter_map(|(id, d)| {
|
||||
let mut d = (*d).clone();
|
||||
apply(&mut d);
|
||||
let info = s.source_info(id);
|
||||
apply(&mut d, &info);
|
||||
s.develop_op(id, d, &label)
|
||||
})
|
||||
.collect();
|
||||
s.commit(&label, Op::Batch { ops })?;
|
||||
return ok();
|
||||
}
|
||||
let id = active(s, "develop.set")?;
|
||||
let info = s.source_info(id);
|
||||
edit(s, "develop.set", &label, |d| {
|
||||
apply(d);
|
||||
apply(d, &info);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -130,7 +137,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let c = str_param(p, "control").ok_or_else(|| bad("develop.adjust", "missing control"))?.to_string();
|
||||
let spec = controls::find(&c).ok_or_else(|| bad("develop.adjust", "unknown control"))?;
|
||||
let delta = f64_req(p, "delta", "develop.adjust")?;
|
||||
let info = s.source_info(active(s, "develop.adjust")?);
|
||||
edit(s, "develop.adjust", spec.label, |d| {
|
||||
if c == "wb.temp" || c == "wb.tint" {
|
||||
if d.wb.mode == WbMode::AsShot {
|
||||
d.wb.temp = info.as_shot_temp;
|
||||
d.wb.tint = info.as_shot_tint;
|
||||
}
|
||||
d.wb.mode = WbMode::Custom;
|
||||
}
|
||||
let v = controls::get(d, &c).unwrap_or(spec.default);
|
||||
controls::set(d, &c, v + delta);
|
||||
Ok(())
|
||||
@@ -149,12 +164,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let ops = ids
|
||||
.iter()
|
||||
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
|
||||
.filter_map(|(id, d)| {
|
||||
.filter_map(|(id, _d)| {
|
||||
// back to the photo's import defaults when a default preset gave it its look
|
||||
let look = s.catalog.photo(id).and_then(|p| p.import_look.clone());
|
||||
let fresh = match look {
|
||||
Some(l) => (*l).clone(),
|
||||
None => DevelopSettings { wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, ..d.wb }, ..Default::default() },
|
||||
None => {
|
||||
let info = s.source_info(id);
|
||||
DevelopSettings {
|
||||
wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, temp: info.as_shot_temp, tint: info.as_shot_tint },
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
};
|
||||
s.develop_op(id, fresh, "Reset")
|
||||
})
|
||||
@@ -203,7 +224,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|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 info = s.source_info(id);
|
||||
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| {
|
||||
@@ -218,7 +239,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let info = s.source_info(id);
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let a = lightcraft_pipeline::auto::auto_tone(&src, &info, &d);
|
||||
edit(s, "develop.auto", "Auto", |d| {
|
||||
@@ -245,7 +266,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let mode: WbMode =
|
||||
serde_json::from_value(p.get("mode").cloned().unwrap_or(json!("custom"))).map_err(|e| bad("develop.wb", e.to_string()))?;
|
||||
let id = active(s, "develop.wb")?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let info = s.source_info(id);
|
||||
let (mut t, mut tint) = match mode {
|
||||
WbMode::AsShot => (info.as_shot_temp, info.as_shot_tint),
|
||||
WbMode::Auto => {
|
||||
@@ -256,7 +277,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.preset()
|
||||
.map(|(t, ti)| {
|
||||
// presets are relative to daylight for rendered files
|
||||
if info.raw { (t, ti) } else { (6500.0 * t / 5500.0, ti) }
|
||||
if info.raw && !info.relative_wb { (t, ti) } else { (6500.0 * t / 5500.0, ti) }
|
||||
})
|
||||
.unwrap_or((info.as_shot_temp, info.as_shot_tint)),
|
||||
};
|
||||
@@ -277,7 +298,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let (x, y) = (f64_req(p, "x", "develop.wbPick")?, f64_req(p, "y", "develop.wbPick")?);
|
||||
let id = active(s, "develop.wbPick")?;
|
||||
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.wbPick", e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let info = s.source_info(id);
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
// Sample a small neighbourhood in the oriented source.
|
||||
let o = src.oriented(d.orientation);
|
||||
@@ -405,7 +426,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, _| {
|
||||
let id = active(s, "crop.autoStraighten")?;
|
||||
let src = s.source_now(id, SourceLevel::Preview).map_err(|e| bad("crop.autoStraighten", e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let info = s.source_info(id);
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let Some(level) = lightcraft_pipeline::upright::level_degrees(&src, &info, &d) else {
|
||||
return Ok(json!({"changed": false, "reason": "no dominant horizontal or vertical lines"}));
|
||||
@@ -501,7 +522,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Upright::Off | Upright::Guided => None,
|
||||
m => {
|
||||
let src = s.source_now(id, SourceLevel::Preview).map_err(|e| bad("geometry.upright", e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let info = s.source_info(id);
|
||||
Some(lightcraft_pipeline::upright::auto_transform(&src, &info, &d, m))
|
||||
}
|
||||
};
|
||||
@@ -657,6 +678,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
|
||||
.filter_map(|(id, d)| {
|
||||
let mut nd = (*d).clone();
|
||||
if nd.wb.mode == WbMode::AsShot && (ctl == "wb.temp" || ctl == "wb.tint") {
|
||||
let info = s.source_info(id);
|
||||
nd.wb.temp = info.as_shot_temp;
|
||||
nd.wb.tint = info.as_shot_tint;
|
||||
}
|
||||
let v = controls::get(&nd, &ctl).unwrap_or(spec.default);
|
||||
controls::set(&mut nd, &ctl, v + delta);
|
||||
if ctl == "wb.temp" || ctl == "wb.tint" {
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -7,6 +7,105 @@ use serde_json::{Value, json};
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param};
|
||||
use crate::{LibrarySource, Result, Selection, Session};
|
||||
|
||||
/// The auto-import folder's visible files with their sizes (the file-system half of
|
||||
/// `library.autoImportScan`; the app lists on a worker thread and passes `listing`).
|
||||
pub fn list_auto_import_folder(folder: &str) -> std::result::Result<Vec<(String, u64)>, String> {
|
||||
let rd = std::fs::read_dir(folder).map_err(|e| format!("{folder}: {e}"))?;
|
||||
Ok(rd
|
||||
.flatten()
|
||||
.filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
|
||||
.filter_map(|e| {
|
||||
let m = std::fs::metadata(e.path()).ok()?;
|
||||
m.is_file().then(|| (e.path().to_string_lossy().to_string(), m.len()))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// `library.import`'s params, checked (no file-system calls).
|
||||
pub struct ImportRequest {
|
||||
pub paths: Vec<String>,
|
||||
pub opts: crate::import::ImportOptions,
|
||||
pub album: Option<u64>,
|
||||
pub album_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse and check `library.import`'s params (the app's import task runs the import itself, on a
|
||||
/// worker thread, with the same options).
|
||||
pub fn import_params(s: &Session, p: &Value) -> Result<ImportRequest> {
|
||||
let paths = strs(p, "paths");
|
||||
let mode = match str_param(p, "mode").unwrap_or("add") {
|
||||
"add" => crate::import::ImportMode::Add,
|
||||
"copy" => crate::import::ImportMode::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) => Some(s.presets.iter().find(|x| x.id == id).cloned().ok_or_else(|| bad("library.import", format!("unknown preset `{id}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let album = p.get("album").and_then(Value::as_u64);
|
||||
if let Some(a) = album
|
||||
&& s.catalog.album(AlbumId(a)).is_none_or(|al| al.folder || al.is_smart())
|
||||
{
|
||||
return Err(bad("library.import", "album must be a regular album"));
|
||||
}
|
||||
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: bool_or(p, "dng", false),
|
||||
local: bool_or(p, "local", false),
|
||||
};
|
||||
let album_name = str_param(p, "albumName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
Ok(ImportRequest { paths, opts, album, album_name })
|
||||
}
|
||||
|
||||
/// After a batch of an import was committed: the album (a new one named `album_name` when there
|
||||
/// is no `album` and something was imported) gets the photos. Returns the report as JSON (with
|
||||
/// `album`).
|
||||
pub fn import_batch_done(s: &mut Session, report: crate::import::ImportReport, mut album: Option<u64>, album_name: Option<&str>) -> Result<Value> {
|
||||
let mut report = serde_json::to_value(report).unwrap_or_default();
|
||||
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
|
||||
if album.is_none()
|
||||
&& let Some(name) = album_name
|
||||
&& !imported.is_empty()
|
||||
{
|
||||
let r = s.execute("album.create", &json!({"name": name}))?;
|
||||
album = r["id"].as_u64();
|
||||
}
|
||||
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);
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn album_param(p: &Value, key: &str, c: &str) -> Result<AlbumId> {
|
||||
p.get(key).and_then(Value::as_u64).map(AlbumId).ok_or_else(|| bad(c, format!("missing album `{key}`")))
|
||||
}
|
||||
@@ -657,9 +756,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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}",
|
||||
"{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), start?: bool (false: return the `library.import` params as `import` instead of importing)} — 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, import?}",
|
||||
always,
|
||||
|s, _| {
|
||||
|s, p| {
|
||||
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
|
||||
@@ -670,18 +769,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// the folder's files and sizes: listed here, or already listed on a worker thread (the app)
|
||||
let listing: Vec<(String, u64)> = match p.get("listing").and_then(Value::as_array) {
|
||||
Some(l) => l.iter().filter_map(|e| Some((e.get(0)?.as_str()?.to_string(), e.get(1)?.as_u64()?))).collect(),
|
||||
None => list_auto_import_folder(&folder).map_err(|e| bad("library.autoImportScan", e))?,
|
||||
};
|
||||
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('.')) {
|
||||
for (ps, size) in listing {
|
||||
if known.contains(&ps) {
|
||||
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);
|
||||
@@ -701,6 +802,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
for f in &fresh {
|
||||
s.auto_import_seen.insert(f.clone(), u64::MAX);
|
||||
}
|
||||
if p.get("start").and_then(Value::as_bool) == Some(false) {
|
||||
// the caller imports them (the app: on a worker thread)
|
||||
return Ok(json!({"imported": [], "folder": folder, "import": params}));
|
||||
}
|
||||
let sel = s.selection.clone();
|
||||
let r = s.execute("library.import", ¶ms)?;
|
||||
// arriving photos don't take over the selection
|
||||
@@ -751,79 +856,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{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");
|
||||
if paths.is_empty() {
|
||||
let req = import_params(s, p)?;
|
||||
if req.paths.is_empty() {
|
||||
return Err(bad("library.import", "no paths"));
|
||||
}
|
||||
let mode = match str_param(p, "mode").unwrap_or("add") {
|
||||
"add" => crate::import::ImportMode::Add,
|
||||
"copy" => crate::import::ImportMode::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) => {
|
||||
Some(s.presets.iter().find(|x| x.id == id).cloned().ok_or_else(|| bad("library.import", format!("unknown preset `{id}`")))?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let mut album = p.get("album").and_then(Value::as_u64);
|
||||
if let Some(a) = album
|
||||
&& s.catalog.album(AlbumId(a)).is_none_or(|al| al.folder || al.is_smart())
|
||||
{
|
||||
return Err(bad("library.import", "album must be a regular album"));
|
||||
}
|
||||
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();
|
||||
if album.is_none()
|
||||
&& let Some(name) = str_param(p, "albumName").map(str::trim).filter(|n| !n.is_empty())
|
||||
&& !imported.is_empty()
|
||||
{
|
||||
let r = s.execute("album.create", &json!({"name": name}))?;
|
||||
album = r["id"].as_u64();
|
||||
}
|
||||
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);
|
||||
}
|
||||
let report = crate::import::import_with(s, &req.paths, &req.opts)?;
|
||||
let report = import_batch_done(s, report, req.album, req.album_name.as_deref())?;
|
||||
// one undo step for the whole import
|
||||
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
|
||||
let n = s.undo.len().saturating_sub(undo0);
|
||||
s.merge_undo(n, &format!("Add {} Photo{}", imported.len(), if imported.len() == 1 { "" } else { "s" }));
|
||||
if let Some(f) = imported.first() {
|
||||
@@ -867,6 +908,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"logRecords": j.log_records(),
|
||||
"logBytes": j.log_bytes(),
|
||||
"lastError": lib.last_error,
|
||||
// settings files that were unreadable or damaged at open (kept, defaults used)
|
||||
"settingsWarnings": lib.settings_warnings,
|
||||
// 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": {
|
||||
|
||||
@@ -185,7 +185,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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 info = s.source_info(id);
|
||||
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))
|
||||
@@ -646,7 +646,7 @@ fn spots_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec<Spo
|
||||
/// An automatic source for `spot` on photo `id` (a small proxy of the photo, framed by `d`).
|
||||
fn pick_source(s: &mut Session, id: crate::PhotoId, d: &lightcraft_develop::DevelopSettings, spot: &Spot, avoid: Option<Point>) -> Option<Point> {
|
||||
let src = s.source_now(id, crate::media::SourceLevel::Thumb).ok()?;
|
||||
let info = crate::media::source_info(s.catalog.photo(id)?);
|
||||
let info = s.source_info(id);
|
||||
lightcraft_pipeline::spots::pick_source(&src, &info, d, spot, avoid)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! 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::{Catalog, Op, Photo, PhotoId, Source};
|
||||
@@ -44,9 +45,10 @@ pub fn missing(s: &Session) -> Vec<(PhotoId, String)> {
|
||||
}
|
||||
|
||||
/// 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())
|
||||
/// the photos its query already narrowed to). In the app `avail` answers from its cache (files
|
||||
/// not checked yet count as present until the background check finds them gone).
|
||||
pub fn is_missing(cat: &Catalog, avail: &crate::availability::Availability, id: PhotoId) -> bool {
|
||||
!cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| avail.is_offline(f))
|
||||
}
|
||||
|
||||
fn relink_op(id: PhotoId, path: &str) -> Op {
|
||||
@@ -83,48 +85,255 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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.
|
||||
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`"))?;
|
||||
/// 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())
|
||||
}
|
||||
|
||||
/// An in-scope photo ([`checked_path`]) as Find Missing Photos sees it: its file, the size and
|
||||
/// content hash the library knows (0 / `None` = unknown).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FindCandidate {
|
||||
pub id: PhotoId,
|
||||
pub path: String,
|
||||
pub size: u64,
|
||||
pub hash: Option<String>,
|
||||
}
|
||||
|
||||
/// Every in-scope photo, for [`plan_find_missing`]: the missing ones are looked for, and no
|
||||
/// photo's file is used for another. No file-system calls, so the app takes this snapshot on the
|
||||
/// UI thread and plans on a worker without holding the session.
|
||||
pub fn find_candidates(cat: &Catalog) -> Vec<FindCandidate> {
|
||||
cat.photos()
|
||||
.filter_map(|p| {
|
||||
checked_path(p).map(|f| FindCandidate { id: p.id, path: f.to_string(), size: p.file_size, hash: stored_hash(p).map(str::to_string) })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How a missing photo's file was recognised.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MatchBy {
|
||||
/// Same name and size (and content, when the library knows its hash).
|
||||
Name,
|
||||
/// Renamed: same size and content hash.
|
||||
Content,
|
||||
}
|
||||
|
||||
impl MatchBy {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
MatchBy::Name => "name",
|
||||
MatchBy::Content => "content",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A missing photo and the file found for it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FoundFile {
|
||||
pub id: PhotoId,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub by: MatchBy,
|
||||
}
|
||||
|
||||
/// A missing photo with several look-alike files (same name and size, no hash to tell them
|
||||
/// apart): skipped, never guessed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AmbiguousFile {
|
||||
pub id: PhotoId,
|
||||
pub from: String,
|
||||
pub candidates: Vec<String>,
|
||||
}
|
||||
|
||||
/// What Find Missing Photos found ([`plan_find_missing`]), before relinking.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FindPlan {
|
||||
pub found: Vec<FoundFile>,
|
||||
/// In-scope photos that stay missing (the ambiguous ones included).
|
||||
pub missing: usize,
|
||||
pub ambiguous: Vec<AmbiguousFile>,
|
||||
}
|
||||
|
||||
impl FindPlan {
|
||||
/// `{found: [{id, from, to, by}], missing, ambiguous: [{id, from, candidates}]}` — the
|
||||
/// command's result, and what `library.findMissing {found, …}` takes back.
|
||||
pub fn to_json(&self) -> Value {
|
||||
let found: Vec<Value> = self.found.iter().map(|f| json!({"id": f.id.0, "from": f.from, "to": f.to, "by": f.by.as_str()})).collect();
|
||||
let ambiguous: Vec<Value> = self.ambiguous.iter().map(|a| json!({"id": a.id.0, "from": a.from, "candidates": a.candidates})).collect();
|
||||
json!({"found": found, "missing": self.missing, "ambiguous": ambiguous})
|
||||
}
|
||||
|
||||
/// The inverse of [`FindPlan::to_json`] (entries it can't read are dropped).
|
||||
pub fn from_json(v: &Value) -> FindPlan {
|
||||
let list = |k: &str| v.get(k).and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let found = list("found")
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let by = if f.get("by").and_then(Value::as_str) == Some("content") { MatchBy::Content } else { MatchBy::Name };
|
||||
Some(FoundFile {
|
||||
id: PhotoId(f.get("id")?.as_u64()?),
|
||||
from: str_param(f, "from")?.to_string(),
|
||||
to: str_param(f, "to")?.to_string(),
|
||||
by,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let ambiguous = list("ambiguous")
|
||||
.iter()
|
||||
.filter_map(|a| {
|
||||
let candidates = a.get("candidates")?.as_array()?.iter().filter_map(|c| c.as_str().map(str::to_string)).collect();
|
||||
Some(AmbiguousFile { id: PhotoId(a.get("id")?.as_u64()?), from: str_param(a, "from")?.to_string(), candidates })
|
||||
})
|
||||
.collect();
|
||||
let missing = v.get("missing").and_then(Value::as_u64).map_or(0, |n| usize::try_from(n).unwrap_or(usize::MAX));
|
||||
FindPlan { found, missing, ambiguous }
|
||||
}
|
||||
}
|
||||
|
||||
/// The file-system half of Find Missing Photos: which `candidates` are gone, and which file in
|
||||
/// `folder` (recursively) is each one's. Touches only the disk — never the session — so the app
|
||||
/// runs it on a worker thread (walking a big folder on a network share and hashing look-alikes
|
||||
/// takes a while) and relinks the result with `library.findMissing {found, missing, ambiguous}`.
|
||||
///
|
||||
/// 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.
|
||||
pub fn plan_find_missing(candidates: &[FindCandidate], folder: &str) -> std::result::Result<FindPlan, String> {
|
||||
if !Path::new(folder).is_dir() {
|
||||
return Err(bad(C, format!("{folder}: not a folder")));
|
||||
return Err(format!("{folder}: not a folder"));
|
||||
}
|
||||
let lost = missing(s);
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Ok(FindPlan::default());
|
||||
}
|
||||
let lost: Vec<&FindCandidate> = candidates.iter().filter(|c| !Path::new(&c.path).exists()).collect();
|
||||
if lost.is_empty() {
|
||||
return Ok(json!({"found": [], "missing": 0}));
|
||||
return Ok(FindPlan::default());
|
||||
}
|
||||
// file name (lower case) → candidate paths
|
||||
let in_use: HashSet<&str> = candidates.iter().map(|c| c.path.as_str()).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.as_str())).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 ops = Vec::new();
|
||||
let mut found = Vec::new();
|
||||
for (id, old) in &lost {
|
||||
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 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 plan = FindPlan::default();
|
||||
for c in &lost {
|
||||
let name = Path::new(&c.path).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
|
||||
let size = c.size;
|
||||
let hash = c.hash.as_deref();
|
||||
let named: Vec<&String> = by_name
|
||||
.get(&name)
|
||||
.map(|v| v.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, MatchBy)> =
|
||||
hash.and_then(|h| named.iter().find(|f| hash_of(f).as_deref() == Some(h))).map(|f| (f.to_string(), MatchBy::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(|v| v.iter().find(|f| !used.contains(*f) && hash_of(f).as_deref() == Some(h)))
|
||||
.map(|f| (f.clone(), MatchBy::Content));
|
||||
}
|
||||
}
|
||||
let still = lost.len() - found.len();
|
||||
if !ops.is_empty() {
|
||||
s.commit("Find Missing Photos", Op::Batch { ops })?;
|
||||
for f in &found {
|
||||
if let Some(id) = f["id"].as_u64() {
|
||||
s.media.forget(PhotoId(id));
|
||||
// 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.as_slice() {
|
||||
[one] => hit = Some(((*one).clone(), MatchBy::Name)),
|
||||
[] => {}
|
||||
_ => {
|
||||
plan.ambiguous.push(AmbiguousFile { id: c.id, from: c.path.clone(), candidates: named.iter().map(|f| (*f).clone()).collect() });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((path, by)) = hit else { continue };
|
||||
used.insert(path.clone());
|
||||
plan.found.push(FoundFile { id: c.id, from: c.path.clone(), to: path, by });
|
||||
}
|
||||
Ok(json!({"found": found, "missing": still}))
|
||||
plan.missing = lost.len() - plan.found.len();
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
/// Relink what [`plan_find_missing`] found, in one undoable step, re-checked against the
|
||||
/// catalog as it is now (the search may have run on a worker while the library changed): a
|
||||
/// photo that no longer points at the path it was missing from (relinked, removed) is left
|
||||
/// alone, and a file that is now some photo's original — or that the plan names twice — is not
|
||||
/// used (that photo stays missing). `missing` is the plan's count, corrected for what changed
|
||||
/// among the photos the plan names.
|
||||
fn commit_find(s: &mut Session, plan: FindPlan) -> Result<Value> {
|
||||
let still_at = |cat: &Catalog, id: PhotoId, from: &str| cat.photo(id).and_then(|p| checked_path(p)) == Some(from);
|
||||
let mut in_use: HashSet<String> = candidates(&s.catalog).into_iter().collect();
|
||||
let mut found = Vec::new();
|
||||
let mut missing = plan.missing;
|
||||
for f in plan.found {
|
||||
if !still_at(&s.catalog, f.id, &f.from) {
|
||||
continue;
|
||||
}
|
||||
if in_use.contains(&f.to) {
|
||||
missing += 1;
|
||||
continue;
|
||||
}
|
||||
in_use.insert(f.to.clone());
|
||||
found.push(f);
|
||||
}
|
||||
let planned = plan.ambiguous.len();
|
||||
let ambiguous: Vec<AmbiguousFile> = plan.ambiguous.into_iter().filter(|a| still_at(&s.catalog, a.id, &a.from)).collect();
|
||||
// ambiguous photos relinked meanwhile aren't missing any more
|
||||
missing = missing.saturating_sub(planned - ambiguous.len());
|
||||
if !found.is_empty() {
|
||||
s.commit("Find Missing Photos", Op::Batch { ops: found.iter().map(|f| relink_op(f.id, &f.to)).collect() })?;
|
||||
for f in &found {
|
||||
s.media.forget(f.id);
|
||||
}
|
||||
}
|
||||
Ok(FindPlan { found, missing, ambiguous }.to_json())
|
||||
}
|
||||
|
||||
/// Search `folder` (recursively) for each missing photo's file ([`plan_find_missing`]) and
|
||||
/// relink every match in one undoable step. With `found` instead (what a search on a worker
|
||||
/// thread returned, as `{found, missing, ambiguous}`), only the relinking is done.
|
||||
fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.findMissing";
|
||||
if p.get("found").is_some_and(Value::is_array) {
|
||||
return commit_find(s, FindPlan::from_json(p));
|
||||
}
|
||||
let folder = str_param(p, "folder").ok_or_else(|| bad(C, "missing `folder`"))?;
|
||||
let plan = plan_find_missing(&find_candidates(&s.catalog), folder).map_err(|e| bad(C, e))?;
|
||||
commit_find(s, plan)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -146,7 +355,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; or {found, missing, ambiguous} — relink what a search already found (the app searches on a worker thread; photos relinked meanwhile and files now in use are skipped) → {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
|
||||
),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! `edit.*` (undo/redo), and queries (`catalog.query`, `photo.inspect`, `develop.get`…).
|
||||
|
||||
mod before;
|
||||
mod browse;
|
||||
pub(crate) mod browse;
|
||||
mod color;
|
||||
pub(crate) mod convert;
|
||||
mod cull;
|
||||
@@ -15,7 +15,7 @@ mod edit;
|
||||
mod export;
|
||||
pub mod filters;
|
||||
pub mod keywords;
|
||||
mod library;
|
||||
pub mod library;
|
||||
pub mod lut_profiles;
|
||||
pub mod manage;
|
||||
mod masks;
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -86,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)?;
|
||||
@@ -101,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
|
||||
)]
|
||||
|
||||
@@ -23,6 +23,12 @@ pub struct PreviewBuild {
|
||||
pub failed: AtomicUsize,
|
||||
pub cancel: AtomicBool,
|
||||
pub finished: AtomicBool,
|
||||
/// Damaged smart previews rebuilt (Build Smart Previews only).
|
||||
pub repaired: AtomicUsize,
|
||||
/// What is built ("" = previews; "smart previews" — Build Smart Previews runs the same way).
|
||||
pub what: &'static str,
|
||||
/// Why the whole build failed (e.g. the smart previews folder isn't there).
|
||||
pub error: std::sync::Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl PreviewBuild {
|
||||
@@ -33,8 +39,15 @@ impl PreviewBuild {
|
||||
"failed": self.failed.load(Ordering::Relaxed),
|
||||
"running": !self.finished.load(Ordering::Relaxed),
|
||||
"cancelled": self.cancel.load(Ordering::Relaxed),
|
||||
"what": if self.what.is_empty() { "previews" } else { self.what },
|
||||
"repaired": self.repaired.load(Ordering::Relaxed),
|
||||
"error": self.error(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn error(&self) -> Option<String> {
|
||||
self.error.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// The jobs that build `id`'s previews: the largest grid thumbnail, then the view render at
|
||||
@@ -94,49 +107,134 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(state.json())
|
||||
}
|
||||
|
||||
/// One photo's smart preview to build or discard: (photo, its file in the smart previews folder,
|
||||
/// how to load the original at preview size).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option<crate::media::SourceRef>);
|
||||
|
||||
/// What Build / Discard Smart Previews did.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Default)]
|
||||
struct SmartCounts {
|
||||
/// Proxies there now (complete ones kept, missing or damaged ones written).
|
||||
built: usize,
|
||||
/// Of those, damaged ones (cut short by a crash or a full drive) that were rebuilt.
|
||||
repaired: usize,
|
||||
removed: usize,
|
||||
/// `[[id, why]]`
|
||||
failed: Vec<Value>,
|
||||
}
|
||||
|
||||
/// The file-system half of Build / Discard Smart Previews (runs on a worker thread in the app).
|
||||
/// `custom`: the folder was chosen (on another drive, perhaps): it must be there, it is never
|
||||
/// recreated. With `state`, progress and cancel.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn smart_run(
|
||||
dir: &std::path::Path,
|
||||
custom: bool,
|
||||
discard: bool,
|
||||
jobs: Vec<SmartJob>,
|
||||
state: Option<&PreviewBuild>,
|
||||
) -> std::result::Result<SmartCounts, String> {
|
||||
let mut n = SmartCounts::default();
|
||||
if !discard {
|
||||
// a chosen folder on another drive must be there: never recreate it on this one
|
||||
if custom {
|
||||
crate::smart::check_writable(dir)?;
|
||||
} else {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
}
|
||||
for (id, path, source) in jobs {
|
||||
if state.is_some_and(|st| st.cancel.load(Ordering::Relaxed)) {
|
||||
break;
|
||||
}
|
||||
if discard {
|
||||
if std::fs::remove_file(&path).is_ok() {
|
||||
n.removed += 1;
|
||||
}
|
||||
} else {
|
||||
// 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) {
|
||||
n.built += 1;
|
||||
} else {
|
||||
// atomic: a failed write leaves no partial proxy that would pass for a built one
|
||||
let r = source
|
||||
.ok_or_else(|| "nothing to build from".to_string())
|
||||
.and_then(|s| s.load_source())
|
||||
.and_then(|src| crate::smart::encode(&src.image, src.info_or(Default::default()).camera_tone.as_ref()))
|
||||
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display())));
|
||||
match r {
|
||||
Ok(()) => {
|
||||
n.built += 1;
|
||||
if damaged {
|
||||
n.repaired += 1;
|
||||
if let Some(st) = state {
|
||||
st.repaired.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
n.failed.push(json!([id.0, e]));
|
||||
if let Some(st) = state {
|
||||
st.failed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(st) = state {
|
||||
st.done.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Smart previews: build (from the original, at preview size) or discard the proxies that keep
|
||||
/// photos editable while their originals are offline.
|
||||
/// photos editable while their originals are offline. With `background` (the app) the files are
|
||||
/// read and written on a worker thread; progress like Build Previews (`library.previewProgress`).
|
||||
#[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 removed, mut failed) = (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())))?;
|
||||
}
|
||||
let background = p.get("background").and_then(Value::as_bool).unwrap_or(false);
|
||||
if background && s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) {
|
||||
return Err(bad(C, "a preview build is already running"));
|
||||
}
|
||||
let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
|
||||
let mut jobs: Vec<SmartJob> = Vec::new();
|
||||
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;
|
||||
}
|
||||
if path.exists() {
|
||||
built += 1;
|
||||
continue;
|
||||
}
|
||||
let r = s
|
||||
.source_now(id, crate::media::SourceLevel::Preview)
|
||||
.and_then(|src| crate::smart::encode(&src))
|
||||
.and_then(|b| std::fs::write(&path, b).map_err(|e| e.to_string()));
|
||||
match r {
|
||||
Ok(()) => built += 1,
|
||||
Err(e) => failed.push(json!([id.0, e])),
|
||||
}
|
||||
let source = (!discard).then(|| s.media.source_ref(&ph, crate::media::SourceLevel::Preview));
|
||||
jobs.push((id, path, source));
|
||||
}
|
||||
Ok(json!({"built": built, "removed": removed, "failed": failed}))
|
||||
let custom = s.smart_previews_dir.is_some();
|
||||
if background {
|
||||
let what = if discard { "discarding smart previews" } else { "smart previews" };
|
||||
let state = Arc::new(PreviewBuild { total: jobs.len(), what, ..Default::default() });
|
||||
s.preview_build = Some(state.clone());
|
||||
let st = state.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("lc-smart-previews".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = smart_run(&dir, custom, discard, jobs, Some(&st)) {
|
||||
log::warn!("{C}: {e}");
|
||||
*st.error.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(e);
|
||||
}
|
||||
st.finished.store(true, Ordering::Relaxed);
|
||||
})
|
||||
.map_err(|e| bad(C, format!("could not start: {e}")))?;
|
||||
return Ok(state.json());
|
||||
}
|
||||
let r = smart_run(&dir, custom, discard, jobs, None).map_err(|e| bad(C, e))?;
|
||||
Ok(json!({"built": r.built, "repaired": r.repaired, "removed": r.removed, "failed": r.failed}))
|
||||
}
|
||||
|
||||
/// The smart previews folder: where it is, whether it is custom, what it holds, whether it can be
|
||||
@@ -205,7 +303,7 @@ fn smart_location(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// 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)) => dir.join(crate::smart::file_name(p)).exists(),
|
||||
(Some(dir), Some(p)) => crate::smart::is_valid(&dir.join(crate::smart::file_name(p))),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -218,7 +316,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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 → {built, removed, failed}",
|
||||
"{ids?, discard?: bool, background?: 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}; with background (the app's menu) the files are read and written on a worker thread → {total, done, failed, repaired, running, what} like library.buildPreviews (progress: library.previewProgress, stop: library.cancelPreviews)",
|
||||
always,
|
||||
smart
|
||||
),
|
||||
@@ -249,7 +347,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
build
|
||||
),
|
||||
cmd!(query "library.previewProgress", "Preview Build Progress", [], None, "{} → {total, done, failed, running, cancelled} | null", always, |s, _| {
|
||||
cmd!(query "library.previewProgress", "Preview Build Progress", [], None, "{} → {total, done, failed, repaired, running, cancelled, what, error} | 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, _| {
|
||||
|
||||
@@ -113,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.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.xmp.naming).map(|(x, _)| x).or_else(|| lightcraft_meta::embedded(&bytes).xmp);
|
||||
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())
|
||||
@@ -186,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
|
||||
),
|
||||
|
||||
@@ -35,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());
|
||||
|
||||
+184
-39
@@ -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.
|
||||
@@ -226,6 +228,7 @@ impl Default for Watermark {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
text: String::new(),
|
||||
vertical: false,
|
||||
size: 0.035,
|
||||
opacity: 0.7,
|
||||
anchor: Anchor::BottomRight,
|
||||
@@ -241,6 +244,9 @@ impl Default for Watermark {
|
||||
|
||||
/// 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) {
|
||||
@@ -288,23 +294,67 @@ fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px:
|
||||
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::*;
|
||||
@@ -328,10 +378,17 @@ 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -930,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 {
|
||||
@@ -952,12 +1011,30 @@ enum Work {
|
||||
},
|
||||
}
|
||||
|
||||
/// 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(p, seq);
|
||||
@@ -979,7 +1056,7 @@ pub fn prepare_export(
|
||||
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 {
|
||||
@@ -1031,14 +1108,16 @@ pub enum DngCompression {
|
||||
}
|
||||
|
||||
/// 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],
|
||||
@@ -1047,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,
|
||||
@@ -1075,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),
|
||||
@@ -1084,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;
|
||||
@@ -1099,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)
|
||||
@@ -1227,6 +1334,44 @@ 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()));
|
||||
|
||||
@@ -131,7 +131,9 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
std::mem::swap(&mut w, &mut h);
|
||||
}
|
||||
let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw));
|
||||
let as_shot_wb = Some((t.round(), tint.round()));
|
||||
// Vendor RGB multipliers do not identify an absolute illuminant without camera calibration.
|
||||
let relative = raw.format == lightcraft_raw::RawFormat::Arw && !lightcraft_raw::color::has_matrix(&raw.color);
|
||||
let as_shot_wb = Some(if relative { (6500.0, 0.0) } else { (t.round(), tint.round()) });
|
||||
let embedded_lens = embedded_lens(&raw);
|
||||
return Ok(ProbeInfo {
|
||||
embedded_lens,
|
||||
@@ -221,6 +223,9 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, xy);
|
||||
let camera_look = crate::camera_preview::fit_preview(&raw, &bytes, &t);
|
||||
drop(bytes);
|
||||
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in.
|
||||
let lens = embedded_lens(&raw.info());
|
||||
@@ -242,12 +247,10 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
|
||||
// the samples aren't needed any more (the colour model below reads only the tags)
|
||||
raw.data = lightcraft_raw::RawData::U16(Vec::new());
|
||||
let mut stages = vec![("develop", t0.elapsed())];
|
||||
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, xy);
|
||||
stages.push(("transform", t0.elapsed()));
|
||||
lightcraft_raw::highlight::reconstruct(&mut img, t.wb, HIGHLIGHT_CLIP);
|
||||
stages.push(("highlights", t0.elapsed()));
|
||||
let m = t.matrix.to_f32();
|
||||
let m = camera_look.map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix).to_f32();
|
||||
let gain = 2f32.powf(t.baseline_exposure as f32);
|
||||
let wb = t.wb;
|
||||
img.map_in_place(|p| {
|
||||
@@ -276,7 +279,12 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
|
||||
eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", "));
|
||||
}
|
||||
let (temp, tint) = xy_to_temp_tint(xy);
|
||||
return Ok((img, SourceInfo { raw: true, as_shot_temp: temp.round(), as_shot_tint: tint.round(), lens }));
|
||||
let relative = raw.format == lightcraft_raw::RawFormat::Arw && t.matrix_is_fallback;
|
||||
let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) };
|
||||
return Ok((
|
||||
img,
|
||||
SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_tone: camera_look.map(|p| p.tone) },
|
||||
));
|
||||
}
|
||||
let d = lightcraft_codecs::decode(&bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).map_err(|e| e.to_string())?;
|
||||
drop(bytes);
|
||||
|
||||
+373
-193
@@ -374,25 +374,14 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
|
||||
}
|
||||
|
||||
/// Copy `src` into `root`/`folders` as `name` (default: its own name), made unique with -1, -2…;
|
||||
/// returns the new path.
|
||||
/// 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));
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
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()));
|
||||
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;
|
||||
}
|
||||
std::fs::copy(src, &dst).map_err(|e| format!("copy {src}: {e}"))?;
|
||||
Ok(dst.to_string_lossy().to_string())
|
||||
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
|
||||
@@ -528,201 +517,392 @@ pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Res
|
||||
}
|
||||
|
||||
/// Import files/folders with options (preset, keywords). See the module docs.
|
||||
///
|
||||
/// The file-system half ([`ImportJob::prepare`]: expanding folders, probing, reading sidecars,
|
||||
/// copying / placing files) and the catalog half ([`commit_prepared`]) are separate so the app can
|
||||
/// run the first on a worker thread; here both run in turn.
|
||||
pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> crate::Result<ImportReport> {
|
||||
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 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() };
|
||||
let mut job = ImportJob::new(s, opts.clone())?;
|
||||
let prepared = job.prepare(paths, &std::sync::atomic::AtomicBool::new(false));
|
||||
// probes a scan left for files not imported now stay for a later import
|
||||
s.import_probes.extend(std::mem::take(&mut job.cache));
|
||||
commit_prepared(s, &job.opts, &job.now, prepared)
|
||||
}
|
||||
|
||||
// existing paths and content hashes
|
||||
let mut by_path: HashMap<&str, 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.as_str(), p.id);
|
||||
}
|
||||
if let Some(h) = &p.content_hash {
|
||||
by_hash.insert(h.clone(), p.id);
|
||||
}
|
||||
}
|
||||
let mut todo = Vec::new();
|
||||
// a file that was only browsed (Local) joins the library when it is imported for real
|
||||
let mut promote = Vec::new();
|
||||
for f in files {
|
||||
match by_path.get(f.as_str()) {
|
||||
Some(id) if !opts.local && s.catalog.photo(*id).is_some_and(|p| p.local) => {
|
||||
promote.push(Op::SetLocal { id: *id, local: false });
|
||||
report.imported.push(id.0);
|
||||
/// Everything the file-system half of an import needs, detached from the session so it can run
|
||||
/// on another thread (a network share or a card can take minutes). Built by [`ImportJob::new`];
|
||||
/// [`ImportJob::prepare`] may be called for several batches in turn (renamed copies keep counting,
|
||||
/// duplicates are found across batches); each batch is then [`commit_prepared`] on the session.
|
||||
pub struct ImportJob {
|
||||
pub opts: ImportOptions,
|
||||
/// The mode in effect (Copy / Move become Add in a library without its own folder).
|
||||
mode: ImportMode,
|
||||
lib_dir: Option<PathBuf>,
|
||||
copy_root: Option<PathBuf>,
|
||||
/// path → (photo, a Local browse record?)
|
||||
by_path: HashMap<String, (PhotoId, bool)>,
|
||||
/// content hash → the photo that has it (`None`: a file earlier in this import)
|
||||
by_hash: HashMap<String, Option<PhotoId>>,
|
||||
probe: Option<crate::media::FileProbe>,
|
||||
file_bytes: Option<crate::merge::ByteReader>,
|
||||
/// Probes from a preceding scan (not read again).
|
||||
cache: HashMap<String, ProbeInfo>,
|
||||
naming: crate::sidecar::SidecarNaming,
|
||||
now: String,
|
||||
seq: usize,
|
||||
/// Files found so far (folders expanded) and handled so far, for progress.
|
||||
pub total: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
pub done: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
/// The outcome of [`ImportJob::prepare`] for one batch, in file order.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Prepared {
|
||||
pub scanned: usize,
|
||||
items: Vec<PreparedItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum PreparedItem {
|
||||
/// A Local (browsed) record imported for real.
|
||||
Promote(PhotoId),
|
||||
Duplicate {
|
||||
path: String,
|
||||
existing: Option<PhotoId>,
|
||||
reason: &'static str,
|
||||
hash: Option<String>,
|
||||
},
|
||||
Failed(String, String),
|
||||
Kept(Kept),
|
||||
Ready(Box<ReadyFile>),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ReadyFile {
|
||||
path: String,
|
||||
stored: String,
|
||||
info: ProbeInfo,
|
||||
sidecar: Option<crate::sidecar::SidecarData>,
|
||||
placed: Option<crate::import_move::Placed>,
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
/// Undo what the batch did on disk that only a commit would make permanent: files placed by a
|
||||
/// Move (their sources were never touched). Copies stay, like copies of a failed commit.
|
||||
pub fn rollback(&self) {
|
||||
for it in &self.items {
|
||||
if let PreparedItem::Ready(r) = it
|
||||
&& let Some(pl) = &r.placed
|
||||
{
|
||||
crate::import_move::rollback(pl);
|
||||
}
|
||||
Some(id) => report.duplicates.push(Duplicate { path: f, existing: Some(id.0), reason: "path" }),
|
||||
None => todo.push(f),
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: {e}");
|
||||
report.failed.push((path, e));
|
||||
/// Entries in the batch (after expanding folders).
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl ImportJob {
|
||||
/// Snapshot what an import with `opts` needs from the session (no file-system calls). Takes
|
||||
/// the probes a preceding scan left ([`Session::import_probes`]).
|
||||
pub fn new(s: &mut Session, opts: ImportOptions) -> crate::Result<Self> {
|
||||
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 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 by_path = HashMap::new();
|
||||
let mut by_hash = HashMap::new();
|
||||
for p in s.catalog.photos() {
|
||||
if let Source::File { path } = &p.source {
|
||||
by_path.insert(path.clone(), (p.id, p.local));
|
||||
}
|
||||
if let Some(h) = &p.content_hash {
|
||||
by_hash.insert(h.clone(), Some(p.id));
|
||||
}
|
||||
}
|
||||
Ok(ImportJob {
|
||||
seq: opts.rename_start.max(1),
|
||||
opts,
|
||||
mode,
|
||||
lib_dir,
|
||||
copy_root,
|
||||
by_path,
|
||||
by_hash,
|
||||
probe: s.media.file_probe.clone(),
|
||||
file_bytes: s.media.file_bytes.clone(),
|
||||
cache: std::mem::take(&mut s.import_probes),
|
||||
naming: s.xmp.naming,
|
||||
now: (s.clock)(),
|
||||
total: Default::default(),
|
||||
done: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The file-system half of importing `paths`: expand folders, skip known paths, probe, skip
|
||||
/// duplicates by content, read sidecars, copy or place (Move) the files. Nothing in the
|
||||
/// catalog changes. Stops before the next file once `cancel` is set.
|
||||
pub fn prepare(&mut self, paths: &[String], cancel: &std::sync::atomic::AtomicBool) -> Prepared {
|
||||
let files = self.expand(paths);
|
||||
self.prepare_files(files, cancel)
|
||||
}
|
||||
|
||||
/// Expand `paths` (folders recursively, the library's own folder skipped) into the files an
|
||||
/// import would handle; counted in [`ImportJob::total`].
|
||||
pub fn expand(&self, paths: &[String]) -> Vec<String> {
|
||||
let files = expand(paths, self.lib_dir.as_deref());
|
||||
self.total.fetch_add(files.len(), std::sync::atomic::Ordering::Relaxed);
|
||||
files
|
||||
}
|
||||
|
||||
/// When the import started ([`Session::clock`]), for [`commit_prepared`].
|
||||
pub fn now(&self) -> &str {
|
||||
&self.now
|
||||
}
|
||||
|
||||
/// [`ImportJob::prepare`] for files [`ImportJob::expand`] returned.
|
||||
pub fn prepare_files(&mut self, files: Vec<String>, cancel: &std::sync::atomic::AtomicBool) -> Prepared {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let opts = self.opts.clone();
|
||||
let mode = self.mode;
|
||||
let mut out = Prepared { scanned: files.len(), items: Vec::new() };
|
||||
let mut todo = Vec::new();
|
||||
for f in files {
|
||||
match self.by_path.get(f.as_str()).copied() {
|
||||
Some((id, true)) if !opts.local => {
|
||||
// a file that was only browsed (Local) joins the library when it is imported for real
|
||||
self.by_path.insert(f, (id, false));
|
||||
out.items.push(PreparedItem::Promote(id));
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
}
|
||||
Some((id, _)) => {
|
||||
out.items.push(PreparedItem::Duplicate { path: f, existing: Some(id), reason: "path", hash: None });
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
}
|
||||
None => todo.push(f),
|
||||
}
|
||||
}
|
||||
|
||||
// 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| self.cache.remove(f)).collect();
|
||||
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
|
||||
let progress = ScanProgress::default();
|
||||
let mut fresh = probe_all(self.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();
|
||||
for (path, info) in todo.into_iter().zip(probed) {
|
||||
if cancel.load(Relaxed) {
|
||||
break;
|
||||
}
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
let mut info = match info {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: {e}");
|
||||
out.items.push(PreparedItem::Failed(path, e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(h) = &info.content_hash
|
||||
&& !opts.local
|
||||
&& let Some(existing) = self.by_hash.get(h)
|
||||
{
|
||||
out.items.push(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) });
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(h) = &info.content_hash
|
||||
&& !opts.local
|
||||
&& let Some(id) = by_hash.get(h)
|
||||
{
|
||||
report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" });
|
||||
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
|
||||
// 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, self.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) {
|
||||
Ok(sc) => Some(sc),
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: XMP: {e}");
|
||||
None
|
||||
}
|
||||
});
|
||||
if info.captured.is_none() {
|
||||
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
|
||||
}
|
||||
});
|
||||
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)) =>
|
||||
let mut placed = None;
|
||||
let stored = match (mode, &self.copy_root) {
|
||||
(ImportMode::Copy | ImportMode::Move, Some(root))
|
||||
if !crate::import_move::inside(Path::new(&path), root)
|
||||
&& !self.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, &self.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, self.seq);
|
||||
self.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 = Some(pl);
|
||||
dst
|
||||
}
|
||||
Err(e) => {
|
||||
out.items.push(PreparedItem::Failed(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";
|
||||
out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }));
|
||||
}
|
||||
match copy_into(root, &path, &folders, name.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
out.items.push(PreparedItem::Failed(path, e));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(ImportMode::Move, _) => {
|
||||
let reason = "already inside the destination or the library: added where it is";
|
||||
out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }));
|
||||
path.clone()
|
||||
}
|
||||
_ => path.clone(),
|
||||
};
|
||||
if let Some(h) = &info.content_hash {
|
||||
self.by_hash.insert(h.clone(), None);
|
||||
}
|
||||
// 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")
|
||||
{
|
||||
// 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
|
||||
match crate::cmd::convert::write_dng_with(self.file_bytes.as_ref(), &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}"),
|
||||
}
|
||||
}
|
||||
out.items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The catalog half of an import: add the photos [`ImportJob::prepare`] readied (one undoable op;
|
||||
/// a Move's sources are removed once it is saved). On a failed commit a Move's placed files are
|
||||
/// taken back.
|
||||
pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepared: Prepared) -> crate::Result<ImportReport> {
|
||||
let now = now.to_string();
|
||||
let mut report = ImportReport { scanned: prepared.scanned, ..Default::default() };
|
||||
let mut ops = Vec::new();
|
||||
// Move: placed at the destination, sources untouched until the records are committed
|
||||
let mut placed: Vec<crate::import_move::Placed> = Vec::new();
|
||||
// photos added by this batch, by content (a duplicate of a file earlier in the import names it)
|
||||
let mut new_hash: HashMap<String, PhotoId> = HashMap::new();
|
||||
for it in prepared.items {
|
||||
match it {
|
||||
PreparedItem::Promote(id) => {
|
||||
if s.catalog.photo(id).is_some_and(|p| p.local) {
|
||||
ops.push(Op::SetLocal { id, local: false });
|
||||
}
|
||||
report.imported.push(id.0);
|
||||
}
|
||||
PreparedItem::Duplicate { path, existing, reason, hash } => {
|
||||
let existing = existing.or_else(|| {
|
||||
let h = hash?;
|
||||
new_hash.get(&h).copied().or_else(|| s.catalog.photos().find(|p| p.content_hash.as_deref() == Some(h.as_str())).map(|p| p.id))
|
||||
});
|
||||
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;
|
||||
}
|
||||
report.duplicates.push(Duplicate { path, existing: existing.map(|i| i.0), reason });
|
||||
}
|
||||
PreparedItem::Failed(path, e) => report.failed.push((path, e)),
|
||||
PreparedItem::Kept(k) => report.kept.push(k),
|
||||
PreparedItem::Ready(r) => {
|
||||
let ReadyFile { path, stored, info, sidecar, placed: pl } = *r;
|
||||
placed.extend(pl);
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
if let Some(h) = &info.content_hash {
|
||||
new_hash.insert(h.clone(), id);
|
||||
}
|
||||
let sidecar = sidecar.map(|sc| sc.resolve_label(&s.catalog)).filter(|sc| *sc != crate::sidecar::SidecarData::default());
|
||||
// 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;
|
||||
p.captured = info.captured;
|
||||
p.meta = info.meta;
|
||||
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);
|
||||
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);
|
||||
if !k.is_empty() && !p.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) {
|
||||
p.meta.keywords.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
(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);
|
||||
}
|
||||
// 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();
|
||||
if let Some(preset) = &opts.preset {
|
||||
let d = std::sync::Arc::new(preset.apply(&p.develop, 1.0));
|
||||
let label = format!("Preset: {}", preset.name);
|
||||
p.develop = d.clone();
|
||||
p.edited = Some(now.clone());
|
||||
p.history.push(lightcraft_catalog::HistoryStep { label, settings: d });
|
||||
}
|
||||
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
|
||||
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) });
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
p.captured = info.captured;
|
||||
p.meta = info.meta;
|
||||
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);
|
||||
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);
|
||||
if !k.is_empty() && !p.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) {
|
||||
p.meta.keywords.push(k);
|
||||
}
|
||||
}
|
||||
if let Some(preset) = &opts.preset {
|
||||
let d = std::sync::Arc::new(preset.apply(&p.develop, 1.0));
|
||||
let label = format!("Preset: {}", preset.name);
|
||||
p.develop = d.clone();
|
||||
p.edited = Some(now.clone());
|
||||
p.history.push(lightcraft_catalog::HistoryStep { label, settings: d });
|
||||
}
|
||||
report.imported.push(id.0);
|
||||
p.local = opts.local;
|
||||
ops.push(Op::AddPhoto { photo: Box::new(p) });
|
||||
}
|
||||
let log0 = s.pending_log.len();
|
||||
if !ops.is_empty()
|
||||
|
||||
@@ -169,6 +169,27 @@ pub(crate) fn place(src: &Path, dir: &Path, name: &str) -> Result<Placed, String
|
||||
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) {
|
||||
@@ -213,7 +234,7 @@ pub(crate) fn finish(p: &Placed) -> Result<Vec<(String, String)>, String> {
|
||||
}
|
||||
|
||||
/// A photo (by extension) still beside a stem-named sidecar, sharing its stem.
|
||||
fn sibling_photo(sidecar: &Path) -> Option<String> {
|
||||
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| {
|
||||
|
||||
+121
-19
@@ -10,6 +10,8 @@
|
||||
//! run them off the UI thread.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod availability;
|
||||
mod camera_preview;
|
||||
pub mod cmd;
|
||||
pub mod crs;
|
||||
pub mod crs_masks;
|
||||
@@ -24,6 +26,7 @@ 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;
|
||||
@@ -52,6 +55,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),
|
||||
}
|
||||
@@ -63,6 +73,22 @@ pub type Result<T> = std::result::Result<T, EngineError>;
|
||||
pub struct UndoEntry {
|
||||
pub label: String,
|
||||
pub op: Op,
|
||||
/// A folder to rename on disk (`from` → `to`) before `op` is applied: Rename / Move Folder
|
||||
/// (whose `op` relinks the photos inside). Never overwrites; refused when `to` exists.
|
||||
pub folder: Option<FolderMove>,
|
||||
}
|
||||
|
||||
/// A folder renamed or moved on disk as part of an undo step.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FolderMove {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
impl FolderMove {
|
||||
fn reversed(&self) -> Self {
|
||||
Self { from: self.to.clone(), to: self.from.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
/// An in-progress slider drag / brush stroke: one undo step when it ends.
|
||||
@@ -168,6 +194,9 @@ pub struct Session {
|
||||
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 {
|
||||
@@ -228,6 +257,7 @@ impl Session {
|
||||
import_defaults: import::ImportDefaults::default(),
|
||||
cache_mb: 0,
|
||||
smart_previews_dir: None,
|
||||
forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,11 +274,22 @@ impl Session {
|
||||
(spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?;
|
||||
let empty = Value::Object(Default::default());
|
||||
let params = if params.is_null() { &empty } else { params };
|
||||
self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params))
|
||||
}
|
||||
|
||||
/// Run `f` as command `id` with what [`Session::execute`] does around every command: the
|
||||
/// panic guard, auto versions, XMP sidecar auto-write and the durable save (a failed save is
|
||||
/// `NotSaved`). Not journaled. The app's import task commits its batches this way.
|
||||
pub fn execute_fn(&mut self, id: &str, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> {
|
||||
self.run_command(id, None, f)
|
||||
}
|
||||
|
||||
fn run_command(&mut self, id: &str, journal: Option<&Value>, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> {
|
||||
let log_start = self.pending_log.len();
|
||||
let was_active = self.active();
|
||||
self.depth += 1;
|
||||
// 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)));
|
||||
let r = guard::catch(&format!("`{id}`"), || f(self)).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;
|
||||
@@ -256,7 +297,10 @@ impl Session {
|
||||
self.auto_version(left);
|
||||
}
|
||||
}
|
||||
if r.is_ok() && spec.journal && self.depth == 0 {
|
||||
if r.is_ok()
|
||||
&& let Some(params) = journal
|
||||
&& self.depth == 0
|
||||
{
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
if self.journal.len() > 10_000 {
|
||||
self.journal.drain(..1000);
|
||||
@@ -266,9 +310,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
|
||||
}
|
||||
@@ -284,7 +337,7 @@ impl Session {
|
||||
let fwd = op.clone();
|
||||
let inv = self.catalog.apply(op)?;
|
||||
self.pending_log.push(fwd);
|
||||
self.undo.push(UndoEntry { label: label.to_string(), op: inv });
|
||||
self.undo.push(UndoEntry { label: label.to_string(), op: inv, folder: None });
|
||||
if self.undo.len() > 1000 {
|
||||
self.undo.remove(0);
|
||||
}
|
||||
@@ -292,6 +345,16 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`Session::commit`] for an op that goes with a folder already renamed on disk; the undo
|
||||
/// step renames it back (`folder` is the undo direction: current place → old place).
|
||||
pub(crate) fn commit_with_folder(&mut self, label: &str, op: Op, folder: FolderMove) -> Result<()> {
|
||||
self.commit(label, op)?;
|
||||
if let Some(e) = self.undo.last_mut() {
|
||||
e.folder = Some(folder);
|
||||
}
|
||||
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.
|
||||
@@ -322,9 +385,13 @@ impl Session {
|
||||
if n < 2 || n > self.undo.len() {
|
||||
return;
|
||||
}
|
||||
// a step that moves a folder on disk stays on its own
|
||||
if self.undo[self.undo.len() - n..].iter().any(|e| e.folder.is_some()) {
|
||||
return;
|
||||
}
|
||||
let tail = self.undo.split_off(self.undo.len() - n);
|
||||
let ops = tail.into_iter().rev().map(|e| e.op).collect();
|
||||
self.undo.push(UndoEntry { label: label.to_string(), op: Op::Batch { ops } });
|
||||
self.undo.push(UndoEntry { label: label.to_string(), op: Op::Batch { ops }, folder: None });
|
||||
}
|
||||
|
||||
/// Apply without recording undo (interactive previews).
|
||||
@@ -335,7 +402,7 @@ impl Session {
|
||||
|
||||
pub fn undo_step(&mut self) -> Result<String> {
|
||||
let e = self.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
|
||||
let redo = match self.apply_with_files(&e.op) {
|
||||
let redo = match self.apply_with_files(&e.op, e.folder.as_ref()) {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
self.undo.push(e);
|
||||
@@ -343,13 +410,13 @@ impl Session {
|
||||
}
|
||||
};
|
||||
self.pending_log.push(e.op);
|
||||
self.redo.push(UndoEntry { label: e.label.clone(), op: redo });
|
||||
self.redo.push(UndoEntry { label: e.label.clone(), op: redo, folder: e.folder.as_ref().map(FolderMove::reversed) });
|
||||
Ok(e.label)
|
||||
}
|
||||
|
||||
pub fn redo_step(&mut self) -> Result<String> {
|
||||
let e = self.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
|
||||
let undo = match self.apply_with_files(&e.op) {
|
||||
let undo = match self.apply_with_files(&e.op, e.folder.as_ref()) {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
self.redo.push(e);
|
||||
@@ -357,19 +424,44 @@ impl Session {
|
||||
}
|
||||
};
|
||||
self.pending_log.push(e.op);
|
||||
self.undo.push(UndoEntry { label: e.label.clone(), op: undo });
|
||||
self.undo.push(UndoEntry { label: e.label.clone(), op: undo, folder: e.folder.as_ref().map(FolderMove::reversed) });
|
||||
Ok(e.label)
|
||||
}
|
||||
|
||||
/// Apply an undo/redo op, first moving the files its renames imply (all or nothing).
|
||||
fn apply_with_files(&mut self, op: &Op) -> Result<Op> {
|
||||
/// Apply an undo/redo op, first renaming the step's folder and 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; the
|
||||
/// folder is only moved back when no file was left behind at its new path.
|
||||
fn apply_with_files(&mut self, op: &Op, folder: Option<&FolderMove>) -> Result<Op> {
|
||||
let fs = rename::RealFs;
|
||||
if let Some(f) = folder {
|
||||
cmd::browse::rename_folder_on_disk(&f.from, &f.to).map_err(|e| EngineError::Other(format!("can't move the folder back: {e}")))?;
|
||||
}
|
||||
let undo_folder = || {
|
||||
if let Some(f) = folder {
|
||||
let _ = cmd::browse::rename_folder_on_disk(&f.to, &f.from);
|
||||
}
|
||||
};
|
||||
let moves = self.file_moves(op);
|
||||
Session::move_files(&moves)?;
|
||||
if let Err(e) = rename::move_all(&fs, &moves) {
|
||||
if e.stuck.is_empty() {
|
||||
undo_folder();
|
||||
}
|
||||
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),
|
||||
Ok(inv) => {
|
||||
if let Some(f) = folder {
|
||||
cmd::browse::follow_folder(self, &f.from, &f.to);
|
||||
}
|
||||
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)));
|
||||
}
|
||||
undo_folder();
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
@@ -484,7 +576,11 @@ impl Session {
|
||||
|
||||
/// Photos shown in the grid/filmstrip for the current source, filter and sort.
|
||||
pub fn visible(&mut self) -> &[PhotoId] {
|
||||
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse));
|
||||
let mut key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse));
|
||||
if self.source == LibrarySource::Missing && self.media.availability.is_background() {
|
||||
// the view fills in as the background checks find files gone
|
||||
key.1.push_str(&format!("|{}", self.media.availability.generation()));
|
||||
}
|
||||
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)()));
|
||||
@@ -519,8 +615,8 @@ impl Session {
|
||||
}
|
||||
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));
|
||||
let (cat, avail) = (&self.catalog, &self.media.availability);
|
||||
visible.retain(|id| cmd::missing::is_missing(cat, avail, *id));
|
||||
}
|
||||
if self.source != LibrarySource::RecentlyDeleted {
|
||||
visible = self.catalog.arrange_stacks(&visible);
|
||||
@@ -599,6 +695,8 @@ 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;
|
||||
@@ -611,8 +709,12 @@ mod tests_merge;
|
||||
#[cfg(test)]
|
||||
mod tests_organize;
|
||||
#[cfg(test)]
|
||||
mod tests_persist;
|
||||
#[cfg(test)]
|
||||
mod tests_prefs;
|
||||
#[cfg(test)]
|
||||
mod tests_settings_files;
|
||||
#[cfg(test)]
|
||||
mod tests_spots;
|
||||
#[cfg(test)]
|
||||
mod tests_xmp;
|
||||
+242
-23
@@ -14,13 +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
|
||||
//! 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};
|
||||
|
||||
@@ -52,8 +55,24 @@ 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>,
|
||||
/// Settings files that were unreadable or damaged when the library opened (`library.info` →
|
||||
/// `settingsWarnings`; shown by the UI once, see [`Session::take_library_warnings`]).
|
||||
pub settings_warnings: Vec<String>,
|
||||
warnings_reported: usize,
|
||||
/// Settings files that couldn't be read or set aside: never overwritten this session.
|
||||
blocked: Vec<&'static str>,
|
||||
/// 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`]).
|
||||
@@ -133,6 +152,9 @@ struct PrefsFile {
|
||||
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 {
|
||||
@@ -155,23 +177,115 @@ fn poll_compaction(lib: &mut Library) {
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
/// 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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Problems reading the settings files (`presets.json`, `prefs.json`, `view.json`) when the library
|
||||
/// opened (issue #103). A missing file is not a problem (defaults). A file that doesn't parse is
|
||||
/// kept as `<name>.corrupt-<unix time>` before the defaults are used (so the next save can't
|
||||
/// lose it); a file that can't be read at all (locked by another program, I/O error) is never
|
||||
/// written this session, so its content survives until the library is opened again.
|
||||
#[derive(Default)]
|
||||
struct SettingsLoad {
|
||||
warnings: Vec<String>,
|
||||
/// Files not to overwrite this session.
|
||||
blocked: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl SettingsLoad {
|
||||
fn read<T: serde::de::DeserializeOwned>(&mut self, store: &mut dyn Store, name: &'static str) -> Option<T> {
|
||||
let bytes = match store.read(name) {
|
||||
Ok(b) => b?,
|
||||
Err(e) => {
|
||||
log::error!("library: {name}: {e}");
|
||||
self.blocked.push(name);
|
||||
self.warnings.push(format!(
|
||||
"{name} couldn't be read ({e}). LightCraft uses the defaults for now and won't overwrite the file; reopen the library to try again."
|
||||
));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let err = match serde_json::from_slice(&bytes) {
|
||||
Ok(v) => return Some(v),
|
||||
Err(e) => e,
|
||||
};
|
||||
let secs = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let keep = format!("{name}.corrupt-{secs}");
|
||||
log::error!("library: {name} is damaged: {err}");
|
||||
match store.write_atomic(&keep, &bytes) {
|
||||
Ok(()) => self.warnings.push(format!("{name} is damaged ({err}). It was kept as {keep}, and the defaults are used.")),
|
||||
Err(w) => {
|
||||
self.blocked.push(name);
|
||||
self.warnings.push(format!(
|
||||
"{name} is damaged ({err}) and couldn't be set aside ({w}). LightCraft uses the defaults and won't overwrite the file."
|
||||
));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -192,8 +306,9 @@ impl Session {
|
||||
crate::demo::load(self);
|
||||
journal.snapshot(&self.catalog)?;
|
||||
}
|
||||
let mut settings = SettingsLoad::default();
|
||||
// presets
|
||||
if let Some(f) = read_json::<PresetsFile>(files.as_mut(), "presets.json") {
|
||||
if let Some(f) = settings.read::<PresetsFile>(files.as_mut(), "presets.json") {
|
||||
for p in &mut self.presets {
|
||||
p.favorite = p.builtin && f.favorites.contains(&p.id) || (!p.builtin && p.favorite);
|
||||
}
|
||||
@@ -207,7 +322,7 @@ impl Session {
|
||||
self.profile_recent = f.profile_recent.into_iter().filter(known).take(crate::presets::RECENT_PROFILES).collect();
|
||||
}
|
||||
// preferences
|
||||
let prefs = read_json::<PrefsFile>(files.as_mut(), "prefs.json").unwrap_or_default();
|
||||
let prefs = settings.read::<PrefsFile>(files.as_mut(), "prefs.json").unwrap_or_default();
|
||||
self.xmp = prefs.xmp;
|
||||
self.last_export = prefs.last_export;
|
||||
self.export_presets = prefs.export_presets;
|
||||
@@ -222,12 +337,13 @@ impl Session {
|
||||
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") {
|
||||
if let Some(v) = settings.read::<ViewFile>(files.as_mut(), "view.json") {
|
||||
self.source = v.source;
|
||||
self.browse = v.browse;
|
||||
self.filter = v.filter;
|
||||
@@ -248,32 +364,75 @@ impl Session {
|
||||
self.media.attach_disk_cache(&dir.join("thumbs"), self.cache_bytes());
|
||||
}
|
||||
let view_written = self.view_json();
|
||||
let library = self.library.insert(Library { dir, on_disk, journal, files, report, last_error: None, presets_written, view_written });
|
||||
Ok(&library.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,
|
||||
settings_warnings: settings.warnings,
|
||||
warnings_reported: 0,
|
||||
blocked: settings.blocked,
|
||||
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.
|
||||
// The snapshot is written by a worker thread (natively); appends go on meanwhile.
|
||||
if lib.journal.wants_snapshot() && self.interaction.is_none() {
|
||||
lib.journal.snapshot_in_background(&self.catalog)?;
|
||||
// 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(()) };
|
||||
if presets != lib.presets_written {
|
||||
if presets != lib.presets_written && !lib.blocked.contains(&"presets.json") {
|
||||
if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) {
|
||||
log::error!("library: presets: {e}");
|
||||
} else {
|
||||
@@ -286,24 +445,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> {
|
||||
@@ -326,7 +513,7 @@ impl Session {
|
||||
}
|
||||
let v = self.view_json();
|
||||
let Some(lib) = self.library.as_mut() else { return };
|
||||
if v != lib.view_written {
|
||||
if v != lib.view_written && !lib.blocked.contains(&"view.json") {
|
||||
match lib.files.write_atomic("view.json", &v) {
|
||||
Ok(()) => lib.view_written = v,
|
||||
Err(e) => log::error!("library: view: {e}"),
|
||||
@@ -351,12 +538,26 @@ impl Session {
|
||||
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(()) };
|
||||
if lib.blocked.contains(&"prefs.json") {
|
||||
return Err(EngineError::Other(
|
||||
"prefs: prefs.json couldn't be read when the library opened, so it isn't overwritten; reopen the library to save preferences".into(),
|
||||
));
|
||||
}
|
||||
lib.files.write_atomic("prefs.json", &v).map_err(|e| EngineError::Other(format!("prefs: {e}")))
|
||||
}
|
||||
|
||||
/// Settings-file warnings of the open library not handed out yet (the UI shows each once).
|
||||
pub fn take_library_warnings(&mut self) -> Vec<String> {
|
||||
let Some(lib) = self.library.as_mut() else { return vec![] };
|
||||
let new = lib.settings_warnings[lib.warnings_reported..].to_vec();
|
||||
lib.warnings_reported = lib.settings_warnings.len();
|
||||
new
|
||||
}
|
||||
|
||||
/// The thumbnail disk cache budget in bytes ([`Session::cache_mb`], else the default).
|
||||
pub fn cache_bytes(&self) -> u64 {
|
||||
if self.cache_mb == 0 { crate::media::DISK_CACHE_BYTES } else { u64::from(self.cache_mb) << 20 }
|
||||
@@ -373,6 +574,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()?;
|
||||
|
||||
+164
-41
@@ -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 = 4;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 6;
|
||||
|
||||
/// 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];
|
||||
@@ -71,9 +71,31 @@ pub type FileLoader = Arc<dyn Fn(&str, usize) -> Result<(Rgb32f, SourceInfo), St
|
||||
/// `max_edge` (set by the app). `None`: no usable preview.
|
||||
pub type PreviewLoader = Arc<dyn Fn(&str, usize) -> Option<Rgba8> + Send + Sync>;
|
||||
|
||||
/// Pixels and the source interpretation learned while decoding; they must be cached together.
|
||||
#[derive(Clone)]
|
||||
pub struct DecodedSource {
|
||||
pub image: Arc<Rgb32f>,
|
||||
pub info: Option<SourceInfo>,
|
||||
/// A smart preview's stored camera tone curve: the one decoder fact its pixels need that the
|
||||
/// catalog's header facts lack (used when `info` is `None`).
|
||||
pub camera_tone: Option<lightcraft_pipeline::tone::CameraTone>,
|
||||
}
|
||||
|
||||
impl DecodedSource {
|
||||
pub fn new(image: Arc<Rgb32f>, info: Option<SourceInfo>) -> Self {
|
||||
DecodedSource { image, info, camera_tone: None }
|
||||
}
|
||||
|
||||
/// What to render these pixels against: the decoder's facts, else `header` (the catalog's)
|
||||
/// with any stored camera tone curve.
|
||||
pub fn info_or(&self, header: SourceInfo) -> SourceInfo {
|
||||
self.info.unwrap_or(SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum SourceRef {
|
||||
Loaded(Arc<Rgb32f>),
|
||||
Loaded(Box<DecodedSource>),
|
||||
Demo {
|
||||
scene: Box<lightcraft_scenes::Scene>,
|
||||
max_edge: usize,
|
||||
@@ -82,6 +104,9 @@ pub enum SourceRef {
|
||||
path: String,
|
||||
max_edge: usize,
|
||||
loader: Option<FileLoader>,
|
||||
/// The photo's smart preview, used when the original can't be read (offline drive). The
|
||||
/// UI thread never checks whether the original is there; the render worker finds out.
|
||||
fallback: Option<std::path::PathBuf>,
|
||||
},
|
||||
/// A smart preview standing in for a missing original.
|
||||
Smart {
|
||||
@@ -91,28 +116,41 @@ pub enum SourceRef {
|
||||
|
||||
impl SourceRef {
|
||||
pub fn load(&self) -> Result<Arc<Rgb32f>, String> {
|
||||
match self {
|
||||
SourceRef::Loaded(a) => Ok(a.clone()),
|
||||
SourceRef::Demo { scene, max_edge } => Ok(Arc::new(scene.render_fit(*max_edge))),
|
||||
SourceRef::File { path, max_edge, loader } => match loader {
|
||||
Some(l) => l(path, *max_edge).map(|(img, _)| Arc::new(img)),
|
||||
None => Err(format!("no decoder available for {path}")),
|
||||
},
|
||||
self.load_source().map(|s| s.image)
|
||||
}
|
||||
|
||||
pub fn load_source(&self) -> Result<DecodedSource, String> {
|
||||
let image = match self {
|
||||
SourceRef::Loaded(a) => return Ok((**a).clone()),
|
||||
SourceRef::Demo { scene, max_edge } => Arc::new(scene.render_fit(*max_edge)),
|
||||
SourceRef::File { path, max_edge, loader, fallback } => {
|
||||
let r = match loader {
|
||||
Some(l) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))),
|
||||
None => Err(format!("no decoder available for {path}")),
|
||||
};
|
||||
// An offline original renders from its smart preview (no decoder facts: header ones).
|
||||
return match (r, fallback) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
(Err(e), Some(sp)) if crate::smart::is_valid(sp) => crate::smart::load(sp).map_err(|e2| format!("{e}; smart preview: {e2}")),
|
||||
(r, _) => r,
|
||||
};
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
SourceRef::Smart { path } => crate::smart::load(path),
|
||||
SourceRef::Smart { path } => return crate::smart::load(path),
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
SourceRef::Smart { .. } => Err("smart previews are not available here".into()),
|
||||
}
|
||||
SourceRef::Smart { .. } => return Err("smart previews are not available here".into()),
|
||||
};
|
||||
Ok(DecodedSource::new(image, None))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MediaCache {
|
||||
/// Decoded thumbnail-level sources (LRU by bytes).
|
||||
thumbs: Lru<PhotoId, Arc<Rgb32f>>,
|
||||
thumbs: Lru<PhotoId, DecodedSource>,
|
||||
/// Decoded preview-level sources with their last use ([`lightcraft_preview::next_tick`]).
|
||||
previews: Vec<(PhotoId, Arc<Rgb32f>, u64)>,
|
||||
previews: Vec<(PhotoId, DecodedSource, u64)>,
|
||||
/// The last full-resolution original (exports; one at a time: ~300 MB at 24 MP).
|
||||
full: Option<(PhotoId, Arc<Rgb32f>, u64)>,
|
||||
full: Option<(PhotoId, DecodedSource, u64)>,
|
||||
/// How many previews to keep (LRU).
|
||||
pub preview_capacity: usize,
|
||||
/// Bytes all decoded sources and rendered previews in memory may take together (the cache
|
||||
@@ -125,6 +163,9 @@ pub struct MediaCache {
|
||||
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>,
|
||||
/// Whether originals (and smart previews) are on disk: cached and checked off the UI thread
|
||||
/// in the app (see [`crate::availability`]).
|
||||
pub availability: crate::availability::Availability,
|
||||
scenes: Vec<lightcraft_scenes::Scene>,
|
||||
/// Rendered thumbnails (memory, plus disk once a library is attached).
|
||||
pub rendered: Arc<PreviewCache>,
|
||||
@@ -144,6 +185,7 @@ impl Default for MediaCache {
|
||||
preview_loader: None,
|
||||
file_bytes: None,
|
||||
smart_dir: None,
|
||||
availability: Default::default(),
|
||||
scenes: Vec::new(),
|
||||
rendered: Arc::new(PreviewCache::memory(rendered_budget(budget))),
|
||||
}
|
||||
@@ -156,7 +198,7 @@ fn rendered_budget(share: usize) -> usize {
|
||||
}
|
||||
|
||||
fn source_bytes(img: &Rgb32f) -> usize {
|
||||
img.data.len() * 12 + 64
|
||||
img.data.len() * 12 + 64 + std::mem::size_of::<SourceInfo>()
|
||||
}
|
||||
|
||||
impl MediaCache {
|
||||
@@ -207,8 +249,8 @@ impl MediaCache {
|
||||
let Some((tick, which)) = candidates.into_iter().flatten().min() else { break };
|
||||
let freed = match which {
|
||||
0 => self.thumbs.pop_oldest(),
|
||||
1 => self.previews.iter().position(|e| e.2 == tick).map(|i| source_bytes(&self.previews.remove(i).1)),
|
||||
2 => self.full.take().map(|e| source_bytes(&e.1)),
|
||||
1 => self.previews.iter().position(|e| e.2 == tick).map(|i| source_bytes(&self.previews.remove(i).1.image)),
|
||||
2 => self.full.take().map(|e| source_bytes(&e.1.image)),
|
||||
_ => self.rendered.evict_oldest(),
|
||||
};
|
||||
match freed {
|
||||
@@ -219,6 +261,10 @@ impl MediaCache {
|
||||
}
|
||||
|
||||
pub fn get(&mut self, id: PhotoId, level: SourceLevel) -> Option<Arc<Rgb32f>> {
|
||||
self.get_source(id, level).map(|s| s.image)
|
||||
}
|
||||
|
||||
fn get_source(&mut self, id: PhotoId, level: SourceLevel) -> Option<DecodedSource> {
|
||||
match level {
|
||||
SourceLevel::Thumb => self.thumbs.get(&id).cloned(),
|
||||
SourceLevel::Preview => self.previews.iter_mut().find(|e| e.0 == id).map(|e| {
|
||||
@@ -232,11 +278,31 @@ impl MediaCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// The facts of photo `id`'s cached sources: the decoder's when one has them, else `header`
|
||||
/// (with a smart preview's stored camera tone curve).
|
||||
fn source_facts(&self, id: PhotoId, header: SourceInfo) -> SourceInfo {
|
||||
let cached = || {
|
||||
self.thumbs
|
||||
.peek(&id)
|
||||
.into_iter()
|
||||
.chain(self.previews.iter().filter(|e| e.0 == id).map(|e| &e.1))
|
||||
.chain(self.full.iter().filter(|e| e.0 == id).map(|e| &e.1))
|
||||
};
|
||||
match cached().find(|s| s.info.is_some()).or_else(|| cached().next()) {
|
||||
Some(s) => s.info_or(header),
|
||||
None => header,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, id: PhotoId, level: SourceLevel, img: Arc<Rgb32f>) {
|
||||
self.insert_source(id, level, DecodedSource::new(img, None));
|
||||
}
|
||||
|
||||
fn insert_source(&mut self, id: PhotoId, level: SourceLevel, img: DecodedSource) {
|
||||
let tick = lightcraft_preview::next_tick();
|
||||
match level {
|
||||
SourceLevel::Thumb => {
|
||||
let cost = source_bytes(&img);
|
||||
let cost = source_bytes(&img.image);
|
||||
self.thumbs.insert(id, img, cost);
|
||||
}
|
||||
SourceLevel::Preview => {
|
||||
@@ -270,29 +336,36 @@ impl MediaCache {
|
||||
/// Decoded sources held: (thumbnail level, preview level, full size).
|
||||
pub fn usage(&self) -> (crate::memory::Usage, crate::memory::Usage, crate::memory::Usage) {
|
||||
use crate::memory::Usage;
|
||||
let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| source_bytes(&e.1)).sum());
|
||||
let full = self.full.as_ref().map(|e| Usage::new(1, source_bytes(&e.1))).unwrap_or_default();
|
||||
let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| source_bytes(&e.1.image)).sum());
|
||||
let full = self.full.as_ref().map(|e| Usage::new(1, source_bytes(&e.1.image))).unwrap_or_default();
|
||||
(Usage::new(self.thumbs.len(), self.thumbs.cost()), previews, full)
|
||||
}
|
||||
|
||||
pub fn source_ref(&mut self, p: &Photo, level: SourceLevel) -> SourceRef {
|
||||
if let Some(a) = self.get(p.id, level) {
|
||||
return SourceRef::Loaded(a);
|
||||
if let Some(a) = self.get_source(p.id, level) {
|
||||
return SourceRef::Loaded(Box::new(a));
|
||||
}
|
||||
// Procedural scenes have a nominal size: "full" is that size, not unbounded.
|
||||
let max_edge = level.max_edge().min(match (&p.source, level) {
|
||||
(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
|
||||
// the original is offline: its smart preview, when there is one. Only cached answers are
|
||||
// used here (no file-system call on the UI thread in the app); an original not known to
|
||||
// be offline is tried first and its smart preview is the render worker's fallback.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir)
|
||||
&& !std::path::Path::new(path).exists()
|
||||
{
|
||||
if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir) {
|
||||
let sp = dir.join(crate::smart::file_name(p));
|
||||
if sp.exists() {
|
||||
// (a proxy cut short fails to load, like the offline original; the worker's fallback
|
||||
// in `SourceRef::load` only uses a proxy that passes `smart::is_valid`)
|
||||
if self.availability.is_offline(path) && self.availability.exists(&sp.to_string_lossy()) == Some(true) {
|
||||
return SourceRef::Smart { path: sp };
|
||||
}
|
||||
let mut r = self.origin_ref(&p.source, max_edge);
|
||||
if let SourceRef::File { fallback, .. } = &mut r {
|
||||
*fallback = Some(sp);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
self.origin_ref(&p.source, max_edge)
|
||||
}
|
||||
@@ -307,10 +380,10 @@ impl MediaCache {
|
||||
}
|
||||
match self.scenes.iter().find(|s| s.id == *scene) {
|
||||
Some(s) => SourceRef::Demo { scene: Box::new(s.clone()), max_edge },
|
||||
None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None },
|
||||
None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None, fallback: None },
|
||||
}
|
||||
}
|
||||
Source::File { path } => SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone() },
|
||||
Source::File { path } => SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone(), fallback: None },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -344,7 +417,7 @@ pub struct RenderResult {
|
||||
pub key: u64,
|
||||
pub rendered: Result<Rendered, String>,
|
||||
/// A source that was loaded by this job (to be inserted into the cache).
|
||||
pub loaded: Option<Arc<Rgb32f>>,
|
||||
pub loaded: Option<DecodedSource>,
|
||||
/// Set for a [`QuickJob`]'s stand-in: where the image came from.
|
||||
pub quick: Option<QuickSource>,
|
||||
}
|
||||
@@ -410,12 +483,14 @@ impl RenderJob {
|
||||
};
|
||||
}
|
||||
let was_loaded = matches!(self.source, SourceRef::Loaded(_));
|
||||
match self.source.load() {
|
||||
Ok(src) => {
|
||||
match self.source.load_source() {
|
||||
Ok(source) => {
|
||||
let src = &source.image;
|
||||
let info = source.info_or(self.info);
|
||||
// Thumbnails (many small jobs side by side) stay on the CPU; views and exports use
|
||||
// the GPU when there is one.
|
||||
let gpu = self.cache.is_none();
|
||||
let rendered = develop(&src, &self.info, &self.settings, &self.request, self.stages.as_deref(), gpu);
|
||||
let rendered = develop(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu);
|
||||
if let Some((cache, key)) = &self.cache {
|
||||
cache.put(*key, Arc::new(rendered.image.clone()));
|
||||
}
|
||||
@@ -429,7 +504,7 @@ impl RenderJob {
|
||||
level: self.level,
|
||||
key: self.key,
|
||||
rendered: Ok(rendered),
|
||||
loaded: (!was_loaded).then_some(src),
|
||||
loaded: (!was_loaded).then_some(source),
|
||||
quick: None,
|
||||
}
|
||||
}
|
||||
@@ -521,11 +596,16 @@ pub fn content_key(p: &Photo) -> String {
|
||||
pub fn source_info(p: &Photo) -> SourceInfo {
|
||||
// Procedural demo scenes are scene-referred HDR (like raw files): use the filmic tone map.
|
||||
if matches!(p.source, Source::Demo { .. }) {
|
||||
return SourceInfo { raw: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None };
|
||||
return SourceInfo { raw: true, ..Default::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() }
|
||||
if p.develops_raw() {
|
||||
let (temp, tint) = if p.relative_wb() { (6500.0, 0.0) } else { p.as_shot_wb.unwrap_or((5500.0, 0.0)) };
|
||||
SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens: p.embedded_lens, relative_wb: p.relative_wb(), ..Default::default() }
|
||||
} else {
|
||||
SourceInfo::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
@@ -680,7 +760,7 @@ impl crate::Session {
|
||||
/// Accept a finished job's loaded source into the cache.
|
||||
pub fn accept(&mut self, r: &RenderResult) {
|
||||
if let Some(src) = &r.loaded {
|
||||
self.media.insert(r.photo, r.level, src.clone());
|
||||
self.media.insert_source(r.photo, r.level, src.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,12 +803,19 @@ impl crate::Session {
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
/// Prefer decoder facts to header-only metadata for pixel-statistics commands.
|
||||
pub fn source_info(&self, id: PhotoId) -> SourceInfo {
|
||||
let header = self.catalog.photo(id).map(|p| source_info(p)).unwrap_or_default();
|
||||
self.media.source_facts(id, header)
|
||||
}
|
||||
|
||||
/// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously.
|
||||
pub fn source_now(&mut self, id: PhotoId, level: SourceLevel) -> Result<Arc<Rgb32f>, String> {
|
||||
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
|
||||
let r = self.media.source_ref(&p, level).load()?;
|
||||
self.media.insert(id, level, r.clone());
|
||||
Ok(r)
|
||||
let r = self.media.source_ref(&p, level).load_source()?;
|
||||
let image = r.image.clone();
|
||||
self.media.insert_source(id, level, r);
|
||||
Ok(image)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -760,6 +847,42 @@ pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decoder_info_survives_render_jobs_cache_and_eviction() {
|
||||
let tone = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| {
|
||||
let x = 0.01 * (i + 1) as f32;
|
||||
[x, (x * 2.0).min(0.9)]
|
||||
}))
|
||||
.unwrap();
|
||||
let info = SourceInfo { raw: true, relative_wb: true, camera_tone: Some(tone), ..Default::default() };
|
||||
let mut s = crate::Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
let mut job = s.render_job(id, 64, 64, false, true).unwrap();
|
||||
job.source = SourceRef::File {
|
||||
path: "synthetic.arw".into(),
|
||||
max_edge: 64,
|
||||
loader: Some(Arc::new(move |_, _| {
|
||||
let mut image = Rgb32f::new(64, 64);
|
||||
image.data.fill([0.1; 3]);
|
||||
Ok((image, info))
|
||||
})),
|
||||
fallback: None,
|
||||
};
|
||||
job.info = SourceInfo::default(); // Header facts cannot override decoder facts.
|
||||
job.settings = Arc::new(DevelopSettings::default());
|
||||
let r = job.clone().run();
|
||||
assert_eq!(r.loaded.as_ref().unwrap().info, Some(info));
|
||||
let expected = lightcraft_pipeline::render(&r.loaded.as_ref().unwrap().image, &info, &job.settings, &job.request);
|
||||
assert_eq!(r.rendered.as_ref().unwrap().image.data, expected.image.data);
|
||||
s.accept(&r);
|
||||
job.source = s.media.source_ref(s.catalog.photo(id).unwrap(), job.level);
|
||||
let again = job.run();
|
||||
assert!(again.loaded.is_none());
|
||||
assert_eq!(r.rendered.unwrap().image.data, again.rendered.unwrap().image.data);
|
||||
s.media.forget(id);
|
||||
assert!(s.media.get_source(id, r.level).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_evicts_least_recently_used_across_caches() {
|
||||
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
|
||||
|
||||
@@ -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,15 +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());
|
||||
let cand = |k: u64| dir.join(if k == 1 { format!("{stem}-{suffix}.dng") } else { format!("{stem}-{suffix}-{k}.dng") });
|
||||
// (`u64::MAX` taken names can't exist; the last candidate is the fallback)
|
||||
let free = (1..u64::MAX).map(cand).find(|c| !c.exists()).unwrap_or_else(|| cand(u64::MAX));
|
||||
free.to_string_lossy().to_string()
|
||||
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.
|
||||
@@ -367,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);
|
||||
}
|
||||
}
|
||||
+605
-125
@@ -11,7 +11,11 @@
|
||||
//!
|
||||
//! Safety:
|
||||
//! - a target that exists on disk, or that another photo in the batch gets, receives a `-1`, `-2`…
|
||||
//! suffix — no file is ever overwritten;
|
||||
//! suffix — no file is ever overwritten. Whether an existing target is the photo itself (a
|
||||
//! change of letter case on a case-insensitive volume) is decided by file identity, not by
|
||||
//! comparing names, so on a case-sensitive volume `img_1.jpg` next to `IMG_1.JPG` is a collision
|
||||
//! (issue #95); moves never replace a file even if one appears after the check (hard link +
|
||||
//! unlink where the volume supports it);
|
||||
//! - the moves happen before the catalog changes; if one fails, the ones already done are moved
|
||||
//! back and nothing is committed;
|
||||
//! - the catalog op ([`Op::SetFile`]) is undoable: undo/redo move the files back and forth (see
|
||||
@@ -297,139 +301,352 @@ fn sidecars(path: &str) -> Vec<(PathBuf, SidecarNaming, bool)> {
|
||||
v
|
||||
}
|
||||
|
||||
/// Move `from` to `to` (never over an existing file) together with its sidecars. On error nothing
|
||||
/// is left moved.
|
||||
pub fn move_file(from: &str, to: &str) -> std::result::Result<(), String> {
|
||||
/// The file-system primitives renaming uses. [`RealFs`] is the disk; tests substitute a model of
|
||||
/// a case-insensitive or case-sensitive volume (and inject failures).
|
||||
pub(crate) trait MoveFs {
|
||||
/// Something (file, folder, link) answers to `p`.
|
||||
fn exists(&self, p: &Path) -> bool;
|
||||
fn is_file(&self, p: &Path) -> bool;
|
||||
/// `a` and `b` name the same file (e.g. `IMG.JPG` and `img.jpg` on a case-insensitive
|
||||
/// volume). Decided by file identity, never by comparing names: on a case-sensitive volume
|
||||
/// they are two different photos. When in doubt: `false`.
|
||||
fn same_file(&self, a: &Path, b: &Path) -> bool;
|
||||
/// Rename `a` to `b`, failing (never replacing) when `b` exists.
|
||||
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()>;
|
||||
/// Copy `a` to a new file `b` (an independent copy, never a link), failing when `b` exists.
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()>;
|
||||
fn remove_file(&self, p: &Path) -> std::io::Result<()>;
|
||||
/// Another photo file beside the stem-named sidecar `sidecar` that shares its stem (e.g.
|
||||
/// `IMG_1.JPG` for `IMG_1.xmp`), which still uses that sidecar.
|
||||
fn stem_sibling(&self, sidecar: &Path) -> Option<String>;
|
||||
}
|
||||
|
||||
/// The real file system.
|
||||
pub(crate) struct RealFs;
|
||||
|
||||
impl MoveFs for RealFs {
|
||||
fn exists(&self, p: &Path) -> bool {
|
||||
std::fs::symlink_metadata(p).is_ok()
|
||||
}
|
||||
|
||||
fn is_file(&self, p: &Path) -> bool {
|
||||
p.is_file()
|
||||
}
|
||||
|
||||
fn same_file(&self, a: &Path, b: &Path) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::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))]
|
||||
{
|
||||
// no stable file id in std here: `b` is `a` under another spelling when they look
|
||||
// alike and the folder has no entry spelled exactly like `b` (on a case-sensitive
|
||||
// folder holding both `IMG.JPG` and `img.jpg`, both entries are listed)
|
||||
let (Some(dir), Some(name)) = (b.parent(), b.file_name()) else { return false };
|
||||
if a.parent() != Some(dir) || ma.len() != mb.len() || ma.modified().ok() != mb.modified().ok() {
|
||||
return false;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return false };
|
||||
!entries.flatten().any(|e| e.file_name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if self.exists(b) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display())));
|
||||
}
|
||||
// A hard link fails when `b` exists — even if it appeared since the check above — so
|
||||
// nothing is ever replaced. Volumes without hard links (FAT, some network shares) fall back
|
||||
// to a plain rename after the check.
|
||||
match std::fs::hard_link(a, b) {
|
||||
Ok(()) => std::fs::remove_file(a).inspect_err(|_| {
|
||||
// both names are the same file: dropping the new one loses nothing
|
||||
let _ = std::fs::remove_file(b);
|
||||
}),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
|
||||
Err(_) => std::fs::rename(a, b),
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let bytes = std::fs::read(a)?;
|
||||
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(b)?;
|
||||
f.write_all(&bytes).and_then(|_| f.sync_all()).inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(b);
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_file(&self, p: &Path) -> std::io::Result<()> {
|
||||
std::fs::remove_file(p)
|
||||
}
|
||||
|
||||
fn stem_sibling(&self, sidecar: &Path) -> Option<String> {
|
||||
crate::import_move::sibling_photo(sidecar)
|
||||
}
|
||||
}
|
||||
|
||||
/// `from` → `to` differs only in letter case and `to` is that very file (a case-insensitive
|
||||
/// volume): the rename must go through a temporary name.
|
||||
fn is_respelling(fs: &dyn MoveFs, from: &Path, to: &Path) -> bool {
|
||||
from != to && from.to_string_lossy().to_lowercase() == to.to_string_lossy().to_lowercase() && fs.exists(to) && fs.same_file(from, to)
|
||||
}
|
||||
|
||||
/// Rename `a` to `b` without ever replacing a file; a change of letter case only goes through a
|
||||
/// unique temporary name so case-insensitive volumes see a real change.
|
||||
fn move_one(fs: &dyn MoveFs, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if !is_respelling(fs, a, b) {
|
||||
return fs.rename_no_replace(a, b);
|
||||
}
|
||||
let tmp = (0..1000u32)
|
||||
.map(|k| a.with_file_name(format!(".lc-rename-{}-{k}", std::process::id())))
|
||||
.find(|t| !fs.exists(t))
|
||||
.ok_or_else(|| std::io::Error::other("no free temporary name"))?;
|
||||
fs.rename_no_replace(a, &tmp)?;
|
||||
fs.rename_no_replace(&tmp, b).map_err(|e| match fs.rename_no_replace(&tmp, a) {
|
||||
Ok(()) => e,
|
||||
Err(e2) => std::io::Error::other(format!("{e}; it could not be given its old name back either ({e2}): it is now {}", tmp.display())),
|
||||
})
|
||||
}
|
||||
|
||||
/// Why [`move_file`] failed, and what it left behind.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MoveError {
|
||||
/// What went wrong, including every step that could not be undone and where that file is now.
|
||||
pub message: String,
|
||||
/// The file itself ended up at the new path: it moved, a sidecar didn't, and moving the file
|
||||
/// back failed too. Its sidecars that did move stay with it.
|
||||
pub moved: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MoveError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Move `from` to `to` (never over an existing file) together with its sidecars. On error the
|
||||
/// moves are undone; whatever can't be undone is named in the error (see [`MoveError`]).
|
||||
pub fn move_file(from: &str, to: &str) -> std::result::Result<(), MoveError> {
|
||||
move_file_with(&RealFs, from, to)
|
||||
}
|
||||
|
||||
/// [`move_file`] on the file system `fs`.
|
||||
pub(crate) fn move_file_with(fs: &dyn MoveFs, from: &str, to: &str) -> std::result::Result<(), MoveError> {
|
||||
if from == to {
|
||||
return Ok(());
|
||||
}
|
||||
let fail = |message: String| MoveError { message, moved: false };
|
||||
let (f, t) = (Path::new(from), Path::new(to));
|
||||
// a case-only change on a case-insensitive file system "exists" already: that is this file
|
||||
let case_only = from.to_lowercase() == to.to_lowercase();
|
||||
if t.exists() && !case_only {
|
||||
return Err(format!("{to} already exists"));
|
||||
// `to` may be this very file spelled differently (case-insensitive volume); any other file
|
||||
// there — e.g. `img.jpg` next to `IMG.JPG` on a case-sensitive volume — is never replaced
|
||||
if fs.exists(t) && !is_respelling(fs, f, t) {
|
||||
return Err(fail(format!("{to} already exists")));
|
||||
}
|
||||
let mut done: Vec<(PathBuf, PathBuf)> = Vec::new();
|
||||
let mv = |a: &Path, b: &Path| -> std::io::Result<()> {
|
||||
if case_only {
|
||||
// via a temporary name, so case-insensitive file systems see a real change
|
||||
let tmp = a.with_file_name(format!(".lc-rename-{}", std::process::id()));
|
||||
std::fs::rename(a, &tmp)?;
|
||||
std::fs::rename(&tmp, b).inspect_err(|_| {
|
||||
let _ = std::fs::rename(&tmp, a);
|
||||
})
|
||||
} else {
|
||||
std::fs::rename(a, b)
|
||||
}
|
||||
};
|
||||
mv(f, t).map_err(|e| format!("rename {from}: {e}"))?;
|
||||
done.push((f.to_path_buf(), t.to_path_buf()));
|
||||
move_one(fs, f, t).map_err(|e| fail(format!("rename {from}: {e}")))?;
|
||||
// sidecars moved (or copied: `true`)
|
||||
let mut done: Vec<(PathBuf, PathBuf, bool)> = Vec::new();
|
||||
for (sc, naming, upper) in sidecars(from) {
|
||||
if !sc.is_file() {
|
||||
if !fs.is_file(&sc) {
|
||||
continue;
|
||||
}
|
||||
let mut dst = crate::sidecar::sidecar_path(to, naming);
|
||||
if upper {
|
||||
dst = dst.with_extension("XMP");
|
||||
}
|
||||
if dst.exists() && !case_only {
|
||||
// a stem sidecar another file still uses (`IMG_1.xmp` of `IMG_1.CR3` and `IMG_1.JPG`)
|
||||
// is copied, not taken away from it (issue #92)
|
||||
let shared = naming == SidecarNaming::Stem && fs.stem_sibling(&sc).is_some();
|
||||
if fs.exists(&dst) && (shared || !is_respelling(fs, &sc, &dst)) {
|
||||
continue; // never overwrite: the old sidecar stays where it was
|
||||
}
|
||||
if let Err(e) = mv(&sc, &dst) {
|
||||
for (a, b) in done.iter().rev() {
|
||||
let _ = std::fs::rename(b, a);
|
||||
let r = if shared { fs.copy_no_replace(&sc, &dst) } else { move_one(fs, &sc, &dst) };
|
||||
if let Err(e) = r {
|
||||
let mut message = format!("rename {}: {e}", sc.display());
|
||||
// the file first: if it can't go back, the sidecars that moved stay with it
|
||||
if let Err(e) = move_one(fs, t, f) {
|
||||
message.push_str(&format!("; {from} could not be moved back ({e}): it is now {to}"));
|
||||
if !done.is_empty() {
|
||||
let names: Vec<String> = done.iter().map(|(_, b, _)| b.display().to_string()).collect();
|
||||
message.push_str(&format!(" with its sidecar {}", names.join(", ")));
|
||||
}
|
||||
return Err(MoveError { message, moved: true });
|
||||
}
|
||||
return Err(format!("rename {}: {e}", sc.display()));
|
||||
for (a, b, copied) in done.iter().rev() {
|
||||
if *copied {
|
||||
// the original is still there: the copy just goes (a leftover copy loses nothing)
|
||||
let _ = fs.remove_file(b);
|
||||
} else if let Err(e) = move_one(fs, b, a) {
|
||||
message.push_str(&format!("; sidecar {} could not be moved back ({e}): it is now {}", a.display(), b.display()));
|
||||
}
|
||||
}
|
||||
return Err(fail(message));
|
||||
}
|
||||
done.push((sc, dst));
|
||||
done.push((sc, dst, shared));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a failed batch of moves ([`move_all`]) left behind.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct BatchMoveError {
|
||||
/// The first failure and every move that could not be undone.
|
||||
pub message: String,
|
||||
/// Moves that stayed done, (from, to): those files are at `to` now.
|
||||
pub stuck: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Move the files `moves` names, all or nothing: after a failure the earlier moves are undone.
|
||||
/// Moves that can't be undone (e.g. the network share went away) are reported, not ignored.
|
||||
pub(crate) fn move_all(fs: &dyn MoveFs, moves: &[(String, String)]) -> std::result::Result<(), BatchMoveError> {
|
||||
let mut done: Vec<&(String, String)> = Vec::new();
|
||||
for m in moves {
|
||||
let Err(e) = move_file_with(fs, &m.0, &m.1) else {
|
||||
done.push(m);
|
||||
continue;
|
||||
};
|
||||
let mut message = e.message;
|
||||
let mut stuck = Vec::new();
|
||||
if e.moved {
|
||||
stuck.push(m.clone());
|
||||
}
|
||||
for d in done.iter().rev() {
|
||||
if let Err(e) = move_file_with(fs, &d.1, &d.0) {
|
||||
message.push_str(&format!("; {} could not be moved back to {}: {}", d.1, d.0, e.message));
|
||||
if !e.moved {
|
||||
stuck.push((*d).clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Err(BatchMoveError { message, stuck });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `SetFile` ops in `op` (nested batches included) that point a photo at one of `to`.
|
||||
fn set_file_ops_to(op: &Op, to: &HashSet<&str>, out: &mut Vec<Op>) {
|
||||
match op {
|
||||
Op::Batch { ops } => ops.iter().for_each(|o| set_file_ops_to(o, to, out)),
|
||||
Op::SetFile { source: Source::File { path }, .. } if to.contains(path.as_str()) => out.push(op.clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The photos among `ids` a rename handles, in order: photos sharing one file (virtual copies)
|
||||
/// rename once. No file-system calls.
|
||||
pub fn rename_photos(cat: &lightcraft_catalog::Catalog, ids: &[PhotoId]) -> Vec<std::sync::Arc<Photo>> {
|
||||
let mut seen_paths: HashSet<String> = HashSet::new();
|
||||
ids.iter()
|
||||
.filter_map(|id| cat.photo(*id))
|
||||
.filter(|p| match &p.source {
|
||||
Source::File { path } => seen_paths.insert(path.clone()),
|
||||
_ => true,
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`Session::plan_rename`] for [`rename_photos`], with the "is this name taken on disk?" check
|
||||
/// supplied: the Rename dialog plans its preview on a worker thread (a check can block on a slow
|
||||
/// drive). Whether a case-only change names this very file is decided on the disk.
|
||||
pub fn plan_rename_photos(photos: &[std::sync::Arc<Photo>], template: &str, start: usize, exists: &dyn Fn(&str) -> bool) -> Vec<RenamePlan> {
|
||||
plan_rename_core(photos, template, start, &|p| exists(&p.to_string_lossy()), &|a, b| RealFs.same_file(a, b))
|
||||
}
|
||||
|
||||
/// Plan renaming `photos`: `exists` says whether something answers to a path, `same_file` whether
|
||||
/// two spellings name one file (see [`MoveFs`]).
|
||||
fn plan_rename_core(
|
||||
photos: &[std::sync::Arc<Photo>],
|
||||
template: &str,
|
||||
start: usize,
|
||||
exists: &dyn Fn(&Path) -> bool,
|
||||
same_file: &dyn Fn(&Path, &Path) -> bool,
|
||||
) -> Vec<RenamePlan> {
|
||||
let mut taken: HashSet<String> = HashSet::new(); // lower-case target paths/names claimed in this batch
|
||||
let mut out = Vec::new();
|
||||
for (seq, p) in (start..).zip(photos) {
|
||||
let want = expand(template, p, seq);
|
||||
let (stem, ext) = split_ext(&want);
|
||||
let (stem, ext) = (stem.to_string(), ext.to_string());
|
||||
let candidate = |k: usize| {
|
||||
if k == 0 {
|
||||
want.clone()
|
||||
} else if ext.is_empty() {
|
||||
format!("{stem}-{k}")
|
||||
} else {
|
||||
format!("{stem}-{k}.{ext}")
|
||||
}
|
||||
};
|
||||
let (to, to_path) = match &p.source {
|
||||
Source::File { path } => {
|
||||
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let mut k = 0;
|
||||
loop {
|
||||
let name = candidate(k);
|
||||
let tp = dir.join(&name).to_string_lossy().to_string();
|
||||
let key = tp.to_lowercase();
|
||||
// this very file, maybe spelled differently (case-insensitive volume)? By
|
||||
// identity: on a case-sensitive volume `img_1.jpg` may be another photo
|
||||
let (from, to) = (Path::new(path), Path::new(&tp));
|
||||
let same = tp == *path || (from != to && key == path.to_lowercase() && exists(to) && same_file(from, to));
|
||||
// free: not claimed in this batch and not on disk (unless it is this very file).
|
||||
// A file this batch moves away still counts as taken: simple and safe.
|
||||
if !taken.contains(&key) && (same || !exists(to)) {
|
||||
taken.insert(key);
|
||||
break (name, Some(tp));
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
Source::Demo { .. } => {
|
||||
let mut k = 0;
|
||||
loop {
|
||||
let name = candidate(k);
|
||||
if taken.insert(format!("demo:{}", name.to_lowercase())) {
|
||||
break (name, None);
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
let from_path = match &p.source {
|
||||
Source::File { path } => Some(path.clone()),
|
||||
_ => None,
|
||||
};
|
||||
out.push(RenamePlan { id: p.id.0, from: p.file_name.clone(), to, from_path, to_path });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Plan renaming `ids` with `template` (sequence numbers from `start`), resolving collisions.
|
||||
pub fn plan_rename(&self, ids: &[PhotoId], template: &str, start: usize) -> Vec<RenamePlan> {
|
||||
// photos sharing one file (virtual copies) rename once
|
||||
let mut seen_paths: HashSet<String> = HashSet::new();
|
||||
let mut taken: HashSet<String> = HashSet::new(); // lower-case target paths/names claimed in this batch
|
||||
let mut out = Vec::new();
|
||||
let mut seq = start;
|
||||
for id in ids {
|
||||
let Some(p) = self.catalog.photo(*id) else { continue };
|
||||
if let Source::File { path } = &p.source
|
||||
&& !seen_paths.insert(path.clone())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let want = expand(template, p, seq);
|
||||
seq += 1;
|
||||
let (stem, ext) = split_ext(&want);
|
||||
let (stem, ext) = (stem.to_string(), ext.to_string());
|
||||
let candidate = |k: usize| {
|
||||
if k == 0 {
|
||||
want.clone()
|
||||
} else if ext.is_empty() {
|
||||
format!("{stem}-{k}")
|
||||
} else {
|
||||
format!("{stem}-{k}.{ext}")
|
||||
}
|
||||
};
|
||||
let (to, to_path) = match &p.source {
|
||||
Source::File { path } => {
|
||||
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let mut k = 0;
|
||||
loop {
|
||||
let name = candidate(k);
|
||||
let tp = dir.join(&name).to_string_lossy().to_string();
|
||||
let key = tp.to_lowercase();
|
||||
let same = key == path.to_lowercase();
|
||||
// free: not claimed in this batch and not on disk (unless it is this very file).
|
||||
// A file this batch moves away still counts as taken: simple and safe.
|
||||
let on_disk = Path::new(&tp).exists() && !same;
|
||||
if !taken.contains(&key) && !on_disk {
|
||||
taken.insert(key);
|
||||
break (name, Some(tp));
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
Source::Demo { .. } => {
|
||||
let mut k = 0;
|
||||
loop {
|
||||
let name = candidate(k);
|
||||
if taken.insert(format!("demo:{}", name.to_lowercase())) {
|
||||
break (name, None);
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
let from_path = match &p.source {
|
||||
Source::File { path } => Some(path.clone()),
|
||||
_ => None,
|
||||
};
|
||||
out.push(RenamePlan { id: id.0, from: p.file_name.clone(), to, from_path, to_path });
|
||||
}
|
||||
out
|
||||
self.plan_rename_with(&RealFs, ids, template, start)
|
||||
}
|
||||
|
||||
/// [`Session::plan_rename`] on the file system `fs`.
|
||||
pub(crate) fn plan_rename_with(&self, fs: &dyn MoveFs, ids: &[PhotoId], template: &str, start: usize) -> Vec<RenamePlan> {
|
||||
plan_rename_core(&rename_photos(&self.catalog, ids), template, start, &|p| fs.exists(p), &|a, b| fs.same_file(a, b))
|
||||
}
|
||||
|
||||
/// Carry out `plans`: move the files (rolled back on failure), then commit one undoable op
|
||||
/// that updates the photos (and their virtual copies).
|
||||
///
|
||||
/// When a failure can't be fully rolled back, the files that kept their new names are
|
||||
/// committed as a smaller undoable rename, so the library points at them (nothing goes
|
||||
/// missing), and the error lists them (old → new).
|
||||
pub fn apply_rename(&mut self, plans: &[RenamePlan]) -> Result<usize> {
|
||||
let mut moved: Vec<(String, String)> = Vec::new();
|
||||
for pl in plans {
|
||||
if let (Some(a), Some(b)) = (&pl.from_path, &pl.to_path)
|
||||
&& a != b
|
||||
{
|
||||
if let Err(e) = move_file(a, b) {
|
||||
for (a, b) in moved.iter().rev() {
|
||||
let _ = move_file(b, a);
|
||||
}
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
moved.push((a.clone(), b.clone()));
|
||||
}
|
||||
}
|
||||
self.apply_rename_with(&RealFs, plans)
|
||||
}
|
||||
|
||||
/// [`Session::apply_rename`] on the file system `fs`.
|
||||
pub(crate) fn apply_rename_with(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan]) -> Result<usize> {
|
||||
let mut ops = Vec::new();
|
||||
let by_path: HashMap<&str, &RenamePlan> = plans.iter().filter_map(|p| p.from_path.as_deref().map(|f| (f, p))).collect();
|
||||
for p in self.catalog.photos() {
|
||||
@@ -447,17 +664,66 @@ impl Session {
|
||||
}
|
||||
}
|
||||
let n = ops.len();
|
||||
let op = Op::Batch { ops };
|
||||
let moves: Vec<(String, String)> = plans
|
||||
.iter()
|
||||
.filter_map(|pl| match (&pl.from_path, &pl.to_path) {
|
||||
(Some(a), Some(b)) if a != b => Some((a.clone(), b.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if let Err(e) = move_all(fs, &moves) {
|
||||
return Err(EngineError::Other(self.follow_stuck(&op, e, true)));
|
||||
}
|
||||
if n > 0
|
||||
&& let Err(e) = self.commit(&format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" }), Op::Batch { ops })
|
||||
&& let Err(err) = self.commit(&format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" }), op)
|
||||
{
|
||||
for (a, b) in moved.iter().rev() {
|
||||
let _ = move_file(b, a);
|
||||
let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect();
|
||||
if let Err(e) = move_all(fs, &back) {
|
||||
return Err(EngineError::Other(format!("{err}; the renamed files could not all be moved back: {}", e.message)));
|
||||
}
|
||||
return Err(e);
|
||||
return Err(err);
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// After a failed batch of moves for `op`: point the photos whose files stayed moved at
|
||||
/// their new paths (an undoable "partly" rename when `undoable`, otherwise a plain logged
|
||||
/// change), so the library matches the disk. Returns the message for the user.
|
||||
pub(crate) fn follow_stuck(&mut self, op: &Op, e: BatchMoveError, undoable: bool) -> String {
|
||||
let mut msg = e.message;
|
||||
if e.stuck.is_empty() {
|
||||
return msg;
|
||||
}
|
||||
let to: HashSet<&str> = e.stuck.iter().map(|m| m.1.as_str()).collect();
|
||||
let mut keep = Vec::new();
|
||||
set_file_ops_to(op, &to, &mut keep);
|
||||
let list: Vec<String> = e.stuck.iter().map(|(a, b)| format!("{a} → {b}")).collect();
|
||||
let n = e.stuck.len();
|
||||
let s = if n == 1 { "" } else { "s" };
|
||||
let fix = Op::Batch { ops: keep };
|
||||
let applied = if undoable {
|
||||
self.commit(&format!("Rename {n} Photo{s} (partly)"), fix)
|
||||
} else {
|
||||
match self.catalog.apply(fix.clone()) {
|
||||
Ok(_) => {
|
||||
self.pending_log.push(fix);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
};
|
||||
match applied {
|
||||
Ok(()) => msg
|
||||
.push_str(&format!(". {n} file{s} could not be moved back and kept the new name; the library now points there: {}", list.join(", "))),
|
||||
Err(err) => msg.push_str(&format!(
|
||||
". {n} file{s} could not be moved back and the library could not follow ({err}); relink with Find Missing Photos: {}",
|
||||
list.join(", ")
|
||||
)),
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
/// File moves an undo/redo op implies: `SetFile` ops whose source path differs from the photo's
|
||||
/// current one.
|
||||
pub(crate) fn file_moves(&self, op: &Op) -> Vec<(String, String)> {
|
||||
@@ -479,26 +745,240 @@ impl Session {
|
||||
rec(self, op, &mut v);
|
||||
v
|
||||
}
|
||||
|
||||
/// Move the files `moves` names; all or nothing.
|
||||
pub(crate) fn move_files(moves: &[(String, String)]) -> Result<()> {
|
||||
let mut done: Vec<&(String, String)> = Vec::new();
|
||||
for m in moves {
|
||||
if let Err(e) = move_file(&m.0, &m.1) {
|
||||
for (a, b) in done.iter().rev().map(|m| (&m.0, &m.1)) {
|
||||
let _ = move_file(b, a);
|
||||
}
|
||||
return Err(EngineError::Other(format!("can't undo the rename: {e}")));
|
||||
}
|
||||
done.push(m);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
/// A model volume: files by path with contents; case-insensitive (default APFS/NTFS) or
|
||||
/// case-sensitive (Linux, case-sensitive APFS). `fail` makes renames from a matching source fail.
|
||||
pub(crate) struct FakeFs {
|
||||
pub ci: bool,
|
||||
pub files: RefCell<Vec<(String, Vec<u8>)>>,
|
||||
pub fail: RefCell<Vec<String>>,
|
||||
}
|
||||
|
||||
impl FakeFs {
|
||||
pub fn new(ci: bool, files: &[(&str, &str)]) -> FakeFs {
|
||||
FakeFs {
|
||||
ci,
|
||||
files: RefCell::new(files.iter().map(|(p, c)| (p.to_string(), c.as_bytes().to_vec())).collect()),
|
||||
fail: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
fn idx(&self, p: &Path) -> Option<usize> {
|
||||
let p = p.to_string_lossy();
|
||||
self.files.borrow().iter().position(|(f, _)| if self.ci { f.to_lowercase() == p.to_lowercase() } else { *f == p })
|
||||
}
|
||||
/// The listing: (exact path, contents), sorted.
|
||||
pub fn listing(&self) -> Vec<(String, String)> {
|
||||
let mut v: Vec<_> = self.files.borrow().iter().map(|(p, c)| (p.clone(), String::from_utf8_lossy(c).to_string())).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
impl MoveFs for FakeFs {
|
||||
fn exists(&self, p: &Path) -> bool {
|
||||
self.idx(p).is_some()
|
||||
}
|
||||
fn is_file(&self, p: &Path) -> bool {
|
||||
self.exists(p)
|
||||
}
|
||||
fn same_file(&self, a: &Path, b: &Path) -> bool {
|
||||
self.idx(a).is_some() && self.idx(a) == self.idx(b)
|
||||
}
|
||||
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if self.fail.borrow().iter().any(|f| a.to_string_lossy().contains(f.as_str())) {
|
||||
return Err(std::io::Error::other("injected failure"));
|
||||
}
|
||||
if self.exists(b) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists"));
|
||||
}
|
||||
let i = self.idx(a).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?;
|
||||
self.files.borrow_mut()[i].0 = b.to_string_lossy().to_string();
|
||||
Ok(())
|
||||
}
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if self.exists(b) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists"));
|
||||
}
|
||||
let i = self.idx(a).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?;
|
||||
let c = self.files.borrow()[i].1.clone();
|
||||
self.files.borrow_mut().push((b.to_string_lossy().to_string(), c));
|
||||
Ok(())
|
||||
}
|
||||
fn remove_file(&self, p: &Path) -> std::io::Result<()> {
|
||||
let i = self.idx(p).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?;
|
||||
self.files.borrow_mut().remove(i);
|
||||
Ok(())
|
||||
}
|
||||
fn stem_sibling(&self, sidecar: &Path) -> Option<String> {
|
||||
let stem = sidecar.with_extension("").to_string_lossy().to_string();
|
||||
self.files.borrow().iter().map(|(f, _)| f.clone()).find(|f| {
|
||||
let p = Path::new(f);
|
||||
p.with_extension("").to_string_lossy() == stem && !p.extension().is_some_and(|e| e.eq_ignore_ascii_case("xmp"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn file_photo(id: u64, path: &str) -> Photo {
|
||||
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
Photo::new(PhotoId(id), Source::File { path: path.into() }, &name, "JPG", 1, 1, "2026-01-01T00:00:00")
|
||||
}
|
||||
|
||||
/// Issue #95: a case-only rename is a real change on a case-insensitive volume and never
|
||||
/// replaces a different file that differs only by case on a case-sensitive one.
|
||||
#[test]
|
||||
fn case_only_renames_never_replace_another_file() {
|
||||
// case-sensitive: IMG_1.JPG and img_1.JPG are two photos
|
||||
let fs = FakeFs::new(false, &[("/p/IMG_1.JPG", "first"), ("/p/img_1.JPG", "second"), ("/p/IMG_1.xmp", "sc1"), ("/p/img_1.xmp", "sc2")]);
|
||||
assert!(move_file_with(&fs, "/p/IMG_1.JPG", "/p/img_1.JPG").unwrap_err().message.contains("already exists"));
|
||||
assert_eq!(fs.listing().len(), 4);
|
||||
assert!(fs.listing().contains(&("/p/img_1.JPG".into(), "second".into())));
|
||||
// the planner picks a free name instead
|
||||
let mut s = Session::new();
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(1, "/p/IMG_1.JPG")) }).unwrap();
|
||||
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "img_1", 1);
|
||||
assert_eq!(plans[0].to, "img_1-1.JPG");
|
||||
move_file_with(&fs, "/p/IMG_1.JPG", plans[0].to_path.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
fs.listing(),
|
||||
vec![
|
||||
("/p/img_1-1.JPG".into(), "first".into()),
|
||||
("/p/img_1-1.xmp".into(), "sc1".into()),
|
||||
("/p/img_1.JPG".into(), "second".into()),
|
||||
("/p/img_1.xmp".into(), "sc2".into())
|
||||
]
|
||||
);
|
||||
// case-sensitive, target free: a plain rename
|
||||
let fs = FakeFs::new(false, &[("/p/IMG_2.JPG", "x")]);
|
||||
move_file_with(&fs, "/p/IMG_2.JPG", "/p/img_2.JPG").unwrap();
|
||||
assert_eq!(fs.listing(), vec![("/p/img_2.JPG".into(), "x".into())]);
|
||||
|
||||
// case-insensitive: the "existing" target is the photo itself → renamed via a temp name
|
||||
let fs = FakeFs::new(true, &[("/p/IMG_1.JPG", "first"), ("/p/IMG_1.xmp", "sc1")]);
|
||||
let mut s = Session::new();
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(1, "/p/IMG_1.JPG")) }).unwrap();
|
||||
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "img_1", 1);
|
||||
assert_eq!(plans[0].to, "img_1.JPG", "not a collision with itself");
|
||||
move_file_with(&fs, "/p/IMG_1.JPG", "/p/img_1.JPG").unwrap();
|
||||
assert_eq!(fs.listing(), vec![("/p/img_1.JPG".into(), "first".into()), ("/p/img_1.xmp".into(), "sc1".into())]);
|
||||
// case-insensitive, another file under another name: still a collision
|
||||
let fs = FakeFs::new(true, &[("/p/A.JPG", "a"), ("/p/b.JPG", "b")]);
|
||||
assert!(move_file_with(&fs, "/p/A.JPG", "/p/B.JPG").is_err());
|
||||
}
|
||||
|
||||
/// Issue #105: a rollback that fails is reported, and the library follows the files that kept
|
||||
/// their new names (an undoable partial rename), so nothing goes missing.
|
||||
#[test]
|
||||
fn failed_rollbacks_are_reported_and_followed() {
|
||||
let fs = FakeFs::new(false, &[("/p/a.jpg", "A"), ("/p/a.xmp", "As"), ("/p/b.jpg", "B"), ("/p/c.jpg", "C")]);
|
||||
let mut s = Session::new();
|
||||
for (i, f) in ["/p/a.jpg", "/p/b.jpg", "/p/c.jpg"].iter().enumerate() {
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(i as u64 + 1, f)) }).unwrap();
|
||||
}
|
||||
let ids = [PhotoId(1), PhotoId(2), PhotoId(3)];
|
||||
let plans = s.plan_rename_with(&fs, &ids, "Trip-{seq}", 1);
|
||||
// c can't be renamed (the share went away), and neither can Trip-1 be moved back
|
||||
fs.fail.borrow_mut().extend(["c.jpg".to_string(), "Trip-1".to_string()]);
|
||||
let err = s.apply_rename_with(&fs, &plans).unwrap_err().to_string();
|
||||
assert!(err.contains("rename /p/c.jpg"), "{err}");
|
||||
assert!(err.contains("/p/Trip-1.jpg could not be moved back"), "{err}");
|
||||
assert!(err.contains("/p/a.jpg → /p/Trip-1.jpg"), "lists what stayed renamed: {err}");
|
||||
assert_eq!(
|
||||
fs.listing(),
|
||||
vec![
|
||||
("/p/Trip-1.jpg".into(), "A".into()),
|
||||
("/p/Trip-1.xmp".into(), "As".into()),
|
||||
("/p/b.jpg".into(), "B".into()),
|
||||
("/p/c.jpg".into(), "C".into())
|
||||
]
|
||||
);
|
||||
// the catalog matches the disk
|
||||
let path = |s: &Session, id: u64| match &s.catalog.photo(PhotoId(id)).unwrap().source {
|
||||
Source::File { path } => path.clone(),
|
||||
Source::Demo { .. } => String::new(),
|
||||
};
|
||||
assert_eq!(path(&s, 1), "/p/Trip-1.jpg");
|
||||
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().file_name, "Trip-1.jpg");
|
||||
assert_eq!((path(&s, 2), path(&s, 3)), ("/p/b.jpg".into(), "/p/c.jpg".into()));
|
||||
assert_eq!(s.undo.last().map(|e| e.label.as_str()), Some("Rename 1 Photo (partly)"));
|
||||
|
||||
// a sidecar that won't move, and a file that won't go back: the file keeps its new name
|
||||
// with the sidecars that did move, and says so
|
||||
let fs = FakeFs::new(false, &[("/p/d.jpg", "D"), ("/p/d.xmp", "Ds"), ("/p/d.jpg.xmp", "Df")]);
|
||||
fs.fail.borrow_mut().extend(["d.jpg.xmp".to_string(), "/p/e.jpg".to_string()]);
|
||||
let e = move_file_with(&fs, "/p/d.jpg", "/p/e.jpg").unwrap_err();
|
||||
assert!(e.moved, "{e}");
|
||||
assert!(e.message.contains("it is now /p/e.jpg with its sidecar /p/e.xmp"), "{e}");
|
||||
assert_eq!(fs.listing(), vec![("/p/d.jpg.xmp".into(), "Df".into()), ("/p/e.jpg".into(), "D".into()), ("/p/e.xmp".into(), "Ds".into())]);
|
||||
// … and when the file does go back, its sidecars follow it
|
||||
let fs = FakeFs::new(false, &[("/p/d.jpg", "D"), ("/p/d.xmp", "Ds"), ("/p/d.jpg.xmp", "Df")]);
|
||||
fs.fail.borrow_mut().push("d.jpg.xmp".to_string());
|
||||
let e = move_file_with(&fs, "/p/d.jpg", "/p/e.jpg").unwrap_err();
|
||||
assert!(!e.moved);
|
||||
assert_eq!(fs.listing().len(), 3);
|
||||
assert!(fs.listing().iter().all(|(p, _)| p.starts_with("/p/d.")));
|
||||
}
|
||||
|
||||
/// Issue #92 (comment): renaming one of `IMG_1.CR3` + `IMG_1.JPG` copies their shared
|
||||
/// `IMG_1.xmp` instead of taking it away from the other; the last one renamed takes it.
|
||||
#[test]
|
||||
fn shared_stem_sidecar_is_copied_not_taken() {
|
||||
let fs = FakeFs::new(false, &[("/p/IMG_1.CR3", "raw"), ("/p/IMG_1.JPG", "jpg"), ("/p/IMG_1.xmp", "shared"), ("/p/IMG_1.CR3.xmp", "own")]);
|
||||
move_file_with(&fs, "/p/IMG_1.CR3", "/p/Trip_1.CR3").unwrap();
|
||||
assert_eq!(
|
||||
fs.listing(),
|
||||
vec![
|
||||
("/p/IMG_1.JPG".into(), "jpg".into()),
|
||||
("/p/IMG_1.xmp".into(), "shared".into()),
|
||||
("/p/Trip_1.CR3".into(), "raw".into()),
|
||||
("/p/Trip_1.CR3.xmp".into(), "own".into()),
|
||||
("/p/Trip_1.xmp".into(), "shared".into()),
|
||||
]
|
||||
);
|
||||
// the JPEG is the last one using it: it moves
|
||||
move_file_with(&fs, "/p/IMG_1.JPG", "/p/Trip_2.JPG").unwrap();
|
||||
assert!(fs.listing().contains(&("/p/Trip_2.xmp".into(), "shared".into())));
|
||||
assert!(!fs.exists(Path::new("/p/IMG_1.xmp")));
|
||||
// a failure after the copy removes the copy and keeps the original
|
||||
let fs = FakeFs::new(false, &[("/p/A.CR3", "raw"), ("/p/A.JPG", "jpg"), ("/p/A.xmp", "shared"), ("/p/A.CR3.xmp", "own")]);
|
||||
fs.fail.borrow_mut().push("A.CR3.xmp".into());
|
||||
assert!(move_file_with(&fs, "/p/A.CR3", "/p/B.CR3").is_err());
|
||||
assert_eq!(fs.listing().len(), 4);
|
||||
assert!(fs.listing().contains(&("/p/A.xmp".into(), "shared".into())) && !fs.exists(Path::new("/p/B.xmp")));
|
||||
}
|
||||
|
||||
/// Issue #95 on the real disk, whichever kind of volume the temp folder is on.
|
||||
#[test]
|
||||
fn case_only_rename_on_disk() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-rename-case-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (upper, lower) = (dir.join("IMG_1.JPG"), dir.join("img_1.JPG"));
|
||||
std::fs::write(&upper, b"first").unwrap();
|
||||
let case_sensitive = !lower.exists();
|
||||
let p = |q: &Path| q.to_string_lossy().to_string();
|
||||
if case_sensitive {
|
||||
std::fs::write(&lower, b"second").unwrap();
|
||||
assert!(move_file(&p(&upper), &p(&lower)).is_err());
|
||||
assert_eq!(std::fs::read(&lower).unwrap(), b"second");
|
||||
assert_eq!(std::fs::read(&upper).unwrap(), b"first");
|
||||
} else {
|
||||
move_file(&p(&upper), &p(&lower)).unwrap();
|
||||
let names: Vec<String> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
assert_eq!(names, vec!["img_1.JPG".to_string()]);
|
||||
assert_eq!(std::fs::read(&lower).unwrap(), b"first");
|
||||
}
|
||||
// a plain rename never replaces
|
||||
std::fs::write(dir.join("other.JPG"), b"other").unwrap();
|
||||
assert!(move_file(&p(&dir.join("other.JPG")), &p(&lower)).is_err());
|
||||
assert_eq!(std::fs::read(dir.join("other.JPG")).unwrap(), b"other");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_templates_stay_inside_the_destination() {
|
||||
|
||||
+162
-13
@@ -5,12 +5,25 @@
|
||||
//! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop
|
||||
//! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`).
|
||||
//!
|
||||
//! An existing sidecar is never replaced wholesale (issue #92): LightCraft's properties
|
||||
//! ([`OWNED`], [`OWNED_IF_STATED`]) are merged into it ([`lightcraft_meta::merge_xmp`]) and
|
||||
//! everything else — another editor's `crs:` develop settings, `xmpMM` history, unknown
|
||||
//! namespaces — is kept byte for byte. A sidecar that can't be read as XMP is first copied to
|
||||
//! `<name>.xmp.bak-<time>` (never overwritten), then replaced. Writes are atomic (temp file,
|
||||
//! fsync, rename).
|
||||
//!
|
||||
//! **Shared names** (Stem naming): `IMG_0001.CR3` and `IMG_0001.JPG` would share `IMG_0001.xmp`.
|
||||
//! When two catalogued files map to one stem sidecar, it belongs to one of them (a raw first, then
|
||||
//! the first by file name — [`Session::sidecar_naming`]); the others use `<file>.xmp`
|
||||
//! (`IMG_0001.JPG.xmp`) for writing and reading, so neither overwrites the other.
|
||||
//!
|
||||
//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata, except
|
||||
//! the capture time: a time embedded in the file wins, and the sidecar's (`exif:DateTimeOriginal`,
|
||||
//! else `photoshop:DateCreated`, else `xmp:CreateDate`) fills in only when the file has none; develop
|
||||
//! settings are restored from `lc:settings` when present, else mapped from interoperable `crs:`
|
||||
//! fields ([`crate::crs`]). For raw/DNG files without a sidecar the file's embedded XMP is used.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -258,13 +271,99 @@ pub fn sidecar_packet(p: &Photo, cat: &lightcraft_catalog::Catalog) -> String {
|
||||
lightcraft_meta::write_xmp_lc(&meta, &lc)
|
||||
}
|
||||
|
||||
/// Write `data` to `path` atomically (temp file + rename).
|
||||
/// The properties LightCraft writes and owns in a sidecar: replaced on every save (removed when
|
||||
/// LightCraft has no value, so clearing a field clears it in the file too). All of them are read
|
||||
/// back on import and by Read Metadata from File.
|
||||
pub const OWNED: &[&str] = &[
|
||||
"xmp:Rating",
|
||||
"xmp:Label",
|
||||
"dc:title",
|
||||
"dc:description",
|
||||
"dc:rights",
|
||||
"dc:creator",
|
||||
"dc:subject",
|
||||
"Iptc4xmpCore:Location",
|
||||
"Iptc4xmpCore:AltTextAccessibility",
|
||||
"Iptc4xmpCore:ExtDescrAccessibility",
|
||||
"photoshop:City",
|
||||
"photoshop:State",
|
||||
"photoshop:Country",
|
||||
"xmpRights:Marked",
|
||||
"xmpRights:UsageTerms",
|
||||
"xmpRights:WebStatement",
|
||||
"lc:*",
|
||||
];
|
||||
|
||||
/// Properties replaced only when LightCraft has a value (capture time; GPS, which LightCraft
|
||||
/// doesn't read from sidecars): otherwise the sidecar's own value stays. Anything else LightCraft
|
||||
/// writes (`xmp:CreatorTool`) goes only into new sidecars.
|
||||
pub const OWNED_IF_STATED: &[&str] = &["exif:DateTimeOriginal", "photoshop:DateCreated", "exif:GPSLatitude", "exif:GPSLongitude"];
|
||||
|
||||
/// Write `data` to `path` atomically: a temp file next to it, synced, then renamed over it.
|
||||
fn write_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
let tmp = path.with_extension(format!("xmp.tmp-{}", std::process::id()));
|
||||
std::fs::write(&tmp, data)?;
|
||||
std::fs::rename(&tmp, path).inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
})
|
||||
lightcraft_catalog::safe_file::write_atomic(path, data)
|
||||
}
|
||||
|
||||
/// Copy `path` to a new `<name>.bak-<stamp>` (`-1`, `-2`… if taken; never overwriting).
|
||||
fn backup(path: &Path, stamp: &str) -> std::io::Result<PathBuf> {
|
||||
use std::io::Write;
|
||||
let bytes = std::fs::read(path)?;
|
||||
let stamp: String = stamp.chars().filter(char::is_ascii_alphanumeric).collect();
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
for k in 0..1000 {
|
||||
let b = path.with_file_name(if k == 0 { format!("{name}.bak-{stamp}") } else { format!("{name}.bak-{stamp}-{k}") });
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&b) {
|
||||
Ok(mut f) => {
|
||||
f.write_all(&bytes)?;
|
||||
f.sync_all()?;
|
||||
return Ok(b);
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(std::io::Error::other("no free backup name"))
|
||||
}
|
||||
|
||||
/// What [`Session::save_sidecar`] did.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SidecarSaved {
|
||||
pub path: PathBuf,
|
||||
/// An existing sidecar was merged into (its other contents kept).
|
||||
pub merged: bool,
|
||||
/// An existing sidecar that couldn't be read as XMP was copied here before being replaced.
|
||||
pub backup: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// The new contents for the sidecar at `path`: `fresh` merged into the existing file, if any.
|
||||
/// Returns (contents, merged, backup).
|
||||
fn sidecar_contents(path: &Path, fresh: String, stamp: &str) -> std::io::Result<(String, bool, Option<PathBuf>)> {
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((fresh, false, None)),
|
||||
// can't see what is there: never overwrite it blindly
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if bytes.iter().all(u8::is_ascii_whitespace) {
|
||||
return Ok((fresh, false, None));
|
||||
}
|
||||
let rules = lightcraft_meta::MergeRules { owned: OWNED, owned_if_present: OWNED_IF_STATED };
|
||||
match std::str::from_utf8(&bytes)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|old| lightcraft_meta::merge_xmp(old, &fresh, rules).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(merged) => Ok((merged, true, None)),
|
||||
Err(why) => {
|
||||
let b = backup(path, stamp)?;
|
||||
log::warn!("{}: not readable as XMP ({why}); kept a copy as {} before replacing it", path.display(), b.display());
|
||||
Ok((fresh, false, Some(b)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Key of the stem sidecar a file maps to (case-insensitive).
|
||||
fn stem_key(original: &str) -> String {
|
||||
sidecar_path(original, SidecarNaming::Stem).to_string_lossy().to_lowercase()
|
||||
}
|
||||
|
||||
fn file_path(p: &Photo) -> Option<&str> {
|
||||
@@ -302,24 +401,70 @@ pub(crate) fn op_photos(op: &Op, out: &mut Vec<PhotoId>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which photo owns each stem sidecar that several catalogued files map to (see
|
||||
/// [`Session::sidecar_naming`]).
|
||||
pub(crate) struct StemOwners(HashMap<String, PhotoId>);
|
||||
|
||||
impl StemOwners {
|
||||
pub(crate) fn of(cat: &lightcraft_catalog::Catalog) -> StemOwners {
|
||||
// per stem: (raw first, then file name, then id) of the best candidate; and how many
|
||||
let mut best: HashMap<String, ((bool, String, u64), PhotoId, usize)> = HashMap::new();
|
||||
for p in cat.photos().filter(|p| p.copy_of.is_none()) {
|
||||
let Some(path) = file_path(p) else { continue };
|
||||
let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0);
|
||||
let e = best.entry(stem_key(path)).or_insert_with(|| (rank.clone(), p.id, 0));
|
||||
e.2 += 1;
|
||||
if rank < e.0 {
|
||||
(e.0, e.1) = (rank, p.id);
|
||||
}
|
||||
}
|
||||
StemOwners(best.into_iter().filter(|(_, v)| v.2 > 1).map(|(k, v)| (k, v.1)).collect())
|
||||
}
|
||||
|
||||
/// `naming` for photo `p`, except that a stem sidecar another file owns becomes Full.
|
||||
fn naming(&self, p: &Photo, naming: SidecarNaming) -> SidecarNaming {
|
||||
match (naming, file_path(p)) {
|
||||
(SidecarNaming::Stem, Some(path)) if self.0.get(&stem_key(path)).is_some_and(|owner| *owner != p.id) => SidecarNaming::Full,
|
||||
_ => naming,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Write the photo's XMP sidecar; returns its path.
|
||||
pub fn save_sidecar(&self, id: PhotoId) -> Result<PathBuf> {
|
||||
/// The sidecar naming for photo `id`: the library's, except under Stem naming when another
|
||||
/// catalogued file shares the stem (`IMG_0001.CR3` + `IMG_0001.JPG`) and owns the stem
|
||||
/// sidecar — a raw first, else the first by file name. The others use Full naming
|
||||
/// (`IMG_0001.JPG.xmp`), so their metadata never overwrites each other.
|
||||
pub fn sidecar_naming(&self, id: PhotoId) -> SidecarNaming {
|
||||
match self.catalog.photo(id) {
|
||||
Some(p) if self.xmp.naming == SidecarNaming::Stem => StemOwners::of(&self.catalog).naming(p, self.xmp.naming),
|
||||
_ => self.xmp.naming,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the photo's XMP sidecar, merged into an existing one (see the module docs).
|
||||
pub fn save_sidecar(&self, id: PhotoId) -> Result<SidecarSaved> {
|
||||
self.save_sidecar_with(id, &StemOwners::of(&self.catalog))
|
||||
}
|
||||
|
||||
pub(crate) fn save_sidecar_with(&self, id: PhotoId, owners: &StemOwners) -> Result<SidecarSaved> {
|
||||
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
||||
if p.copy_of.is_some() {
|
||||
return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name)));
|
||||
}
|
||||
let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?;
|
||||
let path = sidecar_path(orig, self.xmp.naming);
|
||||
write_atomic(&path, sidecar_packet(p, &self.catalog).as_bytes()).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
|
||||
Ok(path)
|
||||
let path = sidecar_path(orig, owners.naming(p, self.xmp.naming));
|
||||
let err = |e: std::io::Error| EngineError::Other(format!("{}: {e}", path.display()));
|
||||
let (data, merged, backup) = sidecar_contents(&path, sidecar_packet(p, &self.catalog), &(self.clock)()).map_err(err)?;
|
||||
write_atomic(&path, data.as_bytes()).map_err(err)?;
|
||||
Ok(SidecarSaved { path, merged, backup })
|
||||
}
|
||||
|
||||
/// The op that applies a photo's sidecar (or embedded XMP) to the catalog, if there is one.
|
||||
pub fn read_sidecar_op(&self, id: PhotoId) -> Result<Option<(Op, PathBuf)>> {
|
||||
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
||||
let Some(orig) = file_path(p) else { return Ok(None) };
|
||||
let Some((packet, from)) = read_packet(orig, p.kind, self.xmp.naming) else { return Ok(None) };
|
||||
let Some((packet, from)) = read_packet(orig, p.kind, self.sidecar_naming(id)) else { return Ok(None) };
|
||||
let sc = parse_sidecar(&packet, p.kind == MediaKind::Raw)
|
||||
.map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?
|
||||
.resolve_label(&self.catalog);
|
||||
@@ -344,9 +489,13 @@ impl Session {
|
||||
pub(crate) fn auto_write_sidecars(&self, ops: &[Op]) {
|
||||
let mut ids = Vec::new();
|
||||
ops.iter().for_each(|o| op_photos(o, &mut ids));
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
let owners = StemOwners::of(&self.catalog);
|
||||
for id in ids {
|
||||
if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none())
|
||||
&& let Err(e) = self.save_sidecar(id)
|
||||
&& let Err(e) = self.save_sidecar_with(id, &owners)
|
||||
{
|
||||
log::warn!("auto-write XMP: {e}");
|
||||
}
|
||||
|
||||
+109
-13
@@ -1,13 +1,16 @@
|
||||
//! Smart previews: a compact proxy of each photo (its decoded preview-size source, about 1 MB)
|
||||
//! kept in the library, so photos stay editable — and exportable at proxy size — while their
|
||||
//! originals are offline (an unplugged drive). The proxy is the scene-linear source scaled into
|
||||
//! 0..1 by a stored factor, sRGB-encoded and saved as a JPEG after a one-line header.
|
||||
//! 0..1 by a stored factor, sRGB-encoded and saved as a JPEG after a one-line header. The header
|
||||
//! also keeps the decoder's file-local camera tone curve (Sony ARW camera look), which is applied
|
||||
//! at render time and is not baked into the pixels.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_catalog::Photo;
|
||||
use lightcraft_color::transfer::linear_to_srgb;
|
||||
use lightcraft_pipeline::tone::CameraTone;
|
||||
use lightcraft_raster::{Rgb32f, Rgba8};
|
||||
|
||||
const MAGIC: &[u8] = b"LCSP1\n";
|
||||
@@ -18,8 +21,11 @@ pub fn file_name(p: &Photo) -> String {
|
||||
format!("{:032x}.lcsp", h.0)
|
||||
}
|
||||
|
||||
/// Encode a source image as a smart preview.
|
||||
pub fn encode(img: &Rgb32f) -> Result<Vec<u8>, String> {
|
||||
/// The most header [`is_valid`] reads (the JSON line before the JPEG).
|
||||
const HEADER_MAX: u64 = 4096;
|
||||
|
||||
/// Encode a source image (and the decoder's camera tone curve, if any) as a smart preview.
|
||||
pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result<Vec<u8>, String> {
|
||||
// scale so all but the brightest 0.05 % fit into 0..1
|
||||
let mut lum: Vec<f32> = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect();
|
||||
let scale = if lum.is_empty() {
|
||||
@@ -47,22 +53,29 @@ pub fn encode(img: &Rgb32f) -> Result<Vec<u8>, String> {
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut out = MAGIC.to_vec();
|
||||
out.extend_from_slice(format!("{{\"w\":{},\"h\":{},\"scale\":{scale}}}\n", img.width, img.height).as_bytes());
|
||||
let mut head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale});
|
||||
if let Some(t) = tone {
|
||||
head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?;
|
||||
}
|
||||
out.extend_from_slice(head.to_string().as_bytes());
|
||||
out.push(b'\n');
|
||||
out.extend_from_slice(&jpg);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decode a smart preview back into a source image.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Rgb32f, String> {
|
||||
/// Decode a smart preview back into a source image and its stored camera tone curve (an invalid
|
||||
/// curve is ignored, like a missing one).
|
||||
pub fn decode(bytes: &[u8]) -> Result<(Rgb32f, Option<CameraTone>), String> {
|
||||
let rest = bytes.strip_prefix(MAGIC).ok_or("not a smart preview")?;
|
||||
let nl = rest.iter().position(|b| *b == b'\n').ok_or("bad smart preview")?;
|
||||
let head: serde_json::Value = serde_json::from_slice(&rest[..nl]).map_err(|e| e.to_string())?;
|
||||
let scale = head["scale"].as_f64().unwrap_or(1.0) as f32;
|
||||
let tone = head.get("tone").and_then(|t| serde_json::from_value::<CameraTone>(t.clone()).ok());
|
||||
let d = lightcraft_codecs::decode(&rest[nl + 1..], Default::default()).map_err(|e| e.to_string())?;
|
||||
// the decoder undoes the sRGB encoding; the values are the source's own primaries
|
||||
let mut img = d.image;
|
||||
img.data.iter_mut().for_each(|c| *c = c.map(|v| v * scale));
|
||||
Ok(img)
|
||||
Ok((img, tone))
|
||||
}
|
||||
|
||||
/// Where a library keeps its smart previews.
|
||||
@@ -130,11 +143,16 @@ pub fn migrate(from: &Path, to: &Path, what: Existing) -> (usize, Vec<String>) {
|
||||
Existing::Discard => std::fs::remove_file(&src),
|
||||
_ => {
|
||||
let dst = to.join(e.file_name());
|
||||
if dst.exists() {
|
||||
// named by content: the copy already there is the same preview
|
||||
if is_valid(&dst) {
|
||||
// named by content: the complete copy already there is the same preview
|
||||
std::fs::remove_file(&src)
|
||||
} else {
|
||||
std::fs::rename(&src, &dst).or_else(|_| std::fs::copy(&src, &dst).and_then(|_| std::fs::remove_file(&src)))
|
||||
// across drives: copied atomically (never a partial proxy at the new place)
|
||||
std::fs::rename(&src, &dst).or_else(|_| {
|
||||
std::fs::read(&src)
|
||||
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&dst, &b))
|
||||
.and_then(|()| std::fs::remove_file(&src))
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -146,10 +164,33 @@ pub fn migrate(from: &Path, to: &Path, what: Existing) -> (usize, Vec<String>) {
|
||||
(done, failed)
|
||||
}
|
||||
|
||||
/// A cheap check that the smart preview at `path` is complete: our header, then a JPEG from its
|
||||
/// start marker to its end marker. A file cut short by a crash, a full drive or an unplugged one
|
||||
/// fails it (and is then rebuilt by Build Smart Previews, not counted as there).
|
||||
pub fn is_valid(path: &Path) -> bool {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let Ok(mut f) = std::fs::File::open(path) else { return false };
|
||||
let mut head = Vec::with_capacity(1024);
|
||||
if (&mut f).take(HEADER_MAX).read_to_end(&mut head).is_err() {
|
||||
return false;
|
||||
}
|
||||
let Some(rest) = head.strip_prefix(MAGIC) else { return false };
|
||||
let Some(nl) = rest.iter().position(|b| *b == b'\n') else { return false };
|
||||
let size_ok = rest
|
||||
.get(..nl)
|
||||
.and_then(|h| serde_json::from_slice::<serde_json::Value>(h).ok())
|
||||
.is_some_and(|h| h["w"].as_u64().is_some_and(|w| w > 0) && h["h"].as_u64().is_some_and(|h| h > 0));
|
||||
if !size_ok || rest.get(nl + 1..nl + 3) != Some(&[0xFF, 0xD8][..]) {
|
||||
return false;
|
||||
}
|
||||
let mut end = [0u8; 2];
|
||||
f.seek(SeekFrom::End(-2)).is_ok() && f.read_exact(&mut end).is_ok() && end == [0xFF, 0xD9]
|
||||
}
|
||||
|
||||
/// Load the proxy at `path`.
|
||||
pub fn load(path: &Path) -> Result<Arc<Rgb32f>, String> {
|
||||
pub fn load(path: &Path) -> Result<crate::media::DecodedSource, String> {
|
||||
let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
decode(&b).map(Arc::new)
|
||||
decode(&b).map(|(image, camera_tone)| crate::media::DecodedSource { image: Arc::new(image), info: None, camera_tone })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -193,11 +234,66 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_proxies_are_invalid_and_never_kept_over_good_ones() {
|
||||
let base = temp("valid");
|
||||
let (a, b) = (base.join("a"), base.join("b"));
|
||||
std::fs::create_dir_all(&a).unwrap();
|
||||
std::fs::create_dir_all(&b).unwrap();
|
||||
let img = lightcraft_scenes::demo_library()[0].render(64, 40);
|
||||
let good = encode(&img, None).unwrap();
|
||||
std::fs::write(a.join("1.lcsp"), &good).unwrap();
|
||||
assert!(is_valid(&a.join("1.lcsp")));
|
||||
for cut in [good.len() - 1, good.len() / 2, 10, 0] {
|
||||
std::fs::write(b.join("1.lcsp"), &good[..cut]).unwrap();
|
||||
assert!(!is_valid(&b.join("1.lcsp")), "cut at {cut}");
|
||||
}
|
||||
assert!(!is_valid(&b.join("missing.lcsp")));
|
||||
// moving onto a damaged copy replaces it instead of deleting the good one
|
||||
assert_eq!(migrate(&a, &b, Existing::Move).0, 1);
|
||||
assert_eq!(std::fs::read(b.join("1.lcsp")).unwrap(), good);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_tone_travels_with_the_proxy() {
|
||||
let tone = CameraTone::new(std::array::from_fn(|i| {
|
||||
let x = 0.004 * 1.17f32.powi(i as i32);
|
||||
[x, 0.95 * (1.0 - (-2.7 * x).exp())]
|
||||
}))
|
||||
.unwrap();
|
||||
let img = lightcraft_scenes::demo_library()[0].render(64, 40);
|
||||
let bytes = encode(&img, Some(&tone)).unwrap();
|
||||
assert_eq!(decode(&bytes).unwrap().1, Some(tone));
|
||||
let dir = temp("tone");
|
||||
let path = dir.join("t.lcsp");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
// the longer header is still read by the validity check, and the curve by `load`
|
||||
assert!(is_valid(&path));
|
||||
let loaded = load(&path).unwrap();
|
||||
assert_eq!(loaded.camera_tone, Some(tone));
|
||||
let header = lightcraft_pipeline::SourceInfo { raw: true, ..Default::default() };
|
||||
assert_eq!(loaded.info_or(header).camera_tone, Some(tone));
|
||||
// a hostile curve (reversing knots) is ignored, not trusted
|
||||
let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap();
|
||||
let mut head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap();
|
||||
let mut knots: Vec<[f32; 2]> = serde_json::from_value(head["tone"]["knots"].clone()).unwrap();
|
||||
knots.swap(3, 4);
|
||||
head["tone"]["knots"] = serde_json::json!(knots);
|
||||
let mut hostile = MAGIC.to_vec();
|
||||
hostile.extend_from_slice(head.to_string().as_bytes());
|
||||
hostile.extend_from_slice(&bytes[nl..]);
|
||||
let (pixels, tone) = decode(&hostile).unwrap();
|
||||
assert_eq!((pixels.width, tone), (img.width, None));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_keeps_the_picture() {
|
||||
let img = lightcraft_scenes::demo_library()[0].render(160, 100);
|
||||
let back = decode(&encode(&img).unwrap()).unwrap();
|
||||
let (back, tone) = decode(&encode(&img, None).unwrap()).unwrap();
|
||||
assert_eq!((back.width, back.height), (img.width, img.height));
|
||||
assert!(tone.is_none());
|
||||
let mut err = 0.0f64;
|
||||
for (a, b) in img.data.iter().zip(&back.data) {
|
||||
for k in 0..3 {
|
||||
|
||||
@@ -10,6 +10,30 @@ fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings {
|
||||
(*s.develop_of(s.active().unwrap()).unwrap()).clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_one_wb_control_resolves_as_shot_without_stale_tint() {
|
||||
use lightcraft_catalog::{Photo, PhotoId, Source};
|
||||
use lightcraft_develop::{DevelopSettings, WbMode};
|
||||
let mut s = demo();
|
||||
let id = PhotoId(100);
|
||||
let mut p = Photo::new(id, Source::File { path: "synthetic.arw".into() }, "synthetic.arw", "ARW", 16, 16, "");
|
||||
// A catalog made by the old generic-matrix Kelvin inference.
|
||||
p.as_shot_wb = Some((6829.0, -127.0));
|
||||
p.develop = std::sync::Arc::new(DevelopSettings::for_raw(6829.0, -127.0));
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
s.execute("library.select", &json!({"ids": [100], "active": 100})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "wb.temp", "value": 8000})).unwrap();
|
||||
let d = active_dev(&s);
|
||||
assert_eq!(d.wb.mode, WbMode::Custom);
|
||||
assert_eq!(d.wb.temp, 8000.0);
|
||||
assert_eq!(d.wb.tint, 0.0, "untouched tint comes from the current as-shot reference");
|
||||
s.execute("develop.wb", &json!({"mode": "asShot"})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "wb.tint", "value": 10})).unwrap();
|
||||
assert_eq!(active_dev(&s).wb.temp, 6500.0);
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
assert_eq!((active_dev(&s).wb.temp, active_dev(&s).wb.tint), (6500.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_library_loads() {
|
||||
let mut s = demo();
|
||||
|
||||
@@ -174,3 +174,152 @@ fn prepared_exports_run_on_another_thread() {
|
||||
assert_eq!(files[0]["width"], 48);
|
||||
assert_eq!(seen.lock().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// Issue #78: a GPU render whose work never ran (a driver that drops a submission without an
|
||||
/// error) gave an all-black export. The export must come from the CPU instead — the same
|
||||
/// image — with the reason recorded.
|
||||
#[test]
|
||||
fn export_falls_back_to_the_cpu_when_gpu_work_is_lost() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.4})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "effects.clarity", "value": 20})).unwrap();
|
||||
let o = ExportOptions::from_json(&json!({"format": "png"}));
|
||||
let mean = |b: &[u8]| {
|
||||
let d = lightcraft_codecs::decode(b, Default::default()).unwrap().image;
|
||||
d.data.iter().map(|p| (p[0] + p[1] + p[2]) as f64).sum::<f64>() / (3 * d.data.len()) as f64
|
||||
};
|
||||
let gpu = lightcraft_gpu::available();
|
||||
let healthy = mean(&export_photo(&mut s, id, &o, 1).unwrap().bytes);
|
||||
lightcraft_gpu::inject_fault(lightcraft_gpu::Fault::DropWork);
|
||||
let faulted = export_photo(&mut s, id, &o, 1).unwrap().bytes;
|
||||
if gpu {
|
||||
let why = lightcraft_gpu::last_fallback().unwrap_or_default();
|
||||
assert!(why.contains("incomplete"), "{why}");
|
||||
// the GPU is off for the process now: this export renders on the CPU
|
||||
assert!(!lightcraft_gpu::available());
|
||||
}
|
||||
let cpu = export_photo(&mut s, id, &o, 1).unwrap().bytes;
|
||||
lightcraft_gpu::reset_failures();
|
||||
let px = |b: &[u8]| lightcraft_codecs::decode(b, Default::default()).unwrap().image.data;
|
||||
assert!(px(&faulted) == px(&cpu), "the fallback is the CPU render");
|
||||
let m = mean(&faulted);
|
||||
assert!(m > 0.02, "not black: mean {m}");
|
||||
assert!((m - healthy).abs() < 0.01, "GPU {healthy} vs CPU {m}");
|
||||
}
|
||||
|
||||
/// A JPEG original `dir/IMG_1.jpg` imported into a library on disk.
|
||||
fn library_with_jpeg(tag: &str) -> (Session, lightcraft_catalog::PhotoId, std::path::PathBuf, std::path::PathBuf, Vec<u8>) {
|
||||
let dir = temp_dir(tag);
|
||||
let src = dir.join("IMG_1.jpg");
|
||||
let img = lightcraft_raster::Rgba8::from_fn(48, 32, |x, y| [(x * 5) as u8, (y * 7) as u8, 120, 255]);
|
||||
let bytes = crate::export::encode_image(&img, &ExportOptions::default()).unwrap();
|
||||
std::fs::write(&src, &bytes).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let id = s.catalog.photos().next().unwrap().id;
|
||||
s.execute("develop.set", &json!({"ids": [id.0], "control": "light.exposure", "value": 1.0})).unwrap();
|
||||
(s, id, dir, src, bytes)
|
||||
}
|
||||
|
||||
fn disk_batch(
|
||||
s: &mut Session,
|
||||
id: lightcraft_catalog::PhotoId,
|
||||
o: &ExportOptions,
|
||||
to: &crate::export::Destination,
|
||||
) -> Result<Vec<serde_json::Value>, String> {
|
||||
crate::export::export_batch(s, &[id], o, to, &mut crate::export::write_file, &|p| std::path::Path::new(p).exists())
|
||||
}
|
||||
|
||||
/// Issue #93: exporting into the photo's own folder as `{name}` with "Overwrite" replaced the
|
||||
/// original with the re-encoded render. It is refused now, whatever the policy or exact path.
|
||||
#[test]
|
||||
fn export_never_overwrites_an_original() {
|
||||
use crate::export::{Conflict, Destination};
|
||||
let (mut s, id, dir, src, original) = library_with_jpeg("export-guard");
|
||||
let folder = Destination { dir: dir.to_string_lossy().to_string(), exact: None };
|
||||
let o = ExportOptions { naming: "{name}".into(), conflict: Conflict::Overwrite, ..Default::default() };
|
||||
let err = disk_batch(&mut s, id, &o, &folder).unwrap_err();
|
||||
assert!(err.contains("original of IMG_1.jpg"), "{err}");
|
||||
assert_eq!(std::fs::read(&src).unwrap(), original, "the original is byte-identical");
|
||||
|
||||
// the exact path of the control channel / MCP, also spelled another way
|
||||
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||||
for exact in [src.to_string_lossy().to_string(), dir.join("sub/../IMG_1.jpg").to_string_lossy().to_string()] {
|
||||
let to = Destination { dir: String::new(), exact: Some(exact) };
|
||||
assert!(disk_batch(&mut s, id, &o, &to).unwrap_err().contains("never writes over an original"));
|
||||
}
|
||||
// the "Original" format re-writing the file onto itself (and its sidecar)
|
||||
let orig = ExportOptions { format: ExportFormat::Original, ..o.clone() };
|
||||
assert!(disk_batch(&mut s, id, &orig, &folder).is_err());
|
||||
assert_eq!(std::fs::read(&src).unwrap(), original);
|
||||
assert!(s.execute("export.checkTarget", &json!({"path": src.to_string_lossy()})).is_err());
|
||||
assert!(s.execute("export.checkTarget", &json!({"path": dir.join("free.jpg").to_string_lossy()})).is_ok());
|
||||
|
||||
// Unique moves past it; Overwrite still replaces an ordinary earlier export
|
||||
let unique = ExportOptions { conflict: Conflict::Unique, ..o.clone() };
|
||||
let files = disk_batch(&mut s, id, &unique, &folder).unwrap();
|
||||
assert!(files[0]["path"].as_str().unwrap().ends_with("IMG_1-2.jpg"), "{files:?}");
|
||||
let earlier = dir.join("IMG_1-2.jpg");
|
||||
std::fs::write(&earlier, b"an earlier export").unwrap();
|
||||
let named = ExportOptions { naming: "{name}-2".into(), ..o };
|
||||
disk_batch(&mut s, id, &named, &folder).unwrap();
|
||||
assert_ne!(std::fs::read(&earlier).unwrap(), b"an earlier export", "an ordinary file is overwritten");
|
||||
assert_eq!(std::fs::read(&src).unwrap(), original);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #93: the XMP sidecar of an "Original" export ignored the conflict policy.
|
||||
#[test]
|
||||
fn export_sidecars_follow_the_conflict_policy() {
|
||||
use crate::export::{Conflict, Destination};
|
||||
let (mut s, id, dir, _src, original) = library_with_jpeg("export-sidecar");
|
||||
let out = dir.join("out");
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
let theirs = out.join("IMG_1.xmp");
|
||||
std::fs::write(&theirs, b"someone else's sidecar").unwrap();
|
||||
let to = Destination { dir: out.to_string_lossy().to_string(), exact: None };
|
||||
let o = ExportOptions { format: ExportFormat::Original, naming: "{name}".into(), ..Default::default() };
|
||||
|
||||
// Unique: the photo and its sidecar both move to the next free name
|
||||
let files = disk_batch(&mut s, id, &ExportOptions { conflict: Conflict::Unique, ..o.clone() }, &to).unwrap();
|
||||
assert!(files[0]["path"].as_str().unwrap().ends_with("IMG_1-2.jpg"), "{files:?}");
|
||||
assert!(files[0]["sidecars"][0].as_str().unwrap().ends_with("IMG_1-2.xmp"), "{files:?}");
|
||||
assert_eq!(std::fs::read(&theirs).unwrap(), b"someone else's sidecar");
|
||||
assert_eq!(std::fs::read(out.join("IMG_1-2.jpg")).unwrap(), original);
|
||||
// Skip: a taken sidecar name skips the photo
|
||||
std::fs::remove_file(out.join("IMG_1-2.jpg")).unwrap();
|
||||
std::fs::remove_file(out.join("IMG_1-2.xmp")).unwrap();
|
||||
let files = disk_batch(&mut s, id, &ExportOptions { conflict: Conflict::Skip, ..o.clone() }, &to).unwrap();
|
||||
assert!(files[0]["skipped"].as_str().is_some(), "{files:?}");
|
||||
assert!(!out.join("IMG_1.jpg").exists());
|
||||
assert_eq!(std::fs::read(&theirs).unwrap(), b"someone else's sidecar");
|
||||
// Overwrite: replaced, as asked
|
||||
disk_batch(&mut s, id, &ExportOptions { conflict: Conflict::Overwrite, ..o }, &to).unwrap();
|
||||
assert!(std::fs::read_to_string(&theirs).unwrap().contains("xmpmeta"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #93: the native writer truncated the target first; a failure mid-write (full disk,
|
||||
/// unplugged drive) now leaves the previous file intact and no partial one.
|
||||
#[test]
|
||||
fn a_failed_export_write_keeps_the_previous_file() {
|
||||
use crate::export::{Conflict, Destination};
|
||||
let (mut s, id, dir, _src, _) = library_with_jpeg("export-midwrite");
|
||||
let out = dir.join("out");
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
let prev = out.join("IMG_1.jpg");
|
||||
std::fs::write(&prev, b"last week's export").unwrap();
|
||||
let to = Destination { dir: out.to_string_lossy().to_string(), exact: None };
|
||||
let o = ExportOptions { conflict: Conflict::Overwrite, ..Default::default() };
|
||||
{
|
||||
let _fault = lightcraft_catalog::safe_file::fail_writes_after(100);
|
||||
let err = disk_batch(&mut s, id, &o, &to).unwrap_err();
|
||||
assert!(err.contains("injected"), "{err}");
|
||||
}
|
||||
assert_eq!(std::fs::read(&prev).unwrap(), b"last week's export");
|
||||
assert_eq!(std::fs::read_dir(&out).unwrap().count(), 1, "no temp file left behind");
|
||||
disk_batch(&mut s, id, &o, &to).unwrap();
|
||||
assert_ne!(std::fs::read(&prev).unwrap(), b"last week's export");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Forgetting untouched Local browse records: by command and when the library opens; changed,
|
||||
//! recent and visible records stay; a crash replays it; browsing again brings records back.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{LibrarySource, Selection, Session};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-forget-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn png(p: &Path, seed: u8) {
|
||||
let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]);
|
||||
let b =
|
||||
crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() }).unwrap();
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(p, b).unwrap();
|
||||
}
|
||||
|
||||
fn open(lib: &Path, clock: &Arc<Mutex<String>>) -> Session {
|
||||
let mut s = Session::new().with_fs();
|
||||
let c = clock.clone();
|
||||
s.clock = Box::new(move || c.lock().unwrap().clone());
|
||||
s.open_library(lib, false).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn set(clock: &Arc<Mutex<String>>, t: &str) {
|
||||
*clock.lock().unwrap() = t.to_string();
|
||||
}
|
||||
|
||||
fn local_paths(s: &Session) -> Vec<String> {
|
||||
let mut v: Vec<String> = s
|
||||
.catalog
|
||||
.photos()
|
||||
.filter(|p| p.local)
|
||||
.filter_map(|p| match &p.source {
|
||||
lightcraft_catalog::Source::File { path } => Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
/// Leave the folder view (nothing browsed is visible or selected any more).
|
||||
fn look_elsewhere(s: &mut Session) {
|
||||
s.execute("library.source", &json!({"kind": "all"})).unwrap();
|
||||
s.selection = Selection::default();
|
||||
s.previous_active = None;
|
||||
s.undo.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untouched_records_of_unbrowsed_folders_are_forgotten_and_come_back() {
|
||||
let root = temp_dir("cmd");
|
||||
let (a, b, lib) = (root.join("a"), root.join("b"), root.join("lib"));
|
||||
for (i, n) in ["a1.png", "a2.png", "a3.png"].iter().enumerate() {
|
||||
png(&a.join(n), i as u8);
|
||||
}
|
||||
png(&b.join("b1.png"), 7);
|
||||
let clock = Arc::new(Mutex::new("2026-08-01T10:00:00".to_string()));
|
||||
let mut s = open(&lib, &clock);
|
||||
s.execute("library.browse", &json!({"path": a.to_string_lossy()})).unwrap();
|
||||
// the user rates a2 (a change: kept)
|
||||
let a2 = s.catalog.photos().find(|p| p.file_name == "a2.png").unwrap().id;
|
||||
s.selection = Selection::single(a2);
|
||||
s.execute("photo.rate", &json!({"rating": 3})).unwrap();
|
||||
set(&clock, "2026-08-25T10:00:00");
|
||||
s.execute("library.browse", &json!({"path": b.to_string_lossy()})).unwrap();
|
||||
look_elsewhere(&mut s);
|
||||
|
||||
// 40 days after a was browsed, 16 after b
|
||||
set(&clock, "2026-09-10T10:00:00");
|
||||
let dry = s.execute("library.forgetLocal", &json!({"dryRun": true})).unwrap();
|
||||
assert_eq!(
|
||||
(dry["local"].as_u64(), dry["forgotten"].as_u64(), dry["keptTouched"].as_u64(), dry["keptRecent"].as_u64()),
|
||||
(Some(4), Some(2), Some(1), Some(1)),
|
||||
"{dry}"
|
||||
);
|
||||
assert_eq!(local_paths(&s).len(), 4, "a dry run changes nothing");
|
||||
let r = s.execute("library.forgetLocal", &json!({})).unwrap();
|
||||
assert_eq!(r["forgotten"], 2, "{r}");
|
||||
assert_eq!(local_paths(&s), vec!["a2.png", "b1.png"]);
|
||||
// files on disk are untouched
|
||||
assert!(["a1.png", "a2.png", "a3.png"].iter().all(|n| a.join(n).exists()));
|
||||
// not an undo step
|
||||
assert!(s.undo.is_empty());
|
||||
|
||||
// a crash now: the log replays the same catalog
|
||||
let expect = s.catalog.to_snapshot();
|
||||
drop(s);
|
||||
let mut s = open(&lib, &clock);
|
||||
assert_eq!(s.library.as_ref().unwrap().report.failed, 0);
|
||||
assert_eq!(s.catalog.to_snapshot(), expect, "nothing more to forget on open, same state after replay");
|
||||
|
||||
// browsing a again brings the forgotten files back, fresh; the rated one is the same record
|
||||
let r = s.execute("library.browse", &json!({"path": a.to_string_lossy()})).unwrap();
|
||||
assert_eq!((r["photos"].as_u64(), r["new"].as_u64()), (Some(3), Some(2)), "{r}");
|
||||
assert_eq!(s.catalog.photo(a2).unwrap().rating, 3);
|
||||
// while a is shown, nothing in it goes, whatever the time
|
||||
set(&clock, "2027-09-10T10:00:00");
|
||||
assert_eq!(s.execute("library.forgetLocal", &json!({"dryRun": true})).unwrap()["forgotten"], 1, "only b1 (not visible)");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_the_library_forgets_per_the_preference() {
|
||||
let root = temp_dir("open");
|
||||
let (a, lib) = (root.join("a"), root.join("lib"));
|
||||
png(&a.join("x.png"), 1);
|
||||
png(&a.join("y.png"), 2);
|
||||
let clock = Arc::new(Mutex::new("2026-08-01T10:00:00".to_string()));
|
||||
let mut s = open(&lib, &clock);
|
||||
s.execute("library.browse", &json!({"path": a.to_string_lossy()})).unwrap();
|
||||
look_elsewhere(&mut s);
|
||||
// 0 = never
|
||||
let r = s.execute("library.preferences", &json!({"forgetLocalDays": 0})).unwrap();
|
||||
assert_eq!(r["forgetLocalDays"], 0);
|
||||
s.close_library().unwrap();
|
||||
drop(s);
|
||||
set(&clock, "2027-01-01T10:00:00");
|
||||
let mut s = open(&lib, &clock);
|
||||
assert_eq!(local_paths(&s).len(), 2, "never");
|
||||
assert!(s.library.as_ref().unwrap().forgot_local.is_none());
|
||||
// back to 30 days: the next open forgets them
|
||||
s.execute("library.preferences", &json!({"forgetLocalDays": 30})).unwrap();
|
||||
s.source = LibrarySource::All;
|
||||
s.close_library().unwrap();
|
||||
drop(s);
|
||||
let s = open(&lib, &clock);
|
||||
assert!(local_paths(&s).is_empty());
|
||||
let info = {
|
||||
let mut s = s;
|
||||
s.execute("library.info", &json!({})).unwrap()
|
||||
};
|
||||
assert_eq!(info["forgotLocal"]["forgotten"], 2, "{info}");
|
||||
assert!(a.join("x.png").exists() && a.join("y.png").exists());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A file with an XMP sidecar comes in with the sidecar's rating and keywords: that is its
|
||||
/// browse state, not a user change, so the record is forgotten like any other — and the sidecar
|
||||
/// stays byte for byte, so browsing again reads the same values back.
|
||||
#[test]
|
||||
fn sidecar_values_are_not_user_changes_and_sidecars_stay() {
|
||||
let root = temp_dir("xmp");
|
||||
let (a, lib) = (root.join("a"), root.join("lib"));
|
||||
png(&a.join("x.png"), 1);
|
||||
let x = a.join("x.png").to_string_lossy().to_string();
|
||||
let mut p = lightcraft_catalog::Photo::new(
|
||||
lightcraft_catalog::PhotoId(1),
|
||||
lightcraft_catalog::Source::File { path: x.clone() },
|
||||
"x.png",
|
||||
"PNG",
|
||||
16,
|
||||
12,
|
||||
"2026-01-01T00:00:00",
|
||||
);
|
||||
p.rating = 4;
|
||||
p.meta.keywords = vec!["dunes".into()];
|
||||
let side = crate::sidecar::sidecar_path(&x, Default::default());
|
||||
std::fs::write(&side, crate::sidecar::sidecar_packet(&p, &lightcraft_catalog::Catalog::new())).unwrap();
|
||||
let packet = std::fs::read(&side).unwrap();
|
||||
|
||||
let clock = Arc::new(Mutex::new("2026-08-01T10:00:00".to_string()));
|
||||
let mut s = open(&lib, &clock);
|
||||
s.execute("library.browse", &json!({"path": a.to_string_lossy()})).unwrap();
|
||||
let rec = s.catalog.photos().find(|p| p.local).unwrap().clone();
|
||||
assert_eq!((rec.rating, rec.meta.keywords.clone()), (4, vec!["dunes".to_string()]), "read from the sidecar");
|
||||
look_elsewhere(&mut s);
|
||||
set(&clock, "2026-10-01T10:00:00");
|
||||
assert_eq!(s.execute("library.forgetLocal", &json!({})).unwrap()["forgotten"], 1);
|
||||
assert_eq!(std::fs::read(&side).unwrap(), packet, "the sidecar is untouched");
|
||||
s.execute("library.browse", &json!({"path": a.to_string_lossy()})).unwrap();
|
||||
let back = s.catalog.photos().find(|p| p.local).unwrap();
|
||||
assert_ne!(back.id, rec.id, "a fresh record");
|
||||
assert_eq!((back.rating, back.meta.keywords.clone()), (4, vec!["dunes".to_string()]));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A catalog written before browse times existed: its Local records are kept on the first open
|
||||
/// (their folders are stamped then), and forgotten only once the period has passed since.
|
||||
#[test]
|
||||
fn upgraded_catalogs_forget_nothing_at_first() {
|
||||
let root = temp_dir("upgrade");
|
||||
let (a, lib) = (root.join("a"), root.join("lib"));
|
||||
png(&a.join("x.png"), 1);
|
||||
let clock = Arc::new(Mutex::new("2026-08-01T10:00:00".to_string()));
|
||||
let mut s = open(&lib, &clock);
|
||||
s.execute("library.browse", &json!({"path": a.to_string_lossy()})).unwrap();
|
||||
look_elsewhere(&mut s);
|
||||
s.close_library().unwrap();
|
||||
drop(s);
|
||||
// strip the times and baselines, as an older version would have written the snapshot
|
||||
let snap = std::fs::read_to_string(lib.join("catalog.snap")).unwrap();
|
||||
let mut v: serde_json::Value = serde_json::from_str(&snap).unwrap();
|
||||
v["catalog"].as_object_mut().unwrap().remove("browsed");
|
||||
for p in v["catalog"]["photos"].as_object_mut().unwrap().values_mut() {
|
||||
p.as_object_mut().unwrap().remove("local_baseline");
|
||||
}
|
||||
std::fs::write(lib.join("catalog.snap"), serde_json::to_vec(&v).unwrap()).unwrap();
|
||||
set(&clock, "2027-01-01T10:00:00");
|
||||
let s = open(&lib, &clock);
|
||||
assert_eq!(local_paths(&s).len(), 1, "kept on the first open after the upgrade");
|
||||
assert_eq!(s.catalog.last_browsed(&a.to_string_lossy()), Some("2027-01-01T10:00:00"));
|
||||
drop(s);
|
||||
set(&clock, "2027-02-15T10:00:00");
|
||||
let s = open(&lib, &clock);
|
||||
assert!(local_paths(&s).is_empty(), "the untouched legacy record goes once 30 days passed");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -144,6 +144,99 @@ fn browsing_a_folder_lists_its_photos_without_adding_them() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Photos a.png, b.png, c.png imported from `old`, then: a renamed into `moved`; b renamed, with
|
||||
/// an impostor (same name and size, other bytes) under its old name; c gone, with two impostors.
|
||||
fn renamed_and_ambiguous_scene(tag: &str) -> (std::path::PathBuf, Session) {
|
||||
let dir = temp_dir(tag);
|
||||
std::fs::create_dir_all(dir.join("old")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("moved/real")).unwrap();
|
||||
for (n, seed) in [("a.png", 1), ("b.png", 2), ("c.png", 3)] {
|
||||
write_png(&dir.join("old").join(n), seed);
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [dir.join("old").to_string_lossy()]})).unwrap();
|
||||
let impostor = |src: &Path, dst: &Path| {
|
||||
let mut b = std::fs::read(src).unwrap();
|
||||
let k = b.len() - 20;
|
||||
b[k] ^= 0xff;
|
||||
std::fs::write(dst, b).unwrap();
|
||||
};
|
||||
std::fs::rename(dir.join("old/a.png"), dir.join("moved/Trip-001.png")).unwrap();
|
||||
impostor(&dir.join("old/b.png"), &dir.join("moved/b.png"));
|
||||
std::fs::rename(dir.join("old/b.png"), dir.join("moved/real/Trip-002.png")).unwrap();
|
||||
impostor(&dir.join("old/c.png"), &dir.join("moved/c.png"));
|
||||
impostor(&dir.join("old/c.png"), &dir.join("moved/real/c.png"));
|
||||
std::fs::remove_file(dir.join("old/c.png")).unwrap();
|
||||
(dir, s)
|
||||
}
|
||||
|
||||
/// Issue #105: Find Missing finds renamed files by content, prefers the content match over a
|
||||
/// same-name same-size impostor, and skips (reports) photos it can't tell apart.
|
||||
#[test]
|
||||
fn find_missing_matches_renamed_files_by_content() {
|
||||
let (dir, mut s) = renamed_and_ambiguous_scene("missing-content");
|
||||
let r = s.execute("library.findMissing", &json!({"folder": dir.join("moved").to_string_lossy()})).unwrap();
|
||||
let found = r["found"].as_array().unwrap();
|
||||
assert_eq!(found.len(), 2, "{r}");
|
||||
let to = |name: &str| {
|
||||
found.iter().find(|f| f["from"].as_str().unwrap().ends_with(name)).map(|f| (f["to"].as_str().unwrap().to_string(), f["by"].clone()))
|
||||
};
|
||||
assert_eq!(to("a.png"), Some((dir.join("moved/Trip-001.png").to_string_lossy().to_string(), json!("content"))));
|
||||
assert_eq!(to("b.png"), Some((dir.join("moved/real/Trip-002.png").to_string_lossy().to_string(), json!("content"))), "not the impostor");
|
||||
assert_eq!(r["missing"], 1);
|
||||
assert_eq!(r["ambiguous"][0]["candidates"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #104: the app plans Find Missing on a worker thread (no session) and relinks the plan
|
||||
/// afterwards; that gives exactly what the synchronous command gives — renamed files by content,
|
||||
/// impostors and look-alikes skipped (`ambiguous`).
|
||||
#[test]
|
||||
fn find_missing_planned_on_a_worker_matches_the_command() {
|
||||
use crate::cmd::missing::{find_candidates, plan_find_missing};
|
||||
let (dir, mut s) = renamed_and_ambiguous_scene("missing-worker");
|
||||
let folder = dir.join("moved").to_string_lossy().to_string();
|
||||
let candidates = find_candidates(&s.catalog);
|
||||
let f = folder.clone();
|
||||
let plan = std::thread::spawn(move || plan_find_missing(&candidates, &f)).join().unwrap().unwrap();
|
||||
assert_eq!((plan.found.len(), plan.missing, plan.ambiguous.len()), (2, 1, 1), "{plan:?}");
|
||||
let applied = s.execute("library.findMissing", &plan.to_json()).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("library.missing", &json!({})).unwrap().as_array().map(Vec::len), Some(3), "undo restores all three");
|
||||
let direct = s.execute("library.findMissing", &json!({"folder": folder})).unwrap();
|
||||
assert_eq!(applied, direct);
|
||||
assert_eq!(direct["found"].as_array().map(Vec::len), Some(2), "{direct}");
|
||||
assert_eq!(direct["ambiguous"].as_array().map(Vec::len), Some(1), "{direct}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #104: a plan made on a worker is re-checked when it is applied: a photo relinked
|
||||
/// meanwhile is left alone, and a file that is now another photo's original isn't used.
|
||||
#[test]
|
||||
fn find_missing_plan_is_rechecked_when_applied() {
|
||||
use crate::cmd::missing::{find_candidates, plan_find_missing};
|
||||
let (dir, mut s) = renamed_and_ambiguous_scene("missing-recheck");
|
||||
let plan = plan_find_missing(&find_candidates(&s.catalog), &dir.join("moved").to_string_lossy()).unwrap();
|
||||
let to = |name: &str| plan.found.iter().find(|f| f.from.ends_with(name)).unwrap().clone();
|
||||
let (a, b) = (to("a.png"), to("b.png"));
|
||||
// meanwhile: a is relinked by hand (elsewhere), and the ambiguous c is pointed at b's file
|
||||
std::fs::copy(&a.to, dir.join("a-elsewhere.png")).unwrap();
|
||||
s.execute("photo.relink", &json!({"id": a.id.0, "path": dir.join("a-elsewhere.png").to_string_lossy()})).unwrap();
|
||||
let c = plan.ambiguous[0].id;
|
||||
s.execute("photo.relink", &json!({"id": c.0, "path": b.to})).unwrap();
|
||||
let r = s.execute("library.findMissing", &plan.to_json()).unwrap();
|
||||
assert_eq!(r["found"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
assert_eq!(r["ambiguous"].as_array().map(Vec::len), Some(0), "c isn't missing any more: {r}");
|
||||
assert_eq!(r["missing"], 1, "b stays missing: its file is c's now: {r}");
|
||||
let path_of = |id: lightcraft_catalog::PhotoId| match &s.catalog.photo(id).unwrap().source {
|
||||
lightcraft_catalog::Source::File { path } => path.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
assert_eq!(path_of(a.id), dir.join("a-elsewhere.png").to_string_lossy());
|
||||
assert_eq!(path_of(b.id), b.from);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_files_are_found_and_relinked() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-missing-{}", std::process::id()));
|
||||
@@ -352,6 +445,58 @@ fn smart_previews_stand_in_for_offline_originals() {
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// Issue #106: a smart preview cut short (crash, full drive) counted as built forever and failed
|
||||
/// exactly when the original went offline. Writes are atomic now, and a damaged proxy is not
|
||||
/// reported as present and is rebuilt.
|
||||
#[test]
|
||||
fn damaged_smart_previews_are_rebuilt_and_failed_writes_leave_none() {
|
||||
let src = temp_dir("smartdmg-src");
|
||||
let lib = temp_dir("smartdmg-lib");
|
||||
write_png(&src.join("a.png"), 5);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
let dir = s.media.smart_dir.clone().unwrap();
|
||||
let file = dir.join(crate::smart::file_name(s.catalog.photo(id).unwrap()));
|
||||
{
|
||||
let _fault = lightcraft_catalog::safe_file::fail_writes_after(64);
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["built"].as_u64(), r["failed"].as_array().map(Vec::len)), (Some(0), Some(1)), "{r}");
|
||||
}
|
||||
assert!(!file.exists(), "no partial proxy");
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "no temp file left");
|
||||
assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["smartPreview"], false);
|
||||
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["built"].as_u64(), r["repaired"].as_u64()), (Some(1), Some(0)), "{r}");
|
||||
assert!(crate::smart::is_valid(&file));
|
||||
// cut short, as by a crash before the fix
|
||||
let full = std::fs::read(&file).unwrap();
|
||||
std::fs::write(&file, &full[..full.len() / 2]).unwrap();
|
||||
assert!(!crate::smart::is_valid(&file));
|
||||
assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["smartPreview"], false, "not counted as there");
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["built"].as_u64(), r["repaired"].as_u64()), (Some(1), Some(1)), "{r}");
|
||||
assert_eq!(std::fs::read(&file).unwrap(), full, "rebuilt whole");
|
||||
// the app's background build (issue #104) checks and repairs the same way, on a worker thread
|
||||
std::fs::write(&file, &full[..full.len() / 2]).unwrap();
|
||||
let r = s.execute("library.smartPreviews", &json!({"background": true})).unwrap();
|
||||
assert_eq!(r["what"], "smart previews", "{r}");
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = loop {
|
||||
let r = s.execute("library.previewProgress", &json!({})).unwrap();
|
||||
if r["running"] == false || t0.elapsed() > std::time::Duration::from_secs(60) {
|
||||
break r;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
};
|
||||
assert_eq!((r["done"].as_u64(), r["failed"].as_u64(), r["repaired"].as_u64()), (Some(1), Some(0), Some(1)), "{r}");
|
||||
assert_eq!(std::fs::read(&file).unwrap(), full, "rebuilt whole in the background");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// LUT profiles: a .cube imports as a creative profile (into the library), renders differently
|
||||
/// (Amount 0 = no change), is listed in the profile browser and comes back with the library.
|
||||
#[test]
|
||||
@@ -441,6 +586,70 @@ fn folders_rename_and_move_with_their_photos() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Rename / Move Folder are undo steps (issue #97): undo renames the folder back on disk (files and
|
||||
/// sidecars along) and restores the photos' paths, redo repeats it; a destination taken in the
|
||||
/// meantime is never overwritten — the step is refused, reported and stays on the stack.
|
||||
#[test]
|
||||
fn folder_rename_and_move_undo_and_redo_on_disk() {
|
||||
let root = temp_dir("folders-undo");
|
||||
let a = root.join("Trip");
|
||||
write_png(&a.join("one.png"), 3);
|
||||
std::fs::write(a.join("one.xmp"), "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"/>").unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [a.to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
let path = |s: &Session| match &s.catalog.photo(id).unwrap().source {
|
||||
lightcraft_catalog::Source::File { path } => path.clone(),
|
||||
_ => panic!(),
|
||||
};
|
||||
let undo0 = s.undo.len();
|
||||
let b = root.join("Italy");
|
||||
s.execute("folder.rename", &json!({"path": a.to_string_lossy(), "name": "Italy"})).unwrap();
|
||||
assert_eq!(s.undo.len(), undo0 + 1, "one undo step");
|
||||
assert!(s.undo.last().unwrap().label.contains("Rename Folder"));
|
||||
assert!(b.join("one.png").exists() && !a.exists());
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(a.join("one.png").exists() && a.join("one.xmp").exists() && !b.exists(), "renamed back on disk");
|
||||
assert_eq!(path(&s), a.join("one.png").to_string_lossy());
|
||||
s.media.forget(id);
|
||||
assert!(s.render_now(id, 16, 16).is_ok());
|
||||
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(b.join("one.png").exists() && b.join("one.xmp").exists() && !a.exists());
|
||||
assert_eq!(path(&s), b.join("one.png").to_string_lossy());
|
||||
|
||||
// the old name is taken now: undo is refused, nothing is overwritten, the step stays
|
||||
std::fs::create_dir_all(&a).unwrap();
|
||||
std::fs::write(a.join("keep.txt"), "mine").unwrap();
|
||||
let undo_n = s.undo.len();
|
||||
let e = s.execute("edit.undo", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("already exists"), "{e}");
|
||||
assert_eq!(s.undo.len(), undo_n, "the step stays on the stack");
|
||||
assert_eq!(std::fs::read_to_string(a.join("keep.txt")).unwrap(), "mine");
|
||||
assert!(b.join("one.png").exists());
|
||||
assert_eq!(path(&s), b.join("one.png").to_string_lossy());
|
||||
std::fs::remove_dir_all(&a).unwrap();
|
||||
|
||||
// move into another folder, undo, redo
|
||||
let into = root.join("Archive");
|
||||
s.execute("folder.move", &json!({"path": b.to_string_lossy(), "into": into.to_string_lossy()})).unwrap();
|
||||
let moved = into.join("Italy");
|
||||
assert_eq!(path(&s), moved.join("one.png").to_string_lossy());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(b.join("one.png").exists() && !moved.exists());
|
||||
assert_eq!(path(&s), b.join("one.png").to_string_lossy());
|
||||
// the redo target is taken (even by an empty folder): refused, nothing replaced
|
||||
std::fs::create_dir_all(&moved).unwrap();
|
||||
assert!(s.execute("edit.redo", &json!({})).is_err());
|
||||
assert!(b.join("one.png").exists());
|
||||
std::fs::remove_dir(&moved).unwrap();
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(moved.join("one.png").exists() && moved.join("one.xmp").exists() && !b.exists());
|
||||
assert_eq!(path(&s), moved.join("one.png").to_string_lossy());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Assisted culling: a burst (same scene, seconds apart) of a sharp and two soft shots, plus an
|
||||
/// unrelated photo: the burst is grouped, the sharp one is its best, blurry shots can be rejected.
|
||||
#[test]
|
||||
@@ -590,6 +799,7 @@ fn smart_previews_folder_is_chosen_per_library() {
|
||||
assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["smartPreview"], true);
|
||||
|
||||
// the setting is saved with the library
|
||||
drop(s); // one session per library (issue #99)
|
||||
let mut again = Session::new().with_fs();
|
||||
again.open_library(&lib, false).unwrap();
|
||||
assert_eq!(again.execute("library.smartPreviewsLocation", &json!({})).unwrap()["path"], to.to_string_lossy().as_ref());
|
||||
|
||||
@@ -163,6 +163,50 @@ fn a_failed_or_corrupt_copy_keeps_the_source() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #96: Import → Copy used a plain unverified copy (and check-then-copy naming that could
|
||||
/// replace a file). It now gets the same verified copy as Move: a copy that fails or comes out
|
||||
/// different is removed and reported as a failed import, nothing is catalogued, and the card
|
||||
/// is untouched; a good copy never replaces an existing file.
|
||||
#[test]
|
||||
fn copy_import_verifies_each_copy() {
|
||||
let import_copy = |s: &mut Session, card: &Path, dest: &Path| {
|
||||
s.execute(
|
||||
"library.import",
|
||||
&json!({"paths": [card.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat"}),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
for fault in [Fault::FailWrite, Fault::Corrupt] {
|
||||
let base = temp_dir(&format!("copyfail-{fault:?}"));
|
||||
let (card, dest) = (base.join("card"), base.join("out"));
|
||||
write_png(&card.join("a.png"), 3);
|
||||
let bytes = std::fs::read(card.join("a.png")).unwrap();
|
||||
let mut s = session();
|
||||
inject(fault);
|
||||
let r = import_copy(&mut s, &card, &dest);
|
||||
inject(Fault::None);
|
||||
assert_eq!(len(&r, "failed"), 1, "{fault:?}: {r}");
|
||||
assert_eq!(len(&r, "imported"), 0, "{fault:?}: {r}");
|
||||
assert!(r.to_string().contains("the original is untouched"), "{r}");
|
||||
assert_eq!(s.catalog.len(), 0);
|
||||
assert_eq!(std::fs::read(card.join("a.png")).unwrap(), bytes, "{fault:?}: source intact");
|
||||
assert_eq!(files_under(&dest), Vec::<String>::new(), "{fault:?}: no bad copy left");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
// a good copy: verified, and a taken name gets -1 instead of being replaced
|
||||
let base = temp_dir("copyok");
|
||||
let (card, dest) = (base.join("card"), base.join("out"));
|
||||
write_png(&card.join("a.png"), 4);
|
||||
std::fs::create_dir_all(&dest).unwrap();
|
||||
std::fs::write(dest.join("a.png"), b"someone else's file").unwrap();
|
||||
let mut s = session();
|
||||
let r = import_copy(&mut s, &card, &dest);
|
||||
assert_eq!(len(&r, "imported"), 1, "{r}");
|
||||
assert_eq!(std::fs::read(dest.join("a.png")).unwrap(), b"someone else's file");
|
||||
assert_eq!(std::fs::read(dest.join("a-1.png")).unwrap(), std::fs::read(card.join("a.png")).unwrap());
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// A source that can't be removed (a read-only card) stays and is reported; the photo is
|
||||
/// imported from its copy.
|
||||
#[test]
|
||||
|
||||
@@ -113,6 +113,7 @@ fn close_writes_snapshot_and_view_state() {
|
||||
let expect = s.catalog.to_snapshot();
|
||||
s.close_library().unwrap();
|
||||
assert_eq!(std::fs::metadata(dir.join("catalog.log")).unwrap().len(), 0);
|
||||
drop(s); // one session per library (issue #99)
|
||||
|
||||
let s2 = open(&dir, true);
|
||||
let r = &s2.library.as_ref().unwrap().report;
|
||||
@@ -178,6 +179,7 @@ fn new_library_without_seed_is_empty_and_compacts() {
|
||||
assert!(p["snapshots"].as_u64() >= Some(1), "{p}");
|
||||
assert!(p["lastSnapshot"]["bytes"].as_u64() > Some(0), "{p}");
|
||||
assert!(p["lastSnapshot"]["totalMs"].as_f64() >= p["lastSnapshot"]["serializeMs"].as_f64(), "{p}");
|
||||
drop(s); // one session per library (issue #99)
|
||||
let s2 = open(&dir, true);
|
||||
assert_eq!(s2.catalog.len(), 0, "an existing library is never seeded");
|
||||
assert_eq!(s2.catalog.albums().count(), 1);
|
||||
@@ -376,3 +378,32 @@ fn scale_100k_library_queries() {
|
||||
worst.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
eprintln!("slowest: {:?}", &worst[..3]);
|
||||
}
|
||||
|
||||
/// Issue #99: a library is open in one session at a time. A second opener is refused (nothing
|
||||
/// read or written), the owning session can reopen it, and it is free again once closed.
|
||||
#[test]
|
||||
fn a_library_open_elsewhere_is_refused() {
|
||||
let dir = temp_dir("locked");
|
||||
let mut s = open(&dir, true);
|
||||
s.execute("photo.rate", &json!({"rating": 3})).unwrap();
|
||||
let log = std::fs::read(dir.join("catalog.log")).unwrap();
|
||||
|
||||
let mut other = Session::new();
|
||||
let e = other.open_library(&dir, true).unwrap_err();
|
||||
assert!(matches!(e, crate::EngineError::LibraryInUse(_)), "{e:?}");
|
||||
assert!(e.to_string().contains("already open in"), "{e}");
|
||||
assert!(other.library.is_none());
|
||||
assert_eq!(std::fs::read(dir.join("catalog.log")).unwrap(), log, "the refused opener wrote nothing");
|
||||
|
||||
// the session that has it open may reopen it (Settings → Open Library on the same folder)
|
||||
s.close_library().unwrap();
|
||||
s.open_library(&dir, true).unwrap();
|
||||
assert!(other.open_library(&dir, true).is_err(), "still locked after reopening");
|
||||
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
|
||||
let expect = s.catalog.to_snapshot();
|
||||
|
||||
drop(s);
|
||||
other.open_library(&dir, true).unwrap();
|
||||
assert_eq!(other.catalog.to_snapshot(), expect);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! A failed save is an error, not a silent success: a command whose journal append fails returns
|
||||
//! [`EngineError::NotSaved`], its change stays applied and queued, and the next save writes it.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use lightcraft_catalog::{Flag, MemStore, SnapshotPolicy, Store};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::library::LibraryStores;
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
/// A store whose writes fail while `fail` is set (a full or unplugged disk).
|
||||
#[derive(Clone, Default)]
|
||||
struct Flaky {
|
||||
files: MemStore,
|
||||
fail: Arc<AtomicBool>,
|
||||
/// Only snapshots fail (appends work).
|
||||
snapshots_only: bool,
|
||||
/// Only appends fail (a dead log handle; fresh files can still be written).
|
||||
appends_only: bool,
|
||||
}
|
||||
|
||||
impl Flaky {
|
||||
fn failing(&self) -> bool {
|
||||
self.fail.load(Ordering::SeqCst)
|
||||
}
|
||||
fn set_failing(&self, on: bool) {
|
||||
self.fail.store(on, Ordering::SeqCst);
|
||||
}
|
||||
/// The files as a crash at this moment would leave them.
|
||||
fn crash_copy(&self) -> Flaky {
|
||||
let m = MemStore::new();
|
||||
*m.files.lock().unwrap() = self.files.files.lock().unwrap().clone();
|
||||
Flaky { files: m, ..Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for Flaky {
|
||||
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<()> {
|
||||
if self.failing() && !self.appends_only {
|
||||
return Err(std::io::Error::other("disk full"));
|
||||
}
|
||||
self.files.write_atomic(name, data)
|
||||
}
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
if self.failing() && !self.snapshots_only {
|
||||
return Err(std::io::Error::other("disk full"));
|
||||
}
|
||||
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 {
|
||||
"flaky".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn open(store: &Flaky, seed: bool) -> Session {
|
||||
let mut s = Session::new();
|
||||
let stores = LibraryStores { dir: "flaky".into(), catalog: Box::new(store.clone()), files: Box::new(MemStore::new()), on_disk: false };
|
||||
s.open_library_in(stores, seed).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_append_fails_the_command_and_is_retried() {
|
||||
let store = Flaky::default();
|
||||
let mut s = open(&store, true);
|
||||
let id = s.selection.active.unwrap();
|
||||
s.execute("photo.rate", &json!({"rating": 1})).unwrap();
|
||||
store.set_failing(true);
|
||||
|
||||
let e = s.execute("photo.rate", &json!({"rating": 4})).unwrap_err();
|
||||
assert!(matches!(e, EngineError::NotSaved(_)), "{e:?}");
|
||||
let msg = e.to_string();
|
||||
assert!(
|
||||
msg.starts_with("saved in memory but not written to disk: ") && msg.contains("disk full") && msg.ends_with("LightCraft will retry"),
|
||||
"{msg}"
|
||||
);
|
||||
// applied in memory, undoable, queued
|
||||
assert_eq!(s.catalog.photo(id).unwrap().rating, 4);
|
||||
assert!(!s.undo.is_empty());
|
||||
assert_eq!(s.unsaved().map(|u| u.0), Some(1));
|
||||
// queries and commands that change nothing are unaffected (they retry the queue, but report
|
||||
// their own result)
|
||||
let info = s.execute("library.info", &json!({})).unwrap();
|
||||
assert_eq!(info["unsavedOps"], 1, "{info}");
|
||||
assert!(info["unsavedError"].as_str().unwrap().contains("disk full"), "{info}");
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
// a second change fails too; both stay queued, in order
|
||||
let e = s.execute("photo.flag", &json!({"flag": "pick"})).unwrap_err();
|
||||
assert!(matches!(e, EngineError::NotSaved(_)));
|
||||
assert_eq!(s.unsaved().map(|u| u.0), Some(2));
|
||||
// the frame loop backs off instead of retrying every frame
|
||||
s.persist_if_dirty();
|
||||
assert_eq!(s.unsaved().map(|u| u.0), Some(2));
|
||||
// a crash now loses only the unsaved changes; the earlier ones are on disk
|
||||
assert_eq!(open(&store.crash_copy(), true).catalog.photo(id).unwrap().rating, 1);
|
||||
|
||||
// the disk comes back: the next save writes the whole queue
|
||||
store.set_failing(false);
|
||||
s.execute("library.info", &json!({})).unwrap();
|
||||
assert_eq!(s.unsaved(), None);
|
||||
assert!(s.library.as_ref().unwrap().last_error.is_none());
|
||||
let expect = s.catalog.to_snapshot();
|
||||
drop(s); // no close: like a crash
|
||||
let s2 = open(&store, true);
|
||||
assert_eq!(s2.catalog.to_snapshot(), expect);
|
||||
let p = s2.catalog.photo(id).unwrap();
|
||||
assert_eq!((p.rating, p.flag), (4, Flag::Pick));
|
||||
}
|
||||
|
||||
/// Undo is a command like any other: a failed write of its op is reported, and retried.
|
||||
#[test]
|
||||
fn failed_append_of_undo_is_reported_and_retried() {
|
||||
let store = Flaky::default();
|
||||
let mut s = open(&store, true);
|
||||
let id = s.selection.active.unwrap();
|
||||
let before = s.catalog.photo(id).unwrap().rating;
|
||||
s.execute("photo.rate", &json!({"rating": 3})).unwrap();
|
||||
store.set_failing(true);
|
||||
assert!(matches!(s.execute("edit.undo", &json!({})), Err(EngineError::NotSaved(_))));
|
||||
assert_eq!(s.catalog.photo(id).unwrap().rating, before);
|
||||
store.set_failing(false);
|
||||
s.persist().unwrap();
|
||||
drop(s);
|
||||
assert_eq!(open(&store, true).catalog.photo(id).unwrap().rating, before);
|
||||
}
|
||||
|
||||
/// A failed compaction is not a failed command: the command's own records were appended.
|
||||
#[test]
|
||||
fn failed_snapshot_does_not_fail_the_command() {
|
||||
let store = Flaky { snapshots_only: true, ..Default::default() };
|
||||
let mut s = open(&store, false);
|
||||
s.library.as_mut().unwrap().journal_mut().policy = SnapshotPolicy { max_records: 1, max_bytes: u64::MAX };
|
||||
store.set_failing(true);
|
||||
s.execute("album.create", &json!({"name": "A"})).unwrap();
|
||||
s.execute("album.create", &json!({"name": "B"})).unwrap();
|
||||
assert_eq!(s.unsaved(), None);
|
||||
assert!(s.library.as_ref().unwrap().last_error.is_some(), "the compaction failure is reported");
|
||||
drop(s);
|
||||
store.set_failing(false);
|
||||
assert_eq!(open(&store, false).catalog.albums().count(), 2);
|
||||
}
|
||||
|
||||
/// Issue #101: quitting while the log can't be appended to still saves the queued changes in the
|
||||
/// closing snapshot (a fresh file), and reports success; they are not appended again later.
|
||||
#[test]
|
||||
fn close_saves_queued_changes_in_the_snapshot_when_appends_fail() {
|
||||
let store = Flaky { appends_only: true, ..Default::default() };
|
||||
let mut s = open(&store, true);
|
||||
let id = s.selection.active.unwrap();
|
||||
store.set_failing(true);
|
||||
assert!(matches!(s.execute("photo.rate", &json!({"rating": 4})), Err(EngineError::NotSaved(_))));
|
||||
assert!(matches!(s.execute("photo.flag", &json!({"flag": "pick"})), Err(EngineError::NotSaved(_))));
|
||||
assert_eq!(s.unsaved().map(|u| u.0), Some(2));
|
||||
s.close_library().unwrap();
|
||||
assert_eq!(s.unsaved(), None);
|
||||
assert!(s.pending_log.is_empty());
|
||||
let expect = s.catalog.to_snapshot();
|
||||
drop(s);
|
||||
store.set_failing(false);
|
||||
let mut s2 = open(&store, true);
|
||||
assert_eq!(s2.catalog.to_snapshot(), expect);
|
||||
let p = s2.catalog.photo(id).unwrap();
|
||||
assert_eq!((p.rating, p.flag), (4, Flag::Pick));
|
||||
// later edits continue after the absorbed ops
|
||||
s2.execute("photo.rate", &json!({"rating": 2})).unwrap();
|
||||
let expect = s2.catalog.to_snapshot();
|
||||
drop(s2);
|
||||
assert_eq!(open(&store, true).catalog.to_snapshot(), expect);
|
||||
}
|
||||
|
||||
/// When neither the log nor the snapshot can be written, closing fails with `NotSaved` and the
|
||||
/// changes stay queued (a later retry can still save them).
|
||||
#[test]
|
||||
fn close_fails_when_nothing_can_be_saved() {
|
||||
let store = Flaky::default();
|
||||
let mut s = open(&store, true);
|
||||
store.set_failing(true);
|
||||
assert!(s.execute("photo.rate", &json!({"rating": 4})).is_err());
|
||||
let e = s.close_library().unwrap_err();
|
||||
assert!(matches!(e, EngineError::NotSaved(_)), "{e:?}");
|
||||
assert_eq!(s.unsaved().map(|u| u.0), Some(1));
|
||||
store.set_failing(false);
|
||||
s.close_library().unwrap();
|
||||
assert_eq!(s.unsaved(), None);
|
||||
}
|
||||
@@ -126,11 +126,13 @@ fn preferences_persist_with_the_library() {
|
||||
assert_eq!(r["persistent"], true);
|
||||
// export presets are library preferences too
|
||||
s.execute("export.savePreset", &json!({"name": "Proof", "params": {"format": "jpeg", "percent": 25}})).unwrap();
|
||||
drop(s); // one session per library (issue #99)
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert_eq!(s.export_params(&json!({"preset": "Proof"})).unwrap(), json!({"format": "jpeg", "percent": 25}));
|
||||
// older prefs.json files (snake_case `last_export`, no import section) still load
|
||||
std::fs::write(lib.join("prefs.json"), r#"{"xmp": {"autoWrite": false}, "last_export": {"format": "png"}}"#).unwrap();
|
||||
drop(s);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert_eq!(s.last_export, Some(json!({"format": "png"})));
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Issue #103: a damaged or unreadable settings file (prefs.json, presets.json, view.json) is
|
||||
//! never silently replaced by the defaults: a damaged one is kept aside, an unreadable one is not
|
||||
//! written this session, and the user is told.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use lightcraft_catalog::{MemStore, Store};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::library::LibraryStores;
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-settings-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_prefs_are_kept_aside_and_reported() {
|
||||
let lib = temp_dir("damaged");
|
||||
let bad = br#"{"exportPresets": [{"name": "Proof", "par"#;
|
||||
std::fs::write(lib.join("prefs.json"), bad).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
let kept: Vec<_> = std::fs::read_dir(&lib)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().to_string_lossy().to_string())
|
||||
.filter(|n| n.starts_with("prefs.json.corrupt-"))
|
||||
.collect();
|
||||
assert_eq!(kept.len(), 1, "{kept:?}");
|
||||
assert_eq!(std::fs::read(lib.join(&kept[0])).unwrap(), bad);
|
||||
let warnings = s.take_library_warnings();
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert!(warnings[0].contains("prefs.json is damaged") && warnings[0].contains(&kept[0]), "{warnings:?}");
|
||||
assert!(s.take_library_warnings().is_empty(), "each warning is handed out once");
|
||||
let info = s.execute("library.info", &json!({})).unwrap();
|
||||
assert_eq!(info["settingsWarnings"].as_array().map(Vec::len), Some(1), "{info}");
|
||||
// saving goes on (the damaged original is safe in its copy)
|
||||
s.execute("library.preferences", &json!({"cacheMb": 300})).unwrap();
|
||||
assert!(String::from_utf8(std::fs::read(lib.join("prefs.json")).unwrap()).unwrap().contains("300"));
|
||||
assert_eq!(std::fs::read(lib.join(&kept[0])).unwrap(), bad);
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// `presets.json` and `prefs.json` can't be read (locked by another program, an I/O error).
|
||||
#[derive(Clone, Default)]
|
||||
struct Unreadable {
|
||||
files: MemStore,
|
||||
unreadable: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl Store for Unreadable {
|
||||
fn read(&mut self, name: &str) -> std::io::Result<Option<Vec<u8>>> {
|
||||
if self.unreadable.lock().unwrap().iter().any(|n| n == name) {
|
||||
return Err(std::io::Error::other("locked by another process"));
|
||||
}
|
||||
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<()> {
|
||||
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 {
|
||||
"unreadable".into()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_settings_are_not_overwritten() {
|
||||
let files = Unreadable::default();
|
||||
let presets = br#"{"user": [{"id": "user.mine", "name": "Mine", "group": "User Presets", "settings": {}}]}"#.to_vec();
|
||||
let prefs = br#"{"cacheMb": 777}"#.to_vec();
|
||||
files.files.set("presets.json", presets.clone());
|
||||
files.files.set("prefs.json", prefs.clone());
|
||||
*files.unreadable.lock().unwrap() = vec!["presets.json".into(), "prefs.json".into()];
|
||||
let mut s = Session::new();
|
||||
let stores = LibraryStores { dir: "unreadable".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false };
|
||||
s.open_library_in(stores, true).unwrap();
|
||||
let warnings = s.take_library_warnings();
|
||||
assert_eq!(warnings.len(), 2, "{warnings:?}");
|
||||
assert!(warnings.iter().all(|w| w.contains("couldn't be read") && w.contains("won't overwrite")), "{warnings:?}");
|
||||
|
||||
// a preset change and a preference change: neither file is replaced by defaults
|
||||
s.execute("preset.create", &json!({"name": "New"})).unwrap();
|
||||
let e = s.execute("library.preferences", &json!({"cacheMb": 300}));
|
||||
assert!(e.is_err(), "saving preferences is refused, not done over the unread file");
|
||||
assert_eq!(files.files.get("presets.json").unwrap(), presets);
|
||||
assert_eq!(files.files.get("prefs.json").unwrap(), prefs);
|
||||
}
|
||||
@@ -329,6 +329,55 @@ fn convert_raw_to_dng() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #106: Convert to DNG wrote straight to the final name (no temp file, sync or check) and
|
||||
/// relinked the photo even when the write was cut short. A failed write now leaves no DNG and
|
||||
/// the photo on its raw; a good one is verified and never replaces an existing file.
|
||||
#[test]
|
||||
fn convert_to_dng_is_verified_and_atomic() {
|
||||
let dir = temp_dir("todng-safe");
|
||||
// a synthetic raw (stored as DNG, catalogued as a NEF so Convert to DNG takes it)
|
||||
let raw = dir.join("shot.dng");
|
||||
let bytes = synthetic_dng_with(None, Default::default());
|
||||
std::fs::write(&raw, &bytes).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [raw.to_string_lossy()]})).unwrap();
|
||||
let id = s.catalog.photos().next().unwrap().id;
|
||||
let path = raw.to_string_lossy().to_string();
|
||||
s.catalog
|
||||
.apply(lightcraft_catalog::Op::Relink {
|
||||
id,
|
||||
file_name: "shot.dng".into(),
|
||||
source: lightcraft_catalog::Source::File { path: path.clone() },
|
||||
format: Some("NEF".into()),
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
let names = || {
|
||||
let mut v: Vec<String> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
{
|
||||
// the drive fills up part-way
|
||||
let _fault = lightcraft_catalog::safe_file::fail_writes_after(200);
|
||||
let r = s.execute("photo.convertToDng", &json!({})).unwrap();
|
||||
assert_eq!(r["converted"].as_array().unwrap().len(), 0, "{r}");
|
||||
assert!(r["skipped"][0][1].as_str().unwrap().contains("the raw is kept"), "{r}");
|
||||
}
|
||||
assert_eq!(names(), ["shot.dng"], "no partial DNG");
|
||||
assert_eq!(s.catalog.photo(id).unwrap().source, lightcraft_catalog::Source::File { path: path.clone() }, "not relinked");
|
||||
// a good conversion: a new name (the existing file is not replaced), decodable, relinked
|
||||
let r = s.execute("photo.convertToDng", &json!({})).unwrap();
|
||||
let out = r["converted"][0]["path"].as_str().unwrap().to_string();
|
||||
assert!(out.ends_with("shot-2.dng"), "{r}");
|
||||
assert_eq!(std::fs::read(&raw).unwrap(), bytes, "the raw is untouched");
|
||||
let back = lightcraft_raw::decode(&std::fs::read(&out).unwrap()).unwrap();
|
||||
assert_eq!(back.data, lightcraft_raw::decode(&bytes).unwrap().data);
|
||||
assert_eq!(s.catalog.photo(id).unwrap().source, lightcraft_catalog::Source::File { path: out });
|
||||
assert_eq!(names(), ["shot-2.dng", "shot.dng"], "no temp file left");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Edit in External Editor (engine half): a 16-bit TIFF `-Edit` copy with the edits, next to
|
||||
/// the original, added and stacked on top of it; a second one doesn't overwrite the first.
|
||||
#[test]
|
||||
@@ -576,3 +625,132 @@ fn copyright_status_usage_terms_and_url_round_trip() {
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// A sidecar another raw developer wrote: develop settings in attributes and elements, a
|
||||
/// structured edit history, an unknown namespace.
|
||||
const OTHER_APP_SIDECAR: &str = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Other Toolkit">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
|
||||
xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/"
|
||||
xmlns:stEvt="http://ns.adobe.com/xap/1.0/sType/ResourceEvent#"
|
||||
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
xmlns:other="http://example.com/other/1.0/"
|
||||
xmp:Rating="2"
|
||||
crs:Version="99.0"
|
||||
crs:Exposure2012="+0.65"
|
||||
crs:HasSettings="True"
|
||||
other:Secret="keep me">
|
||||
<xmpMM:History>
|
||||
<rdf:Seq>
|
||||
<rdf:li stEvt:action="derived" stEvt:parameters="converted from image/x-raw to image/png"/>
|
||||
</rdf:Seq>
|
||||
</xmpMM:History>
|
||||
<crs:ToneCurvePV2012>
|
||||
<rdf:Seq>
|
||||
<rdf:li>0, 0</rdf:li>
|
||||
<rdf:li>128, 140</rdf:li>
|
||||
<rdf:li>255, 255</rdf:li>
|
||||
</rdf:Seq>
|
||||
</crs:ToneCurvePV2012>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
<?xpacket end="w"?>"#;
|
||||
|
||||
/// Issue #92: saving merges into another app's sidecar instead of replacing it.
|
||||
#[test]
|
||||
fn saving_merges_into_another_apps_sidecar() {
|
||||
let src = temp_dir("merge");
|
||||
let lib = temp_dir("merge-lib");
|
||||
write_png(&src.join("shot.png"), 1);
|
||||
std::fs::write(src.join("shot.xmp"), OTHER_APP_SIDECAR).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [src.join("shot.png").to_string_lossy()]})).unwrap();
|
||||
let id = only(&s);
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("photo.rate", &json!({"rating": 5})).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"title": "Harbour", "keywords": ["boats"]})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "effects.clarity", "value": 30})).unwrap();
|
||||
let r = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||||
assert_eq!(r["merged"][0], src.join("shot.xmp").to_string_lossy().as_ref(), "{r}");
|
||||
let out = std::fs::read_to_string(src.join("shot.xmp")).unwrap();
|
||||
// the other app's data, byte for byte
|
||||
for part in [
|
||||
"crs:Exposure2012=\"+0.65\"",
|
||||
"other:Secret=\"keep me\"",
|
||||
" <xmpMM:History>\n <rdf:Seq>\n <rdf:li stEvt:action=\"derived\" stEvt:parameters=\"converted from image/x-raw to image/png\"/>\n </rdf:Seq>\n </xmpMM:History>",
|
||||
" <crs:ToneCurvePV2012>\n <rdf:Seq>\n <rdf:li>0, 0</rdf:li>\n <rdf:li>128, 140</rdf:li>",
|
||||
] {
|
||||
assert!(out.contains(part), "lost {part:?}:\n{out}");
|
||||
}
|
||||
let d = lightcraft_meta::parse_xmp(&out).unwrap();
|
||||
assert_eq!(d.metadata.rating, Some(5), "ours replaces theirs");
|
||||
assert_eq!(d.metadata.title.as_deref(), Some("Harbour"));
|
||||
assert_eq!(d.properties.get("crs:Version"), Some(&vec!["99.0".to_string()]));
|
||||
let saved = s.catalog.photo(id).unwrap().develop.clone();
|
||||
// reading it back restores our settings (lc:settings wins over crs:)
|
||||
s.execute("develop.set", &json!({"control": "effects.clarity", "value": 0})).unwrap();
|
||||
s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().develop, saved);
|
||||
// saving again keeps one LightCraft description and the other app's data
|
||||
s.execute("photo.rate", &json!({"rating": 3})).unwrap();
|
||||
s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||||
let again = std::fs::read_to_string(src.join("shot.xmp")).unwrap();
|
||||
assert_eq!(again.matches("<rdf:Description").count(), 2, "{again}");
|
||||
assert!(again.contains("other:Secret=\"keep me\"") && again.contains("crs:Exposure2012=\"+0.65\""));
|
||||
assert_eq!(lightcraft_meta::parse_xmp(&again).unwrap().metadata.rating, Some(3));
|
||||
|
||||
// a sidecar that isn't XMP is kept as a backup before it is replaced
|
||||
std::fs::write(src.join("shot.xmp"), b"<not xmp").unwrap();
|
||||
let r = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||||
let backup = r["backups"][0]["backup"].as_str().unwrap().to_string();
|
||||
assert!(backup.contains("shot.xmp.bak-"), "{r}");
|
||||
assert_eq!(std::fs::read(&backup).unwrap(), b"<not xmp");
|
||||
assert_eq!(lightcraft_meta::parse_xmp(&std::fs::read_to_string(src.join("shot.xmp")).unwrap()).unwrap().metadata.rating, Some(3));
|
||||
// … and another one never overwrites the first backup
|
||||
std::fs::write(src.join("shot.xmp"), b"<still not xmp").unwrap();
|
||||
let r = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||||
let second = r["backups"][0]["backup"].as_str().unwrap().to_string();
|
||||
assert_ne!(second, backup);
|
||||
assert_eq!(std::fs::read(&backup).unwrap(), b"<not xmp");
|
||||
assert_eq!(std::fs::read(&second).unwrap(), b"<still not xmp");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// Issue #92: two catalogued files with one stem (`IMG_1.jpg` + `IMG_1.png`) don't share a
|
||||
/// Stem-named sidecar: the first by name keeps `IMG_1.xmp`, the other writes and reads
|
||||
/// `IMG_1.png.xmp`.
|
||||
#[test]
|
||||
fn files_sharing_a_stem_get_their_own_sidecars() {
|
||||
let src = temp_dir("stem");
|
||||
let lib = temp_dir("stem-lib");
|
||||
write_png(&src.join("IMG_1.png"), 1);
|
||||
let img = lightcraft_raster::Rgba8::from_fn(40, 24, |x, y| [(x * 6) as u8, (y * 9) as u8, 7, 255]);
|
||||
let jpg =
|
||||
crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Jpeg, ..Default::default() }).unwrap();
|
||||
std::fs::write(src.join("IMG_1.jpg"), jpg).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let id = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id;
|
||||
let (j, p) = (id(&s, "IMG_1.jpg"), id(&s, "IMG_1.png"));
|
||||
assert_eq!(s.sidecar_naming(j), crate::sidecar::SidecarNaming::Stem);
|
||||
assert_eq!(s.sidecar_naming(p), crate::sidecar::SidecarNaming::Full);
|
||||
s.execute("photo.rate", &json!({"ids": [j.0], "rating": 2})).unwrap();
|
||||
s.execute("photo.rate", &json!({"ids": [p.0], "rating": 5})).unwrap();
|
||||
let r = s.execute("photo.saveMetadataToFile", &json!({"ids": [j.0, p.0]})).unwrap();
|
||||
assert_eq!(r["written"].as_array().unwrap().len(), 2, "{r}");
|
||||
let rating = |f: &str| lightcraft_meta::parse_xmp(&std::fs::read_to_string(src.join(f)).unwrap()).unwrap().metadata.rating;
|
||||
assert_eq!(rating("IMG_1.xmp"), Some(2));
|
||||
assert_eq!(rating("IMG_1.png.xmp"), Some(5));
|
||||
// each reads its own back
|
||||
s.execute("photo.rate", &json!({"ids": [j.0, p.0], "rating": 0})).unwrap();
|
||||
s.execute("photo.readMetadataFromFile", &json!({"ids": [j.0, p.0]})).unwrap();
|
||||
assert_eq!((s.catalog.photo(j).unwrap().rating, s.catalog.photo(p).unwrap().rating), (2, 5));
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
+222
-24
@@ -275,37 +275,47 @@ fn module_source(m: &Module, consts: &str) -> String {
|
||||
}
|
||||
|
||||
impl Gpu {
|
||||
/// Create a device on the best available adapter (no software fallback), or `None`.
|
||||
pub fn new() -> Option<Gpu> {
|
||||
/// Create a device on the best available adapter (no software fallback), or why there is none.
|
||||
pub fn new() -> Result<Gpu, String> {
|
||||
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
desc.backends = wgpu::Backends::PRIMARY;
|
||||
let instance = wgpu::Instance::new(desc);
|
||||
let adapter = pollster::block_on(
|
||||
instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }),
|
||||
)
|
||||
.ok()?;
|
||||
.map_err(|e| format!("no GPU adapter ({e})"))?;
|
||||
let info = adapter.get_info();
|
||||
if info.device_type == wgpu::DeviceType::Cpu {
|
||||
log::info!("gpu: only a software adapter ({}), using the CPU pipeline", info.name);
|
||||
return None;
|
||||
return Err(format!("software adapter ({}) skipped: the CPU pipeline is faster", info.name));
|
||||
}
|
||||
let limits = adapter.limits();
|
||||
let mut limits = adapter.limits();
|
||||
if limits.max_storage_buffers_per_shader_stage < 10 {
|
||||
log::info!("gpu: {} has too few storage buffers per stage", info.name);
|
||||
return None;
|
||||
return Err(format!("{} has too few storage buffers per shader stage ({} < 10)", info.name, limits.max_storage_buffers_per_shader_stage));
|
||||
}
|
||||
if let Some((binding, buffer)) = limits_override() {
|
||||
limits.max_storage_buffer_binding_size = limits.max_storage_buffer_binding_size.min(binding);
|
||||
limits.max_buffer_size = limits.max_buffer_size.min(buffer);
|
||||
log::info!("gpu: LIGHTCRAFT_GPU_LIMITS: storage bindings ≤ {} MiB, buffers ≤ {} MiB", binding >> 20, buffer >> 20);
|
||||
}
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("lightcraft"),
|
||||
required_limits: limits.clone(),
|
||||
..Default::default()
|
||||
}))
|
||||
.ok()?;
|
||||
// Errors outside an error scope (e.g. out of memory during a render): log, and stop using
|
||||
// the GPU (the render in flight is redone on the CPU).
|
||||
.map_err(|e| format!("{}: device creation failed ({e})", info.name))?;
|
||||
// Errors outside a render's error scopes (see `ErrorScopes`): log, and stop using the GPU.
|
||||
device.on_uncaptured_error(std::sync::Arc::new(|e| {
|
||||
log::error!("gpu: {e}");
|
||||
crate::mark_broken();
|
||||
crate::mark_broken(&format!("device error: {e}"));
|
||||
}));
|
||||
// wgpu reports a lost device (driver reset, GPU hang, watchdog) only here — never as an
|
||||
// error — so without this callback a lost device went unnoticed.
|
||||
device.set_device_lost_callback(|reason, msg| {
|
||||
log::error!("gpu: device lost ({reason:?}): {msg}");
|
||||
crate::mark_broken(&format!("device lost ({reason:?}): {msg}"));
|
||||
});
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let consts = constants();
|
||||
let mut kernels = HashMap::new();
|
||||
@@ -334,7 +344,7 @@ impl Gpu {
|
||||
}
|
||||
if let Some(e) = pollster::block_on(scope.pop()) {
|
||||
log::error!("gpu: kernels failed to build on {}: {e}", info.name);
|
||||
return None;
|
||||
return Err(format!("{}: kernels failed to build ({e})", info.name));
|
||||
}
|
||||
let dummy = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("dummy"),
|
||||
@@ -343,13 +353,20 @@ impl Gpu {
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let max_buffer = limits.max_storage_buffer_binding_size.min(limits.max_buffer_size);
|
||||
log::info!("gpu: {} ({:?})", info.name, info.backend);
|
||||
Some(Gpu { device, queue, info, kernels, dummy, free: Default::default(), max_buffer })
|
||||
log::info!("gpu: {} ({:?}), buffers ≤ {} MiB", info.name, info.backend, max_buffer >> 20);
|
||||
Ok(Gpu { device, queue, info, kernels, dummy, free: Default::default(), max_buffer })
|
||||
}
|
||||
|
||||
/// A buffer of `len` 32-bit values with undefined contents (a recycled one when possible).
|
||||
///
|
||||
/// A buffer over the device limit fails the render ([`FailKind::Limit`]) instead of raising a
|
||||
/// validation error: the caller gets a placeholder and the render is redone on the CPU.
|
||||
pub fn buffer(&self, len: usize) -> Buf {
|
||||
let size = (len.max(1) * 4) as u64;
|
||||
if size > self.limit() {
|
||||
self.over_limit(size);
|
||||
return Buf { buf: Some(Tracked::new(self.placeholder())), len };
|
||||
}
|
||||
let recycled = {
|
||||
let mut f = self.free.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// the smallest pooled buffer that holds `size` with at most 25 % to spare: photos of
|
||||
@@ -426,6 +443,10 @@ impl Gpu {
|
||||
if bytes.is_empty() {
|
||||
return self.buffer(1);
|
||||
}
|
||||
if bytes.len() as u64 > self.limit() {
|
||||
self.over_limit(bytes.len() as u64);
|
||||
return Buf { buf: Some(Tracked::new(self.placeholder())), len };
|
||||
}
|
||||
let buf = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytes,
|
||||
@@ -434,9 +455,32 @@ impl Gpu {
|
||||
Buf { buf: Some(Tracked::new(buf)), len }
|
||||
}
|
||||
|
||||
fn over_limit(&self, size: u64) {
|
||||
fail(
|
||||
FailKind::Limit,
|
||||
format!("a {} MiB buffer exceeds the device's {} MiB storage-buffer limit", size.div_ceil(1 << 20), self.limit() >> 20),
|
||||
);
|
||||
}
|
||||
|
||||
/// Stands in for a buffer that could not be created (the render has failed already).
|
||||
fn placeholder(&self) -> wgpu::Buffer {
|
||||
self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("placeholder"),
|
||||
size: 16,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a buffer of `len` 32-bit values fits the device limits.
|
||||
pub fn fits(&self, len: usize) -> bool {
|
||||
(len as u64) * 4 <= self.max_buffer
|
||||
(len as u64) * 4 <= self.limit()
|
||||
}
|
||||
|
||||
/// Largest buffer a render may use (bytes): the device limit, or a lower one set for the
|
||||
/// render on this thread ([`LimitOverride`]).
|
||||
pub fn limit(&self) -> u64 {
|
||||
LIMIT.try_with(|l| l.get()).ok().flatten().map_or(self.max_buffer, |l| l.min(self.max_buffer))
|
||||
}
|
||||
|
||||
pub fn encoder(&self) -> wgpu::CommandEncoder {
|
||||
@@ -445,10 +489,12 @@ impl Gpu {
|
||||
|
||||
/// Record kernel `name` with parameters `p` over buffers `bufs` (None = unused binding).
|
||||
pub fn run(&self, enc: &mut wgpu::CommandEncoder, name: &str, p: &[u32], bufs: &[Option<&Buf>], groups: [u32; 3]) {
|
||||
if failed() {
|
||||
return; // the render is redone on the CPU: don't spend the GPU on it
|
||||
}
|
||||
let Some(k) = self.kernels.get(name) else {
|
||||
// a kernel name typo: fail this render over to the CPU instead of crashing
|
||||
log::error!("gpu: unknown kernel {name}");
|
||||
crate::mark_broken();
|
||||
fail(FailKind::Fatal, format!("unknown kernel {name}"));
|
||||
return;
|
||||
};
|
||||
assert_eq!(bufs.len(), k.nbuf, "{name}: binding count");
|
||||
@@ -470,8 +516,16 @@ impl Gpu {
|
||||
}
|
||||
|
||||
/// Submit `enc`, then read back the first `len` 32-bit values of `src`.
|
||||
///
|
||||
/// A failed readback (device lost, map error, timeout) fails the render ([`FailKind::Fatal`])
|
||||
/// and returns zeros, which the caller discards; after an earlier failure in the same render
|
||||
/// nothing more is submitted.
|
||||
pub fn finish_and_read<T: bytemuck::Pod>(&self, mut enc: wgpu::CommandEncoder, src: &Buf, len: usize) -> Vec<T> {
|
||||
assert!(len <= src.len, "readback of {len} values from a buffer of {}", src.len);
|
||||
let zeros = || vec![<T as bytemuck::Zeroable>::zeroed(); len];
|
||||
if failed() {
|
||||
return zeros();
|
||||
}
|
||||
let size = (len * 4) as u64;
|
||||
let staging = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("readback"),
|
||||
@@ -486,17 +540,20 @@ impl Gpu {
|
||||
slice.map_async(wgpu::MapMode::Read, move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
|
||||
// A failed readback (device lost, out of memory) marks the GPU broken: the caller
|
||||
// (`crate::render`) then discards this render's zeroed result and renders on the CPU.
|
||||
let failed = |why: String| {
|
||||
log::error!("gpu readback failed: {why}");
|
||||
crate::mark_broken();
|
||||
vec![<T as bytemuck::Zeroable>::zeroed(); len]
|
||||
fail(FailKind::Fatal, format!("readback failed: {why}"));
|
||||
zeros()
|
||||
};
|
||||
match rx.recv() {
|
||||
// A lost device makes the wait fail and the map callback never run: checking both keeps
|
||||
// this from blocking forever (or handing back an unwritten buffer as the image).
|
||||
if let Err(e) = self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(READBACK_TIMEOUT) }) {
|
||||
return failed(format!("{e}"));
|
||||
}
|
||||
// another thread's poll may be the one that runs the callback: allow it a moment
|
||||
match rx.recv_timeout(std::time::Duration::from_secs(10)) {
|
||||
Ok(Ok(())) => {}
|
||||
other => return failed(format!("{other:?}")),
|
||||
Ok(Err(e)) => return failed(format!("{e}")),
|
||||
Err(e) => return failed(format!("map callback: {e}")),
|
||||
}
|
||||
let data = match slice.get_mapped_range() {
|
||||
Ok(d) => d,
|
||||
@@ -509,6 +566,127 @@ impl Gpu {
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest wait for a render's GPU work: beyond it the render fails over to the CPU (drivers
|
||||
/// normally reset a hung GPU much sooner).
|
||||
const READBACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// `LIGHTCRAFT_GPU_LIMITS`: lower the device limits, e.g. to reproduce a smaller adapter.
|
||||
/// `webgpu` / `downlevel` = 128 MiB storage bindings and 256 MiB buffers (the WebGPU defaults),
|
||||
/// `<n>` = n MiB storage bindings and 2n MiB buffers. Returns (binding bytes, buffer bytes).
|
||||
fn limits_override() -> Option<(u64, u64)> {
|
||||
parse_limits(&std::env::var("LIGHTCRAFT_GPU_LIMITS").ok()?)
|
||||
}
|
||||
|
||||
fn parse_limits(v: &str) -> Option<(u64, u64)> {
|
||||
let v = v.trim().to_ascii_lowercase();
|
||||
match v.as_str() {
|
||||
"" => None,
|
||||
"webgpu" | "downlevel" | "default" => {
|
||||
let d = wgpu::Limits::default();
|
||||
Some((d.max_storage_buffer_binding_size, d.max_buffer_size))
|
||||
}
|
||||
n => {
|
||||
let mb: u64 = n.trim_end_matches("mib").trim_end_matches("mb").trim().parse().ok()?;
|
||||
let b = mb.max(1) << 20;
|
||||
Some((b, 2 * b))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a GPU render failed (it is then redone on the CPU).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum FailKind {
|
||||
/// A buffer over the device limits: this render only.
|
||||
Limit,
|
||||
/// The device ran out of memory: this render only (the buffer pool is emptied).
|
||||
OutOfMemory,
|
||||
/// The image came back entirely black: redone on the CPU in case the GPU dropped work.
|
||||
Blank,
|
||||
/// Device error or loss, readback failure, incomplete image: the GPU is not used again.
|
||||
Fatal,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Failure {
|
||||
pub kind: FailKind,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// A lower buffer limit for the render on this thread (fault injection in tests).
|
||||
static LIMIT: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Lowers [`Gpu::limit`] on this thread while it lives.
|
||||
pub(crate) struct LimitOverride;
|
||||
|
||||
impl LimitOverride {
|
||||
pub fn new(bytes: u64) -> LimitOverride {
|
||||
LIMIT.with(|l| l.set(Some(bytes)));
|
||||
LimitOverride
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LimitOverride {
|
||||
fn drop(&mut self) {
|
||||
let _ = LIMIT.try_with(|l| l.set(None));
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The first failure of the render running on this thread.
|
||||
static FAILURE: std::cell::RefCell<Option<Failure>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Record a failure of the render running on this thread (the first one is kept).
|
||||
pub(crate) fn fail(kind: FailKind, reason: String) {
|
||||
let _ = FAILURE.try_with(|f| {
|
||||
let mut f = f.borrow_mut();
|
||||
if f.is_none() {
|
||||
*f = Some(Failure { kind, reason });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Has the render on this thread failed?
|
||||
pub(crate) fn failed() -> bool {
|
||||
FAILURE.try_with(|f| f.borrow().is_some()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The render's failure, clearing it.
|
||||
pub(crate) fn take_failure() -> Option<Failure> {
|
||||
FAILURE.try_with(|f| f.borrow_mut().take()).ok().flatten()
|
||||
}
|
||||
|
||||
/// wgpu error scopes around a render: out-of-memory, validation and internal errors raised by
|
||||
/// this thread's calls are captured (instead of reaching the uncaptured-error handler) and fail
|
||||
/// the render.
|
||||
pub(crate) struct ErrorScopes([wgpu::ErrorScopeGuard; 3]);
|
||||
|
||||
impl ErrorScopes {
|
||||
pub fn push(g: &Gpu) -> ErrorScopes {
|
||||
ErrorScopes([
|
||||
g.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory),
|
||||
g.device.push_error_scope(wgpu::ErrorFilter::Validation),
|
||||
g.device.push_error_scope(wgpu::ErrorFilter::Internal),
|
||||
])
|
||||
}
|
||||
|
||||
/// Pop the scopes: the most telling error captured (out of memory first — an allocation
|
||||
/// failure makes later commands invalid too — then internal, then validation).
|
||||
pub fn pop(self) -> Option<Failure> {
|
||||
let [oom, validation, internal] = self.0;
|
||||
// scopes pop in reverse order of their creation
|
||||
let internal = pollster::block_on(internal.pop());
|
||||
let validation = pollster::block_on(validation.pop());
|
||||
let oom = pollster::block_on(oom.pop());
|
||||
if let Some(e) = oom {
|
||||
return Some(Failure { kind: FailKind::OutOfMemory, reason: format!("out of device memory: {e}") });
|
||||
}
|
||||
internal.or(validation).map(|e| Failure { kind: FailKind::Fatal, reason: format!("device error: {e}") })
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_entry(binding: u32, read_only: bool) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
@@ -529,3 +707,23 @@ pub fn groups1(n: usize) -> [u32; 3] {
|
||||
let x = g.min(32768);
|
||||
[x as u32, g.div_ceil(x) as u32, 1]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn limits_override_parses() {
|
||||
assert_eq!(super::parse_limits("webgpu"), Some((128 << 20, 256 << 20)));
|
||||
assert_eq!(super::parse_limits(" Downlevel "), Some((128 << 20, 256 << 20)));
|
||||
assert_eq!(super::parse_limits("64"), Some((64 << 20, 128 << 20)));
|
||||
assert_eq!(super::parse_limits("512MiB"), Some((512 << 20, 1024 << 20)));
|
||||
assert_eq!(super::parse_limits(""), None);
|
||||
assert_eq!(super::parse_limits("lots"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatches_stay_within_the_workgroup_limit() {
|
||||
// 1-D kernels over a 100 MP image and 2-D ones over 16k × 16k stay under 65535 per axis
|
||||
assert!(super::groups1(100_000_000 * 3).iter().all(|g| *g <= 65535));
|
||||
assert!(super::groups2(16384, 16384, [16, 16]).iter().all(|g| *g <= 65535));
|
||||
}
|
||||
}
|
||||
+141
-15
@@ -7,14 +7,16 @@
|
||||
//! CPU inside the same render (per-stage hybrid); see `docs/gpu-pipeline.md`.
|
||||
//!
|
||||
//! Use [`render`]: it returns `None` when the GPU is unavailable, disabled (`LIGHTCRAFT_GPU=0` or
|
||||
//! [`set_enabled`]), or the render does not fit the device — callers then render on the CPU.
|
||||
//! [`set_enabled`]), the render does not fit the device, or the device reported an error, ran out
|
||||
//! of memory or returned an incomplete image — callers then render on the CPU. [`unavailable_reason`]
|
||||
//! and [`last_fallback`] say why (`ui.inspect` → `perf.gpuReason` / `perf.gpuFallback`).
|
||||
//! The browser build has no GPU path yet (WebGPU device creation is asynchronous): everything here
|
||||
//! compiles to the CPU fallback on wasm32.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
use lightcraft_pipeline::{RenderRequest, Rendered, SourceInfo, StageCache};
|
||||
@@ -34,12 +36,95 @@ static ENABLED: AtomicBool = AtomicBool::new(true);
|
||||
/// Set when a GPU render failed: the process stays on the CPU from then on.
|
||||
static BROKEN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Why the GPU stopped being used (the first fatal failure).
|
||||
static BROKEN_REASON: Mutex<Option<String>> = Mutex::new(None);
|
||||
/// The latest render that fell back to the CPU, and why.
|
||||
static LAST_FALLBACK: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
/// Stop using the GPU for the rest of the process (after a device error).
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
fn mark_broken() {
|
||||
fn mark_broken(reason: &str) {
|
||||
let mut r = BROKEN_REASON.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if r.is_none() {
|
||||
*r = Some(reason.to_string());
|
||||
}
|
||||
BROKEN.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
fn record_fallback(reason: String) {
|
||||
*LAST_FALLBACK.lock().unwrap_or_else(|e| e.into_inner()) = Some(reason);
|
||||
}
|
||||
|
||||
/// Why renders do not use the GPU, or `None` when they do (or will, once the device is created).
|
||||
/// E.g. "disabled by LIGHTCRAFT_GPU=0", "software adapter (llvmpipe …) skipped", "device lost …".
|
||||
/// Never blocks.
|
||||
pub fn unavailable_reason() -> Option<String> {
|
||||
if env_disabled() {
|
||||
return Some("disabled by LIGHTCRAFT_GPU=0".into());
|
||||
}
|
||||
if !ENABLED.load(Ordering::Relaxed) {
|
||||
return Some("disabled by the GPU rendering preference (app.gpu)".into());
|
||||
}
|
||||
if BROKEN.load(Ordering::Relaxed) {
|
||||
let r = BROKEN_REASON.lock().unwrap_or_else(|e| e.into_inner()).clone().unwrap_or_else(|| "device error".into());
|
||||
return Some(format!("stopped after a GPU failure: {r}"));
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
match GPU.get() {
|
||||
Some(Err(e)) => Some(e.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
Some("the browser build has no GPU path yet".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// The latest render that fell back from the GPU to the CPU and why (device limit, out of memory,
|
||||
/// device error, incomplete or blank result), if any.
|
||||
pub fn last_fallback() -> Option<String> {
|
||||
LAST_FALLBACK.lock().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
|
||||
/// A simulated GPU failure for the next [`render`] on this thread (tests of the CPU fallback).
|
||||
#[doc(hidden)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Fault {
|
||||
/// A command the device rejects (validation error).
|
||||
Validation,
|
||||
/// The per-pixel stage's work never runs (what a driver that silently drops or resets a
|
||||
/// submission looks like: the readback holds no image).
|
||||
DropWork,
|
||||
/// A storage-buffer limit of this many bytes for this render.
|
||||
Limit(u64),
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static FAULT: std::cell::Cell<Option<Fault>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Make the next [`render`] on this thread fail with `f` (tests).
|
||||
#[doc(hidden)]
|
||||
pub fn inject_fault(f: Fault) {
|
||||
FAULT.with(|c| c.set(Some(f)));
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
fn take_fault() -> Option<Fault> {
|
||||
FAULT.with(|c| c.take())
|
||||
}
|
||||
|
||||
/// Use the GPU again after a failure (tests that inject faults).
|
||||
#[doc(hidden)]
|
||||
pub fn reset_failures() {
|
||||
BROKEN.store(false, Ordering::Relaxed);
|
||||
*BROKEN_REASON.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
*LAST_FALLBACK.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
|
||||
/// Allow or forbid GPU rendering at runtime (a preference). `LIGHTCRAFT_GPU=0` forbids it for the
|
||||
/// whole process regardless.
|
||||
pub fn set_enabled(on: bool) {
|
||||
@@ -57,20 +142,20 @@ fn env_disabled() -> bool {
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
static GPU: std::sync::OnceLock<Option<ctx::Gpu>> = std::sync::OnceLock::new();
|
||||
static GPU: std::sync::OnceLock<Result<ctx::Gpu, String>> = std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn device() -> Option<&'static ctx::Gpu> {
|
||||
if env_disabled() {
|
||||
return None;
|
||||
}
|
||||
GPU.get_or_init(|| std::panic::catch_unwind(ctx::Gpu::new).ok().flatten()).as_ref()
|
||||
GPU.get_or_init(|| std::panic::catch_unwind(ctx::Gpu::new).unwrap_or_else(|_| Err("device creation panicked".into()))).as_ref().ok()
|
||||
}
|
||||
|
||||
/// The device if it has been created (never creates it).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn existing_device() -> Option<&'static ctx::Gpu> {
|
||||
GPU.get().and_then(|g| g.as_ref())
|
||||
GPU.get().and_then(|g| g.as_ref().ok())
|
||||
}
|
||||
|
||||
/// Create the device and compile the kernels on a background thread now (app start), so the
|
||||
@@ -160,7 +245,7 @@ pub fn set_pool_limit(bytes: u64) {
|
||||
/// Free recycled buffers until at most `keep` bytes stay pooled (e.g. when the app goes idle).
|
||||
pub fn trim_pool(keep: u64) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(g) = GPU.get().and_then(|g| g.as_ref()) {
|
||||
if let Some(g) = existing_device() {
|
||||
g.trim(keep);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -191,19 +276,60 @@ pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &R
|
||||
}
|
||||
let gpu = device()?;
|
||||
let ext = stages.map(|c| c.extension::<GpuStages>());
|
||||
let r = {
|
||||
let fault = take_fault();
|
||||
let _ = ctx::take_failure(); // nothing left over from an earlier render on this thread
|
||||
let (r, scoped) = {
|
||||
let _scope = ctx::RenderScope::new(gpu);
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| render::render(gpu, src, info, s, req, ext.as_deref())))
|
||||
let errors = ctx::ErrorScopes::push(gpu);
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| render::render(gpu, src, info, s, req, ext.as_deref(), fault)));
|
||||
(r, errors.pop())
|
||||
};
|
||||
match r {
|
||||
// a device error during the render: its result is not trustworthy
|
||||
Ok(_) if BROKEN.load(Ordering::Relaxed) => None,
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
// An error the device raised explains the readback failures that follow it; a buffer
|
||||
// over the limit explains the errors its placeholder raises.
|
||||
let failure = match (ctx::take_failure(), scoped) {
|
||||
(Some(f), _) if f.kind == ctx::FailKind::Limit => Some(f),
|
||||
(f, scoped) => scoped.or(f),
|
||||
};
|
||||
match (r, failure) {
|
||||
(Err(_), _) => {
|
||||
log::error!("gpu: render failed; using the CPU pipeline from now on");
|
||||
BROKEN.store(true, Ordering::Relaxed);
|
||||
mark_broken("a GPU render panicked");
|
||||
record_fallback("a GPU render panicked; the GPU is not used again".into());
|
||||
None
|
||||
}
|
||||
(Ok(r), Some(f)) => {
|
||||
// the view's cached device stages may hold results of the failed passes
|
||||
if let Some(e) = &ext {
|
||||
e.clear();
|
||||
}
|
||||
let what = match &r {
|
||||
Some(r) => format!("{}×{}", r.image.width, r.image.height),
|
||||
None => "GPU render".into(),
|
||||
};
|
||||
let reason = match f.kind {
|
||||
ctx::FailKind::Fatal => {
|
||||
mark_broken(&f.reason);
|
||||
format!("{what}: {}; the GPU is not used again", f.reason)
|
||||
}
|
||||
ctx::FailKind::OutOfMemory => {
|
||||
gpu.trim(0);
|
||||
format!("{what}: {}", f.reason)
|
||||
}
|
||||
ctx::FailKind::Limit | ctx::FailKind::Blank => format!("{what}: {}", f.reason),
|
||||
};
|
||||
log::warn!("gpu: rendering on the CPU instead: {reason}");
|
||||
if render::profiling() {
|
||||
eprintln!(" gpu fallback to the CPU: {reason}");
|
||||
}
|
||||
record_fallback(reason);
|
||||
None
|
||||
}
|
||||
// a device error on another thread or a lost device during the render
|
||||
(Ok(Some(_)), None) if BROKEN.load(Ordering::Relaxed) => {
|
||||
record_fallback(format!("device failure during the render: {}", unavailable_reason().unwrap_or_default()));
|
||||
None
|
||||
}
|
||||
(Ok(r), None) => r,
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -71,6 +71,8 @@ const FIELDS: &[(&str, usize)] = &[
|
||||
("OUT_Y", 3),
|
||||
("OUT_TRC", 1),
|
||||
("OUT_GAMMA", 1),
|
||||
// first row of a band dispatch (the kernel runs over rows Y0.., see `render`)
|
||||
("Y0", 1),
|
||||
];
|
||||
|
||||
/// `(name, index)` of every field (for the WGSL constants).
|
||||
@@ -87,7 +89,7 @@ pub fn finish_fields() -> Vec<(&'static str, usize)> {
|
||||
}
|
||||
|
||||
/// Offset of field `name`; `None` (and a debug assertion) for a name not in [`FIELDS`].
|
||||
fn index(name: &str) -> Option<usize> {
|
||||
pub fn index(name: &str) -> Option<usize> {
|
||||
let mut i = 0;
|
||||
for (n, len) in FIELDS {
|
||||
if *n == name {
|
||||
|
||||
+98
-24
@@ -12,7 +12,7 @@ use lightcraft_pipeline::{Plan, RenderRequest, Rendered, SourceInfo, local};
|
||||
use lightcraft_raster::resample::Filter;
|
||||
use lightcraft_raster::{Histogram, Plane, Rgb32f, Rgba8};
|
||||
|
||||
use crate::ctx::{Buf, Gpu, groups1, groups2};
|
||||
use crate::ctx::{Buf, FailKind, Gpu, fail, groups1, groups2};
|
||||
use crate::params::{Present, finish_block};
|
||||
|
||||
/// Device-resident stages of one view, kept next to the CPU [`lightcraft_pipeline::StageCache`]
|
||||
@@ -128,27 +128,54 @@ impl GpuStages {
|
||||
pub(crate) struct Cx<'a> {
|
||||
pub gpu: &'a Gpu,
|
||||
enc: Option<wgpu::CommandEncoder>,
|
||||
/// Kernel invocations recorded since the last submit.
|
||||
pending: u64,
|
||||
}
|
||||
|
||||
/// Kernel invocations recorded before a render submits them (under one full-size pass over a
|
||||
/// 24 MP export, several passes over a preview): no single submission runs long.
|
||||
const FLUSH_INVOCATIONS: u64 = 16 << 20;
|
||||
|
||||
/// Pixels per dispatch of the per-pixel stage (the heaviest kernel).
|
||||
const BAND_PIXELS: usize = 4 << 20;
|
||||
|
||||
impl<'a> Cx<'a> {
|
||||
pub fn new(gpu: &'a Gpu) -> Cx<'a> {
|
||||
Cx { gpu, enc: Some(gpu.encoder()) }
|
||||
Cx { gpu, enc: Some(gpu.encoder()), pending: 0 }
|
||||
}
|
||||
|
||||
/// Record kernel `name` (see [`Gpu::run`]).
|
||||
pub fn run(&mut self, name: &str, p: &[u32], bufs: &[Option<&Buf>], groups: [u32; 3]) {
|
||||
let enc = self.enc.get_or_insert_with(|| self.gpu.encoder());
|
||||
self.gpu.run(enc, name, p, bufs, groups);
|
||||
// every kernel runs 256 invocations per workgroup
|
||||
self.pending += groups.iter().map(|g| *g as u64).product::<u64>() * 256;
|
||||
if self.pending >= FLUSH_INVOCATIONS {
|
||||
self.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit what is recorded and read back `len` values of `b`.
|
||||
pub fn read<T: bytemuck::Pod>(&mut self, b: &Buf, len: usize) -> Vec<T> {
|
||||
self.pending = 0;
|
||||
let enc = self.enc.take().unwrap_or_else(|| self.gpu.encoder());
|
||||
self.gpu.finish_and_read(enc, b, len)
|
||||
}
|
||||
|
||||
/// Submit what is recorded so far without waiting. Renders submit stage by stage and every
|
||||
/// [`FLUSH_INVOCATIONS`]: long submissions are what GPU watchdogs reset (Windows TDR, the
|
||||
/// i915 hang check; issue #78's iGPU spent seconds on an export recorded as one submission),
|
||||
/// and buffers dropped by earlier work become reusable by later work (lower peak memory).
|
||||
pub fn flush(&mut self) {
|
||||
self.pending = 0;
|
||||
if let Some(enc) = self.enc.take() {
|
||||
self.gpu.submit([enc.finish()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit what is recorded and wait for it (profiling only: stage timings).
|
||||
pub fn sync(&mut self) {
|
||||
self.pending = 0;
|
||||
if let Some(enc) = self.enc.take() {
|
||||
self.gpu.submit([enc.finish()]);
|
||||
}
|
||||
@@ -434,12 +461,13 @@ struct Host {
|
||||
log_l: Option<Arc<Plane>>,
|
||||
}
|
||||
|
||||
fn profiling() -> bool {
|
||||
pub(crate) 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())
|
||||
}
|
||||
|
||||
/// Render on `gpu`, reusing `stages` (if given). `None` when the render does not fit the device.
|
||||
/// Render on `gpu`, reusing `stages` (if given). `None` (or a result the caller discards) when the
|
||||
/// render failed: the reason is recorded with [`crate::ctx::fail`].
|
||||
pub fn render(
|
||||
gpu: &Gpu,
|
||||
src: &Arc<Rgb32f>,
|
||||
@@ -447,20 +475,31 @@ pub fn render(
|
||||
s: &DevelopSettings,
|
||||
req: &RenderRequest,
|
||||
stages: Option<&GpuStages>,
|
||||
fault: Option<crate::Fault>,
|
||||
) -> Option<Rendered> {
|
||||
let mut t = profiling().then(std::time::Instant::now);
|
||||
// Under `LIGHTCRAFT_PROFILE` each stage is submitted and waited for, so the timings are real.
|
||||
let lap = |what: &str, t: &mut Option<std::time::Instant>, cx: &mut Cx<'_>| {
|
||||
if let Some(t) = t {
|
||||
// Each stage is submitted on its own; under `LIGHTCRAFT_PROFILE` also waited for, so the
|
||||
// timings are real.
|
||||
let lap = |what: &str, t: &mut Option<std::time::Instant>, cx: &mut Cx<'_>| match t {
|
||||
Some(t) => {
|
||||
cx.sync();
|
||||
eprintln!(" gpu {what}: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
*t = std::time::Instant::now();
|
||||
}
|
||||
None => cx.flush(),
|
||||
};
|
||||
let plan = lightcraft_pipeline::plan(src, info, s, req);
|
||||
let (w, h) = (plan.w, plan.h);
|
||||
let n = w * h;
|
||||
let _limit = match fault {
|
||||
Some(crate::Fault::Limit(bytes)) => Some(crate::ctx::LimitOverride::new(bytes)),
|
||||
_ => None,
|
||||
};
|
||||
if !gpu.fits(n * 3) {
|
||||
fail(
|
||||
FailKind::Limit,
|
||||
format!("{w}×{h} needs {} MiB buffers, over the device's {} MiB storage-buffer limit", (n * 12).div_ceil(1 << 20), gpu.limit() >> 20),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let s = &*plan.settings;
|
||||
@@ -521,23 +560,35 @@ pub fn render(
|
||||
};
|
||||
let (p, aux) = finish_block(&fp, &terms, &present);
|
||||
let aux = gpu.upload(&aux);
|
||||
let out = gpu.buffer(n);
|
||||
cx.run(
|
||||
"main",
|
||||
&p,
|
||||
&[
|
||||
Some(&lin),
|
||||
Some(&prep.log_l),
|
||||
Some(&prep.base),
|
||||
prep.clarity.as_deref(),
|
||||
prep.texture.as_deref(),
|
||||
prep.dark.as_deref(),
|
||||
masks.as_ref(),
|
||||
Some(&aux),
|
||||
Some(&out),
|
||||
],
|
||||
groups2(w, h, [16, 16]),
|
||||
);
|
||||
// cleared, so that work which never ran cannot pass for an image (see the check below)
|
||||
let out = cx.zeroed(n);
|
||||
if fault == Some(crate::Fault::Validation) {
|
||||
// a dispatch the device rejects (too many workgroups)
|
||||
cx.run("box_h", &[0; 5], &[Some(&aux), Some(&out)], [gpu.device.limits().max_compute_workgroups_per_dimension + 1, 1, 1]);
|
||||
}
|
||||
// in bands of rows: each dispatch (and submission) stays short on a slow GPU
|
||||
let rows = (BAND_PIXELS / w.max(1) / 16 * 16).max(16); // whole workgroups: bands never overlap
|
||||
let y0_at = crate::params::index("Y0").unwrap_or(0);
|
||||
let mut p = p;
|
||||
for y0 in (0..h).step_by(rows).filter(|_| fault != Some(crate::Fault::DropWork)) {
|
||||
p[y0_at] = y0 as u32;
|
||||
cx.run(
|
||||
"main",
|
||||
&p,
|
||||
&[
|
||||
Some(&lin),
|
||||
Some(&prep.log_l),
|
||||
Some(&prep.base),
|
||||
prep.clarity.as_deref(),
|
||||
prep.texture.as_deref(),
|
||||
prep.dark.as_deref(),
|
||||
masks.as_ref(),
|
||||
Some(&aux),
|
||||
Some(&out),
|
||||
],
|
||||
groups2(w, rows.min(h - y0), [16, 16]),
|
||||
);
|
||||
}
|
||||
// the alpha a mask overlay shows: copied out before the readback below submits
|
||||
let overlay_mask = req.overlay.mask(s).map(|m| {
|
||||
let list = s.masks.iter().filter(|m| m.visible && !m.components.is_empty());
|
||||
@@ -549,8 +600,25 @@ pub fn render(
|
||||
let data: Vec<[u8; 4]> = cx.read(&out, n);
|
||||
let image = Rgba8 { width: w, height: h, data };
|
||||
lap("finish + readback", &mut t, &mut cx);
|
||||
if crate::ctx::failed() {
|
||||
return None;
|
||||
}
|
||||
// The per-pixel kernel writes alpha 255 everywhere and `out` was cleared: a pixel with any
|
||||
// other alpha means work silently did not run (a driver that reset or dropped a submission
|
||||
// without reporting it — issue #78's all-black exports).
|
||||
let unwritten = unwritten(&image.data);
|
||||
if unwritten > 0 {
|
||||
fail(FailKind::Fatal, format!("the GPU returned an incomplete image ({unwritten} of {n} pixels unwritten)"));
|
||||
return None;
|
||||
}
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
lap("histogram", &mut t, &mut cx);
|
||||
// An entirely black result (upstream work that did not run would give that too): redo it on
|
||||
// the CPU, which costs time only for the rare photo that really is black.
|
||||
if n >= 4096 && [&histogram.r, &histogram.g, &histogram.b].iter().all(|c| c.first() == Some(&histogram.total)) {
|
||||
fail(FailKind::Blank, "the GPU result is entirely black; rendering it on the CPU to be sure".into());
|
||||
return None;
|
||||
}
|
||||
let mut image = image;
|
||||
let overlay_mask = overlay_mask.map(|r| match r {
|
||||
Ok(b) => cx.read_plane(&b, w, h),
|
||||
@@ -565,6 +633,11 @@ pub fn render(
|
||||
Some(Rendered { image, histogram, deep: None })
|
||||
}
|
||||
|
||||
/// Pixels whose alpha is not 255 (cheap: no early exit, so it vectorizes).
|
||||
fn unwritten(data: &[[u8; 4]]) -> usize {
|
||||
data.chunks(4096).filter(|c| c.iter().fold(255u8, |a, p| a & p[3]) != 255).map(|c| c.iter().filter(|p| p[3] != 255).count()).sum()
|
||||
}
|
||||
|
||||
/// The white-balanced, retouched, denoised image.
|
||||
fn linear(cx: &mut Cx<'_>, sampled: &Buf, info: &SourceInfo, plan: &Plan<'_>, host: &mut Host) -> Buf {
|
||||
let (w, h) = (plan.w, plan.h);
|
||||
@@ -679,6 +752,7 @@ fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, pla
|
||||
let sub = cx.gpu.buffer(m);
|
||||
map(cx, "subsample", m, &[local::AIRLIGHT_STEP as u32], [Some(&d), None, None], &sub);
|
||||
let air = local::airlight_of(cx.read(&sub, m));
|
||||
cx.flush();
|
||||
let b = Arc::new(d);
|
||||
planes.dark = Some((sg.to_bits(), b.clone(), air));
|
||||
(Some(b), air)
|
||||
|
||||
@@ -243,11 +243,11 @@ fn out_encode(v: f32) -> f32 {
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = pu(F_W);
|
||||
let h = pu(F_H);
|
||||
if (gid.x >= w || gid.y >= h) {
|
||||
let x = gid.x;
|
||||
let y = gid.y + pu(F_Y0);
|
||||
if (x >= w || y >= h) {
|
||||
return;
|
||||
}
|
||||
let x = gid.x;
|
||||
let y = gid.y;
|
||||
let i = y * w + x;
|
||||
let n = w * h;
|
||||
let gain = pf(F_GAIN);
|
||||
|
||||
@@ -22,6 +22,27 @@ fn gpu() -> bool {
|
||||
ok
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_tone_and_relative_wb() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let src = scene(2, 320, 240);
|
||||
let curve = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| {
|
||||
let x = 0.004 * 1.18f32.powi(i as i32);
|
||||
[x, 1.0 - (-2.0 * x).exp()]
|
||||
}))
|
||||
.unwrap();
|
||||
let info = SourceInfo { raw: true, relative_wb: true, camera_tone: Some(curve), ..Default::default() };
|
||||
let mut s = DevelopSettings::default();
|
||||
check("camera tone neutral", &src, &info, &s, &RenderRequest::fit(320, 240));
|
||||
s.light.exposure = 1.0;
|
||||
s.light.contrast = 40.0;
|
||||
s.wb.mode = WbMode::Custom;
|
||||
s.wb.temp = 8000.0;
|
||||
check("camera tone edited", &src, &info, &s, &RenderRequest::fit(320, 240));
|
||||
}
|
||||
|
||||
fn scene(i: usize, w: usize, h: usize) -> Arc<Rgb32f> {
|
||||
Arc::new(lightcraft_scenes::demo_library()[i].render(w, h))
|
||||
}
|
||||
@@ -65,6 +86,32 @@ fn typical(s: &mut DevelopSettings) {
|
||||
s.detail.sharpen_amount = 40.0;
|
||||
}
|
||||
|
||||
/// A render big enough for the per-pixel stage to run in several bands of rows (and the render
|
||||
/// in many submissions), with grain (position-dependent) and masks.
|
||||
#[test]
|
||||
fn banded_full_size_render_matches() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let (w, h) = (3000, 2000);
|
||||
let src = scene(3, w, h);
|
||||
let mut s = DevelopSettings::default();
|
||||
typical(&mut s);
|
||||
s.grain.amount = 30.0;
|
||||
s.vignette.amount = -30.0;
|
||||
s.masks = vec![Mask {
|
||||
components: vec![MaskComponent {
|
||||
name: None,
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.9) },
|
||||
}],
|
||||
adjust: lightcraft_develop::LocalAdjustments { exposure: -0.6, ..Default::default() },
|
||||
..Default::default()
|
||||
}];
|
||||
check("banded 3000×2000", &src, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, h));
|
||||
}
|
||||
|
||||
fn cases() -> Vec<(&'static str, Edit)> {
|
||||
vec![
|
||||
("default", |_| {}),
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Issue #78: a GPU render that cannot be trusted — over the device's buffer limits, a device
|
||||
//! error, work that silently did not run — must come back as `None` (the caller renders on the
|
||||
//! CPU) with the reason recorded, never as an image of zeros. One test, sequential: the fatal
|
||||
//! faults stop GPU use for the whole process until `reset_failures`.
|
||||
//! Skips (passes with a note) when no GPU adapter exists.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_develop::{DevelopSettings, Mask, MaskComponent, MaskOp, MaskShape};
|
||||
use lightcraft_geom::Point;
|
||||
use lightcraft_gpu::Fault;
|
||||
use lightcraft_pipeline::{RenderRequest, SourceInfo, render};
|
||||
use lightcraft_raster::Rgba8;
|
||||
|
||||
fn mean_abs_diff(a: &Rgba8, b: &Rgba8) -> f64 {
|
||||
let sum: u64 = a.data.iter().zip(&b.data).map(|(p, q)| (0..3).map(|c| p[c].abs_diff(q[c]) as u64).sum::<u64>()).sum();
|
||||
sum as f64 / (a.data.len() * 3) as f64
|
||||
}
|
||||
|
||||
fn masked(s: &mut DevelopSettings, k: usize) {
|
||||
for i in 0..k {
|
||||
let c = Point::new(0.2 + 0.2 * i as f64, 0.5);
|
||||
s.masks.push(Mask {
|
||||
id: i as u32 + 1,
|
||||
components: vec![MaskComponent {
|
||||
name: None,
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::Radial { center: c, rx: 0.2, ry: 0.15, angle: 0.0, feather: 50.0, invert: false },
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
s.masks[i].adjust.exposure = 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_gpu_renders_fall_back_with_a_reason() {
|
||||
if !lightcraft_gpu::available() {
|
||||
eprintln!("skipped: no GPU adapter ({:?})", lightcraft_gpu::unavailable_reason());
|
||||
return;
|
||||
}
|
||||
assert_eq!(lightcraft_gpu::unavailable_reason(), None);
|
||||
let (w, h) = (1200, 800);
|
||||
let src = Arc::new(lightcraft_scenes::demo_library()[0].render(w, h));
|
||||
let info = SourceInfo { raw: true, ..Default::default() };
|
||||
let mut s = DevelopSettings::default();
|
||||
s.light.exposure = 0.3;
|
||||
s.effects.clarity = 20.0;
|
||||
masked(&mut s, 4);
|
||||
let req = RenderRequest::fit(w, h);
|
||||
let cpu = render(&src, &info, &s, &req).image;
|
||||
let ok = |what: &str| {
|
||||
let g = lightcraft_gpu::render(&src, &info, &s, &req, None)
|
||||
.unwrap_or_else(|| panic!("{what}: gpu render ({:?})", lightcraft_gpu::last_fallback()));
|
||||
let d = mean_abs_diff(&cpu, &g.image);
|
||||
assert!(d < 0.5, "{what}: GPU differs from the CPU by {d:.3} LSB");
|
||||
};
|
||||
ok("healthy");
|
||||
|
||||
// the output itself is over the limit: refused up front, the GPU stays in use
|
||||
lightcraft_gpu::inject_fault(Fault::Limit(1 << 20));
|
||||
assert!(lightcraft_gpu::render(&src, &info, &s, &req, None).is_none());
|
||||
let why = lightcraft_gpu::last_fallback().unwrap_or_default();
|
||||
assert!(why.contains("limit"), "{why}");
|
||||
assert!(lightcraft_gpu::available(), "a limit is no device failure");
|
||||
|
||||
// the output fits but the mask planes (4 masks: 4 planes in one buffer) don't: caught when the
|
||||
// buffer is created, without a device error, and the GPU stays in use
|
||||
lightcraft_gpu::inject_fault(Fault::Limit((w * h * 3 * 4 + 1024) as u64));
|
||||
assert!(lightcraft_gpu::render(&src, &info, &s, &req, None).is_none());
|
||||
let why = lightcraft_gpu::last_fallback().unwrap_or_default();
|
||||
assert!(why.contains("exceeds"), "{why}");
|
||||
assert!(lightcraft_gpu::available(), "{:?}", lightcraft_gpu::unavailable_reason());
|
||||
ok("after the limit faults");
|
||||
|
||||
// a device (validation) error: captured by the render's error scope; the GPU is not used again
|
||||
lightcraft_gpu::inject_fault(Fault::Validation);
|
||||
assert!(lightcraft_gpu::render(&src, &info, &s, &req, None).is_none());
|
||||
let why = lightcraft_gpu::last_fallback().unwrap_or_default();
|
||||
assert!(why.contains("device error"), "{why}");
|
||||
assert!(!lightcraft_gpu::available());
|
||||
assert!(lightcraft_gpu::unavailable_reason().is_some_and(|r| r.contains("device error")), "{:?}", lightcraft_gpu::unavailable_reason());
|
||||
lightcraft_gpu::reset_failures();
|
||||
ok("after a device error");
|
||||
|
||||
// the per-pixel work never runs (a driver dropping a submission without an error): before
|
||||
// the fix this came back as a fully black image
|
||||
lightcraft_gpu::inject_fault(Fault::DropWork);
|
||||
assert!(lightcraft_gpu::render(&src, &info, &s, &req, None).is_none(), "an unwritten image must not be returned");
|
||||
let why = lightcraft_gpu::last_fallback().unwrap_or_default();
|
||||
assert!(why.contains("incomplete"), "{why}");
|
||||
assert!(!lightcraft_gpu::available());
|
||||
lightcraft_gpu::reset_failures();
|
||||
ok("after dropped work");
|
||||
|
||||
// a black photo is still rendered (on the CPU, to be sure)
|
||||
let black = Arc::new(lightcraft_raster::Rgb32f::from_fn(256, 256, |_, _| [0.0; 3]));
|
||||
let s0 = DevelopSettings::default();
|
||||
assert!(lightcraft_gpu::render(&black, &info, &s0, &RenderRequest::fit(256, 256), None).is_none());
|
||||
assert!(lightcraft_gpu::last_fallback().unwrap_or_default().contains("black"));
|
||||
assert!(lightcraft_gpu::available());
|
||||
}
|
||||
@@ -78,12 +78,7 @@ impl Headless {
|
||||
None => vec![self.photo_or_active(p)?],
|
||||
};
|
||||
let dir = p.get("dir").and_then(Value::as_str).unwrap_or("");
|
||||
let write = &mut |path: &str, bytes: &[u8]| {
|
||||
if let Some(parent) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(parent).map_err(|err| err.to_string())?;
|
||||
}
|
||||
std::fs::write(path, bytes).map_err(|err| format!("{path}: {err}"))
|
||||
};
|
||||
let write = &mut lightcraft_engine::export::write_file;
|
||||
let files =
|
||||
export_batch(&mut self.session, &ids, &opts, &Destination { dir: dir.to_string(), exact: exact.map(str::to_string) }, write, &|path| {
|
||||
Path::new(path).exists()
|
||||
@@ -118,6 +113,7 @@ impl Backend for Headless {
|
||||
let img = self.render(&p, 1600)?;
|
||||
match p.get("path").and_then(Value::as_str) {
|
||||
Some(path) => {
|
||||
self.session.check_write_target(path)?;
|
||||
write_image(Path::new(path), &img, 92)?;
|
||||
Ok(json!({"path": path, "width": img.width, "height": img.height}))
|
||||
}
|
||||
@@ -183,7 +179,7 @@ pub fn encode_image(ext: &str, img: &Rgba8, quality: u8) -> Result<Vec<u8>, Stri
|
||||
pub fn write_image(path: &Path, img: &Rgba8, quality: u8) -> Result<(), String> {
|
||||
let ext = path.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let bytes = encode_image(&ext, img, quality)?;
|
||||
std::fs::write(path, bytes).map_err(|e| format!("{}: {e}", path.display()))
|
||||
lightcraft_engine::export::write_file(&path.to_string_lossy(), &bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+29
-1
@@ -1,6 +1,6 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Headless, PROTOCOL_VERSION, Server, call_tool, command_tool_name};
|
||||
use crate::{Backend, Headless, PROTOCOL_VERSION, Server, call_tool, command_tool_name};
|
||||
|
||||
fn server() -> Server {
|
||||
Server::new(Box::new(Headless::demo()))
|
||||
@@ -92,6 +92,34 @@ fn import_tool_copies_with_a_folder_template() {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Issue #93: `render_photo`, `ui.render` and `app.export` with an exact `path` must not replace
|
||||
/// a photo's original.
|
||||
#[test]
|
||||
fn path_writes_never_replace_an_original() {
|
||||
let base = std::env::temp_dir().join(format!("lc-mcp-guard-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 30, 9, 255]; 64] };
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
let orig = base.join("a.png");
|
||||
std::fs::write(&orig, &png).unwrap();
|
||||
let mut b = Headless::demo();
|
||||
let r = call_tool(&mut b, "import", &json!({"paths": [orig.to_string_lossy()]}));
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
let id = b.session.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id.0;
|
||||
let path = orig.to_string_lossy().to_string();
|
||||
let r = call_tool(&mut b, "render_photo", &json!({"id": id, "path": path}));
|
||||
assert!(r.is_error, "{r:?}");
|
||||
assert!(b.call("ui.render", json!({"id": id, "path": path})).unwrap_err().contains("original"));
|
||||
let e = b.call("app.export", json!({"ids": [id], "path": path})).unwrap_err();
|
||||
assert!(e.contains("never writes over an original"), "{e}");
|
||||
assert_eq!(std::fs::read(&orig).unwrap(), png, "the original is untouched");
|
||||
// another path still works
|
||||
let r = call_tool(&mut b, "render_photo", &json!({"id": id, "path": base.join("render.png").to_string_lossy()}));
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// The `import` helper moves: renamed into the folder template, the source removed.
|
||||
#[test]
|
||||
fn import_tool_moves_with_a_folder_template() {
|
||||
|
||||
+17
-3
@@ -205,7 +205,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
||||
"removeLocation": {"type": "boolean"},
|
||||
"colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"},
|
||||
"bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), AVIF 8|10; JPEG/WebP are 8-bit"},
|
||||
"watermark": {"description": "Text, or {text, size (fraction of short edge), opacity, anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset, color [r,g,b], shadow}"}
|
||||
"watermark": {"description": "Text, or {text, vertical (boolean; defaults to false), size (fraction of short edge), opacity, anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset, color [r,g,b], shadow}"}
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
@@ -352,9 +352,9 @@ fn image_result(file: &std::path::Path, max: Option<u32>, format: &str, save_to:
|
||||
(bytes, w, h)
|
||||
};
|
||||
if let Some(p) = save_to
|
||||
&& let Err(e) = std::fs::write(p, &bytes)
|
||||
&& let Err(e) = lightcraft_engine::export::write_file(p, &bytes)
|
||||
{
|
||||
return ToolResult::error(format!("{p}: {e}"));
|
||||
return ToolResult::error(e);
|
||||
}
|
||||
let mime = if jpeg { "image/jpeg" } else { "image/png" };
|
||||
let mut info = meta;
|
||||
@@ -372,7 +372,18 @@ fn image_result(file: &std::path::Path, max: Option<u32>, format: &str, save_to:
|
||||
}
|
||||
}
|
||||
|
||||
/// A user-given `path` to save to must not be a photo's original (or its sidecar).
|
||||
fn check_save_path(b: &mut dyn Backend, args: &Value) -> Result<(), String> {
|
||||
match args.get("path").and_then(Value::as_str) {
|
||||
Some(p) => exec(b, "export.checkTarget", json!({"path": p})).map(|_| ()),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_photo(b: &mut dyn Backend, args: &Value) -> ToolResult {
|
||||
if let Err(e) = check_save_path(b, args) {
|
||||
return ToolResult::error(e);
|
||||
}
|
||||
let size = args.get("size").and_then(Value::as_u64).unwrap_or(1024).clamp(16, 4096);
|
||||
let id = match args.get("id").and_then(Value::as_u64) {
|
||||
Some(id) => Some(id),
|
||||
@@ -394,6 +405,9 @@ fn render_photo(b: &mut dyn Backend, args: &Value) -> ToolResult {
|
||||
}
|
||||
|
||||
fn screenshot(b: &mut dyn Backend, args: &Value) -> ToolResult {
|
||||
if let Err(e) = check_save_path(b, args) {
|
||||
return ToolResult::error(e);
|
||||
}
|
||||
let file = temp_path("screenshot");
|
||||
let path = file.to_string_lossy().to_string();
|
||||
match b.call("ui.screenshot", json!({"path": path})) {
|
||||
|
||||
@@ -71,7 +71,7 @@ impl DateTime {
|
||||
};
|
||||
let (h, m) = match rest.split_once(':') {
|
||||
Some((h, m)) => (num(h)?, num(m)?),
|
||||
None if rest.len() == 4 => (num(&rest[..2])?, num(&rest[2..])?),
|
||||
None if rest.len() == 4 => (num(rest.get(..2)?)?, num(rest.get(2..)?)?),
|
||||
None => (num(rest)?, 0),
|
||||
};
|
||||
if h > 18 || m > 59 {
|
||||
@@ -180,6 +180,17 @@ mod tests {
|
||||
assert_eq!(DateTime::parse_offset("+99:00"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_offsets_are_rejected_without_panicking() {
|
||||
for offset in ["+1€", "-1€", "+€1", "-€1"] {
|
||||
assert_eq!(DateTime::parse_offset(offset), None);
|
||||
let date = format!("2024-06-01T13:45:59{offset}");
|
||||
let metadata = crate::Metadata { capture_time: DateTime::parse_iso("2024-06-01T13:45:59Z"), ..Default::default() };
|
||||
let xmp = crate::write_xmp(&metadata, None).replace("2024-06-01T13:45:59+00:00", &date);
|
||||
assert!(crate::parse_xmp(&xmp).unwrap().metadata.capture_time.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_roundtrip() {
|
||||
for s in ["2024-06-01T13:45:59", "2024-06-01T13:45:59.250+02:00", "1999-12-31T23:59:00-05:30", "2024-02-29T00:00:00Z"] {
|
||||
|
||||
@@ -17,6 +17,7 @@ mod gpx;
|
||||
mod iptc;
|
||||
pub mod tags;
|
||||
mod xmp;
|
||||
mod xmp_merge;
|
||||
|
||||
pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks};
|
||||
pub use datetime::DateTime;
|
||||
@@ -26,6 +27,7 @@ pub use iptc::parse_iptc;
|
||||
pub use lightcraft_geom::Orientation;
|
||||
pub use tags::{TagRow, file_tag_rows, tag_rows};
|
||||
pub use xmp::{CRS_NS, LC_NS, XmpData, XmpError, XmpValue, parse_xmp, write_xmp, write_xmp_lc};
|
||||
pub use xmp_merge::{MergeRules, merge_xmp};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
@@ -145,6 +145,11 @@ impl Node {
|
||||
const MAX_DEPTH: usize = 64;
|
||||
const MAX_NODES: usize = 200_000;
|
||||
|
||||
/// `prefix:local` with the prefix canonicalised by namespace URI (see [`NAMESPACES`]).
|
||||
pub(crate) fn canonical_name(qname: &str, scopes: &[Vec<(String, String)>]) -> String {
|
||||
canonical(qname, scopes)
|
||||
}
|
||||
|
||||
fn canonical(qname: &str, scopes: &[Vec<(String, String)>]) -> String {
|
||||
let (prefix, local) = qname.split_once(':').unwrap_or(("", qname));
|
||||
if prefix == "xml" || prefix == "xmlns" {
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
//! Merging a freshly written XMP packet into an existing one (a sidecar another application
|
||||
//! wrote, e.g. with its develop settings and edit history), keeping everything the writer does not
|
||||
//! own byte for byte.
|
||||
//!
|
||||
//! The existing packet is scanned once (namespace-resolved, with byte positions). Only top-level
|
||||
//! properties of the `rdf:Description` elements directly under `rdf:RDF` are considered, in
|
||||
//! attribute form (`xmp:Rating="3"`) and element form (`<dc:subject>…</dc:subject>`):
|
||||
//! - properties the writer owns ([`MergeRules::owned`]) are removed, and written from the fresh
|
||||
//! packet when it has them (so a cleared field is cleared in the file too);
|
||||
//! - properties owned only when stated ([`MergeRules::owned_if_present`]) are replaced when the
|
||||
//! fresh packet has them and kept otherwise;
|
||||
//! - any other property of the fresh packet (e.g. `xmp:CreatorTool`) is not written: the file
|
||||
//! keeps describing itself as its creator left it;
|
||||
//! - everything else — other namespaces (e.g. `crs:`), `xmpMM:History`, unknown properties,
|
||||
//! comments, the packet wrapper and padding — is copied unchanged.
|
||||
//!
|
||||
//! The written properties go into one new `rdf:Description` (declaring its own namespaces, so
|
||||
//! the existing prefix bindings never matter) just before `</rdf:RDF>`. A description left empty
|
||||
//! by the removals is dropped, so repeated saves don't accumulate. The result is checked by
|
||||
//! parsing it again: every written property must read back as in the fresh packet.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use quick_xml::events::Event;
|
||||
|
||||
use crate::xmp::{XmpError, canonical_name, parse_xmp};
|
||||
|
||||
/// Which properties [`merge_xmp`] takes from the fresh packet. Names are canonical
|
||||
/// `prefix:name` (prefixes as [`crate::parse_xmp`] reports them); `prefix:*` is a whole namespace.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct MergeRules<'a> {
|
||||
/// Always replaced: removed from the existing packet, written from the fresh one if stated.
|
||||
pub owned: &'a [&'a str],
|
||||
/// Replaced only when the fresh packet states them; otherwise the existing value stays.
|
||||
pub owned_if_present: &'a [&'a str],
|
||||
}
|
||||
|
||||
fn listed(list: &[&str], name: &str) -> bool {
|
||||
list.iter().any(|p| match p.strip_suffix('*') {
|
||||
Some(ns) => name.starts_with(ns),
|
||||
None => *p == name,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Attr {
|
||||
raw: String,
|
||||
name: String,
|
||||
/// The value as written (still escaped).
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Prop {
|
||||
name: String,
|
||||
start: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Desc {
|
||||
tag_start: usize,
|
||||
tag_end: usize,
|
||||
/// The element name as written (`rdf:Description`, or another prefix for the RDF namespace).
|
||||
raw_name: String,
|
||||
empty: bool,
|
||||
attrs: Vec<Attr>,
|
||||
props: Vec<Prop>,
|
||||
/// The end of `</rdf:Description>` (`tag_end` when self-closing).
|
||||
end: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Scan {
|
||||
descs: Vec<Desc>,
|
||||
/// Where the first `</rdf:RDF>` starts.
|
||||
rdf_close: Option<usize>,
|
||||
}
|
||||
|
||||
enum Kind {
|
||||
Other,
|
||||
Rdf,
|
||||
Desc(usize),
|
||||
Prop { desc: usize, start: usize, name: String },
|
||||
}
|
||||
|
||||
fn is_meta_attr(name: &str) -> bool {
|
||||
name.starts_with("xmlns")
|
||||
|| matches!(name, "rdf:about" | "rdf:ID" | "rdf:nodeID" | "xml:lang" | "rdf:parseType" | "rdf:resource" | "rdf:datatype")
|
||||
}
|
||||
|
||||
fn pos(r: &quick_xml::Reader<&[u8]>) -> usize {
|
||||
usize::try_from(r.buffer_position()).unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
/// Locate the descriptions, their top-level properties and `</rdf:RDF>` in a packet.
|
||||
fn scan(s: &str) -> Result<Scan, XmpError> {
|
||||
let mut reader = quick_xml::Reader::from_reader(s.as_bytes());
|
||||
reader.config_mut().check_end_names = true;
|
||||
let mut out = Scan::default();
|
||||
let mut stack: Vec<Kind> = Vec::new();
|
||||
let mut scopes: Vec<Vec<(String, String)>> = vec![vec![("xml".into(), "http://www.w3.org/XML/1998/namespace".into())]];
|
||||
let mut count = 0usize;
|
||||
loop {
|
||||
let before = pos(&reader);
|
||||
let ev = reader.read_event().map_err(|e| XmpError::Xml(e.to_string()))?;
|
||||
let after = pos(&reader);
|
||||
match ev {
|
||||
Event::Start(ref e) | Event::Empty(ref e) => {
|
||||
count += 1;
|
||||
if count > 200_000 || stack.len() > 64 {
|
||||
return Err(XmpError::TooComplex);
|
||||
}
|
||||
let empty = matches!(ev, Event::Empty(_));
|
||||
let mut scope = Vec::new();
|
||||
let mut raw_attrs = Vec::new();
|
||||
for a in e.attributes() {
|
||||
let a = a.map_err(|e| XmpError::Xml(e.to_string()))?;
|
||||
let k = String::from_utf8_lossy(a.key.as_ref()).into_owned();
|
||||
let v = String::from_utf8_lossy(&a.value).into_owned();
|
||||
if k == "xmlns" {
|
||||
scope.push((String::new(), v.clone()));
|
||||
} else if let Some(p) = k.strip_prefix("xmlns:") {
|
||||
scope.push((p.to_string(), v.clone()));
|
||||
}
|
||||
raw_attrs.push((k, v));
|
||||
}
|
||||
scopes.push(scope);
|
||||
let raw_name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
|
||||
let name = canonical_name(&raw_name, &scopes);
|
||||
let kind = match stack.last() {
|
||||
Some(Kind::Rdf) if name == "rdf:Description" => {
|
||||
let attrs = raw_attrs.into_iter().map(|(raw, value)| Attr { name: canonical_name(&raw, &scopes), raw, value }).collect();
|
||||
out.descs.push(Desc { tag_start: before, tag_end: after, raw_name, empty, attrs, props: Vec::new(), end: after });
|
||||
Kind::Desc(out.descs.len() - 1)
|
||||
}
|
||||
Some(Kind::Desc(i)) => Kind::Prop { desc: *i, start: before, name },
|
||||
_ if name == "rdf:RDF" && out.rdf_close.is_none() => Kind::Rdf,
|
||||
_ => Kind::Other,
|
||||
};
|
||||
if empty {
|
||||
scopes.pop();
|
||||
if let Kind::Prop { desc, start, name } = kind
|
||||
&& let Some(d) = out.descs.get_mut(desc)
|
||||
{
|
||||
d.props.push(Prop { name, start, end: after });
|
||||
}
|
||||
} else {
|
||||
stack.push(kind);
|
||||
}
|
||||
}
|
||||
Event::End(_) => {
|
||||
scopes.pop();
|
||||
match stack.pop() {
|
||||
Some(Kind::Prop { desc, start, name }) => {
|
||||
if let Some(d) = out.descs.get_mut(desc) {
|
||||
d.props.push(Prop { name, start, end: after });
|
||||
}
|
||||
}
|
||||
Some(Kind::Desc(i)) => {
|
||||
if let Some(d) = out.descs.get_mut(i) {
|
||||
d.end = after;
|
||||
}
|
||||
}
|
||||
Some(Kind::Rdf) => out.rdf_close = out.rdf_close.or(Some(before)),
|
||||
Some(Kind::Other) => {}
|
||||
None => return Err(XmpError::Xml("unbalanced end tag".into())),
|
||||
}
|
||||
}
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !stack.is_empty() {
|
||||
return Err(XmpError::Xml("unclosed element".into()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `start` moved back over the indentation and line break before it (so a removed property
|
||||
/// takes its line with it).
|
||||
fn line_start(s: &str, start: usize) -> usize {
|
||||
let b = s.as_bytes();
|
||||
let mut i = start;
|
||||
while i > 0 && matches!(b.get(i - 1), Some(b' ' | b'\t')) {
|
||||
i -= 1;
|
||||
}
|
||||
if i > 0 && b.get(i - 1) == Some(&b'\n') {
|
||||
i -= 1;
|
||||
if i > 0 && b.get(i - 1) == Some(&b'\r') {
|
||||
i -= 1;
|
||||
}
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Merge `fresh` (a packet as [`crate::write_xmp_lc`] writes it) into `existing` following
|
||||
/// `rules` (see the module docs). Fails — and the caller must not overwrite `existing` blindly —
|
||||
/// when `existing` isn't a well-formed XMP packet.
|
||||
pub fn merge_xmp(existing: &str, fresh: &str, rules: MergeRules) -> Result<String, XmpError> {
|
||||
let (bom, old) = match existing.strip_prefix('\u{feff}') {
|
||||
Some(rest) => ("\u{feff}", rest),
|
||||
None => ("", existing),
|
||||
};
|
||||
parse_xmp(old)?;
|
||||
let ex = scan(old)?;
|
||||
let rdf_close = ex.rdf_close.ok_or_else(|| XmpError::Xml("no rdf:RDF element".into()))?;
|
||||
let new = scan(fresh)?;
|
||||
let fresh_desc = new.descs.first().ok_or_else(|| XmpError::Xml("the new packet has no rdf:Description".into()))?;
|
||||
let fresh_names: BTreeSet<&str> = fresh_desc.props.iter().map(|p| p.name.as_str()).collect();
|
||||
let remove = |name: &str| listed(rules.owned, name) || (listed(rules.owned_if_present, name) && fresh_names.contains(name));
|
||||
let write = |name: &str| listed(rules.owned, name) || listed(rules.owned_if_present, name);
|
||||
|
||||
// edits on `old`: (start, end, replacement), applied back to front
|
||||
let mut edits: Vec<(usize, usize, String)> = Vec::new();
|
||||
for d in &ex.descs {
|
||||
let gone_attrs: Vec<&Attr> = d.attrs.iter().filter(|a| !is_meta_attr(&a.name) && remove(&a.name)).collect();
|
||||
let gone_props: Vec<&Prop> = d.props.iter().filter(|p| remove(&p.name)).collect();
|
||||
if gone_attrs.is_empty() && gone_props.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let attrs_left = d.attrs.iter().any(|a| !is_meta_attr(&a.name) && !remove(&a.name));
|
||||
if !attrs_left && gone_props.len() == d.props.len() {
|
||||
// nothing of its own left: drop the whole description
|
||||
edits.push((line_start(old, d.tag_start), d.end, String::new()));
|
||||
continue;
|
||||
}
|
||||
if !gone_attrs.is_empty() {
|
||||
let mut tag = format!("<{}", d.raw_name);
|
||||
for a in d.attrs.iter().filter(|a| is_meta_attr(&a.name) || !remove(&a.name)) {
|
||||
tag.push_str(&format!("\n {}=\"{}\"", a.raw, a.value.replace('"', """)));
|
||||
}
|
||||
tag.push_str(if d.empty { "/>" } else { ">" });
|
||||
edits.push((d.tag_start, d.tag_end, tag));
|
||||
}
|
||||
for p in gone_props {
|
||||
edits.push((line_start(old, p.start), p.end, String::new()));
|
||||
}
|
||||
}
|
||||
// the new description: its own namespace declarations, the properties to write
|
||||
let mut block = String::from("\n <rdf:Description rdf:about=\"\"\n xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"");
|
||||
for a in fresh_desc.attrs.iter().filter(|a| a.raw.starts_with("xmlns:") && a.raw != "xmlns:rdf") {
|
||||
block.push_str(&format!("\n {}=\"{}\"", a.raw, a.value));
|
||||
}
|
||||
block.push('>');
|
||||
let mut written = 0;
|
||||
for p in fresh_desc.props.iter().filter(|p| write(&p.name)) {
|
||||
let text = fresh.get(p.start..p.end).ok_or_else(|| XmpError::Xml("bad property span".into()))?;
|
||||
block.push_str("\n ");
|
||||
block.push_str(text);
|
||||
written += 1;
|
||||
}
|
||||
block.push_str("\n </rdf:Description>");
|
||||
if written > 0 {
|
||||
let at = old.get(..rdf_close).map_or(rdf_close, |h| h.trim_end().len());
|
||||
edits.push((at, at, block));
|
||||
}
|
||||
edits.sort_by_key(|e| (e.0, e.1));
|
||||
// overlapping edits can't happen (properties sit inside their description, which is either
|
||||
// dropped whole or edited piecewise), but never splice blindly
|
||||
let mut out = String::with_capacity(old.len() + 4096);
|
||||
out.push_str(bom);
|
||||
let mut at = 0;
|
||||
for (s, e, r) in &edits {
|
||||
if *s < at {
|
||||
return Err(XmpError::Xml("overlapping edits".into()));
|
||||
}
|
||||
out.push_str(old.get(at..*s).ok_or_else(|| XmpError::Xml("bad span".into()))?);
|
||||
out.push_str(r);
|
||||
at = *e;
|
||||
}
|
||||
out.push_str(old.get(at..).ok_or_else(|| XmpError::Xml("bad span".into()))?);
|
||||
|
||||
// read back: everything written must come out as written
|
||||
let merged = parse_xmp(&out)?;
|
||||
let want = parse_xmp(fresh)?;
|
||||
for (k, v) in &want.properties {
|
||||
let top = k.split('/').next().unwrap_or(k);
|
||||
if write(top) && merged.properties.get(k) != Some(v) {
|
||||
return Err(XmpError::Xml(format!("{k} did not survive the merge")));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const RULES: MergeRules =
|
||||
MergeRules { owned: &["xmp:Rating", "xmp:Label", "dc:subject", "dc:title", "lc:*"], owned_if_present: &["exif:DateTimeOriginal"] };
|
||||
|
||||
/// A sidecar as another raw developer writes it: attribute-form simple properties, develop
|
||||
/// settings, a structured history, an unknown namespace.
|
||||
const FOREIGN: &str = "<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>
|
||||
<x:xmpmeta xmlns:x=\"adobe:ns:meta/\" x:xmptk=\"Some Toolkit 1.0\">
|
||||
<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">
|
||||
<rdf:Description rdf:about=\"\"
|
||||
xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\"
|
||||
xmlns:xmpMM=\"http://ns.adobe.com/xap/1.0/mm/\"
|
||||
xmlns:stEvt=\"http://ns.adobe.com/xap/1.0/sType/ResourceEvent#\"
|
||||
xmlns:crs=\"http://ns.adobe.com/camera-raw-settings/1.0/\"
|
||||
xmlns:dc=\"http://purl.org/dc/elements/1.1/\"
|
||||
xmlns:foo=\"http://example.com/foo/\"
|
||||
xmp:Rating=\"2\"
|
||||
xmp:CreatorTool=\"Other App\"
|
||||
crs:Exposure2012=\"+0.50\"
|
||||
crs:Contrast2012=\"-12\"
|
||||
foo:Thing=\"a & b\">
|
||||
<xmpMM:History>
|
||||
<rdf:Seq>
|
||||
<rdf:li stEvt:action=\"saved\" stEvt:when=\"2026-01-02T03:04:05\"/>
|
||||
</rdf:Seq>
|
||||
</xmpMM:History>
|
||||
<crs:ToneCurvePV2012>
|
||||
<rdf:Seq>
|
||||
<rdf:li>0, 0</rdf:li>
|
||||
<rdf:li>255, 255</rdf:li>
|
||||
</rdf:Seq>
|
||||
</crs:ToneCurvePV2012>
|
||||
<dc:subject>
|
||||
<rdf:Bag>
|
||||
<rdf:li>old</rdf:li>
|
||||
</rdf:Bag>
|
||||
</dc:subject>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
";
|
||||
|
||||
/// With the packet's whitespace padding and trailer.
|
||||
fn foreign() -> String {
|
||||
format!("{FOREIGN}{}\n<?xpacket end=\"w\"?>", " ".repeat(100))
|
||||
}
|
||||
|
||||
fn fresh(rating: i8, keywords: &[&str]) -> String {
|
||||
let m = crate::Metadata {
|
||||
software: Some("LightCraft".into()),
|
||||
rating: Some(rating),
|
||||
keywords: keywords.iter().map(|k| k.to_string()).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
crate::write_xmp_lc(&m, &[("settings", "{\"a\":1}"), ("flag", "pick")])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_foreign_properties_and_replaces_owned_ones() {
|
||||
let out = merge_xmp(&foreign(), &fresh(4, &["new", "two"]), RULES).unwrap();
|
||||
let d = parse_xmp(&out).unwrap();
|
||||
let p = |k: &str| d.properties.get(k).cloned().unwrap_or_default();
|
||||
assert_eq!(p("xmp:Rating"), vec!["4"]);
|
||||
assert_eq!(p("dc:subject"), vec!["new", "two"]);
|
||||
assert_eq!(p("lc:settings"), vec!["{\"a\":1}"]);
|
||||
assert_eq!(p("lc:flag"), vec!["pick"]);
|
||||
assert_eq!(p("xmp:CreatorTool"), vec!["Other App"], "not ours: kept");
|
||||
assert_eq!(p("crs:Exposure2012"), vec!["+0.50"]);
|
||||
assert_eq!(p("foo:Thing").len(), 1);
|
||||
// untouched parts are byte for byte the same
|
||||
for part in [
|
||||
" <xmpMM:History>\n <rdf:Seq>\n <rdf:li stEvt:action=\"saved\" stEvt:when=\"2026-01-02T03:04:05\"/>\n </rdf:Seq>\n </xmpMM:History>",
|
||||
" <crs:ToneCurvePV2012>\n <rdf:Seq>\n <rdf:li>0, 0</rdf:li>",
|
||||
"crs:Contrast2012=\"-12\"",
|
||||
"foo:Thing=\"a & b\"",
|
||||
"<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n<x:xmpmeta xmlns:x=\"adobe:ns:meta/\" x:xmptk=\"Some Toolkit 1.0\">",
|
||||
&format!("</x:xmpmeta>\n{}\n<?xpacket end=\"w\"?>", " ".repeat(100)),
|
||||
] {
|
||||
assert!(out.contains(part), "lost {part:?} in\n{out}");
|
||||
}
|
||||
assert!(!out.contains("xmp:Rating=\"2\""));
|
||||
assert!(!out.contains("<rdf:li>old</rdf:li>"));
|
||||
// saving again changes only our part, and doesn't pile up descriptions
|
||||
let again = merge_xmp(&out, &fresh(1, &[]), RULES).unwrap();
|
||||
let d = parse_xmp(&again).unwrap();
|
||||
assert_eq!(d.properties.get("xmp:Rating"), Some(&vec!["1".to_string()]));
|
||||
assert!(!d.properties.contains_key("dc:subject"), "a cleared field is cleared");
|
||||
assert_eq!(again.matches("<rdf:Description").count(), 2, "{again}");
|
||||
assert_eq!(merge_xmp(&again, &fresh(1, &[]), RULES).unwrap(), again, "idempotent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_if_present_and_prefix_independence() {
|
||||
// the existing packet binds the namespaces to other prefixes, and has a capture time
|
||||
let ex = "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><r:RDF xmlns:r=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\
|
||||
<r:Description r:about=\"\" xmlns:a=\"http://ns.adobe.com/xap/1.0/\" xmlns:e=\"http://ns.adobe.com/exif/1.0/\" \
|
||||
a:Rating=\"5\" e:DateTimeOriginal=\"2020-01-01T00:00:00\"/></r:RDF></x:xmpmeta>";
|
||||
let out = merge_xmp(ex, &fresh(3, &[]), RULES).unwrap();
|
||||
let d = parse_xmp(&out).unwrap();
|
||||
assert_eq!(d.properties.get("xmp:Rating"), Some(&vec!["3".to_string()]));
|
||||
assert_eq!(d.properties.get("exif:DateTimeOriginal"), Some(&vec!["2020-01-01T00:00:00".to_string()]), "fresh has none: kept");
|
||||
let mut m = crate::Metadata { rating: Some(3), ..Default::default() };
|
||||
m.capture_time = crate::DateTime::parse_iso("2024-05-06T07:08:09");
|
||||
let out = merge_xmp(ex, &crate::write_xmp_lc(&m, &[]), RULES).unwrap();
|
||||
let d = parse_xmp(&out).unwrap();
|
||||
assert_eq!(d.properties.get("exif:DateTimeOriginal"), Some(&vec!["2024-05-06T07:08:09".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_what_it_cannot_read() {
|
||||
for bad in ["", "not xml at all", "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF", "<a><b></a>", "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"/>"] {
|
||||
assert!(merge_xmp(bad, &fresh(1, &[]), RULES).is_err(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use lightcraft_meta::{Metadata, extract, parse_iptc, parse_xmp, read_exif, write_xmp};
|
||||
use lightcraft_meta::{MergeRules, Metadata, extract, merge_xmp, parse_iptc, parse_xmp, read_exif, write_xmp};
|
||||
use proptest::prelude::*;
|
||||
|
||||
proptest! {
|
||||
@@ -21,6 +21,23 @@ proptest! {
|
||||
let _ = parse_xmp(&s);
|
||||
}
|
||||
|
||||
/// Merging into arbitrary (also damaged) packets never panics, and a merge that succeeds
|
||||
/// reads back with the written values.
|
||||
#[test]
|
||||
fn merge_never_panics(cut in 0usize..900, junk in ".{0,20}", rating in 0i8..=5) {
|
||||
let base = write_xmp(&Metadata { title: Some("t".into()), rating: Some(1), ..Default::default() }, Some("{}"));
|
||||
let mut s: String = base.chars().take(cut).collect();
|
||||
s.push_str(&junk);
|
||||
s.extend(base.chars().skip(cut));
|
||||
let fresh = write_xmp(&Metadata { rating: Some(rating), ..Default::default() }, Some("{\"x\":1}"));
|
||||
let rules = MergeRules { owned: &["xmp:Rating", "dc:title", "lc:*"], owned_if_present: &[] };
|
||||
if let Ok(out) = merge_xmp(&s, &fresh, rules) {
|
||||
let d = parse_xmp(&out).unwrap();
|
||||
prop_assert_eq!(d.metadata.rating, Some(rating));
|
||||
prop_assert_eq!(d.lc_settings.as_deref(), Some("{\"x\":1}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_text_roundtrips(title in "[a-zA-Z0-9 &<>\"'éü€;|/\\\\]{1,40}", kw in proptest::collection::vec("[a-zA-Z0-9 &<>\"'|]{1,12}", 0..5), rating in -1i8..=5) {
|
||||
let title = title.trim().to_string();
|
||||
|
||||
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user