Compare commits
197
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fb8ad4dcd | ||
|
|
dc67d348e6 | ||
|
|
7f386aa258 | ||
|
|
399aedd23a | ||
|
|
b5fcf58e55 | ||
|
|
261994acbc | ||
|
|
b936c1a683 | ||
|
|
cab6af8608 | ||
|
|
4eb9b565e0 | ||
|
|
ccdd9e45d1 | ||
|
|
24c800f46f | ||
|
|
e471e7c346 | ||
|
|
aaec05dd11 | ||
|
|
d808749792 | ||
|
|
c03a67c59f | ||
|
|
cd8d2b96a2 | ||
|
|
a12fabd588 | ||
|
|
99fa19099b | ||
|
|
bac4666e7f | ||
|
|
b7f60a1fac | ||
|
|
4d506494d9 | ||
|
|
90fe6d1616 | ||
|
|
fa630a233a | ||
|
|
9e9e45b3c7 | ||
|
|
259de3d9df | ||
|
|
53584512f4 | ||
|
|
e4737f68c7 | ||
|
|
21de86b3df | ||
|
|
0b101be155 | ||
|
|
7d2bb5e5ec | ||
|
|
3a9fe13d3a | ||
|
|
2565f893c4 | ||
|
|
4e2584e559 | ||
|
|
cb173c22dc | ||
|
|
98da2a0229 | ||
|
|
1ece9341d6 | ||
|
|
6d52e0744a | ||
|
|
dd783b1d38 | ||
|
|
81e28f25c9 | ||
|
|
b5b6f8caa8 | ||
|
|
7e21d185b8 | ||
|
|
70db2f2320 | ||
|
|
74b9129a05 | ||
|
|
36c6920f55 | ||
|
|
6d22d1235b | ||
|
|
5f9548a7ba | ||
|
|
0e188f6a40 | ||
|
|
a66eebf778 | ||
|
|
e3dc820d89 | ||
|
|
a6cedcc9e8 | ||
|
|
493600743d | ||
|
|
e4ab8422b4 | ||
|
|
0b5ef3fe11 | ||
|
|
38d2ff07c9 | ||
|
|
6c19090fde | ||
|
|
5b80fc6bcf | ||
|
|
0a474cc06e | ||
|
|
2928880e81 | ||
|
|
14a03052de | ||
|
|
7f6e4e48f7 | ||
|
|
912f56cac8 | ||
|
|
0e254bfbc6 | ||
|
|
8d8178dc0a | ||
|
|
90c63af951 | ||
|
|
69640df8a7 | ||
|
|
a47afdc1f4 | ||
|
|
2472021091 | ||
|
|
b4a192a54e | ||
|
|
60828c3eaf | ||
|
|
ebe54f934e | ||
|
|
4256865cf4 | ||
|
|
57c9e252c1 | ||
|
|
46c4a2709d | ||
|
|
198286e589 | ||
|
|
6f33aacd22 | ||
|
|
5d13d59520 | ||
|
|
0732edc64f | ||
|
|
82e827b9d9 | ||
|
|
88c1e768f1 | ||
|
|
cc425d0cea | ||
|
|
6277af55bf | ||
|
|
b8df5f472c | ||
|
|
30f9eb3674 | ||
|
|
7177b9b9f3 | ||
|
|
c60482f753 | ||
|
|
c1861e5416 | ||
|
|
88d9b7196d | ||
|
|
47f40eb2d5 | ||
|
|
39e04ae7ab | ||
|
|
ee0b0ef67e | ||
|
|
169a4ed059 | ||
|
|
f7c0b9e49f | ||
|
|
b7e37a388d | ||
|
|
87dd1c201a | ||
|
|
15feb6e1fc | ||
|
|
03bc1469f9 | ||
|
|
4e012a59a6 | ||
|
|
8dd3afce51 | ||
|
|
4b57d5dc5a | ||
|
|
fc4b054fa4 | ||
|
|
d614dc72bd | ||
|
|
024e983a7e | ||
|
|
629e39380e | ||
|
|
1ba0421a85 | ||
|
|
8734e95dc8 | ||
|
|
4159ceb1b0 | ||
|
|
2694d7089f | ||
|
|
88f38c7e2a | ||
|
|
df574a537a | ||
|
|
5bc923b4f9 | ||
|
|
71c8382b10 | ||
|
|
0a952b3536 | ||
|
|
f9cfe15db6 | ||
|
|
ed0a2a4f07 | ||
|
|
0801219b17 | ||
|
|
e716ede8e4 | ||
|
|
2e48f25997 | ||
|
|
d716d74af5 | ||
|
|
a1c5e2dad1 | ||
|
|
c816ca8e35 | ||
|
|
62325bd45e | ||
|
|
4d5504cf16 | ||
|
|
aec820033b | ||
|
|
b90c06b347 | ||
|
|
52f64ce0b9 | ||
|
|
767ed15adc | ||
|
|
1c025fbb97 | ||
|
|
9b41e33a24 | ||
|
|
0143704c7d | ||
|
|
9da8163ae2 | ||
|
|
46f5d212e7 | ||
|
|
eba0b9723b | ||
|
|
5372309ad8 | ||
|
|
b63ea8f5c2 | ||
|
|
2dda7cf842 | ||
|
|
be87e1b6e7 | ||
|
|
97e34434d0 | ||
|
|
30192541ea | ||
|
|
c4d7c00e74 | ||
|
|
3fe0bef364 | ||
|
|
590b387a53 | ||
|
|
51c878ddd8 | ||
|
|
034bb6e324 | ||
|
|
41f9ae0c2b | ||
|
|
e2a941887e | ||
|
|
e574839724 | ||
|
|
f0632e7022 | ||
|
|
d3c6dc0aa8 | ||
|
|
ce165dfa4a | ||
|
|
1237e198d5 | ||
|
|
2b2ebfc7ca | ||
|
|
6b6e756830 | ||
|
|
e9931d6871 | ||
|
|
b4dc8c10ee | ||
|
|
73cf0d50bb | ||
|
|
c7f5664ce2 | ||
|
|
dbc05bedc7 | ||
|
|
f2f7a1cb26 | ||
|
|
7f3d9ce25f | ||
|
|
f73aedcc94 | ||
|
|
928c2c974f | ||
|
|
ee9a989834 | ||
|
|
94fe30015c | ||
|
|
e99ce5a578 | ||
|
|
6aebd48eec | ||
|
|
030ccf9782 | ||
|
|
6b0274120b | ||
|
|
855d3a7959 | ||
|
|
fb01966a00 | ||
|
|
520d7b8616 | ||
|
|
a15bd574f9 | ||
|
|
b040f9bb8f | ||
|
|
036993f194 | ||
|
|
7aab3fd97f | ||
|
|
6de619ca9b | ||
|
|
89446df6a8 | ||
|
|
b310de1bd6 | ||
|
|
17daca8a87 | ||
|
|
a72118ec6e | ||
|
|
c58128c36b | ||
|
|
94780ed299 | ||
|
|
cabe5b7399 | ||
|
|
7fa7506db4 | ||
|
|
6bd64e4ad4 | ||
|
|
9089092969 | ||
|
|
6408ccf244 | ||
|
|
517c745fad | ||
|
|
30225fb6a0 | ||
|
|
b35487add2 | ||
|
|
65bc68fd33 | ||
|
|
d5be201431 | ||
|
|
d379e780cc | ||
|
|
b52f43d1d5 | ||
|
|
fefd61c967 | ||
|
|
a5966f9b2e | ||
|
|
4e0f8ced0c | ||
|
|
6468bfc06b |
No files matched your search
@@ -54,4 +54,15 @@ jobs:
|
||||
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"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.lightcraft.yml')); assert m['id'] == 'ai.storyteller.lightcraft' and m['command'] == 'lightcraft'"
|
||||
python3 - <<'EOF'
|
||||
import yaml
|
||||
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.lightcraft.yml"))
|
||||
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.lightcraft.bundle.yml"))
|
||||
for m in (src, bundle):
|
||||
assert m["id"] == "ai.storyteller.lightcraft" and m["command"] == "lightcraft", m["id"]
|
||||
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
|
||||
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
|
||||
print("Flatpak manifests ok")
|
||||
EOF
|
||||
- name: FreeBSD tarball layout (stub binaries)
|
||||
run: bash packaging/freebsd/package.sh --dry-run
|
||||
+103
-11
@@ -1,9 +1,12 @@
|
||||
# Release pipeline: every push to `release` builds signed installers for every platform and
|
||||
# creates or updates the draft GitHub Release "LightCraft v<version>". See docs/releasing.md.
|
||||
# Release pipeline: every push to `release` builds platform artifacts (signed where credentials
|
||||
# are configured) and creates or updates the draft GitHub Release "LightCraft v<version>".
|
||||
# See docs/releasing.md.
|
||||
#
|
||||
# All jobs run in the `release` environment (restricted to the `release` branch), which holds
|
||||
# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a
|
||||
# warning, never a failed build.
|
||||
# The signing jobs (macOS, Windows) and the draft-release job run in the `release` environment
|
||||
# (restricted to the `release` branch), which holds the signing secrets. Every secret is optional:
|
||||
# missing ones produce unsigned artifacts and a warning, never a failed build. The jobs that sign
|
||||
# nothing (version, Linux, Flatpak, FreeBSD, web) run without it, so a dispatch on any branch is
|
||||
# a dry run of them; the release job needs the signed jobs, so a branch run never drafts a release.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -35,7 +38,7 @@ jobs:
|
||||
version:
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
# No `environment: release` here or on the other jobs that sign nothing: no release secrets.
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
@@ -63,7 +66,7 @@ jobs:
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15
|
||||
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
|
||||
environment: release
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
@@ -173,7 +176,6 @@ jobs:
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
|
||||
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -197,7 +199,7 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
|
||||
desktop-file-utils appstream python3-yaml
|
||||
desktop-file-utils appstream python3-yaml zsync
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
@@ -222,6 +224,9 @@ jobs:
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
# Update information is embedded, and the .zsync for delta updates sits beside it.
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -229,11 +234,98 @@ jobs:
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
|
||||
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
- name: Install flatpak-builder
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
|
||||
# `appstreamcli compose`, which renders the scalable icon and fails without it.
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/lightcraft-$LIGHTCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
|
||||
# .github/workflows/freebsd.yml.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
path: craft-fonts
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Passed into the VM by name; never templated into the script below.
|
||||
envs: "LIGHTCRAFT_VERSION LIGHTCRAFT_BUILD_DATE LIGHTCRAFT_BUILD_SHA INPUT_VERSION"
|
||||
# The FreeBSD CI packages, plus bash for the packaging script.
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM (copying the
|
||||
# target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
|
||||
export CARGO_TARGET_DIR=/var/tmp/lightcraft-target CARGO_BUILD_JOBS=4
|
||||
if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$LIGHTCRAFT_VERSION"; fi
|
||||
# craft-fonts was checked out next to the sources and synced into the VM with them.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/lightcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/lightcraft$|/bin/lightcraft-cli$|/share/applications/ai\.storyteller\.lightcraft\.desktop$|/share/doc/lightcraft/LICENSE-MIT$'
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
@@ -275,7 +367,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, web]
|
||||
needs: [version, macos, windows, linux, flatpak, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
|
||||
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
|
||||
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
|
||||
# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.)
|
||||
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
|
||||
name: Windows ARM64
|
||||
|
||||
on:
|
||||
|
||||
@@ -8,3 +8,9 @@ plan/
|
||||
.DS_Store
|
||||
.claude/
|
||||
/craft-fonts
|
||||
# nix build / nix develop
|
||||
/result
|
||||
/result-*
|
||||
# model weights are never committed (SAM 3: docs/ai-masks.md)
|
||||
*.safetensors
|
||||
*.safetensors.part
|
||||
@@ -1,6 +1,6 @@
|
||||
# LightCraft — instructions for agents
|
||||
|
||||
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../printcraft` (Acrobat), `../photocraft` (Photoshop), `../drawcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
|
||||
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../pdfcraft` (Acrobat), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
|
||||
@@ -63,10 +63,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
- **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not.
|
||||
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
|
||||
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL);
|
||||
Japanese and Chinese fonts come from craft-fonts (below). **One exception (maintainer decision, 2026-10-07): Noto
|
||||
Sans/Serif CJK** (Google-branded, OFL, co-developed with Adobe as Source Han) is allowed via craft-fonts for Chinese
|
||||
text, because nearly every OFL Chinese face derives from it. Use it unmodified under its OFL; this does not open the
|
||||
door to Source Han under Adobe's name or to any other Adobe asset.
|
||||
Japanese fonts come from craft-fonts (below).
|
||||
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
|
||||
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
|
||||
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
|
||||
@@ -75,7 +72,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
|
||||
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
|
||||
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI
|
||||
(`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset,
|
||||
(`theme::font_definitions`) and the export watermark renderer use its Japanese faces as fallbacks after Inter. Unset,
|
||||
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build
|
||||
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
|
||||
without it; the FreeBSD CI job runs them with it. Rules: craftrules
|
||||
@@ -122,4 +119,25 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
## Map of the code
|
||||
`geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine`
|
||||
→ `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`).
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` +
|
||||
`nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the
|
||||
desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not
|
||||
in CI: it may lag behind the workspace; see README → Quick start.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
|
||||
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
themselves). See `docs/contributors.md`.
|
||||
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
@@ -1,6 +1,6 @@
|
||||
# LightCraft — instructions for agents
|
||||
|
||||
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../printcraft` (Acrobat), `../photocraft` (Photoshop), `../drawcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
|
||||
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../pdfcraft` (Acrobat), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
|
||||
@@ -63,10 +63,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
- **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not.
|
||||
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
|
||||
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL);
|
||||
Japanese and Chinese fonts come from craft-fonts (below). **One exception (maintainer decision, 2026-10-07): Noto
|
||||
Sans/Serif CJK** (Google-branded, OFL, co-developed with Adobe as Source Han) is allowed via craft-fonts for Chinese
|
||||
text, because nearly every OFL Chinese face derives from it. Use it unmodified under its OFL; this does not open the
|
||||
door to Source Han under Adobe's name or to any other Adobe asset.
|
||||
Japanese fonts come from craft-fonts (below).
|
||||
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
|
||||
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
|
||||
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
|
||||
@@ -75,7 +72,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
|
||||
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
|
||||
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI
|
||||
(`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset,
|
||||
(`theme::font_definitions`) and the export watermark renderer use its Japanese faces as fallbacks after Inter. Unset,
|
||||
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build
|
||||
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
|
||||
without it; the FreeBSD CI job runs them with it. Rules: craftrules
|
||||
@@ -122,4 +119,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
## Map of the code
|
||||
`geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine`
|
||||
→ `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`).
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` +
|
||||
`nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the
|
||||
desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not
|
||||
in CI: it may lag behind the workspace; see README → Quick start.
|
||||
Generated
+1483
-30
File diff suppressed because it is too large.
Load diff
+7
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.1"
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -29,6 +29,8 @@ lightcraft-merge = { path = "crates/merge" }
|
||||
lightcraft-engine = { path = "crates/engine" }
|
||||
lightcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
lightcraft-mcp = { path = "crates/mcp" }
|
||||
lightcraft-fetch = { path = "crates/fetch" }
|
||||
lightcraft-segment = { path = "crates/segment" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -62,6 +64,10 @@ jxl-oxide = { version = "0.12", default-features = false }
|
||||
ravif = { version = "0.13", default-features = false, features = ["threading"] } # no "asm": it needs nasm (non-Rust)
|
||||
|
||||
# gpu (crates/gpu; native backends only, no GL: everything stays pure Rust)
|
||||
# SAM 3 inference (lightcraft-segment). 0.9.2: later releases pull `tokenizers` with the
|
||||
# Oniguruma C library into candle-core, and the product is pure Rust.
|
||||
candle-core = { version = "=0.9.2", default-features = false }
|
||||
candle-nn = { version = "=0.9.2", default-features = false }
|
||||
wgpu = { version = "30", default-features = false, features = ["std", "parking_lot", "metal", "vulkan", "dx12", "wgsl"] }
|
||||
pollster = "1"
|
||||
|
||||
|
||||
@@ -15,6 +15,18 @@ 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.
|
||||
|
||||
crates/segment (lightcraft-segment) ports the SAM 3 model code of Hugging Face Transformers
|
||||
(https://github.com/huggingface/transformers, src/transformers/models/sam3 and sam3_tracker),
|
||||
Copyright The HuggingFace Team and Meta Platforms, Inc., licensed under the Apache License 2.0.
|
||||
The port was modified by the LightCraft contributors (2026): translated from Python/PyTorch to
|
||||
Rust on candle and restructured; each ported file says so. Because of this, crates/segment is
|
||||
licensed under the Apache License 2.0 only (LICENSE-APACHE), not under the MIT option.
|
||||
|
||||
The SAM 3 weights are not part of LightCraft and are never distributed with it. A user downloads
|
||||
the facebook/sam3 checkpoint only when they choose to (in the app, or by hand), under Meta's SAM
|
||||
License (https://github.com/facebookresearch/sam3/blob/main/LICENSE), a separate, non-OSI
|
||||
licence. See docs/ai-masks.md.
|
||||
|
||||
Builds made with craft-fonts (the optional CRAFT_FONTS_DIR build input; all official releases)
|
||||
also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md
|
||||
(the Japanese fonts BIZ UDPGothic, BIZ UDMincho and Shippori Mincho; the web build embeds only BIZ
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
<a href="#feature-status">Status</a> ·
|
||||
<a href="#quick-start">Quick start</a> ·
|
||||
<a href="ROADMAP.md">Roadmap</a> ·
|
||||
<a href="#downloads">Downloads</a> ·
|
||||
<a href="#the-crafting-apps">Crafting Apps</a>
|
||||
</p>
|
||||
|
||||
@@ -200,7 +201,7 @@ A library that stays out of your way: **All Photos**, **Recently Added**, **Pick
|
||||
**Folders**, and **Recently Deleted**. Rate with <kbd>0</kbd>–<kbd>5</kbd>, flag with <kbd>P</kbd> / <kbd>X</kbd> /
|
||||
<kbd>U</kbd>, colour-label with <kbd>6</kbd>–<kbd>9</kbd>. Search understands fields:
|
||||
`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains`. Every view sorts by capture date,
|
||||
import date, edit date, name, rating or size. The justified **Photo Grid** and **Square Grid** views are virtualized,
|
||||
import date, edit date, name, rating, size or at random (a stable shuffle; View → Sort → Reshuffle for a new one). The justified **Photo Grid** and **Square Grid** views are virtualized,
|
||||
so they stay smooth whether you have forty photos or forty thousand.
|
||||
|
||||
<table>
|
||||
@@ -233,11 +234,11 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
{"method": "ui.screenshot", "params": {"path": "after.png"}}
|
||||
```
|
||||
|
||||
- **91 engine commands** and **48 UI commands.** List them all with `engine.commands`; read every slider's range,
|
||||
default and current value with `develop.controls`.
|
||||
- **MCP server.** `lightcraft-cli mcp` gives Claude (or any MCP client) ~100 tools: import, query, develop, mask,
|
||||
render (returned as an image), export. It runs headless, or attached to the running app with screenshots, clicks
|
||||
and gestures. See [docs/mcp.md](docs/mcp.md).
|
||||
- **Command registry.** `engine.commands` lists the available commands; `develop.controls` lists every slider's
|
||||
range, default and current value.
|
||||
- **MCP server.** `lightcraft-cli mcp` exposes the command registry to Claude (or any MCP client), alongside
|
||||
helpers for import, query, develop, mask, render (returned as an image) and export. It runs headless, or attached
|
||||
to the running app with screenshots, clicks and gestures. See [docs/mcp.md](docs/mcp.md).
|
||||
|
||||
```sh
|
||||
cargo build --release -p lightcraft-cli
|
||||
@@ -258,8 +259,8 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
## Fast, native, private
|
||||
|
||||
- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans,
|
||||
Panasonic RW2, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP, PSD
|
||||
composites and JPEG XL open today.
|
||||
Panasonic RW2 / Leica RWL, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP,
|
||||
PSD composites and JPEG XL open today.
|
||||
- **Scene-referred & wide-gamut.** Linear Rec.2020 float internally, Bradford-adapted white balance, gamut mapping
|
||||
instead of clipping, a filmic shoulder for raw and pixel-exact pass-through for JPEGs you haven't touched.
|
||||
- **Resolution-independent edits.** Radii and brush sizes are relative to the image, so a 400 px preview, your
|
||||
@@ -301,9 +302,9 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
|
||||
| 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) |
|
||||
| Camera colour: DNG files use their own matrices | ✅ · our own calibration for other raws ⬜ (top priority; ARW, NEF and RW2 start from a look fitted to their own JPEG, other raws from a neutral fallback) |
|
||||
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
|
||||
| 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 ⬜ |
|
||||
| RAW: DNG, CR2, ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), 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 / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR export ⬜ |
|
||||
@@ -311,7 +312,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
|
||||
| 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) ⬜ |
|
||||
| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files and Panasonic / Leica RW2 / RWL distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
|
||||
| Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ |
|
||||
| GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 |
|
||||
| AI: segmentation masks, AI denoise, super resolution, faces; HDR editing; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) |
|
||||
@@ -334,6 +335,14 @@ cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light
|
||||
cargo xtask ci # fmt, clippy, tests, layering, wasm checks
|
||||
```
|
||||
|
||||
CI defaults to line-table debug information and one build job per 1.5 GB of RAM
|
||||
available when it starts (at most one per CPU; 4 if memory can't be read), so an
|
||||
8 GB machine with 6 GB free builds with 4, and a busy machine with little free
|
||||
builds with fewer. Test threads follow, capped at 4. Full-debuginfo linkers, one
|
||||
per CPU, otherwise exhaust RAM before any test runs. GPU coverage is unchanged. Explicit
|
||||
`CARGO_PROFILE_DEV_DEBUG`, `CARGO_BUILD_JOBS` and `RUST_TEST_THREADS` settings
|
||||
override these defaults. Ordinary development commands keep their own settings.
|
||||
|
||||
**Chinese and Japanese text** need the shared font repo, an optional build input (official releases always include it):
|
||||
|
||||
```sh
|
||||
@@ -347,9 +356,47 @@ repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftru
|
||||
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語) or **Settings → General →
|
||||
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
|
||||
|
||||
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
|
||||
(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`, by default `~/.config/lightcraft/logs/`; macOS
|
||||
`~/Library/Application Support/LightCraft/logs/`; Windows `%APPDATA%\LightCraft\logs\`), never in the library. A
|
||||
launch from a desktop menu or the Dock has no terminal, so attach this file to a bug report. Each start moves the
|
||||
previous log to `lightcraft.1.log` and that one to `lightcraft.2.log`, so the log of a run that crashed survives the
|
||||
next start; the file stops growing at 16 MiB, `--version` and `--help` write none, and runs with
|
||||
`LIGHTCRAFT_NO_PREFS` log to standard error only. By default LightCraft's own crates log at `info` and everything else
|
||||
at `warn`. `LIGHTCRAFT_LOG=info` or `debug` works as before (that level for LightCraft's own crates, warnings and
|
||||
errors from the rest; any other value: warnings and errors only) and wins over `RUST_LOG`, which otherwise replaces
|
||||
the default with env_logger-style directives such as `RUST_LOG=debug` or `RUST_LOG=warn,lightcraft_pipeline=trace` (a
|
||||
directive ending in `*` covers every target starting with it, as in `lightcraft*=debug`). Panics are recorded there
|
||||
too, and still in `lightcraft-panics.log` in the temp folder. The logger is `apps/lightcraft/src/logging.rs`.
|
||||
|
||||
The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI
|
||||
(`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md).
|
||||
|
||||
**Nix** builds the desktop app and `lightcraft-cli` (the Nix build always includes the craft-fonts input, so Japanese
|
||||
text has glyphs):
|
||||
|
||||
```sh
|
||||
nix run github:storytold/lightcraft # the desktop app
|
||||
nix build github:storytold/lightcraft # → ./result/bin/{lightcraft,lightcraft-cli}
|
||||
nix develop github:storytold/lightcraft # rust toolchain + native deps + fonts
|
||||
```
|
||||
|
||||
In a flake configuration (NixOS, home-manager, nix-darwin):
|
||||
|
||||
```nix
|
||||
# flake.nix
|
||||
inputs.lightcraft.url = "github:storytold/lightcraft";
|
||||
# optional: build against your own nixpkgs instead of the one LightCraft pins
|
||||
# inputs.lightcraft.inputs.nixpkgs.follows = "nixpkgs";
|
||||
|
||||
# then, in a NixOS or home-manager module (where `inputs` is in scope):
|
||||
nixpkgs.overlays = [ inputs.lightcraft.overlays.default ]; # makes `pkgs.lightcraft` available
|
||||
environment.systemPackages = [ pkgs.lightcraft ]; # home-manager: home.packages = [ pkgs.lightcraft ];
|
||||
```
|
||||
|
||||
`nix build` installs the same desktop file, hicolor icons and AppStream metadata as the .deb/.rpm, and runs
|
||||
`cargo test --workspace` as its check phase (skip it with `pkgs.lightcraft.overrideAttrs { doCheck = false; }`).
|
||||
|
||||
**Keyboard:** <kbd>G</kbd> grid · <kbd>D</kbd> detail · <kbd>E</kbd> edit · <kbd>C</kbd> crop · <kbd>M</kbd> masking ·
|
||||
<kbd>Shift</kbd>+<kbd>P</kbd> presets · <kbd>\\</kbd> original · <kbd>Y</kbd> before/after · <kbd>Z</kbd> zoom ·
|
||||
<kbd>J</kbd> clipping · <kbd>⌘Z</kbd> undo · <kbd>⌘/</kbd> all shortcuts.
|
||||
@@ -381,6 +428,53 @@ Questions, ideas or a bug you'd like to talk through first? Bring them to [Disco
|
||||
|
||||
<br>
|
||||
|
||||
## Downloads
|
||||
|
||||
**New to LightCraft?** Download it from the [LightCraft page on getartcraft.com](https://getartcraft.com/apps/lightcraft). That's the easiest way to install it.
|
||||
|
||||
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/lightcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/lightcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
|
||||
### Windows
|
||||
|
||||
| Build | Installer | Portable |
|
||||
|---|---|---|
|
||||
| x64 (64-bit Intel/AMD) | `lightcraft-<ver>-windows-x64.msi` | `lightcraft-<ver>-windows-x64-portable.zip` |
|
||||
| arm64 (Snapdragon and other ARM PCs) | `lightcraft-<ver>-windows-arm64.msi` | `lightcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `lightcraft-<ver>-windows-x86.msi` | `lightcraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Installers and executables are code-signed.
|
||||
|
||||
### macOS
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| App, universal (Apple silicon + Intel) | `lightcraft-<ver>-macos-universal.dmg` | Signed and notarized |
|
||||
| Command-line tool, universal | `lightcraft-cli-<ver>-macos-universal.zip` | Signed and notarized |
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | aarch64 (ARM64) | Notes |
|
||||
|---|---|---|---|
|
||||
| AppImage | `lightcraft-<ver>-linux-x86_64.AppImage` | `lightcraft-<ver>-linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
|
||||
| Flatpak | `lightcraft-<ver>-linux-x86_64.flatpak` | `lightcraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
|
||||
| Debian/Ubuntu | `lightcraft-<ver>-linux-x86_64.deb` | `lightcraft-<ver>-linux-aarch64.deb` | |
|
||||
| Fedora/RHEL/openSUSE | `lightcraft-<ver>-linux-x86_64.rpm` | `lightcraft-<ver>-linux-aarch64.rpm` | |
|
||||
| Tarball | `lightcraft-<ver>-linux-x86_64.tar.gz` | `lightcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| Build | File |
|
||||
|---|---|
|
||||
| x86_64 | `lightcraft-<ver>-freebsd-x86_64.tar.gz` |
|
||||
|
||||
### Web (WebAssembly)
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| Static site | `lightcraft-web-<ver>.zip` | Runs in a modern browser; host it on any static server |
|
||||
|
||||
<br>
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
LightCraft is one of the **Crafting Apps**: free, open-source creative tools from the
|
||||
@@ -393,7 +487,7 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | **Photo library and raw development · you are here** | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
|
||||
|
||||
+33
-22
@@ -10,7 +10,7 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
|
||||
[`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
|
||||
**In one line:** the checklist says **79.7%** (P0 98.2%, P1 95.6%, P2 41.6%), 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.
|
||||
|
||||
@@ -22,9 +22,9 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
||||
|
||||
| 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. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). 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 and NEF have 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) |
|
||||
| **Feature checklist** | 79.7% | 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 | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
|
||||
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 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, NEF and RW2 have 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 ⬜ |
|
||||
@@ -36,19 +36,19 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
||||
| 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 and NEF preview estimates are only a starting point) |
|
||||
| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); Panasonic compacts and kit zooms also miss their embedded distortion correction |
|
||||
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only (CR3: the full-size JPEG with full metadata) |
|
||||
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
|
||||
| Relies on AI (masks, denoise) | ~25% | No segmentation or AI denoise models |
|
||||
| Relies on AI (masks, denoise) | ~25% | Object / Describe masks via optional SAM 3 (download not yet hosted); no AI denoise |
|
||||
|
||||
## 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 and Nikon NEF have the file-local fit (matrix + tone
|
||||
curve from their own JPEG); generalise it to the other makes' raws (RW2, PEF, ORF…), then validate fidelity.
|
||||
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, RW2 v4, NEF
|
||||
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF and Panasonic RW2 have the file-local fit
|
||||
(matrix + tone curve from their own JPEG); generalise it to the other makes' raws (PEF, ORF…), then validate fidelity.
|
||||
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, 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
|
||||
@@ -57,7 +57,9 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par
|
||||
(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.
|
||||
segmentation masks and denoise; then pure-Rust inference. Unblocks M12 and Enhance. Object / Describe masks now
|
||||
run Meta's SAM 3 in pure Rust (optional, user-consented download under the non-OSI SAM License; CDN mirrors still
|
||||
to be set up, docs/ai-masks.md); Subject / Sky / People and Enhance are still open.
|
||||
7. **Then:** HDR (Q), the Classic output modules (Print first, then Map view, Book, Slideshow), video (R), localisation
|
||||
and accessibility.
|
||||
|
||||
@@ -65,6 +67,10 @@ Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29
|
||||
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).
|
||||
|
||||
Image navigation now supports native pinch zoom and two-finger pan in Detail, Compare and Reference views.
|
||||
The whole-image preview still has a configurable size cap (2,560 px default), so 100% on larger photos does not
|
||||
yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-VIEW-ZOOM).
|
||||
|
||||
## Milestones
|
||||
|
||||
**Status legend:** ✅ done · 🚧 in progress · ⬜ not started
|
||||
@@ -82,7 +88,7 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help
|
||||
| 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 ✅; **camera colour calibration** 🚧 (guarded ARW and NEF preview fitting; measured database still missing), CR3, compressed 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 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF and RW2 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 | ⬜ |
|
||||
@@ -96,10 +102,10 @@ parity` prints this line on every run, so it stays current):
|
||||
|
||||
| Scope | Weighted completion | Rows | 2026-10-02 |
|
||||
|---|---:|---:|---:|
|
||||
| P0 (core) | **98.5%** | 200 | 98.7% |
|
||||
| P1 (important parity) | **95.9%** | 147 | 94.4% |
|
||||
| P2 (later / AI / niche, incl. Classic modules) | **39.2%** | 158 | 29.4% |
|
||||
| **All in-scope rows** | **79.2%** | 505 | 75.6% |
|
||||
| P0 (core) | **98.2%** | 200 | 98.7% |
|
||||
| P1 (important parity) | **95.6%** | 149 | 94.4% |
|
||||
| P2 (later / AI / niche, incl. Classic modules) | **41.6%** | 160 | 29.4% |
|
||||
| **All in-scope rows** | **79.7%** | 509 | 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.
|
||||
@@ -116,7 +122,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 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 |
|
||||
| Raw codecs: CR3 (CRX), compressed ORF / RAF, NEF lossy-after-split, 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 |
|
||||
@@ -160,10 +166,15 @@ The milestone estimates in the table above were made before work started and are
|
||||
|
||||
## Raw format coverage and known gaps
|
||||
|
||||
Sony ILCE-7M4 downsized lossless ARW (subsampled YCbCr 4:2:0 / 4:2:2 tiles) now decodes to linear RGB;
|
||||
private 3:2 & 4:3 examples verified through native rendering & full-resolution 16-bit export.
|
||||
|
||||
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; as-shot white balance and black level of pre-2017 bodies from the enciphered
|
||||
maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), 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
|
||||
X-Trans), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
|
||||
packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from
|
||||
118 bodies, `crates/raw/src/vendor/rw2.rs`), 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.
|
||||
|
||||
@@ -172,14 +183,11 @@ Not decoded yet — preview only (no permissively licensed description; black-bo
|
||||
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:** 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
|
||||
**Camera colour matrices:** ARW, NEF and RW2 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
|
||||
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
|
||||
own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
|
||||
## Log
|
||||
@@ -191,6 +199,9 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
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.
|
||||
- 2026-10-07: Panasonic RW2 / Leica RWL / Panasonic RAW decode in every raw format (compressed 4/6/8, packed 2/5/7,
|
||||
16-bit words), black levels, in-camera aspect crops and mapped-out defects established on 178 CC0 files from 118
|
||||
bodies; RW2 gets the file-local camera look (accepted on 140 of 174 files with a preview).
|
||||
|
||||
## Chinese and Japanese interfaces, and text watermarks
|
||||
|
||||
|
||||
@@ -71,6 +71,8 @@ USAGE:
|
||||
per line: {\"method\": \"ui.set\", \"params\": {\"view\": \"detail\"}}.
|
||||
Replies go to stdout. `ui.screenshot` without a path writes -o (then
|
||||
OUT-2.png, OUT-3.png…); `ui.settle {timeoutMs?}` waits for renders.
|
||||
A failed request (\"ok\": false) does not stop the script, but the exit
|
||||
status is non-zero when any request failed.
|
||||
-o, --output OUT PNG path (a final screenshot is written here if the script took none)
|
||||
--size WxH window size in points (default 1600x1000)
|
||||
--scale S pixels per point (default 1)
|
||||
@@ -122,6 +124,9 @@ fn main() -> ExitCode {
|
||||
dhat::Profiler::builder().file_name(file).build()
|
||||
};
|
||||
alloc_release::install();
|
||||
// Warnings (a GPU render redone on the CPU, an unknown backend name) on stderr; LIGHTCRAFT_LOG
|
||||
// or RUST_LOG picks another level (#168).
|
||||
lightcraft_engine::logging::install("lightcraft-cli");
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let r = match args.first().map(String::as_str) {
|
||||
Some("run") => run(&args[1..]),
|
||||
@@ -659,7 +664,7 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
for (k, v) in opts {
|
||||
p[k] = v;
|
||||
}
|
||||
let mut o = ExportOptions::from_json(&p);
|
||||
let mut o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?;
|
||||
if p.get("format").is_none() {
|
||||
let ext = Path::new(&output).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
|
||||
o.format = ExportFormat::parse(&ext)
|
||||
@@ -755,6 +760,9 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
Some(p.with_file_name(format!("{stem}-{shots}.{ext}")).to_string_lossy().to_string())
|
||||
};
|
||||
let mut wrote_output = false;
|
||||
// Issue #166: a scripted request that fails still gets its reply printed and the run carries
|
||||
// on (like `run --keep-going`), but the exit status reports it — CI judges by exit status.
|
||||
let mut failed = 0usize;
|
||||
if let Some(script) = &script {
|
||||
let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
|
||||
let mut out = std::io::stdout().lock();
|
||||
@@ -787,6 +795,9 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
_ => h.request(&method, params, timeout),
|
||||
};
|
||||
if reply["ok"] != true {
|
||||
failed += 1;
|
||||
}
|
||||
if let Some(o) = reply.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
// wall time of the request (incl. the frames it ran), and since the start
|
||||
@@ -823,7 +834,7 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
eprintln!("lightcraft-cli snapshot: done in {:.2} s ({} frames)", t0.elapsed().as_secs_f64(), h.frames());
|
||||
Ok(())
|
||||
if failed > 0 { Err(format!("{failed} scripted request(s) failed")) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn commands(args: &[String]) -> Result<(), String> {
|
||||
|
||||
@@ -183,6 +183,39 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
|
||||
assert_eq!((d.width, d.height), (640, 480));
|
||||
}
|
||||
|
||||
/// Issue #166: a scripted request that fails makes `snapshot` exit non-zero. The failed reply is
|
||||
/// still printed, the later lines still run, and the final `-o` screenshot is still written.
|
||||
#[test]
|
||||
fn snapshot_script_failure_exits_non_zero() {
|
||||
let script = tmp("snap-fail.jsonl");
|
||||
let out = tmp("snap-fail.png");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!(
|
||||
"{}\n{}\n",
|
||||
json!({"method": "ui.set", "params": {"view": "grid"}}), // not a view: `photoGrid`, `detail`, …
|
||||
json!({"method": "ui.set", "params": {"view": "photoGrid"}}),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let _ = std::fs::remove_file(&out);
|
||||
let o = Command::new(BIN)
|
||||
.args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", "320x200"])
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(!o.status.success(), "a failed scripted request must fail the run\n{stderr}");
|
||||
assert!(stderr.contains("1 scripted request(s) failed"), "{stderr}");
|
||||
let replies: Vec<Value> = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(replies.len(), 2, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false, "{replies:?}");
|
||||
assert!(replies[0]["error"].as_str().unwrap_or("").contains("grid"), "{replies:?}");
|
||||
assert_eq!(replies[1]["ok"], true, "{replies:?}");
|
||||
// continue-and-report: the final screenshot is still written
|
||||
let d = lightcraft_codecs::decode(&std::fs::read(&out).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (320, 200));
|
||||
}
|
||||
|
||||
/// Issue #136: with the GPU switched off from the environment the UI starts and renders on the CPU,
|
||||
/// without creating a GPU device (no driver is loaded), and says why.
|
||||
#[test]
|
||||
@@ -292,7 +325,9 @@ fn devices_are_listed_and_imported_from() {
|
||||
assert_eq!(dev["name"], "CARD", "{line}");
|
||||
// the import review of its DCIM folder finds the photo
|
||||
let dcim = dev["path"].as_str().unwrap().to_string();
|
||||
let o = Command::new(BIN).args(["run", "library.importPreview", &format!("paths=[\"{dcim}\"]")]).output().unwrap();
|
||||
// JSON-escaped: a Windows path has backslashes
|
||||
let paths = serde_json::to_string(&[&dcim]).unwrap();
|
||||
let o = Command::new(BIN).args(["run", "library.importPreview", &format!("paths={paths}")]).output().unwrap();
|
||||
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}");
|
||||
}
|
||||
@@ -334,3 +369,29 @@ fn connect_failure_hint_names_the_attempted_port() {
|
||||
assert!(err.contains("--control 18437"), "the hint must name the port it tried: {err}");
|
||||
assert!(!err.contains("--control 7980"), "{err}");
|
||||
}
|
||||
|
||||
/// The CLI logs warnings on stderr (#168): with `RUST_LOG=warn` an unknown GPU backend name is
|
||||
/// reported, and `RUST_LOG=off` silences it. Before, no logger was installed and nothing appeared.
|
||||
#[test]
|
||||
fn warnings_are_logged_on_stderr() {
|
||||
let input = tmp("log-in.png");
|
||||
gradient_png(&input);
|
||||
let out = tmp("log-out.jpg");
|
||||
let run = |level: &str| {
|
||||
let o = Command::new(BIN)
|
||||
.args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap()])
|
||||
.env("LIGHTCRAFT_GPU_BACKEND", "bogus")
|
||||
.env("RUST_LOG", level)
|
||||
.env_remove("LIGHTCRAFT_LOG")
|
||||
.env_remove("LIGHTCRAFT_GPU")
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&o.stderr).into_owned();
|
||||
assert!(o.status.success(), "{stderr}");
|
||||
stderr
|
||||
};
|
||||
let warn = run("warn");
|
||||
assert!(warn.contains("LIGHTCRAFT_GPU_BACKEND=bogus names no known backend"), "{warn}");
|
||||
let off = run("off");
|
||||
assert!(!off.contains("names no known backend"), "{off}");
|
||||
}
|
||||
@@ -428,8 +428,18 @@ impl WebApp {
|
||||
let cores = window().map_or(1, |w| w.navigator().hardware_concurrency() as usize);
|
||||
cores.saturating_sub(1).clamp(1, 4)
|
||||
});
|
||||
let workers =
|
||||
(n > 0).then(|| Workers::start(n, backend.as_ref().map_or("memory", |b| b.kind()), backend.clone(), index, cc.egui_ctx.clone()));
|
||||
let cache = app.session.media.rendered.clone();
|
||||
let workers = (n > 0).then(|| {
|
||||
Workers::start(
|
||||
n,
|
||||
backend.as_ref().map_or("memory", |b| b.kind()),
|
||||
backend.clone(),
|
||||
index,
|
||||
&cache,
|
||||
app.session.cache_bytes(),
|
||||
cc.egui_ctx.clone(),
|
||||
)
|
||||
});
|
||||
if let Some(w) = &workers {
|
||||
app.renderer.set_offload(Box::new(w.clone()));
|
||||
}
|
||||
@@ -600,11 +610,16 @@ 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.app.ui.toast = Some((text, now + NOTICE_SECS, None));
|
||||
}
|
||||
self.run_inbox();
|
||||
self.import_dropped(ctx);
|
||||
self.load_originals(ctx);
|
||||
if let Some(w) = &self.workers {
|
||||
// `library.preferences ▸ cacheMb` is read by the session; the worker index prunes on
|
||||
// its own thread, so it gets the budget every frame and applies it on the next store.
|
||||
w.set_budget(self.app.session.cache_bytes());
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
self.save();
|
||||
if let Some(b) = self.bench.as_mut()
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
//! ([`ThumbIndex`]).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use lightcraft_engine::catalog::Source;
|
||||
use lightcraft_engine::develop::{DevelopSettings, EmbeddedLens};
|
||||
use lightcraft_engine::media::{DecodedSource, MediaCache, RenderJob, SourceLevel, SourceRef};
|
||||
use lightcraft_engine::pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache};
|
||||
use lightcraft_preview::Lru;
|
||||
use lightcraft_preview::{Hash128, Lru, PreviewCache};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::store::hash_of_path;
|
||||
@@ -25,6 +25,10 @@ use crate::store::hash_of_path;
|
||||
/// One render request for a worker.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct WireJob {
|
||||
#[serde(default)]
|
||||
pub cache_generation: u64,
|
||||
#[serde(default)]
|
||||
pub source_identity: Option<String>,
|
||||
pub level: SourceLevel,
|
||||
pub origin: Source,
|
||||
/// Decode the source at most this long.
|
||||
@@ -60,6 +64,8 @@ impl WireJob {
|
||||
SourceRef::Smart { .. } => 2560,
|
||||
};
|
||||
WireJob {
|
||||
cache_generation: job.cache_generation,
|
||||
source_identity: job.source_key.map(|key| key.to_string()),
|
||||
level: job.level,
|
||||
origin: job.origin.clone(),
|
||||
max_edge,
|
||||
@@ -108,8 +114,8 @@ impl WireJob {
|
||||
/// Identifies the decoded source this job needs.
|
||||
fn source_key(&self) -> String {
|
||||
match &self.origin {
|
||||
Source::File { path } => format!("{}:{path}", self.max_edge),
|
||||
Source::Demo { scene } => format!("{}:demo:{scene}", self.max_edge),
|
||||
Source::File { path } => format!("{}:{:?}:{}:{path}", self.cache_generation, self.source_identity, self.max_edge),
|
||||
Source::Demo { scene } => format!("{}:{:?}:{}:demo:{scene}", self.cache_generation, self.source_identity, self.max_edge),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +192,8 @@ impl WorkerCore {
|
||||
/// pruned like the native disk cache (least recently used first, down to 80 % of the budget).
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub struct ThumbIndex {
|
||||
#[serde(default)]
|
||||
namespace: u64,
|
||||
/// hex key → (bytes, last use).
|
||||
entries: HashMap<String, (u64, u64)>,
|
||||
clock: u64,
|
||||
@@ -196,6 +204,24 @@ pub struct ThumbIndex {
|
||||
}
|
||||
|
||||
impl ThumbIndex {
|
||||
pub fn namespace(&self) -> u64 {
|
||||
self.namespace
|
||||
}
|
||||
|
||||
/// A persistent new namespace prevents late old workers restoring an invalidated disk hit.
|
||||
pub fn cache_key(&self, key: Hash128) -> String {
|
||||
if self.namespace == 0 {
|
||||
return key.to_string();
|
||||
}
|
||||
lightcraft_preview::Hasher128::new().str(&key.to_string()).str("cleared").u64(self.namespace).finish().to_string()
|
||||
}
|
||||
|
||||
pub fn invalidate(&mut self) -> Vec<String> {
|
||||
self.namespace = self.namespace.wrapping_add(1);
|
||||
self.total = 0;
|
||||
self.dirty = true;
|
||||
self.entries.drain().map(|(key, _)| key).collect()
|
||||
}
|
||||
pub fn from_json(bytes: &[u8]) -> ThumbIndex {
|
||||
let mut i: ThumbIndex = serde_json::from_slice(bytes).unwrap_or_default();
|
||||
i.total = i.entries.values().map(|e| e.0).sum();
|
||||
@@ -273,11 +299,55 @@ impl ThumbIndex {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which rendered-thumbnail cache, at which generation, the stored thumbnails ([`ThumbIndex`])
|
||||
/// belong to (main thread). Clearing the previews bumps the cache's generation, and opening
|
||||
/// another library replaces the cache: either makes every stored thumbnail obsolete.
|
||||
pub struct CacheWatch(Option<(Weak<PreviewCache>, u64)>);
|
||||
|
||||
impl CacheWatch {
|
||||
/// Watch `active`, the session's cache when the workers start: the persisted index belongs
|
||||
/// to it at its current generation. (Without this baseline, the first observation after a
|
||||
/// clear would only record the cleared generation and keep the obsolete thumbnails.)
|
||||
pub fn new(active: &Arc<PreviewCache>) -> CacheWatch {
|
||||
CacheWatch(Some((Arc::downgrade(active), active.generation())))
|
||||
}
|
||||
|
||||
/// The watched cache, while it's alive.
|
||||
pub fn cache(&self) -> Option<Arc<PreviewCache>> {
|
||||
self.0.as_ref().and_then(|(cache, _)| cache.upgrade())
|
||||
}
|
||||
|
||||
/// `cache` at `generation` is the active one now. If it was cleared or replaced since the
|
||||
/// last observation, invalidate `index` and return the keys whose files should be deleted.
|
||||
pub fn observe(&mut self, cache: &Arc<PreviewCache>, generation: u64, index: &mut ThumbIndex) -> Vec<String> {
|
||||
let changed = self.0.as_ref().is_some_and(|(old, g)| old.as_ptr() != Arc::as_ptr(cache) || *g != generation);
|
||||
self.0 = Some((Arc::downgrade(cache), generation));
|
||||
if changed { index.invalidate() } else { Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_engine::Session;
|
||||
|
||||
#[test]
|
||||
fn clearing_index_changes_disk_namespace_and_survives_restart() {
|
||||
let key = lightcraft_preview::hash_bytes(b"thumbnail");
|
||||
let mut index = ThumbIndex::default();
|
||||
let old = index.cache_key(key);
|
||||
assert_eq!(old, key.to_string());
|
||||
index.insert(&old, 100);
|
||||
assert_eq!(index.invalidate(), vec![old.clone()]);
|
||||
let new = index.cache_key(key);
|
||||
assert_ne!(old, new);
|
||||
index.insert(&new, 100);
|
||||
let reopened = ThumbIndex::from_json(&index.to_json());
|
||||
assert_eq!(reopened.cache_key(key), new);
|
||||
assert!(!reopened.contains(&old));
|
||||
assert!(reopened.contains(&new));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_job_renders_like_the_engine() {
|
||||
let mut s = Session::with_demo();
|
||||
@@ -304,6 +374,8 @@ mod tests {
|
||||
let hash = crate::store::content_hash(&bytes);
|
||||
let origin = Source::File { path: crate::store::original_path(&hash, "a.png") };
|
||||
let job = WireJob {
|
||||
cache_generation: 0,
|
||||
source_identity: None,
|
||||
level: SourceLevel::Thumb,
|
||||
origin,
|
||||
max_edge: 512,
|
||||
@@ -328,6 +400,68 @@ mod tests {
|
||||
let r = core.render(&job, Some(&bytes)).unwrap();
|
||||
assert_eq!(r.image.width, 32);
|
||||
assert_eq!(core.needs_original(&job), None, "decoded source is kept");
|
||||
let mut reloaded = job.clone();
|
||||
reloaded.source_identity = Some("changed content".into());
|
||||
assert_eq!(core.needs_original(&reloaded).as_deref(), Some(hash.as_str()), "content reload must not reuse an old decoded source");
|
||||
let mut refreshed = job.clone();
|
||||
refreshed.cache_generation = 1;
|
||||
assert_eq!(core.needs_original(&refreshed).as_deref(), Some(hash.as_str()), "explicit refresh must not reuse the old decoded source");
|
||||
}
|
||||
|
||||
/// The persisted index as a restarted page loads it: one stored thumbnail.
|
||||
fn persisted_index(key: Hash128) -> (ThumbIndex, String) {
|
||||
let mut index = ThumbIndex::default();
|
||||
let hex = index.cache_key(key);
|
||||
index.insert(&hex, 100);
|
||||
(ThumbIndex::from_json(&index.to_json()), hex)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_previews_before_the_first_request_invalidates_stored_thumbnails() {
|
||||
let key = lightcraft_preview::hash_bytes(b"thumbnail");
|
||||
let (mut index, old) = persisted_index(key);
|
||||
let cache = Arc::new(PreviewCache::memory(1 << 20));
|
||||
let mut watch = CacheWatch::new(&cache);
|
||||
let mut gone = Vec::new();
|
||||
// each frame (`Workers::finished`) observes the watched cache, even with no requests
|
||||
let mut frame = |watch: &mut CacheWatch, index: &mut ThumbIndex| {
|
||||
if let Some(c) = watch.cache() {
|
||||
gone.extend(watch.observe(&c, c.generation(), index));
|
||||
}
|
||||
};
|
||||
frame(&mut watch, &mut index); // an empty filtered view: no render request yet
|
||||
cache.clear(); // File ▸ Clear Preview Cache
|
||||
frame(&mut watch, &mut index);
|
||||
// the filter is removed: the first thumbnail request (`Workers::try_start`)
|
||||
gone.extend(watch.observe(&cache, cache.generation(), &mut index));
|
||||
let disk_key = index.cache_key(key);
|
||||
assert!(!index.touch(&disk_key), "the pre-clear thumbnail must not be read back");
|
||||
assert_eq!(gone, vec![old.clone()], "the stored file is deleted");
|
||||
assert_ne!(disk_key, old, "a fresh namespace: a late old write can't come back");
|
||||
assert!(index.dirty, "the invalidated index is saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_the_cache_before_the_first_request_invalidates_stored_thumbnails() {
|
||||
let key = lightcraft_preview::hash_bytes(b"thumbnail");
|
||||
let (mut index, old) = persisted_index(key);
|
||||
let first = Arc::new(PreviewCache::memory(1 << 20));
|
||||
let mut watch = CacheWatch::new(&first);
|
||||
let other = Arc::new(PreviewCache::memory(1 << 20)); // another library, generation 0 again
|
||||
assert_eq!(watch.observe(&other, other.generation(), &mut index), vec![old]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_normal_start_keeps_stored_thumbnails() {
|
||||
let key = lightcraft_preview::hash_bytes(b"thumbnail");
|
||||
let (mut index, old) = persisted_index(key);
|
||||
let cache = Arc::new(PreviewCache::memory(1 << 20));
|
||||
let mut watch = CacheWatch::new(&cache);
|
||||
for _ in 0..3 {
|
||||
assert!(watch.observe(&cache, cache.generation(), &mut index).is_empty());
|
||||
}
|
||||
assert_eq!(index.cache_key(key), old);
|
||||
assert!(index.touch(&old), "persisted thumbnails survive a restart");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -18,7 +18,7 @@ use std::sync::Arc;
|
||||
|
||||
use js_sys::{Array, Object, Reflect, Uint8Array, Uint32Array};
|
||||
use lightcraft_engine::catalog::PhotoId;
|
||||
use lightcraft_engine::media::{DISK_CACHE_BYTES, RenderJob, RenderResult, SourceLevel};
|
||||
use lightcraft_engine::media::{RenderJob, RenderResult, SourceLevel};
|
||||
use lightcraft_engine::pipeline::Rendered;
|
||||
use lightcraft_preview::{Hash128, PreviewCache};
|
||||
use lightcraft_raster::{Histogram, Rgba8};
|
||||
@@ -27,7 +27,7 @@ use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::wire::{ThumbIndex, WireJob, WorkerCore, thumb_storage_key};
|
||||
use crate::wire::{CacheWatch, ThumbIndex, WireJob, WorkerCore, thumb_storage_key};
|
||||
|
||||
/// Storage key of the thumbnail index.
|
||||
pub const THUMB_INDEX: &str = "thumbs/index.json";
|
||||
@@ -155,6 +155,10 @@ async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option<Bac
|
||||
// main side
|
||||
|
||||
struct Busy {
|
||||
disk_key: Option<String>,
|
||||
namespace: u64,
|
||||
request_id: u64,
|
||||
cache_generation: u64,
|
||||
id: u32,
|
||||
slot: Slot,
|
||||
photo: PhotoId,
|
||||
@@ -171,12 +175,17 @@ struct W {
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
/// The rendered-thumbnail cache the stored thumbnails belong to.
|
||||
preview_generation: CacheWatch,
|
||||
workers: Vec<W>,
|
||||
done: Vec<(Slot, RenderResult, f64)>,
|
||||
next_id: u32,
|
||||
/// (photo, level) → worker that last decoded it.
|
||||
affinity: HashMap<(PhotoId, SourceLevel), usize>,
|
||||
index: ThumbIndex,
|
||||
/// The session's thumbnail cache budget in bytes. Kept here because the prune happens on a
|
||||
/// worker message, where the session is out of reach; the app refreshes it every frame.
|
||||
budget: u64,
|
||||
backend: Option<Backend>,
|
||||
ctx: egui::Context,
|
||||
/// Jobs finished by workers / inline (for diagnostics).
|
||||
@@ -185,19 +194,46 @@ struct Inner {
|
||||
inline_this_frame: bool,
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
fn observe_cache(&mut self, cache: &Arc<PreviewCache>, generation: u64) {
|
||||
let gone = self.preview_generation.observe(cache, generation, &mut self.index);
|
||||
if !gone.is_empty()
|
||||
&& let Some(backend) = self.backend.clone()
|
||||
{
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
for key in gone {
|
||||
let _ = backend.remove(&thumb_storage_key(&key)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The main thread's handle on the render workers.
|
||||
#[derive(Clone)]
|
||||
pub struct Workers(Rc<RefCell<Inner>>);
|
||||
|
||||
impl Workers {
|
||||
/// Start `n` workers (none: every job runs inline). `store` is the backend kind for workers.
|
||||
pub fn start(n: usize, store: &str, backend: Option<Backend>, index: ThumbIndex, ctx: egui::Context) -> Workers {
|
||||
/// `index` lists the stored thumbnails of `cache`, the session's rendered-thumbnail cache
|
||||
/// now: watching it from the start, a clear before the first render request still counts.
|
||||
pub fn start(
|
||||
n: usize,
|
||||
store: &str,
|
||||
backend: Option<Backend>,
|
||||
index: ThumbIndex,
|
||||
cache: &Arc<PreviewCache>,
|
||||
budget: u64,
|
||||
ctx: egui::Context,
|
||||
) -> Workers {
|
||||
let w = Workers(Rc::new(RefCell::new(Inner {
|
||||
preview_generation: CacheWatch::new(cache),
|
||||
workers: Vec::new(),
|
||||
done: Vec::new(),
|
||||
next_id: 0,
|
||||
affinity: HashMap::new(),
|
||||
index,
|
||||
budget,
|
||||
backend,
|
||||
ctx,
|
||||
remote_done: 0,
|
||||
@@ -248,7 +284,16 @@ impl Workers {
|
||||
w.dead = true;
|
||||
w.worker.terminate();
|
||||
if let Some(b) = w.busy.take() {
|
||||
let r = RenderResult { photo: b.photo, level: b.level, key: b.key, rendered: Err(msg), loaded: None, quick: None };
|
||||
let r = RenderResult {
|
||||
request_id: b.request_id,
|
||||
source_key: None,
|
||||
photo: b.photo,
|
||||
level: b.level,
|
||||
key: b.key,
|
||||
rendered: Err(msg),
|
||||
loaded: None,
|
||||
quick: None,
|
||||
};
|
||||
g.done.push((b.slot, r, 0.0));
|
||||
}
|
||||
}
|
||||
@@ -284,16 +329,21 @@ impl Workers {
|
||||
};
|
||||
if let Ok(r) = &rendered
|
||||
&& let Some((cache, key)) = &b.cache
|
||||
&& cache.generation() == b.cache_generation
|
||||
&& b.namespace == g.index.namespace()
|
||||
{
|
||||
cache.put(*key, Arc::new(r.image.clone()));
|
||||
let hex = key.to_string();
|
||||
cache.put_at(b.cache_generation, *key, Arc::new(r.image.clone()));
|
||||
let hex = b.disk_key.clone().unwrap_or_else(|| key.to_string());
|
||||
let stored = get(&data, "stored").as_f64().unwrap_or(0.0) as u64;
|
||||
if get(&data, "cacheMiss").is_truthy() {
|
||||
g.index.remove(&hex);
|
||||
}
|
||||
if stored > 0 {
|
||||
g.index.insert(&hex, stored);
|
||||
let gone = g.index.prune(DISK_CACHE_BYTES);
|
||||
// The session's own budget (`library.preferences ▸ cacheMb`), not the native
|
||||
// default: the browser accepted the setting, so it has to be the one enforced
|
||||
// (#245).
|
||||
let gone = g.index.prune(g.budget);
|
||||
if !gone.is_empty()
|
||||
&& let Some(be) = g.backend.clone()
|
||||
{
|
||||
@@ -307,8 +357,31 @@ impl Workers {
|
||||
}
|
||||
}
|
||||
g.affinity.insert((b.photo, b.level), i);
|
||||
// A cleared worker may finish writing after the initial deletion. Its old namespace
|
||||
// is never reused, so removing this file cannot delete a replacement's cache entry.
|
||||
if let Some((cache, _)) = &b.cache
|
||||
&& (cache.generation() != b.cache_generation || b.namespace != g.index.namespace())
|
||||
&& let (Some(backend), Some(key)) = (g.backend.clone(), b.disk_key.clone())
|
||||
{
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let _ = backend.remove(&thumb_storage_key(&key)).await;
|
||||
});
|
||||
}
|
||||
g.remote_done += 1;
|
||||
g.done.push((b.slot, RenderResult { photo: b.photo, level: b.level, key: b.key, rendered, loaded: None, quick: None }, ms));
|
||||
g.done.push((
|
||||
b.slot,
|
||||
RenderResult {
|
||||
request_id: b.request_id,
|
||||
source_key: None,
|
||||
photo: b.photo,
|
||||
level: b.level,
|
||||
key: b.key,
|
||||
rendered,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
},
|
||||
ms,
|
||||
));
|
||||
}
|
||||
|
||||
/// The thumbnail index as JSON if it changed since the last call (the host saves it).
|
||||
@@ -328,19 +401,30 @@ impl Workers {
|
||||
let ready = g.workers.iter().filter(|w| w.ready && !w.dead).count();
|
||||
(alive, ready, g.remote_done, g.inline_done)
|
||||
}
|
||||
|
||||
/// The budget the thumbnail index is pruned to. The app refreshes it from the session every
|
||||
/// frame, so `library.preferences ▸ cacheMb` applies to the next stored thumbnail.
|
||||
pub fn set_budget(&self, bytes: u64) {
|
||||
self.0.borrow_mut().budget = bytes;
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOffload for Workers {
|
||||
fn try_start(&mut self, slot: Slot, job: RenderJob) -> Option<RenderJob> {
|
||||
let mut g = self.0.borrow_mut();
|
||||
let g = &mut *g;
|
||||
if let Some((cache, _)) = job.cache.as_ref().or(job.view_cache.as_ref()) {
|
||||
g.observe_cache(cache, job.cache_generation);
|
||||
}
|
||||
// rendered thumbnail in memory: no worker needed
|
||||
if let Some((cache, key)) = &job.cache
|
||||
&& let Some(img) = cache.get(*key)
|
||||
&& let Some(img) = cache.get_at(job.cache_generation, *key)
|
||||
{
|
||||
let image = Arc::unwrap_or_clone(img);
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
let r = RenderResult {
|
||||
request_id: job.request_id,
|
||||
source_key: job.source_key,
|
||||
photo: job.photo,
|
||||
level: job.level,
|
||||
key: job.key,
|
||||
@@ -374,25 +458,45 @@ impl RenderOffload for Workers {
|
||||
let Some(i) = pick else { return Some(job) };
|
||||
// decided here, so the next job for this photo follows even before this one finishes
|
||||
g.affinity.insert((job.photo, job.level), i);
|
||||
let thumb_cached = job.cache.as_ref().is_some_and(|(_, k)| g.index.touch(&k.to_string()));
|
||||
let wire = WireJob::from_job(&job, thumb_cached, &format!("{slot:?}"));
|
||||
let disk_key = job.cache.as_ref().map(|(_, key)| g.index.cache_key(*key));
|
||||
let thumb_cached = disk_key.as_ref().is_some_and(|key| g.index.touch(key));
|
||||
let mut wire = WireJob::from_job(&job, thumb_cached, &format!("{slot:?}"));
|
||||
// The namespace also changes when a new library's cache starts again at generation zero.
|
||||
wire.cache_generation = g.index.namespace();
|
||||
wire.thumb = disk_key.clone();
|
||||
let Ok(text) = serde_json::to_string(&wire) else { return Some(job) };
|
||||
g.next_id = g.next_id.wrapping_add(1);
|
||||
let id = g.next_id;
|
||||
let msg = Object::new();
|
||||
set(&msg, "id", &id.into());
|
||||
set(&msg, "job", &text.into());
|
||||
let namespace = g.index.namespace();
|
||||
let w = &mut g.workers[i];
|
||||
if let Err(e) = w.worker.post_message(&msg) {
|
||||
log::warn!("render worker {i}: {e:?}");
|
||||
return Some(job);
|
||||
}
|
||||
w.busy = Some(Busy { id, slot, photo: job.photo, level: job.level, key: job.key, cache: job.cache });
|
||||
w.busy = Some(Busy {
|
||||
disk_key,
|
||||
namespace,
|
||||
request_id: job.request_id,
|
||||
cache_generation: job.cache_generation,
|
||||
id,
|
||||
slot,
|
||||
photo: job.photo,
|
||||
level: job.level,
|
||||
key: job.key,
|
||||
cache: job.cache,
|
||||
});
|
||||
None
|
||||
}
|
||||
|
||||
fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> {
|
||||
let mut g = self.0.borrow_mut();
|
||||
// Persist invalidation even when the cleared library has no visible render requests.
|
||||
if let Some(cache) = g.preview_generation.cache() {
|
||||
g.observe_cache(&cache, cache.generation());
|
||||
}
|
||||
g.inline_this_frame = false;
|
||||
std::mem::take(&mut g.done)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ default-run = "lightcraft"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
lightcraft-engine = { workspace = true }
|
||||
lightcraft-engine = { workspace = true, features = ["sam"] }
|
||||
lightcraft-sysmem = { workspace = true }
|
||||
lightcraft-ui-egui = { workspace = true }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
|
||||
@@ -28,11 +28,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("lightcraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
eprintln!("lightcraft: control server listening on 127.0.0.1:{port}");
|
||||
log::info!("control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
rx
|
||||
}
|
||||
|
||||
@@ -0,0 +1,689 @@
|
||||
//! The desktop app's logger: `log::` records go to standard error and to a log file.
|
||||
//!
|
||||
//! It replaces the stderr-only `StderrLog` that `main.rs` used to install. A launch from a
|
||||
//! desktop menu or the Dock has no terminal, so the file is what a bug report can attach:
|
||||
//! `<settings folder>/logs/lightcraft.log`, next to `ui.json` (`config_dir` in `main.rs`; Linux
|
||||
//! `$XDG_CONFIG_HOME/lightcraft/logs`, by default `~/.config/lightcraft/logs`). Never in the
|
||||
//! library folder. Each start moves the previous log to `lightcraft.1.log` (and that one to `.2`),
|
||||
//! so the log of a run that crashed survives the next launch. Runs with `LIGHTCRAFT_NO_PREFS`
|
||||
//! (tests, scripts) log to standard error only, so they don't rotate away the user's own logs.
|
||||
//!
|
||||
//! Levels ([`filter_spec`]): `info` for LightCraft's own crates, `warn` for everything else (wgpu
|
||||
//! and naga are chatty). `LIGHTCRAFT_LOG` keeps the meaning it had with the old logger: `info` or
|
||||
//! `debug` lowers LightCraft's own crates to that level, any other value means warnings and errors
|
||||
//! only. Without it, `RUST_LOG` replaces the default with env_logger-style directives: `debug`,
|
||||
//! `warn,lightcraft_pipeline=trace`, `wgpu_core=info`. A directive ending in `*` matches every
|
||||
//! target that starts with it (`lightcraft*=debug`).
|
||||
//!
|
||||
//! Records logged before the settings folder is known are kept (up to [`MAX_PENDING`]) and
|
||||
//! written once the file is attached. Writing never panics: a file that can't be created or
|
||||
//! written leaves standard error as the only sink. Panics that escape everything are added to the
|
||||
//! file by [`AppLogger::record_panic`] (the default hook already printed them on standard error).
|
||||
//!
|
||||
//! This is the desktop app's logger only; `lightcraft-cli` doesn't share a process with it.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock, PoisonError, TryLockError};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use log::LevelFilter;
|
||||
|
||||
/// The current log file's name inside the log folder.
|
||||
pub const LOG_FILE: &str = "lightcraft.log";
|
||||
/// How many previous logs are kept (`lightcraft.1.log` … `lightcraft.<KEEP>.log`).
|
||||
pub const KEEP: usize = 2;
|
||||
/// The log file stops growing past this size (a runaway warning can't fill the disk).
|
||||
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
/// Records kept in memory until the log file is attached.
|
||||
pub const MAX_PENDING: usize = 512;
|
||||
/// The built-in filter when neither `LIGHTCRAFT_LOG` nor `RUST_LOG` says otherwise.
|
||||
pub const DEFAULT_FILTER: &str = "warn,lightcraft*=info";
|
||||
|
||||
/// The filter directives for this run from `LIGHTCRAFT_LOG` and `RUST_LOG` (blank = unset).
|
||||
///
|
||||
/// `LIGHTCRAFT_LOG` wins when set and means what it did for the old stderr logger: `info` or
|
||||
/// `debug` for LightCraft's own crates (warnings and errors from everything else), any other value
|
||||
/// warnings and errors only. Else `RUST_LOG` as env_logger directives, else [`DEFAULT_FILTER`].
|
||||
pub fn filter_spec(lightcraft_log: Option<&str>, rust_log: Option<&str>) -> String {
|
||||
let set = |v: Option<&str>| v.map(str::trim).filter(|v| !v.is_empty()).map(str::to_owned);
|
||||
if let Some(level) = set(lightcraft_log) {
|
||||
return match level.as_str() {
|
||||
"debug" => "warn,lightcraft*=debug".to_owned(),
|
||||
"info" => "warn,lightcraft*=info".to_owned(),
|
||||
_ => "warn".to_owned(),
|
||||
};
|
||||
}
|
||||
set(rust_log).unwrap_or_else(|| DEFAULT_FILTER.to_owned())
|
||||
}
|
||||
|
||||
/// Per-target level filter parsed from env_logger-style directives.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Filter {
|
||||
default: LevelFilter,
|
||||
directives: Vec<(String, LevelFilter)>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
|
||||
/// directive names log errors only.
|
||||
pub fn parse(spec: &str) -> Filter {
|
||||
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
|
||||
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
|
||||
match part.split_once('=') {
|
||||
Some((name, level)) => {
|
||||
let name = name.trim();
|
||||
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
|
||||
f.directives.push((name.to_owned(), level));
|
||||
}
|
||||
}
|
||||
None => match parse_level(part) {
|
||||
Some(level) => f.default = level,
|
||||
// A bare target name: everything from it (env_logger does the same).
|
||||
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
|
||||
},
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// The level that applies to `target` (the most specific matching directive wins).
|
||||
pub fn level_for(&self, target: &str) -> LevelFilter {
|
||||
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
|
||||
}
|
||||
|
||||
/// The most verbose level any target can reach (for `log::set_max_level`).
|
||||
pub fn max(&self) -> LevelFilter {
|
||||
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_level(s: &str) -> Option<LevelFilter> {
|
||||
s.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
|
||||
/// `name*` matches every target starting with `name`.
|
||||
fn matches(name: &str, target: &str) -> bool {
|
||||
match name.strip_suffix('*') {
|
||||
Some(prefix) => target.starts_with(prefix),
|
||||
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
|
||||
/// value can overflow it.
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("lightcraft.{n}.log")) }
|
||||
}
|
||||
|
||||
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
|
||||
/// and create a fresh, empty log file in `dir` (created if missing).
|
||||
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
for n in (1..=KEEP).rev() {
|
||||
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let path = numbered(dir, 0);
|
||||
let file = File::create(&path)?;
|
||||
Ok((path, file))
|
||||
}
|
||||
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
/// then appended to it until it reaches its size cap.
|
||||
pub struct Sink {
|
||||
file: Option<File>,
|
||||
pending: Vec<String>,
|
||||
dropped: usize,
|
||||
written: u64,
|
||||
max_bytes: u64,
|
||||
capped: bool,
|
||||
}
|
||||
|
||||
impl Sink {
|
||||
pub fn new(max_bytes: u64) -> Sink {
|
||||
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
|
||||
}
|
||||
|
||||
/// Append `line` to the file, or keep it until [`Sink::attach`].
|
||||
pub fn write(&mut self, line: &str) {
|
||||
if self.file.is_none() {
|
||||
if self.capped {
|
||||
// No file is coming ([`Sink::no_file`]): standard error had the line.
|
||||
} else if self.pending.len() < MAX_PENDING {
|
||||
self.pending.push(line.to_owned());
|
||||
} else {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.append(line);
|
||||
}
|
||||
|
||||
/// Start writing to `file` (a fresh one: the size cap starts over), first the lines kept so far.
|
||||
pub fn attach(&mut self, file: File) {
|
||||
self.file = Some(file);
|
||||
self.written = 0;
|
||||
self.capped = false;
|
||||
for line in std::mem::take(&mut self.pending) {
|
||||
self.append(&line);
|
||||
}
|
||||
if self.dropped > 0 {
|
||||
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
|
||||
self.dropped = 0;
|
||||
self.append(¬e);
|
||||
}
|
||||
}
|
||||
|
||||
/// There will be no log file: forget the lines kept for it and keep no more.
|
||||
pub fn no_file(&mut self) {
|
||||
self.file = None;
|
||||
self.pending = Vec::new();
|
||||
self.dropped = 0;
|
||||
self.capped = true;
|
||||
}
|
||||
|
||||
fn append(&mut self, line: &str) {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
let Some(file) = self.file.as_mut() else { return };
|
||||
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
|
||||
let text = if self.written.saturating_add(len) > self.max_bytes {
|
||||
self.capped = true;
|
||||
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
|
||||
} else {
|
||||
self.written = self.written.saturating_add(len);
|
||||
line.to_owned()
|
||||
};
|
||||
// A full disk or a vanished file must not take the app down: drop the file sink.
|
||||
if file.write_all(text.as_bytes()).is_err() {
|
||||
self.file = None;
|
||||
self.capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
|
||||
pub struct AppLogger {
|
||||
filter: Filter,
|
||||
stderr: bool,
|
||||
sink: Mutex<Sink>,
|
||||
}
|
||||
|
||||
impl AppLogger {
|
||||
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
|
||||
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
|
||||
}
|
||||
|
||||
/// Rotate the logs in `dir` and send records to the fresh file; returns its path. On an error
|
||||
/// standard error stays the only sink.
|
||||
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
|
||||
// A thread that panicked while logging leaves the sink usable: take it back.
|
||||
let mut sink = self.sink.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
match rotate(dir) {
|
||||
Ok((path, file)) => {
|
||||
sink.attach(file);
|
||||
Ok(path)
|
||||
}
|
||||
Err(e) => {
|
||||
sink.no_file();
|
||||
Err(format!("{}: {e}", dir.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// No log file for this run (`LIGHTCRAFT_NO_PREFS`): log to standard error only.
|
||||
pub fn no_file(&self) {
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).no_file();
|
||||
}
|
||||
|
||||
/// Add a panic report to the log file only (the default panic hook has printed it on standard
|
||||
/// error), whatever the filter. Skipped when the sink is busy: the panic may have happened on
|
||||
/// this very thread while it held the lock, and waiting would hang the app.
|
||||
pub fn record_panic(&self, thread: &str, report: &str) {
|
||||
let line = format_line(×tamp(SystemTime::now()), log::Level::Error, thread, "lightcraft", report);
|
||||
let mut sink = match self.sink.try_lock() {
|
||||
Ok(sink) => sink,
|
||||
Err(TryLockError::Poisoned(p)) => p.into_inner(),
|
||||
Err(TryLockError::WouldBlock) => return,
|
||||
};
|
||||
sink.write(&line);
|
||||
}
|
||||
}
|
||||
|
||||
impl log::Log for AppLogger {
|
||||
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
||||
metadata.level() <= self.filter.level_for(metadata.target())
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record<'_>) {
|
||||
if !self.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
// Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock.
|
||||
let thread = std::thread::current();
|
||||
let line =
|
||||
format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
|
||||
if self.stderr {
|
||||
// No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting.
|
||||
let _ = std::io::stderr().write_all(line.as_bytes());
|
||||
}
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Install the logger (filter from [`filter_spec`]); `None` when another logger was installed
|
||||
/// first. Call [`AppLogger::attach_dir`] (or [`AppLogger::no_file`]) once the arguments are parsed.
|
||||
pub fn install() -> Option<&'static AppLogger> {
|
||||
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
|
||||
let var = |name| std::env::var(name).ok();
|
||||
let filter = Filter::parse(&filter_spec(var("LIGHTCRAFT_LOG").as_deref(), var("RUST_LOG").as_deref()));
|
||||
let max = filter.max();
|
||||
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
|
||||
log::set_logger(logger).ok()?;
|
||||
log::set_max_level(max);
|
||||
Some(logger)
|
||||
}
|
||||
|
||||
/// Chain a panic hook after the current one (the engine guard's: standard error plus
|
||||
/// `lightcraft-panics.log` in the temp folder) that also puts the panic in the log file.
|
||||
pub fn record_panics(logger: &'static AppLogger) {
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
previous(info);
|
||||
let thread = std::thread::current();
|
||||
let at = info.location().map(|l| format!(" at {}:{}", l.file(), l.line())).unwrap_or_default();
|
||||
let report = format!("panic{at}: {}", lightcraft_engine::guard::panic_message(info.payload()));
|
||||
logger.record_panic(thread.name().unwrap_or("?"), &report);
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("lightcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> String {
|
||||
std::fs::read_to_string(path).expect("read log")
|
||||
}
|
||||
|
||||
fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) {
|
||||
use log::Log;
|
||||
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
|
||||
}
|
||||
|
||||
/// The logger this replaced (`StderrLog`): warnings and errors from every target, `info` and
|
||||
/// `debug` only from LightCraft's own crates, and only when `LIGHTCRAFT_LOG` asked for them.
|
||||
fn old_stderr_log_enabled(lightcraft_log: &str, level: log::Level, target: &str) -> bool {
|
||||
let max = match lightcraft_log {
|
||||
"debug" => LevelFilter::Debug,
|
||||
"info" => LevelFilter::Info,
|
||||
_ => LevelFilter::Warn,
|
||||
};
|
||||
level <= max && (level <= log::Level::Warn || target.starts_with("lightcraft"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_filter_shows_lightcraft_info_and_other_crates_warnings() {
|
||||
let f = Filter::parse(DEFAULT_FILTER);
|
||||
assert_eq!(f.level_for("lightcraft"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("lightcraft::control_server"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("lightcraft_engine::guard"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("lightcraft_ui_egui::render"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
|
||||
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
|
||||
assert_eq!(f.max(), LevelFilter::Info);
|
||||
assert_eq!(filter_spec(None, None), DEFAULT_FILTER);
|
||||
// An empty variable is the same as an unset one.
|
||||
assert_eq!(filter_spec(Some(""), Some(" ")), DEFAULT_FILTER);
|
||||
}
|
||||
|
||||
/// `LIGHTCRAFT_LOG` keeps working exactly as it did with the old stderr-only logger.
|
||||
#[test]
|
||||
fn lightcraft_log_keeps_its_meaning() {
|
||||
let targets =
|
||||
["lightcraft", "lightcraft::control_server", "lightcraft_engine::segment", "lightcraft_gpu", "wgpu_core::device", "naga", "eframe"];
|
||||
let levels = [log::Level::Error, log::Level::Warn, log::Level::Info, log::Level::Debug, log::Level::Trace];
|
||||
for value in ["info", "debug", "warn", "error", "verbose", "INFO"] {
|
||||
let f = Filter::parse(&filter_spec(Some(value), None));
|
||||
for target in targets {
|
||||
for level in levels {
|
||||
assert_eq!(level <= f.level_for(target), old_stderr_log_enabled(value, level, target), "LIGHTCRAFT_LOG={value} {level} {target}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(Filter::parse(&filter_spec(Some("debug"), None)).max(), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse(&filter_spec(Some("warn"), None)).max(), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_log_replaces_the_default_and_lightcraft_log_wins_over_it() {
|
||||
assert_eq!(filter_spec(None, Some("warn,wgpu_core=info")), "warn,wgpu_core=info");
|
||||
let f = Filter::parse(&filter_spec(None, Some("debug")));
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Debug);
|
||||
// The app's own variable is the more specific choice.
|
||||
let f = Filter::parse(&filter_spec(Some("info"), Some("trace")));
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Warn);
|
||||
assert_eq!(f.level_for("lightcraft_engine"), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
|
||||
let f = Filter::parse("info,wgpu_core=error,lightcraft_pipeline=trace,lightcraft_pipeline::tiles=off");
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
|
||||
assert_eq!(f.level_for("lightcraft_pipeline::stage"), LevelFilter::Trace);
|
||||
assert_eq!(f.level_for("lightcraft_pipeline::tiles"), LevelFilter::Off);
|
||||
assert_eq!(f.max(), LevelFilter::Trace);
|
||||
// A module name is matched at `::` boundaries, not as a bare prefix.
|
||||
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
|
||||
// A trailing `*` is a prefix.
|
||||
assert_eq!(Filter::parse("lightcraft*=debug").level_for("lightcraft_raw::cr2"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("lightcraft*=debug").level_for("eframe"), LevelFilter::Error);
|
||||
// A bare target name sets that target to the most verbose level, as env_logger does.
|
||||
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
|
||||
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
|
||||
// Levels are case-insensitive and surrounding blanks are ignored.
|
||||
assert_eq!(Filter::parse(" WARN , lightcraft = Debug ").level_for("lightcraft"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse(" WARN , lightcraft = Debug ").level_for("eframe"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_specs_never_panic_and_fall_back_sensibly() {
|
||||
let long = "x".repeat(100_000);
|
||||
let many = "a=info,".repeat(10_000);
|
||||
for spec in [
|
||||
"",
|
||||
",,,",
|
||||
"=",
|
||||
"==",
|
||||
"=debug",
|
||||
"lightcraft=",
|
||||
"nonsense=loud",
|
||||
"🦀=info",
|
||||
"*",
|
||||
"*=",
|
||||
"=*",
|
||||
"a=b=c",
|
||||
"warn,,lightcraft=DEBUG",
|
||||
"\0",
|
||||
"é::ü=trace",
|
||||
&long,
|
||||
&many,
|
||||
] {
|
||||
let f = Filter::parse(spec);
|
||||
for target in ["", "lightcraft", "🦀", "a::b", "*"] {
|
||||
let _ = f.level_for(target);
|
||||
}
|
||||
let _ = f.max();
|
||||
let _ = filter_spec(Some(spec), Some(spec));
|
||||
}
|
||||
assert_eq!(Filter::parse("warn,,lightcraft=DEBUG").level_for("lightcraft"), LevelFilter::Debug);
|
||||
// An unknown level is skipped: that target keeps the default.
|
||||
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("lightcraft=").level_for("lightcraft"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_with_milliseconds() {
|
||||
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
|
||||
// Leap day, and the day after it.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_carries_time_level_thread_target_and_message() {
|
||||
assert_eq!(
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Info, "main", "lightcraft", "library /photos: 12 photos"),
|
||||
"2026-10-08T07:59:17.728Z INFO [main] lightcraft: library /photos: 12 photos\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let (path, _file) = rotate(&dir).expect("rotate");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
assert_eq!(read(&path), "");
|
||||
std::fs::write(&path, format!("run {run}")).expect("write");
|
||||
}
|
||||
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
|
||||
assert_eq!(read(&dir.join("lightcraft.1.log")), "run 3");
|
||||
assert_eq!(read(&dir.join("lightcraft.2.log")), "run 2");
|
||||
assert!(!dir.join("lightcraft.3.log").exists(), "only {KEEP} old logs are kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
|
||||
let dir = temp_dir("blocked");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "a file where the directory should be").expect("block");
|
||||
assert!(rotate(&dir).is_err());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
||||
let dir = temp_dir("pending");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early 1\n");
|
||||
sink.write("early 2\n");
|
||||
sink.attach(file);
|
||||
sink.write("late\n");
|
||||
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
||||
let dir = temp_dir("bounded");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING + 5 {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
|
||||
assert!(text.contains("5 earlier log lines were dropped"), "{text}");
|
||||
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_a_full_pending_buffer_drops_nothing() {
|
||||
let dir = temp_dir("full");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), MAX_PENDING);
|
||||
assert!(!text.contains("dropped"), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_file_nothing_is_kept_in_memory() {
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early\n");
|
||||
sink.no_file();
|
||||
sink.write("late\n");
|
||||
assert!(sink.pending.is_empty() && sink.dropped == 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_stops_at_its_size_cap_with_one_note() {
|
||||
let dir = temp_dir("cap");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(100);
|
||||
sink.attach(file);
|
||||
for i in 0..50 {
|
||||
sink.write(&format!("line {i:02} with some padding\n"));
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
|
||||
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
|
||||
use log::Log;
|
||||
let dir = temp_dir("logger");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
record(&logger, log::Level::Info, "lightcraft", "before the settings directory");
|
||||
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
|
||||
record(&logger, log::Level::Error, "lightcraft_engine::guard", "`develop.reset` failed unexpectedly: boom");
|
||||
record(&logger, log::Level::Debug, "lightcraft", "below info");
|
||||
let text = read(&path);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 3, "{text}");
|
||||
assert!(lines[0].contains(" INFO [") && lines[0].ends_with("lightcraft: before the settings directory"), "{text}");
|
||||
assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}");
|
||||
assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("lightcraft_engine::guard: `develop.reset` failed unexpectedly: boom"), "{text}");
|
||||
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("lightcraft_ui_egui").build()));
|
||||
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
|
||||
// Attaching again rotates: the first log becomes `.1`.
|
||||
logger.attach_dir(&dir).expect("attach again");
|
||||
assert_eq!(read(&dir.join("lightcraft.1.log")), text);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_from_many_threads_are_whole_lines() {
|
||||
let dir = temp_dir("threads");
|
||||
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
let workers: Vec<_> = (0..8)
|
||||
.map(|t| {
|
||||
let l = std::sync::Arc::clone(&logger);
|
||||
std::thread::spawn(move || {
|
||||
for i in 0..100 {
|
||||
record(&l, log::Level::Info, "lightcraft", &format!("thread {t} record {i}"));
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for w in workers {
|
||||
w.join().expect("worker");
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), 800);
|
||||
assert!(text.lines().all(|l| l.contains(" lightcraft: thread ")), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
|
||||
let dir = temp_dir("unusable");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "not a directory").expect("block");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
record(&logger, log::Level::Info, "lightcraft", "early");
|
||||
assert!(logger.attach_dir(&dir).is_err());
|
||||
record(&logger, log::Level::Error, "x", "still fine");
|
||||
// Nothing waits in memory for a file that won't come.
|
||||
assert!(logger.sink.lock().expect("sink").pending.is_empty());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poisoned_sink_still_logs() {
|
||||
let dir = temp_dir("poison");
|
||||
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
|
||||
let l = std::sync::Arc::clone(&logger);
|
||||
let _ = std::thread::spawn(move || {
|
||||
let _guard = l.sink.lock().expect("lock");
|
||||
panic!("poison the sink");
|
||||
})
|
||||
.join();
|
||||
assert!(logger.sink.is_poisoned());
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
record(&logger, log::Level::Warn, "lightcraft", "after the poison");
|
||||
assert!(read(&path).contains("lightcraft: after the poison"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The panic hook's report lands in the file, and never deadlocks when the panic happened
|
||||
/// while this thread held the sink.
|
||||
#[test]
|
||||
fn a_panic_report_goes_to_the_file_and_never_deadlocks() {
|
||||
let dir = temp_dir("panic");
|
||||
let logger = AppLogger::new(Filter::parse("off"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
logger.record_panic("render", "panicked at crates/x/src/lib.rs:3:5:\nboom");
|
||||
{
|
||||
let _held = logger.sink.lock().expect("lock");
|
||||
logger.record_panic("main", "while the sink is held");
|
||||
}
|
||||
let text = read(&path);
|
||||
assert!(text.contains(" ERROR [render] lightcraft: panicked at crates/x/src/lib.rs:3:5:\nboom\n"), "{text}");
|
||||
assert!(!text.contains("while the sink is held"), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
+211
-35
@@ -24,6 +24,7 @@
|
||||
|
||||
mod alloc_release;
|
||||
mod control_server;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
|
||||
@@ -63,10 +64,10 @@ impl eframe::App for App {
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
if let Err(e) = self.1.save(&self.0) {
|
||||
eprintln!("lightcraft: {e}");
|
||||
log::error!("{e}");
|
||||
}
|
||||
if let Err(e) = self.0.session.close_library() {
|
||||
eprintln!("lightcraft: saving the library failed: {e}");
|
||||
log::error!("saving the library failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,10 +94,23 @@ fn gpu_marker_path() -> Option<std::path::PathBuf> {
|
||||
|
||||
/// Arm the GPU crash sentinel; if the previous launch left it behind (it died inside the GPU
|
||||
/// driver while creating the device), the notice to show — GPU rendering then starts off.
|
||||
fn gpu_crash_check() -> Option<String> {
|
||||
let marker = gpu_marker_path();
|
||||
let left = marker.as_deref().and_then(lightcraft_engine::gpu::backend::take_init_marker);
|
||||
lightcraft_engine::gpu::backend::set_init_marker(marker);
|
||||
///
|
||||
/// A `--memory` session writes nothing (issue #169): it neither arms the sentinel — which would
|
||||
/// create the settings folder — nor removes a marker it finds. It still honours one, so the GPU
|
||||
/// starts off there too, and the next ordinary launch reports and clears it.
|
||||
fn gpu_crash_check(in_memory: bool) -> Option<String> {
|
||||
gpu_crash_check_at(gpu_marker_path(), in_memory)
|
||||
}
|
||||
|
||||
fn gpu_crash_check_at(marker: Option<std::path::PathBuf>, in_memory: bool) -> Option<String> {
|
||||
use lightcraft_engine::gpu::backend;
|
||||
let left = if in_memory {
|
||||
marker.as_deref().and_then(backend::read_init_marker)
|
||||
} else {
|
||||
let left = marker.as_deref().and_then(backend::take_init_marker);
|
||||
backend::set_init_marker(marker);
|
||||
left
|
||||
};
|
||||
left.map(|what| gpu_crash_notice(&what))
|
||||
}
|
||||
|
||||
@@ -112,19 +126,28 @@ fn config_dir() -> Option<std::path::PathBuf> {
|
||||
lightcraft_engine::camera_profiles::config_dir()
|
||||
}
|
||||
|
||||
/// `<config>/ui.json`, the saved UI state and app settings — `None` when nothing is read or
|
||||
/// written: `LIGHTCRAFT_NO_PREFS` (tests, scripts) or no home folder.
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return None;
|
||||
}
|
||||
config_dir().map(|d| d.join("ui.json"))
|
||||
}
|
||||
|
||||
/// 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, None, false);
|
||||
}
|
||||
let Some(path) = config_dir().map(|d| d.join("ui.json")) else { return (None, None, false) };
|
||||
load_prefs_at(&path)
|
||||
///
|
||||
/// A `--memory` session reads the settings (so it looks like the user's app) but writes nothing
|
||||
/// (issue #164), so a damaged file is left as it is there, not set aside.
|
||||
fn load_prefs(in_memory: bool) -> (Option<UiState>, Option<String>, bool) {
|
||||
let Some(path) = prefs_path() else { return (None, None, false) };
|
||||
load_prefs_at(&path, !in_memory)
|
||||
}
|
||||
|
||||
fn load_prefs_at(path: &std::path::Path) -> (Option<UiState>, Option<String>, bool) {
|
||||
fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (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),
|
||||
@@ -135,6 +158,10 @@ fn load_prefs_at(path: &std::path::Path) -> (Option<UiState>, Option<String>, bo
|
||||
};
|
||||
match serde_json::from_slice::<UiState>(&bytes) {
|
||||
Ok(ui) => (Some(ui.sanitized()), None, false),
|
||||
Err(e) if !set_aside_damaged => {
|
||||
let msg = format!("The app settings ({}) are damaged ({e}). Defaults are used; nothing is saved in this session.", path.display());
|
||||
(None, Some(msg), true)
|
||||
}
|
||||
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}"));
|
||||
@@ -177,8 +204,15 @@ fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Nothing is written while the session is one that promises to save nothing: `--memory`
|
||||
/// (`path` is `None`; issue #164), or the one running because the library couldn't be opened — the
|
||||
/// question window and Continue Without Saving (issue #100; `app.openLibrary` ends it, and the
|
||||
/// library it opened is saved then).
|
||||
#[derive(Default)]
|
||||
struct PrefsWriter {
|
||||
/// Where to write; `None` writes nothing (`--memory`, `LIGHTCRAFT_NO_PREFS`, no home folder).
|
||||
path: Option<std::path::PathBuf>,
|
||||
written: Vec<u8>,
|
||||
library: String,
|
||||
checked: f64,
|
||||
@@ -190,19 +224,26 @@ struct PrefsWriter {
|
||||
|
||||
impl PrefsWriter {
|
||||
fn save(&mut self, app: &LightcraftApp) -> Result<(), String> {
|
||||
if self.keep_file || std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
self.save_ui(&app.ui, app.library_problem.is_some())
|
||||
}
|
||||
|
||||
/// Write `ui` if it changed since the last write; `temporary` (the library couldn't be
|
||||
/// opened) saves nothing.
|
||||
fn save_ui(&mut self, ui: &UiState, temporary: bool) -> Result<(), String> {
|
||||
if self.keep_file || temporary {
|
||||
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())?;
|
||||
let Some(path) = self.path.clone() else { return Ok(()) };
|
||||
let bytes = serde_json::to_vec_pretty(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))
|
||||
path.parent()
|
||||
.map_or(Ok(()), std::fs::create_dir_all)
|
||||
.and_then(|()| write_atomic(&path, &bytes))
|
||||
.map_err(|e| format!("saving the app settings failed: {e}"))?;
|
||||
self.written = bytes;
|
||||
self.library = app.ui.settings.library_path.clone();
|
||||
self.library = ui.settings.library_path.clone();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -216,8 +257,8 @@ impl PrefsWriter {
|
||||
match self.save(app) {
|
||||
Ok(()) => self.failing = false,
|
||||
Err(e) => {
|
||||
eprintln!("lightcraft: {e}");
|
||||
if !self.failing {
|
||||
log::warn!("{e}");
|
||||
app.notices.push(format!("{e}. LightCraft keeps trying."));
|
||||
}
|
||||
self.failing = true;
|
||||
@@ -272,7 +313,8 @@ fn services() -> Services {
|
||||
let status = if cfg!(target_os = "macos") {
|
||||
std::process::Command::new("open").args(["-R", path]).status()
|
||||
} else if cfg!(target_os = "windows") {
|
||||
std::process::Command::new("explorer").arg(format!("/select,{path}")).status()
|
||||
let win_path = path.replace('/', "\\");
|
||||
std::process::Command::new("explorer").arg(format!("/select,{win_path}")).status()
|
||||
} else {
|
||||
let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_else(|| ".".into());
|
||||
std::process::Command::new("xdg-open").arg(dir).status()
|
||||
@@ -286,8 +328,8 @@ fn services() -> Services {
|
||||
.add_filter(
|
||||
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",
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw",
|
||||
"pef", "psd", "jxl", "gif", "bmp",
|
||||
],
|
||||
)
|
||||
.pick_files()
|
||||
@@ -366,7 +408,7 @@ fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: boo
|
||||
}
|
||||
let unopened = || Session::new().with_fs().with_system_clock();
|
||||
let Some(dir) = dir else {
|
||||
eprintln!("lightcraft: no library location (set --library or LIGHTCRAFT_LIBRARY)");
|
||||
log::warn!("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();
|
||||
@@ -374,8 +416,8 @@ fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: boo
|
||||
match s.open_library(&dir, seed_demo) {
|
||||
Ok(r) => {
|
||||
let (replayed, torn) = (r.replayed, r.torn_bytes);
|
||||
eprintln!(
|
||||
"lightcraft: library {}: {} photos, {replayed} log records replayed{} ({:.1} ms)",
|
||||
log::info!(
|
||||
"library {}: {} photos, {replayed} log records replayed{} ({:.1} ms)",
|
||||
dir.display(),
|
||||
s.catalog.len(),
|
||||
if torn > 0 { ", torn tail repaired" } else { "" },
|
||||
@@ -384,7 +426,7 @@ fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: boo
|
||||
(s, None)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("lightcraft: can't open library {}: {e}", dir.display());
|
||||
log::error!("can't open library {}: {e}", dir.display());
|
||||
(unopened(), Some(LibraryProblem::new(dir.to_string_lossy(), e.to_string())))
|
||||
}
|
||||
}
|
||||
@@ -406,12 +448,43 @@ OPTIONS:
|
||||
ENVIRONMENT:
|
||||
LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|auto|off graphics backend (default: DX12 on Windows, Metal on macOS,
|
||||
Vulkan on Linux; off = render on the CPU); else WGPU_BACKEND. LIGHTCRAFT_GPU=0: CPU rendering.
|
||||
LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: <settings folder>/models/sam3;
|
||||
optional: LightCraft offers to download it when first needed)
|
||||
LIGHTCRAFT_SAM3_MIRRORS=URL,… where to download the SAM 3 model from (base URLs, tried in order)
|
||||
LIGHTCRAFT_LOG=info|debug more log detail from LightCraft itself (default: info from LightCraft, warnings
|
||||
from other crates); else RUST_LOG=<env_logger directives>. Records go to stderr and to
|
||||
<settings folder>/logs/lightcraft.log (previous runs: lightcraft.1.log, lightcraft.2.log)
|
||||
";
|
||||
|
||||
/// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`).
|
||||
/// None with `LIGHTCRAFT_NO_PREFS` (tests, scripts), so those runs don't rotate away the user's logs.
|
||||
fn log_dir() -> Option<std::path::PathBuf> {
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return None;
|
||||
}
|
||||
config_dir().map(|d| d.join("logs"))
|
||||
}
|
||||
|
||||
/// The control port `value` names; a value that isn't a port is logged and ignored (`what` is
|
||||
/// the option or variable it came from). Logged before the log file opens, so it is kept for it.
|
||||
fn control_port_from(what: &str, value: Option<String>) -> Option<u16> {
|
||||
let value = value?;
|
||||
let port = value.trim().parse().ok();
|
||||
if port.is_none() {
|
||||
log::warn!("{what} {value:?} is not a port number; no control server");
|
||||
}
|
||||
port
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so every start-up record is kept for the log file (`logging`).
|
||||
let logger = logging::install();
|
||||
lightcraft_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log"));
|
||||
if let Some(logger) = logger {
|
||||
logging::record_panics(logger);
|
||||
}
|
||||
alloc_release::install();
|
||||
let mut control_port: Option<u16> = std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut control_port = control_port_from("LIGHTCRAFT_CONTROL_PORT", std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().filter(|v| !v.is_empty()));
|
||||
let mut files = Vec::new();
|
||||
let mut seed_demo = true;
|
||||
let mut in_memory = false;
|
||||
@@ -419,7 +492,7 @@ fn main() -> eframe::Result {
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--control" => control_port = control_port_from("--control", Some(args.next().unwrap_or_default())),
|
||||
"--library" => library_dir = args.next().map(std::path::PathBuf::from),
|
||||
"--no-demo" => seed_demo = false,
|
||||
"--memory" | "--demo" => in_memory = true,
|
||||
@@ -438,7 +511,20 @@ fn main() -> eframe::Result {
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let (prefs, prefs_warning, keep_prefs_file) = load_prefs();
|
||||
// The log file: opened after the arguments, so `--version`, `--help` and a usage error leave
|
||||
// no file behind. Records logged until now are written to it first.
|
||||
if let Some(logger) = logger {
|
||||
// a --memory session writes nothing (issue #164), a log file included
|
||||
match log_dir().filter(|_| !in_memory) {
|
||||
Some(dir) => match logger.attach_dir(&dir) {
|
||||
Ok(path) => log::info!("LightCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
|
||||
// The file sink has given up by now, so this goes to standard error only.
|
||||
Err(e) => log::warn!("no log file: {e}"),
|
||||
},
|
||||
None => logger.no_file(),
|
||||
}
|
||||
}
|
||||
let (prefs, prefs_warning, keep_prefs_file) = load_prefs(in_memory);
|
||||
// --library, else the library last opened from Settings, else the default location
|
||||
let library_dir = library_dir.or_else(|| {
|
||||
prefs
|
||||
@@ -450,7 +536,7 @@ fn main() -> eframe::Result {
|
||||
let library_dir = library_dir.or_else(lightcraft_engine::library::default_dir);
|
||||
// GPU compute: off if the preference says so, or if the last launch died creating the device
|
||||
// (issue #136) — before anything can create it
|
||||
let gpu_crash = gpu_crash_check();
|
||||
let gpu_crash = gpu_crash_check(in_memory);
|
||||
let gpu_on = prefs.as_ref().is_none_or(|u| u.settings.gpu) && gpu_crash.is_none();
|
||||
lightcraft_engine::gpu::set_enabled(gpu_on);
|
||||
let options = eframe::NativeOptions {
|
||||
@@ -472,7 +558,13 @@ fn main() -> eframe::Result {
|
||||
"LightCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let (session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
|
||||
let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
|
||||
// AI masks: the SAM 3 checkpoint (facebook/sam3) in <config>/models/sam3, or LIGHTCRAFT_SAM3_DIR
|
||||
// (never required: without it, AI masks offer to download it; see docs/ai-masks.md)
|
||||
session.segmenter.dir =
|
||||
std::env::var_os("LIGHTCRAFT_SAM3_DIR").map(std::path::PathBuf::from).or_else(|| config_dir().map(|d| d.join("models").join("sam3")));
|
||||
// the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
|
||||
session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt"));
|
||||
let mut app = LightcraftApp::new(session, services());
|
||||
if let Some(ui) = prefs {
|
||||
app.ui = ui;
|
||||
@@ -482,6 +574,7 @@ fn main() -> eframe::Result {
|
||||
app.notices.extend(prefs_warning);
|
||||
// what's on disk now: only changes are written
|
||||
let writer = PrefsWriter {
|
||||
path: if in_memory { None } else { prefs_path() },
|
||||
written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(),
|
||||
library: app.ui.settings.library_path.clone(),
|
||||
keep_file: keep_prefs_file,
|
||||
@@ -537,7 +630,7 @@ mod tests {
|
||||
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);
|
||||
let (ui, warning, keep) = load_prefs_at(&path, true);
|
||||
assert!(ui.is_none() && !keep);
|
||||
let warning = warning.unwrap();
|
||||
assert!(warning.contains("damaged") && warning.contains("ui.json.corrupt-"), "{warning}");
|
||||
@@ -545,7 +638,80 @@ mod tests {
|
||||
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);
|
||||
assert_eq!(load_prefs_at(&path, true).1, None);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Issue #164: a `--memory` session reads the settings but writes nothing — a damaged file is
|
||||
/// reported and left where it is, not set aside.
|
||||
#[test]
|
||||
fn memory_session_leaves_a_damaged_ui_json_alone() {
|
||||
let d = dir("damaged-memory");
|
||||
let path = d.join("ui.json");
|
||||
let bytes = br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#;
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let (ui, warning, keep) = load_prefs_at(&path, false);
|
||||
assert!(ui.is_none() && keep);
|
||||
let warning = warning.unwrap();
|
||||
assert!(warning.contains("damaged") && warning.contains("nothing is saved"), "{warning}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Issue #164: the writer saves changed settings to its file — and nothing at all without one
|
||||
/// (`--memory`) or while the session is the temporary one offered when the library can't open.
|
||||
#[test]
|
||||
fn prefs_writer_writes_nothing_in_a_memory_or_temporary_session() {
|
||||
let d = dir("writer");
|
||||
let path = d.join("sub").join("ui.json");
|
||||
let mut ui = UiState::default();
|
||||
// an ordinary session: a change is written, the folder created on the way
|
||||
let mut w = PrefsWriter { path: Some(path.clone()), ..Default::default() };
|
||||
ui.thumb_size += 50.0;
|
||||
w.save_ui(&ui, false).unwrap();
|
||||
let saved = std::fs::read(&path).unwrap();
|
||||
assert!(String::from_utf8_lossy(&saved).contains("thumbSize"), "{}", String::from_utf8_lossy(&saved));
|
||||
// the temporary session (library problem): a further change is not written…
|
||||
ui.thumb_size += 50.0;
|
||||
w.save_ui(&ui, true).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), saved);
|
||||
// …until a library opens
|
||||
w.save_ui(&ui, false).unwrap();
|
||||
assert_ne!(std::fs::read(&path).unwrap(), saved);
|
||||
// --memory: no path, so no file and no folder, whatever changes
|
||||
let d2 = dir("writer-memory");
|
||||
let _ = std::fs::remove_dir_all(&d2);
|
||||
let mut w = PrefsWriter { path: None, ..Default::default() };
|
||||
ui.thumb_size += 50.0;
|
||||
w.save_ui(&ui, false).unwrap();
|
||||
assert!(w.written.is_empty() && !d2.exists());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Issue #169: a `--memory` session neither arms the GPU crash sentinel (which would create
|
||||
/// the settings folder) nor removes a marker a crashed launch left; it still reports it, and
|
||||
/// the next ordinary launch finds it, reports it and clears it.
|
||||
#[test]
|
||||
fn memory_session_leaves_the_gpu_marker_and_folder_alone() {
|
||||
let d = dir("gpu-marker-memory");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
let m = d.join("gpu-init.marker");
|
||||
// nothing left behind: no notice, and the folder is not created
|
||||
assert_eq!(gpu_crash_check_at(Some(m.clone()), true), None);
|
||||
assert!(!d.exists(), "a --memory session must not create the settings folder");
|
||||
// a marker left by a crashed launch: reported, kept
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
std::fs::write(&m, "GPU device creation started (backends METAL)").unwrap();
|
||||
let notice = gpu_crash_check_at(Some(m.clone()), true).unwrap();
|
||||
assert!(notice.contains("GPU rendering is now off"), "{notice}");
|
||||
assert!(m.exists(), "the marker stays for the next ordinary launch");
|
||||
// the next ordinary launch reports it once and removes it
|
||||
assert!(gpu_crash_check_at(Some(m.clone()), false).is_some());
|
||||
lightcraft_engine::gpu::backend::set_init_marker(None);
|
||||
assert!(!m.exists());
|
||||
assert_eq!(gpu_crash_check_at(Some(m.clone()), false), None);
|
||||
lightcraft_engine::gpu::backend::set_init_marker(None);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
@@ -578,6 +744,16 @@ mod tests {
|
||||
assert!(!b.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_control_port_that_is_not_a_number_is_ignored() {
|
||||
assert_eq!(control_port_from("--control", Some("7980".into())), Some(7980));
|
||||
assert_eq!(control_port_from("--control", Some(" 18001 ".into())), Some(18001));
|
||||
assert_eq!(control_port_from("--control", Some("nope".into())), None);
|
||||
assert_eq!(control_port_from("--control", Some(String::new())), None);
|
||||
assert_eq!(control_port_from("--control", Some("70000".into())), None);
|
||||
assert_eq!(control_port_from("LIGHTCRAFT_CONTROL_PORT", None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_json_is_written_atomically() {
|
||||
let d = dir("atomic");
|
||||
@@ -592,7 +768,7 @@ mod tests {
|
||||
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);
|
||||
let (loaded, warning, _) = load_prefs_at(&path, true);
|
||||
assert!(loaded.is_some() && warning.is_none());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
//! the in-window menus ([`lightcraft_ui_egui::menubar`]): the registry's menu paths, shortcuts,
|
||||
//! enabled and checked state, and live labels ("Undo Exposure").
|
||||
//!
|
||||
//! Key handling: a native key equivalent consumes the key press before egui sees it, so
|
||||
//! - shortcuts installed natively are listed in `LightcraftApp::native_shortcuts` and skipped by
|
||||
//! the egui shortcut handler (nothing fires twice);
|
||||
//! Key handling: AppKit offers a key press to the menu bar only when it carries ⌘ or ⌃, or is a
|
||||
//! function key (F1…). Every other key (`E`, `1`, `⇧P`, `⌥Y`…) goes straight to the window, whose
|
||||
//! winit view always takes it, so a menu item never fires from it. Hence
|
||||
//! - shortcuts the menu bar really receives ([`menu_delivers`]) are listed in
|
||||
//! `LightcraftApp::native_shortcuts` and skipped by the egui shortcut handler (nothing fires
|
||||
//! twice); the others stay on their menu items for display and egui runs them;
|
||||
//! - while a text field has keyboard focus, accelerators without ⌘ (`G`, `1`, `Delete`…) and the
|
||||
//! text-editing ones (⌘A/⌘C/⌘V/⌘X/⌘Z) are removed, so typing and text editing work; they come
|
||||
//! back when the field loses focus.
|
||||
@@ -146,6 +149,18 @@ fn yields_to_text(sc: &str) -> bool {
|
||||
!sc.contains("Cmd") && !sc.contains("Ctrl") || TEXT_EDIT.contains(&sc)
|
||||
}
|
||||
|
||||
/// Whether AppKit hands this shortcut to the menu bar: only key presses with ⌘ or ⌃, and function
|
||||
/// keys. A plain `C` or `⇧P` never reaches it (the window's view takes the key), so egui has to run
|
||||
/// those even though their menu item shows the key.
|
||||
fn menu_delivers(sc: &str) -> bool {
|
||||
sc.split('+').any(|part| matches!(part, "Cmd" | "Ctrl") || part.strip_prefix('F').is_some_and(|n| n.parse::<u8>().is_ok()))
|
||||
}
|
||||
|
||||
/// The shortcuts the menu bar runs itself, for `LightcraftApp::native_shortcuts`.
|
||||
fn owned_by_menu<'a>(installed: impl Iterator<Item = &'a str>, text_focus: bool) -> HashSet<String> {
|
||||
installed.filter(|sc| menu_delivers(sc) && !(text_focus && yields_to_text(sc))).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// `&` marks a mnemonic in muda labels.
|
||||
fn label_text(s: &str) -> String {
|
||||
lightcraft_ui_egui::i18n::tr(s).replace('&', "&&")
|
||||
@@ -329,13 +344,8 @@ impl NativeMenu {
|
||||
|
||||
/// Tell the egui shortcut handler which shortcuts the menu bar currently owns.
|
||||
fn publish_shortcuts(&self, app: &mut LightcraftApp) {
|
||||
app.native_shortcuts = self
|
||||
.items
|
||||
.values()
|
||||
.filter(|i| i.accel.is_some())
|
||||
.filter_map(|i| i.shortcut.clone())
|
||||
.filter(|sc| !(self.text_focus && yields_to_text(sc)))
|
||||
.collect::<HashSet<_>>();
|
||||
let installed = self.items.values().filter(|i| i.accel.is_some()).filter_map(|i| i.shortcut.as_deref());
|
||||
app.native_shortcuts = owned_by_menu(installed, self.text_focus);
|
||||
app.native_shortcuts.insert(SETTINGS_KEY.to_string());
|
||||
}
|
||||
|
||||
@@ -467,4 +477,39 @@ mod tests {
|
||||
assert!(accelerator(sc).is_some(), "{sc}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Single keys shown in the menu bar (E, C, ⇧P, ratings…) never reach it on macOS, so egui must
|
||||
/// keep handling them; ⌘ / ⌃ combinations and function keys are the menu bar's own.
|
||||
#[test]
|
||||
fn single_key_shortcuts_stay_with_egui() {
|
||||
assert!(menu_delivers("Cmd+Shift+H") && menu_delivers("Ctrl+H") && menu_delivers("F2") && menu_delivers("Cmd+F11"));
|
||||
assert!(!menu_delivers("E") && !menu_delivers("Shift+P") && !menu_delivers("Alt+Y") && !menu_delivers("1") && !menu_delivers("F"));
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
|
||||
fn all(n: &[MenuNode], out: &mut Vec<String>) {
|
||||
for x in n {
|
||||
match x {
|
||||
MenuNode::Item { shortcut: Some(s), .. } if accelerator(s).is_some() => out.push(s.clone()),
|
||||
MenuNode::Submenu { children, .. } => all(children, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut installed = Vec::new();
|
||||
for (_, v) in menu_bar(&app) {
|
||||
all(&v, &mut installed);
|
||||
}
|
||||
for sc in ["E", "C", "Shift+P", "Cmd+Shift+H", "F2"] {
|
||||
assert!(installed.iter().any(|s| s == sc), "{sc} is in the menu bar");
|
||||
}
|
||||
let owned = owned_by_menu(installed.iter().map(String::as_str), false);
|
||||
for sc in ["E", "C", "Shift+P", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "P", "U"] {
|
||||
assert!(installed.iter().any(|s| s == sc), "{sc} is displayed in the menu");
|
||||
assert!(!owned.contains(sc), "{sc} must be left to egui");
|
||||
}
|
||||
for sc in ["Cmd+Shift+H", "F2"] {
|
||||
assert!(owned.contains(sc), "{sc} is run by the menu bar");
|
||||
}
|
||||
// while typing, the menu bar gives up F2 too (the text field has it)
|
||||
assert!(!owned_by_menu(installed.iter().map(String::as_str), true).contains("F2"));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `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/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` |
|
||||
| `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none |
|
||||
| `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
|
||||
| `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 |
|
||||
| `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"repo": "storytold/lightcraft",
|
||||
"branch": "main",
|
||||
"head": "2694d7089fbf4ada62c6738a7c62fecdfd5711fb",
|
||||
"head_date": "2026-10-08T00:53:19Z",
|
||||
"commits": 424,
|
||||
"unattributed_commits": 0,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "echelon",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 71,
|
||||
"commits": 330,
|
||||
"lines_added": 125623,
|
||||
"lines_deleted": 8643,
|
||||
"binary_added": 38,
|
||||
"binary_deleted": 8,
|
||||
"first_commit": "2026-09-30T14:04:12Z",
|
||||
"last_commit": "2026-10-08T00:32:34Z"
|
||||
},
|
||||
{
|
||||
"login": "leipsfur",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 131,
|
||||
"lines_deleted": 5,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-02T15:51:58Z",
|
||||
"last_commit": "2026-10-02T15:51:58Z"
|
||||
},
|
||||
{
|
||||
"login": "GlemNL",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 9,
|
||||
"commits": 13,
|
||||
"lines_added": 1233,
|
||||
"lines_deleted": 96,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-02T21:16:29Z",
|
||||
"last_commit": "2026-10-04T09:42:39Z"
|
||||
},
|
||||
{
|
||||
"login": "storybored379",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 7,
|
||||
"commits": 7,
|
||||
"lines_added": 1194,
|
||||
"lines_deleted": 82,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-04T00:28:49Z",
|
||||
"last_commit": "2026-10-06T06:51:43Z"
|
||||
},
|
||||
{
|
||||
"login": "midasdf",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 424,
|
||||
"lines_deleted": 25,
|
||||
"binary_added": 1,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T16:07:26Z",
|
||||
"last_commit": "2026-10-05T16:07:26Z"
|
||||
},
|
||||
{
|
||||
"login": "voshart",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 12,
|
||||
"lines_added": 2481,
|
||||
"lines_deleted": 1389,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T16:23:27Z",
|
||||
"last_commit": "2026-10-07T16:07:24Z"
|
||||
},
|
||||
{
|
||||
"login": "arata-1972",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 4,
|
||||
"lines_added": 2892,
|
||||
"lines_deleted": 726,
|
||||
"binary_added": 2,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T20:14:35Z",
|
||||
"last_commit": "2026-10-05T22:16:15Z"
|
||||
},
|
||||
{
|
||||
"login": "benlukka",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 49,
|
||||
"lines_deleted": 4,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T11:18:03Z",
|
||||
"last_commit": "2026-10-06T11:18:03Z"
|
||||
},
|
||||
{
|
||||
"login": "kywk",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 72,
|
||||
"lines_deleted": 8,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T20:21:14Z",
|
||||
"last_commit": "2026-10-06T20:21:14Z"
|
||||
},
|
||||
{
|
||||
"login": "treyreynolds",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 159,
|
||||
"lines_deleted": 27,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T22:21:46Z",
|
||||
"last_commit": "2026-10-06T22:21:46Z"
|
||||
},
|
||||
{
|
||||
"login": "kovalskiguinther",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 1,
|
||||
"lines_added": 4328,
|
||||
"lines_deleted": 17,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T23:15:04Z",
|
||||
"last_commit": "2026-10-06T23:15:04Z"
|
||||
},
|
||||
{
|
||||
"login": "cruzer45",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 3,
|
||||
"lines_added": 658,
|
||||
"lines_deleted": 26,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T01:30:23Z",
|
||||
"last_commit": "2026-10-07T03:53:09Z"
|
||||
},
|
||||
{
|
||||
"login": "coygeek",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 4,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T02:27:15Z",
|
||||
"last_commit": "2026-10-07T02:27:15Z"
|
||||
},
|
||||
{
|
||||
"login": "lzjtugjc",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1895,
|
||||
"lines_deleted": 294,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T03:34:33Z",
|
||||
"last_commit": "2026-10-07T03:34:33Z"
|
||||
},
|
||||
{
|
||||
"login": "itsmunzir",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 46,
|
||||
"lines_deleted": 30,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T07:40:55Z",
|
||||
"last_commit": "2026-10-07T07:40:55Z"
|
||||
},
|
||||
{
|
||||
"login": "haraldh",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 5,
|
||||
"lines_added": 1163,
|
||||
"lines_deleted": 141,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T07:54:27Z",
|
||||
"last_commit": "2026-10-07T13:16:08Z"
|
||||
},
|
||||
{
|
||||
"login": "sakamoto824",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 2,
|
||||
"lines_added": 3649,
|
||||
"lines_deleted": 167,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T09:32:12Z",
|
||||
"last_commit": "2026-10-07T10:54:19Z"
|
||||
},
|
||||
{
|
||||
"login": "anarion80",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 402,
|
||||
"lines_deleted": 2,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T11:54:51Z",
|
||||
"last_commit": "2026-10-07T11:54:51Z"
|
||||
},
|
||||
{
|
||||
"login": "radialmonster",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 19,
|
||||
"lines_added": 2630,
|
||||
"lines_deleted": 259,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T12:33:51Z",
|
||||
"last_commit": "2026-10-08T00:46:10Z"
|
||||
},
|
||||
{
|
||||
"login": "bogusyogi",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 434,
|
||||
"lines_deleted": 29,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T17:06:00Z",
|
||||
"last_commit": "2026-10-07T17:06:00Z"
|
||||
},
|
||||
{
|
||||
"login": "nledford",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 8,
|
||||
"lines_added": 525,
|
||||
"lines_deleted": 60,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T17:33:38Z",
|
||||
"last_commit": "2026-10-07T22:49:00Z"
|
||||
},
|
||||
{
|
||||
"login": "MusseJusse",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 34,
|
||||
"lines_deleted": 3,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T18:38:23Z",
|
||||
"last_commit": "2026-10-07T18:38:23Z"
|
||||
},
|
||||
{
|
||||
"login": "Ndsanes",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 332,
|
||||
"lines_deleted": 86,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T19:08:28Z",
|
||||
"last_commit": "2026-10-07T19:08:33Z"
|
||||
},
|
||||
{
|
||||
"login": "RicSchonfelder",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1439,
|
||||
"lines_deleted": 6,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T20:03:20Z",
|
||||
"last_commit": "2026-10-07T20:03:20Z"
|
||||
},
|
||||
{
|
||||
"login": "Gremiger",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 19,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T21:43:23Z",
|
||||
"last_commit": "2026-10-07T21:43:23Z"
|
||||
},
|
||||
{
|
||||
"login": "DWAA1660",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 54,
|
||||
"lines_deleted": 12,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T22:08:01Z",
|
||||
"last_commit": "2026-10-07T22:08:01Z"
|
||||
},
|
||||
{
|
||||
"login": "agorevski",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 68,
|
||||
"lines_deleted": 11,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T22:18:44Z",
|
||||
"last_commit": "2026-10-07T22:18:44Z"
|
||||
},
|
||||
{
|
||||
"login": "andrewyao",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1,
|
||||
"lines_deleted": 1,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T00:53:19Z",
|
||||
"last_commit": "2026-10-08T00:53:19Z"
|
||||
},
|
||||
{
|
||||
"login": "GuintherKovalski",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 0,
|
||||
"lines_added": 0,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": null,
|
||||
"last_commit": null
|
||||
},
|
||||
{
|
||||
"login": "XRTce",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 0,
|
||||
"lines_added": 0,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": null,
|
||||
"last_commit": null
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
{
|
||||
"id": "anthropic/claude-opus/5.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "5.5",
|
||||
"commits": 364,
|
||||
"lines_added": 131810,
|
||||
"lines_deleted": 9367
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-sonnet/5.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Sonnet",
|
||||
"version": "5.5",
|
||||
"commits": 31,
|
||||
"lines_added": 3941,
|
||||
"lines_deleted": 1543
|
||||
},
|
||||
{
|
||||
"id": "openai/codex",
|
||||
"company": "OpenAI",
|
||||
"model": "Codex",
|
||||
"version": "",
|
||||
"commits": 14,
|
||||
"lines_added": 8341,
|
||||
"lines_deleted": 995
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude",
|
||||
"version": "",
|
||||
"commits": 2,
|
||||
"lines_added": 1304,
|
||||
"lines_deleted": 192
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-sonnet/5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Sonnet",
|
||||
"version": "5",
|
||||
"commits": 2,
|
||||
"lines_added": 298,
|
||||
"lines_deleted": 2
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-opus/4.8",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "4.8",
|
||||
"commits": 1,
|
||||
"lines_added": 1633,
|
||||
"lines_deleted": 3
|
||||
},
|
||||
{
|
||||
"id": "deepseek/deepseek/v3.2",
|
||||
"company": "DeepSeek",
|
||||
"model": "DeepSeek",
|
||||
"version": "V3.2",
|
||||
"commits": 1,
|
||||
"lines_added": 1895,
|
||||
"lines_deleted": 294
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
# display_name = "Nickname" # shown when the viewer picks "Display name"
|
||||
# sync_github_name = true # or: use the name on your public GitHub profile as display name
|
||||
#
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
@@ -0,0 +1,226 @@
|
||||
//! The library's folder tree: where on disk the imported photos live.
|
||||
//!
|
||||
//! A *folder* here is a folder holding at least one photo of the library (imported, not deleted,
|
||||
//! not merely browsed in Local; demo scenes have no folder). The tree lists those folders and the
|
||||
//! ones above them, never a folder that holds nothing of the library, with how many photos each
|
||||
//! holds — its subfolders included, like a year in By Date counts its months.
|
||||
//!
|
||||
//! Every folder is a row of its own, the ones above the imported photos included
|
||||
//! (`/Users` → `me` → `Pictures`), so a row is exactly the folder it names. A folder spelled two
|
||||
//! ways (`/` or `\`, repeated or trailing separators, `.`) is one folder (see
|
||||
//! [`crate::query::folder_key`]).
|
||||
//!
|
||||
//! Each disk is a *volume*, a top-level row of its own: a drive letter (`D:`), a Windows share
|
||||
//! (`//server/share`), a mounted disk or share (macOS `/Volumes/tokyo`, Linux `/media/me/usb`,
|
||||
//! `/mnt/disk`, `/run/user/1000/gvfs/smb-share:…`, `/net/host`) or, for everything else, the
|
||||
//! startup disk (`/`). Only the spelling of the stored paths tells them apart: no disk is
|
||||
//! touched, so a share that is asleep or gone lists and counts like one that is there, and the
|
||||
//! sidebar never waits on the network.
|
||||
//!
|
||||
//! Limits of reading only the stored spelling of a path: file names are compared as written
|
||||
//! (Windows folds case, other systems do not), so on a case-insensitive disk `/Volumes/tokyo` and
|
||||
//! `/Volumes/TOKYO` are two volumes here; and one place reached by two spellings
|
||||
//! (`/Volumes/Macintosh HD/Users/me` and `/Users/me`, `/media/me/usb` and `/run/media/me/usb`) is
|
||||
//! two rows. Each row is still consistent: choosing it shows exactly the photos it counts.
|
||||
//!
|
||||
//! This is the library's own view of its photos; browsing any folder on disk without importing
|
||||
//! is Local's job (see [`crate::local`]).
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::query::{folder_key, folder_within, split_path};
|
||||
use crate::{Catalog, Source};
|
||||
|
||||
/// Folder levels kept per photo. No real path comes near it; a deeper one is counted at its
|
||||
/// ancestor this many levels down, which bounds the tree's depth (and so every walk of it)
|
||||
/// whatever a catalog holds.
|
||||
pub const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// One row of the library's folder tree.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct FolderNode {
|
||||
/// What the row says: the folder's name; for a volume, its name (`nas`, `D:`,
|
||||
/// `//server/share`, `/` for the startup disk).
|
||||
pub name: String,
|
||||
/// The folder the row stands for, written out from the names of the photos' paths (forward
|
||||
/// slashes, no verbatim prefix); hand it back to [`crate::Filter::library_folder`] to see its
|
||||
/// photos.
|
||||
pub path: String,
|
||||
/// Photos in this folder or any folder inside it.
|
||||
pub count: usize,
|
||||
/// Photos directly in this folder.
|
||||
pub own: usize,
|
||||
/// A disk rather than a folder in one; its `path` is where it is mounted.
|
||||
pub volume: bool,
|
||||
/// Whether choosing the row (`Filter::library_folder`) shows exactly its `count` photos. Not
|
||||
/// so for the startup disk and for folders that hold other disks (`/Volumes`, `/mnt`…): their
|
||||
/// path covers those disks' photos too, so choosing them is not offered.
|
||||
pub selectable: bool,
|
||||
pub children: Vec<FolderNode>,
|
||||
}
|
||||
|
||||
/// A folder while the tree is being built (children by identity key).
|
||||
struct Level {
|
||||
name: String,
|
||||
path: String,
|
||||
own: usize,
|
||||
volume: bool,
|
||||
children: Vec<String>,
|
||||
}
|
||||
|
||||
/// Where a stored folder path lies: its volume and the folder names below it.
|
||||
struct Placed {
|
||||
mount: String,
|
||||
volume: String,
|
||||
names: Vec<String>,
|
||||
}
|
||||
|
||||
/// A gvfs mount's name made readable: `smb-share:server=tokyo,share=photos` → `photos on tokyo`;
|
||||
/// any other (`sftp:host=nas`) as it is.
|
||||
fn mount_name(n: &str) -> String {
|
||||
let field = |key: &str| {
|
||||
let rest = n.strip_prefix("smb-share:")?;
|
||||
rest.split(',').find_map(|kv| kv.strip_prefix(key)?.strip_prefix('=')).filter(|v| !v.is_empty())
|
||||
};
|
||||
match (field("share"), field("server")) {
|
||||
(Some(share), Some(server)) => format!("{share} on {server}"),
|
||||
_ => n.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The volume `dir` lies on and the folder names below it, read the way [`folder_key`] reads a
|
||||
/// path (so a row and the filter that chooses it never disagree). `None` for a path that is not
|
||||
/// absolute or names no share.
|
||||
fn place(dir: &str) -> Option<Placed> {
|
||||
let sp = split_path(dir, false);
|
||||
let names: Vec<&str> = sp.parts.iter().map(String::as_str).collect();
|
||||
let named = |mount: String, volume: &str, used: usize| (mount, volume.to_string(), used);
|
||||
let (mount, volume, used) = if sp.prefix.starts_with("//") {
|
||||
// `//server/share`: a server alone (no share) is no place to keep photos
|
||||
if sp.prefix.ends_with('/') {
|
||||
return None;
|
||||
}
|
||||
named(sp.prefix.clone(), &sp.prefix, 0)
|
||||
} else if !sp.prefix.is_empty() {
|
||||
named(sp.prefix.clone(), &sp.prefix, 0)
|
||||
} else if sp.absolute {
|
||||
match names.as_slice() {
|
||||
["Volumes", n, ..] => named(format!("/Volumes/{n}"), n, 2),
|
||||
["run", "media", u, n, ..] => named(format!("/run/media/{u}/{n}"), n, 4),
|
||||
["media", u, n, ..] => named(format!("/media/{u}/{n}"), n, 3),
|
||||
["mnt", n, ..] => named(format!("/mnt/{n}"), n, 2),
|
||||
// Linux desktops mount network places per user, naming server and share
|
||||
["run", "user", uid, "gvfs", n, ..] => named(format!("/run/user/{uid}/gvfs/{n}"), &mount_name(n), 5),
|
||||
// NFS automounts: Linux `/net/<host>`, macOS `/Network/Servers/<host>`
|
||||
["net", host, ..] => named(format!("/net/{host}"), host, 2),
|
||||
["Network", "Servers", host, ..] => named(format!("/Network/Servers/{host}"), host, 3),
|
||||
_ => named("/".to_string(), "/", 0),
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(Placed { mount, volume, names: names.iter().skip(used).take(MAX_DEPTH).map(|n| n.to_string()).collect() })
|
||||
}
|
||||
|
||||
/// The disk or share (`/Volumes/nas`, `D:`, `/` for the startup disk) a file-backed photo is on,
|
||||
/// from its stored path alone.
|
||||
pub fn volume_of(p: &crate::Photo) -> Option<String> {
|
||||
let Source::File { path } = &p.source else { return None };
|
||||
let dir = path.rfind(['/', '\\']).and_then(|i| path.get(..i.max(1)))?;
|
||||
place(dir).map(|pl| folder_key(&pl.mount))
|
||||
}
|
||||
|
||||
/// Whether `path` is a whole disk or share (`/`, `C:\`, `\\srv\share`, `/Volumes/nas`), not a
|
||||
/// folder in one.
|
||||
pub fn is_disk_root(path: &str) -> bool {
|
||||
place(path).is_some_and(|p| p.names.is_empty())
|
||||
}
|
||||
|
||||
/// Whether `path` is the startup disk's root (`/`), whose path also covers every other disk.
|
||||
pub fn is_startup_disk(path: &str) -> bool {
|
||||
place(path).is_some_and(|p| p.names.is_empty() && p.mount == "/")
|
||||
}
|
||||
|
||||
/// A folder named by its last two names (`photos/travel`), so two folders called `travel` are
|
||||
/// told apart; the path itself when it has no names (`/`).
|
||||
pub fn folder_label(path: &str) -> String {
|
||||
let names: Vec<&str> = path.split(['/', '\\']).filter(|n| !n.is_empty()).collect();
|
||||
if names.is_empty() { path.to_string() } else { names.iter().skip(names.len().saturating_sub(2)).copied().collect::<Vec<_>>().join("/") }
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// The library's volumes with their folders and photo counts: volumes by name
|
||||
/// (case-insensitive), each folder with its subfolders the same way.
|
||||
pub fn folder_tree(&self) -> Vec<FolderNode> {
|
||||
// photos per containing folder, as spelled (sorted, so the spelling that names a folder
|
||||
// never depends on hash order)
|
||||
let mut dirs: BTreeMap<&str, usize> = BTreeMap::new();
|
||||
for p in self.photos().filter(|p| p.in_library()) {
|
||||
if let Source::File { path } = &p.source
|
||||
&& let Some(i) = path.rfind(['/', '\\'])
|
||||
// a photo straight in the root has an empty folder part: the root itself
|
||||
&& let Some(dir) = path.get(..i.max(1))
|
||||
{
|
||||
*dirs.entry(dir).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let mut levels: HashMap<String, Level> = HashMap::new();
|
||||
let mut roots: Vec<String> = Vec::new();
|
||||
for (dir, n) in dirs {
|
||||
let Some(placed) = place(dir) else { continue };
|
||||
let vkey = folder_key(&placed.mount);
|
||||
if !levels.contains_key(&vkey) {
|
||||
levels.insert(
|
||||
vkey.clone(),
|
||||
Level { name: placed.volume.clone(), path: placed.mount.clone(), own: 0, volume: true, children: Vec::new() },
|
||||
);
|
||||
roots.push(vkey.clone());
|
||||
}
|
||||
let mut prefix = if placed.mount == "/" { String::new() } else { placed.mount.clone() };
|
||||
let mut parent = vkey;
|
||||
for name in &placed.names {
|
||||
prefix.push('/');
|
||||
prefix.push_str(name);
|
||||
let key = folder_key(&prefix);
|
||||
if !levels.contains_key(&key) {
|
||||
levels.insert(key.clone(), Level { name: name.clone(), path: prefix.clone(), own: 0, volume: false, children: Vec::new() });
|
||||
if let Some(l) = levels.get_mut(&parent) {
|
||||
l.children.push(key.clone());
|
||||
}
|
||||
}
|
||||
parent = key;
|
||||
}
|
||||
if let Some(l) = levels.get_mut(&parent) {
|
||||
l.own += n;
|
||||
}
|
||||
}
|
||||
let mut tree: Vec<FolderNode> = roots.iter().filter_map(|k| build(k, &levels)).collect();
|
||||
sort(&mut tree);
|
||||
// a folder that holds another disk's mount covers that disk's photos too
|
||||
let mounts: Vec<String> = tree.iter().filter(|v| v.path != "/").map(|v| v.path.clone()).collect();
|
||||
mark_selectable(&mut tree, &mounts);
|
||||
tree
|
||||
}
|
||||
}
|
||||
|
||||
/// The node for `key` with everything below it (at most [`MAX_DEPTH`] deep).
|
||||
fn build(key: &str, levels: &HashMap<String, Level>) -> Option<FolderNode> {
|
||||
let l = levels.get(key)?;
|
||||
let mut children: Vec<FolderNode> = l.children.iter().filter_map(|k| build(k, levels)).collect();
|
||||
sort(&mut children);
|
||||
let count = l.own + children.iter().map(|c| c.count).sum::<usize>();
|
||||
Some(FolderNode { name: l.name.clone(), path: l.path.clone(), count, own: l.own, volume: l.volume, selectable: true, children })
|
||||
}
|
||||
|
||||
fn mark_selectable(nodes: &mut [FolderNode], mounts: &[String]) {
|
||||
for n in nodes {
|
||||
n.selectable = !(n.volume && n.path == "/") && (n.volume || !mounts.iter().any(|m| folder_within(m, &n.path)));
|
||||
mark_selectable(&mut n.children, mounts);
|
||||
}
|
||||
}
|
||||
|
||||
fn sort(v: &mut [FolderNode]) {
|
||||
v.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()).then_with(|| a.name.cmp(&b.name)));
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod dates;
|
||||
pub mod folders;
|
||||
pub mod journal;
|
||||
pub mod keywords;
|
||||
pub mod local;
|
||||
@@ -29,13 +30,14 @@ use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use dates::{DateRun, GroupBy};
|
||||
pub use folders::FolderNode;
|
||||
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, Person, RatingOp, Sort, SortKey};
|
||||
pub use query::{DateGroup, Filter, Person, RatingOp, Sort, SortKey, mix64};
|
||||
pub use rules::{Match, Rule, RuleSet};
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use store::{FsStore, MemStore, Store};
|
||||
@@ -612,6 +614,22 @@ impl Catalog {
|
||||
Op::Batch { ops }
|
||||
}
|
||||
|
||||
/// [`Self::delete_permanently_ops`] for several photos at once. One op list built against the
|
||||
/// catalog as it is now: albums lose all of them in one write each, and stacks are shortened
|
||||
/// (or dissolved) once, however many of their photos go.
|
||||
pub fn delete_photos_permanently_ops(&self, ids: &[PhotoId]) -> Op {
|
||||
let gone: std::collections::HashSet<PhotoId> = ids.iter().copied().collect();
|
||||
let mut ops: Vec<Op> = self
|
||||
.albums
|
||||
.values()
|
||||
.filter(|a| a.photos.iter().any(|p| gone.contains(p)))
|
||||
.map(|a| Op::SetAlbumPhotos { id: a.id, photos: a.photos.iter().copied().filter(|p| !gone.contains(p)).collect() })
|
||||
.collect();
|
||||
ops.extend(self.remove_from_stacks_ops(ids));
|
||||
ops.extend(gone.iter().map(|id| Op::RemovePhoto { id: *id }));
|
||||
Op::Batch { ops }
|
||||
}
|
||||
|
||||
// ---- persistence
|
||||
|
||||
/// Full snapshot as JSON.
|
||||
@@ -655,6 +673,8 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_background;
|
||||
#[cfg(test)]
|
||||
mod tests_folders;
|
||||
#[cfg(test)]
|
||||
mod tests_format_version;
|
||||
#[cfg(test)]
|
||||
mod tests_journal;
|
||||
|
||||
@@ -336,11 +336,12 @@ impl Photo {
|
||||
pub fn develops_raw(&self) -> bool {
|
||||
self.kind == MediaKind::Raw && self.preview_only.is_none()
|
||||
}
|
||||
/// The current ARW and NEF readers have vendor WB multipliers but no measured camera
|
||||
/// The current ARW, NEF and RW2 readers have vendor WB multipliers but no measured camera
|
||||
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
|
||||
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats).
|
||||
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and
|
||||
/// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files).
|
||||
pub fn relative_wb(&self) -> bool {
|
||||
self.develops_raw() && ["ARW", "NEF", "NRW"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
|
||||
self.develops_raw() && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
|
||||
}
|
||||
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
|
||||
/// default preset applied on top of them ([`Photo::import_look`]).
|
||||
@@ -430,7 +431,15 @@ mod edited_tests {
|
||||
|
||||
#[test]
|
||||
fn sony_and_nikon_raws_use_relative_white_balance() {
|
||||
for (name, format, relative) in [("a.arw", "ARW", true), ("a.nef", "NEF", true), ("a.nrw", "nrw", true), ("a.dng", "DNG", false)] {
|
||||
for (name, format, relative) in [
|
||||
("a.arw", "ARW", true),
|
||||
("a.nef", "NEF", true),
|
||||
("a.nrw", "nrw", true),
|
||||
("a.rw2", "RW2", true),
|
||||
("a.rwl", "RWL", true),
|
||||
("a.raw", "RAW", true),
|
||||
("a.dng", "DNG", false),
|
||||
] {
|
||||
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00");
|
||||
p.kind = MediaKind::Raw;
|
||||
p.as_shot_wb = Some((5200.0, 4.0));
|
||||
|
||||
+114
-20
@@ -37,6 +37,10 @@ pub struct Filter {
|
||||
pub deleted: bool,
|
||||
/// Capture date prefix (`2026`, `2026-04`, `2026-04-12`).
|
||||
pub date: Option<String>,
|
||||
/// A folder the library's photos were imported from: those photos and the ones imported from
|
||||
/// the folders inside it (see [`crate::folders`]). Photos only browsed in Local are never part
|
||||
/// of it, unlike [`Filter::folder`].
|
||||
pub library_folder: Option<String>,
|
||||
/// A keyword; hierarchical keywords match their children too (`travel` finds `travel|italy`).
|
||||
pub keyword: Option<String>,
|
||||
/// A person: photos with a named face region of this name (case-insensitive), as read from XMP.
|
||||
@@ -72,6 +76,9 @@ pub enum SortKey {
|
||||
FileName,
|
||||
Rating,
|
||||
FileSize,
|
||||
/// A shuffle fixed by [`Sort::seed`]: the same seed always gives the same order, and photos
|
||||
/// added or edited later never reshuffle the others. Pick a new seed to reshuffle.
|
||||
Random,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -81,14 +88,30 @@ pub struct Sort {
|
||||
pub ascending: bool,
|
||||
/// Date headers in the grid (date sort keys only).
|
||||
pub group: crate::GroupBy,
|
||||
/// Which shuffle [`SortKey::Random`] gives (ignored by the other keys).
|
||||
pub seed: u64,
|
||||
}
|
||||
|
||||
impl Default for Sort {
|
||||
fn default() -> Self {
|
||||
Sort { key: SortKey::CaptureDate, ascending: false, group: crate::GroupBy::Auto }
|
||||
Sort { key: SortKey::CaptureDate, ascending: false, group: crate::GroupBy::Auto, seed: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// splitmix64 finaliser: a stateless, platform-independent mix (no RNG state, no `rand` version
|
||||
/// to drift), so a seed names the same shuffle on every machine.
|
||||
pub fn mix64(x: u64) -> u64 {
|
||||
let mut z = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// A photo's place in the shuffle for `seed`.
|
||||
fn shuffle_rank(seed: u64, id: PhotoId) -> u64 {
|
||||
mix64(mix64(seed) ^ id.0)
|
||||
}
|
||||
|
||||
fn token_matches(p: &Photo, tok: &str) -> bool {
|
||||
let t = tok.to_lowercase();
|
||||
if let Some((field, val)) = t.split_once(':') {
|
||||
@@ -167,6 +190,7 @@ impl Filter {
|
||||
("camera", &self.camera),
|
||||
("lens", &self.lens),
|
||||
("date", &self.date),
|
||||
("folder", &self.library_folder),
|
||||
("imported", &self.imported),
|
||||
] {
|
||||
if let Some(x) = val {
|
||||
@@ -194,6 +218,19 @@ impl Filter {
|
||||
}
|
||||
|
||||
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
|
||||
self.matches_in(p, cat, self.library_root().as_deref())
|
||||
}
|
||||
|
||||
/// [`Filter::library_folder`] as a folder identity ([`folder_key`]); `None` without a choice.
|
||||
fn library_root(&self) -> Option<String> {
|
||||
// as given: a folder may be named with spaces at its edge, and trimming would make it
|
||||
// another folder
|
||||
self.library_folder.as_deref().filter(|d| !d.trim().is_empty()).map(folder_key)
|
||||
}
|
||||
|
||||
/// [`Filter::matches`] with the library folder already turned into its identity, so a query
|
||||
/// over a big library does that once, not once per photo.
|
||||
fn matches_in(&self, p: &Photo, cat: &Catalog, root: Option<&str>) -> bool {
|
||||
if p.deleted != self.deleted {
|
||||
return false;
|
||||
}
|
||||
@@ -266,6 +303,11 @@ impl Filter {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(root) = root
|
||||
&& !photo_in_root(p, root)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(d) = &self.imported
|
||||
&& !p.imported.starts_with(d.as_str())
|
||||
{
|
||||
@@ -298,7 +340,8 @@ impl Filter {
|
||||
impl Catalog {
|
||||
/// Photos matching `filter`, in `sort` order (ties broken by id for stability).
|
||||
pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec<PhotoId> {
|
||||
let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches(p, self)).collect();
|
||||
let root = filter.library_root();
|
||||
let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, root.as_deref())).collect();
|
||||
v.sort_by(|a, b| {
|
||||
let o = match sort.key {
|
||||
SortKey::CaptureDate => a.date().cmp(b.date()),
|
||||
@@ -307,6 +350,7 @@ impl Catalog {
|
||||
SortKey::FileName => a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()),
|
||||
SortKey::Rating => a.rating.cmp(&b.rating),
|
||||
SortKey::FileSize => a.file_size.cmp(&b.file_size),
|
||||
SortKey::Random => shuffle_rank(sort.seed, a.id).cmp(&shuffle_rank(sort.seed, b.id)),
|
||||
}
|
||||
.then(a.id.cmp(&b.id));
|
||||
if sort.ascending { o } else { o.reverse() }
|
||||
@@ -364,7 +408,8 @@ impl Catalog {
|
||||
pub fn people_in(&self, filter: &Filter) -> Vec<Person> {
|
||||
let filter = Filter { person: None, ..filter.clone() };
|
||||
let mut m: std::collections::HashMap<String, (Person, f64)> = Default::default();
|
||||
for p in self.photos().filter(|p| filter.matches(p, self)) {
|
||||
let root = filter.library_root();
|
||||
for p in self.photos().filter(|p| filter.matches_in(p, self, root.as_deref())) {
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
for r in p.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) {
|
||||
let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue };
|
||||
@@ -418,12 +463,18 @@ pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool {
|
||||
!rest.is_empty() && (deep || !rest.contains(['/', '\\']))
|
||||
}
|
||||
|
||||
/// A folder path's identity, for telling whether two spellings name the same folder: `/` and
|
||||
/// `\\` are both separators, repeated separators, `.` and a trailing separator are dropped,
|
||||
/// `..` is resolved lexically, a Windows verbatim prefix (`\\?\`) is removed and the drive
|
||||
/// letter lower-cased; on Windows (case-insensitive file names) the whole path is lower-cased.
|
||||
/// No file-system access: the paths compared should already be absolute.
|
||||
pub fn folder_key(path: &str) -> String {
|
||||
/// A path read the way [`folder_key`] reads it: the part `..` can't climb above (`//server/share`,
|
||||
/// `c:`; empty for `/` and relative paths), the names below it, and whether it is absolute.
|
||||
pub(crate) struct SplitPath {
|
||||
pub prefix: String,
|
||||
pub parts: Vec<String>,
|
||||
pub absolute: bool,
|
||||
}
|
||||
|
||||
/// Both separators, repeated separators (also between a server and its share), `.` and a
|
||||
/// trailing separator dropped, `..` resolved lexically, a Windows verbatim prefix removed;
|
||||
/// `lower_drive` lower-cases a drive letter (the identity does, a display name wants it as is).
|
||||
pub(crate) fn split_path(path: &str, lower_drive: bool) -> SplitPath {
|
||||
let mut s = path.replace('\\', "/");
|
||||
if let Some(rest) = s.strip_prefix("//?/").or_else(|| s.strip_prefix("//./")) {
|
||||
s = match rest.strip_prefix("UNC/") {
|
||||
@@ -431,28 +482,39 @@ pub fn folder_key(path: &str) -> String {
|
||||
None => rest.to_string(),
|
||||
};
|
||||
}
|
||||
// the part `..` can't climb above: `//server/share`, `c:` or `/`
|
||||
let (prefix, rest) = if let Some(unc) = s.strip_prefix("//").filter(|u| !u.is_empty() && !u.starts_with('/')) {
|
||||
let mut it = unc.splitn(3, '/');
|
||||
let (server, share, rest) = (it.next().unwrap_or(""), it.next().unwrap_or(""), it.next().unwrap_or(""));
|
||||
(format!("//{server}/{share}"), rest.to_string())
|
||||
} else if s.len() >= 2 && s.as_bytes()[1] == b':' && s.as_bytes()[0].is_ascii_alphabetic() {
|
||||
(s[..2].to_ascii_lowercase(), s[2..].to_string())
|
||||
let mut segs = unc.split('/').filter(|x| !x.is_empty());
|
||||
let (server, share) = (segs.next().unwrap_or(""), segs.next().unwrap_or(""));
|
||||
(format!("//{server}/{share}"), segs.collect::<Vec<_>>().join("/"))
|
||||
} else if let (true, Some(drive), Some(rest)) = (matches!(s.as_bytes(), [a, b':', ..] if a.is_ascii_alphabetic()), s.get(..2), s.get(2..)) {
|
||||
(if lower_drive { drive.to_ascii_lowercase() } else { drive.to_string() }, rest.to_string())
|
||||
} else {
|
||||
(String::new(), s.clone())
|
||||
};
|
||||
let absolute = rest.starts_with('/') || !prefix.is_empty();
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
for c in rest.split('/') {
|
||||
match c {
|
||||
"" | "." => {}
|
||||
".." if parts.last().is_some_and(|p| *p != "..") => {
|
||||
".." if parts.last().is_some_and(|p| p != "..") => {
|
||||
parts.pop();
|
||||
}
|
||||
".." if absolute => {}
|
||||
c => parts.push(c),
|
||||
c => parts.push(c.to_string()),
|
||||
}
|
||||
}
|
||||
SplitPath { prefix, parts, absolute }
|
||||
}
|
||||
|
||||
/// A folder path's identity, for telling whether two spellings name the same folder: `/` and
|
||||
/// `\\` are both separators, repeated separators, `.` and a trailing separator are dropped,
|
||||
/// `..` is resolved lexically, a Windows verbatim prefix (`\\?\`) is removed and the drive
|
||||
/// letter lower-cased; on Windows (case-insensitive file names) the whole path is lower-cased.
|
||||
/// File names are otherwise compared as written, so on a case-insensitive disk elsewhere
|
||||
/// (macOS, a Samba share) two spellings that differ in case are two folders here.
|
||||
/// No file-system access: the paths compared should already be absolute.
|
||||
pub fn folder_key(path: &str) -> String {
|
||||
let SplitPath { prefix, parts, absolute } = split_path(path, true);
|
||||
let key = if absolute { format!("{prefix}/{}", parts.join("/")) } else { parts.join("/") };
|
||||
if cfg!(windows) { key.to_lowercase() } else { key }
|
||||
}
|
||||
@@ -460,8 +522,40 @@ pub fn folder_key(path: &str) -> String {
|
||||
/// Whether `path` is folder `root` or lies somewhere inside it, however either is spelled
|
||||
/// (see [`folder_key`]).
|
||||
pub fn folder_within(path: &str, root: &str) -> bool {
|
||||
let (p, r) = (folder_key(path), folder_key(root));
|
||||
p == r || (p.starts_with(&r) && (r.ends_with('/') || p[r.len()..].starts_with('/')))
|
||||
key_within(&folder_key(path), &folder_key(root))
|
||||
}
|
||||
|
||||
/// Whether a library photo (not one only browsed in Local) lies in the folder whose
|
||||
/// [`folder_key`] is `root`. A plain POSIX path (no `\`, `.`, `..`, repeated or trailing
|
||||
/// separators) is its own identity, so it is read without building a key for it: the check runs
|
||||
/// for every photo of the library on each refresh, and this keeps it cheap.
|
||||
pub(crate) fn photo_in_root(p: &Photo, root: &str) -> bool {
|
||||
if p.local {
|
||||
return false;
|
||||
}
|
||||
let crate::Source::File { path } = &p.source else { return false };
|
||||
let plain = path.starts_with('/')
|
||||
&& !path.starts_with("//")
|
||||
&& !path.ends_with('/')
|
||||
&& !path.contains(['\\'])
|
||||
&& !path.contains("//")
|
||||
&& !path.contains("/.");
|
||||
if !cfg!(windows) && plain && root.starts_with('/') && !root.starts_with("//") {
|
||||
let r = root.trim_end_matches('/');
|
||||
return path.strip_prefix(r).is_some_and(|rest| rest.starts_with('/'));
|
||||
}
|
||||
crate::local::folder_of(p).is_some_and(|f| key_within(&f, root))
|
||||
}
|
||||
|
||||
/// [`folder_within`] for two paths already turned into their [`folder_key`].
|
||||
pub(crate) fn key_within(p: &str, r: &str) -> bool {
|
||||
// `.` or `a/..` name no folder: not "everything"
|
||||
if r.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// a disk's root key ends in `/` (`c:/`, `//srv/share/`), a folder's does not: same folder
|
||||
let (p, r) = (p.trim_end_matches('/'), r.trim_end_matches('/'));
|
||||
p == r || p.strip_prefix(r).is_some_and(|rest| rest.starts_with('/'))
|
||||
}
|
||||
|
||||
/// The kind of Photo Merge result a file is, from the name merges give it (`IMG_1-HDR.dng`,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{Catalog, Photo};
|
||||
use crate::{Catalog, Photo, Source};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -62,6 +62,7 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[
|
||||
("keywords", "Keywords", Kind::Keywords),
|
||||
("text", "Any Searchable Text", Kind::Text),
|
||||
("fileName", "Filename", Kind::Text),
|
||||
("filePath", "File Path", Kind::Text),
|
||||
("format", "File Format", Kind::Text),
|
||||
("title", "Title", Kind::Text),
|
||||
("caption", "Caption", Kind::Text),
|
||||
@@ -304,6 +305,20 @@ impl Rule {
|
||||
text(&all)
|
||||
}
|
||||
"fileName" => text(&p.file_name),
|
||||
"filePath" => {
|
||||
// `/` and `\` both separate, whatever platform the catalog came from; demo photos have no path
|
||||
let path = match &p.source {
|
||||
Source::File { path } => path.replace('\\', "/"),
|
||||
Source::Demo { .. } => String::new(),
|
||||
};
|
||||
let want = want.replace('\\', "/");
|
||||
match op {
|
||||
// the whole string, spaces included (not word by word like the other text fields)
|
||||
"contains" => !want.trim().is_empty() && path.to_lowercase().contains(want.trim()),
|
||||
"notContains" => want.trim().is_empty() || !path.to_lowercase().contains(want.trim()),
|
||||
_ => text_op(op, &path, &want),
|
||||
}
|
||||
}
|
||||
"format" => text(&p.format),
|
||||
"title" => text(&m.title),
|
||||
"caption" => text(&m.caption),
|
||||
@@ -445,6 +460,17 @@ mod tests {
|
||||
);
|
||||
no(json!({"rules": [{"field": "keywords", "op": "isEmpty"}]}));
|
||||
yes(json!({"rules": [{"field": "fileName", "op": "startsWith", "value": "img_"}, {"field": "fileName", "op": "endsWith", "value": ".cr2"}]}));
|
||||
// file path: the whole string (spaces included), either separator, case-insensitive
|
||||
let mut fp = photo(3);
|
||||
fp.source = Source::File { path: "D:\\Photos\\Aliah Ira Polanco-Grylls\\2026\\IMG_0042.CR2".into() };
|
||||
let fpm = |v: serde_json::Value| rs(v).matches(&fp, &cat);
|
||||
assert!(fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Aliah Ira Polanco-Grylls/"}]})));
|
||||
assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Ira Aliah/"}]})));
|
||||
assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "Polanco Grylls"}]})));
|
||||
assert!(fpm(json!({"rules": [{"field": "filePath", "op": "notContains", "value": "/Other/"}]})));
|
||||
assert!(fpm(json!({"rules": [{"field": "filePath", "op": "startsWith", "value": "d:/photos/"}]})));
|
||||
no(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Aliah/"}]})); // demo photo has no path
|
||||
yes(json!({"rules": [{"field": "filePath", "op": "isEmpty"}]}));
|
||||
yes(json!({"rules": [{"field": "camera", "op": "contains", "value": "x2"}, {"field": "title", "op": "isEmpty"}]}));
|
||||
yes(json!({"rules": [{"field": "iso", "op": "between", "value": [800, 3200]}, {"field": "aperture", "op": "lte", "value": "f/4"}]}));
|
||||
yes(json!({"rules": [{"field": "megapixels", "op": "gte", "value": 24}]}));
|
||||
|
||||
@@ -380,3 +380,69 @@ fn empty_regions_are_not_serialized_and_default_when_missing() {
|
||||
let back: Meta = serde_json::from_str(&serde_json::to_string(&with).unwrap()).unwrap();
|
||||
assert_eq!(back, with);
|
||||
}
|
||||
|
||||
// ---- random sort: Given a seed, the order is a stable, reproducible shuffle of the matching photos
|
||||
|
||||
fn many(c: &mut Catalog, n: usize) -> Vec<PhotoId> {
|
||||
(0..n).map(|i| photo(c, &format!("p{i:03}.jpg"), &format!("2026-04-{:02}T10:00:00", i % 28 + 1))).collect()
|
||||
}
|
||||
|
||||
fn random(seed: u64) -> Sort {
|
||||
Sort { key: SortKey::Random, seed, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_sort_is_a_reproducible_permutation() {
|
||||
let mut c = Catalog::new();
|
||||
let ids = many(&mut c, 50);
|
||||
let a = c.query(&Filter::default(), &random(7));
|
||||
assert_eq!(a, c.query(&Filter::default(), &random(7)), "same seed, same order");
|
||||
let mut sorted = a.clone();
|
||||
sorted.sort();
|
||||
let mut all = ids.clone();
|
||||
all.sort();
|
||||
assert_eq!(sorted, all, "every photo exactly once");
|
||||
assert_ne!(a, c.query(&Filter::default(), &random(8)), "another seed, another order");
|
||||
assert_ne!(a, c.query(&Filter::default(), &Sort::default()), "not just the date order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_sort_keeps_the_relative_order_when_the_set_changes() {
|
||||
let mut c = Catalog::new();
|
||||
let ids = many(&mut c, 30);
|
||||
let before = c.query(&Filter::default(), &random(3));
|
||||
let added = photo(&mut c, "new.jpg", "2026-05-01T10:00:00");
|
||||
c.apply(Op::SetRating { id: ids[0], rating: 5 }).unwrap();
|
||||
let after: Vec<PhotoId> = c.query(&Filter::default(), &random(3)).into_iter().filter(|id| *id != added).collect();
|
||||
assert_eq!(after, before, "adding a photo or editing metadata must not reshuffle the others");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_sort_respects_the_filter_and_handles_tiny_sets() {
|
||||
let mut c = Catalog::new();
|
||||
assert!(c.query(&Filter::default(), &random(1)).is_empty());
|
||||
let ids = many(&mut c, 10);
|
||||
c.apply(Op::SetRating { id: ids[4], rating: 5 }).unwrap();
|
||||
assert_eq!(c.query(&Filter { rating: 5, ..Default::default() }, &random(1)), vec![ids[4]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_sort_direction_reverses_and_serde_defaults_hold() {
|
||||
let mut c = Catalog::new();
|
||||
many(&mut c, 20);
|
||||
let desc = c.query(&Filter::default(), &Sort { ascending: false, ..random(5) });
|
||||
let mut asc = c.query(&Filter::default(), &Sort { ascending: true, ..random(5) });
|
||||
asc.reverse();
|
||||
assert_eq!(desc, asc);
|
||||
// saved sorts from before the seed existed still load
|
||||
let old: Sort = serde_json::from_str(r#"{"key":"fileName","ascending":true}"#).unwrap();
|
||||
assert_eq!(old.seed, 0);
|
||||
assert_eq!(serde_json::from_str::<Sort>(r#"{"key":"random","seed":9}"#).unwrap(), Sort { key: SortKey::Random, seed: 9, ..Default::default() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_sort_has_no_date_headers() {
|
||||
let mut c = Catalog::new();
|
||||
let ids = many(&mut c, 5);
|
||||
assert!(c.date_runs(&ids, SortKey::Random, GroupBy::Day).is_empty());
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
//! The library's folder tree: where the imported photos live on disk (see [`crate::folders`]).
|
||||
//!
|
||||
//! Scenarios, in the words of someone browsing their library:
|
||||
//!
|
||||
//! * Given photos imported from several folders, the tree lists those folders and no others,
|
||||
//! each with how many photos it holds, under the disk (volume) they are on.
|
||||
//! * A folder's count includes the folders inside it.
|
||||
//! * Every folder is a row of its own, the ones above the imported photos included.
|
||||
//! * Photos only browsed in Local, deleted photos and demo scenes are not "imported from" anywhere.
|
||||
//! * However a folder is spelled, it is one folder.
|
||||
//! * Choosing a folder shows what was imported from it and from the folders inside it — exactly
|
||||
//! as many photos as the row says.
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn add(c: &mut Catalog, path: &str) -> PhotoId {
|
||||
add_with(c, path, |_| {})
|
||||
}
|
||||
|
||||
fn add_with(c: &mut Catalog, path: &str, tweak: impl FnOnce(&mut Photo)) -> PhotoId {
|
||||
let id = c.alloc_photo_id();
|
||||
let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
|
||||
let mut p = Photo::new(id, Source::File { path: path.into() }, name, "JPEG", 60, 40, "2026-01-01T10:00:00");
|
||||
tweak(&mut p);
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn library(paths: &[&str]) -> Catalog {
|
||||
let mut c = Catalog::new();
|
||||
paths.iter().for_each(|p| {
|
||||
add(&mut c, p);
|
||||
});
|
||||
c
|
||||
}
|
||||
|
||||
/// `(name, count, own)` of each node, depth first.
|
||||
fn outline(nodes: &[FolderNode]) -> Vec<(String, usize, usize)> {
|
||||
fn walk(n: &FolderNode, out: &mut Vec<(String, usize, usize)>) {
|
||||
out.push((n.name.clone(), n.count, n.own));
|
||||
n.children.iter().for_each(|c| walk(c, out));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
nodes.iter().for_each(|n| walk(n, &mut out));
|
||||
out
|
||||
}
|
||||
|
||||
fn row(name: &str, count: usize, own: usize) -> (String, usize, usize) {
|
||||
(name.to_string(), count, own)
|
||||
}
|
||||
|
||||
fn rows(paths: &[&str]) -> Vec<(String, usize, usize)> {
|
||||
outline(&library(paths).folder_tree())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_library_has_no_folders() {
|
||||
assert!(Catalog::new().folder_tree().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_folders_with_imported_photos_are_listed_with_their_counts() {
|
||||
let c = library(&["/pics/trip/a.jpg", "/pics/trip/b.jpg", "/pics/home/c.jpg"]);
|
||||
let tree = c.folder_tree();
|
||||
assert_eq!(outline(&tree), vec![row("/", 3, 0), row("pics", 3, 0), row("home", 1, 1), row("trip", 2, 2)]);
|
||||
assert!(tree[0].volume && !tree[0].children[0].volume);
|
||||
assert_eq!(tree[0].children[0].children[1].path, "/pics/trip", "a row knows its folder, for choosing it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folders_count_includes_the_folders_inside_it() {
|
||||
let r = rows(&["/pics/trip/a.jpg", "/pics/trip/day1/b.jpg", "/pics/trip/day1/c.jpg", "/pics/trip/day2/d.jpg"]);
|
||||
assert_eq!(r, vec![row("/", 4, 0), row("pics", 4, 0), row("trip", 4, 1), row("day1", 2, 2), row("day2", 1, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_folder_is_a_row_of_its_own_even_when_it_holds_nothing_itself() {
|
||||
let c = library(&["/Users/me/Pictures/2024/a.jpg", "/Users/me/Pictures/2025/b.jpg", "/Volumes/nas/photos/c.jpg"]);
|
||||
let tree = c.folder_tree();
|
||||
assert_eq!(
|
||||
outline(&tree),
|
||||
vec![
|
||||
row("/", 2, 0),
|
||||
row("Users", 2, 0),
|
||||
row("me", 2, 0),
|
||||
row("Pictures", 2, 0),
|
||||
row("2024", 1, 1),
|
||||
row("2025", 1, 1),
|
||||
row("nas", 1, 0),
|
||||
row("photos", 1, 1)
|
||||
]
|
||||
);
|
||||
assert_eq!(tree[0].children[0].children[0].children[0].path, "/Users/me/Pictures", "a row is exactly the folder it names");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_with_photos_of_its_own_is_not_folded_into_its_child() {
|
||||
assert_eq!(rows(&["/pics/a.jpg", "/pics/trip/b.jpg"]), vec![row("/", 2, 0), row("pics", 2, 1), row("trip", 1, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browsed_deleted_and_demo_photos_are_not_imported_from_anywhere() {
|
||||
let mut c = Catalog::new();
|
||||
add(&mut c, "/pics/kept/a.jpg");
|
||||
add_with(&mut c, "/pics/browsed/b.jpg", |p| p.local = true);
|
||||
add_with(&mut c, "/pics/gone/c.jpg", |p| p.deleted = true);
|
||||
let id = c.alloc_photo_id();
|
||||
let demo = Photo::new(id, Source::Demo { scene: 1 }, "demo", "JPEG", 60, 40, "2026-01-01T10:00:00");
|
||||
c.apply(Op::AddPhoto { photo: Box::new(demo) }).unwrap();
|
||||
assert_eq!(outline(&c.folder_tree()), vec![row("/", 1, 0), row("pics", 1, 0), row("kept", 1, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn however_a_folder_is_spelled_it_is_one_folder() {
|
||||
let r = rows(&["/pics/trip/a.jpg", "/pics//trip/./b.jpg", "/pics\\trip\\c.jpg", "/pics/x/../trip/d.jpg"]);
|
||||
assert_eq!(r, vec![row("/", 4, 0), row("pics", 4, 0), row("trip", 4, 4)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_disk_is_a_volume_of_its_own() {
|
||||
// macOS, Linux and Windows habits for where disks appear
|
||||
let r = rows(&["/Volumes/nas/p/1.jpg", "/media/me/usb/2.jpg", "/run/media/me/card/3.jpg", "/mnt/disk/4.jpg"]);
|
||||
assert_eq!(r, vec![row("card", 1, 1), row("disk", 1, 1), row("nas", 1, 0), row("p", 1, 1), row("usb", 1, 1)]);
|
||||
let r = rows(&["D:\\Photos\\2024\\a.jpg", "D:\\Photos\\2025\\b.jpg", "C:\\x\\c.jpg"]);
|
||||
assert_eq!(r, vec![row("C:", 1, 0), row("x", 1, 1), row("D:", 2, 0), row("Photos", 2, 0), row("2024", 1, 1), row("2025", 1, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_network_share_is_a_volume_and_two_spellings_are_one() {
|
||||
let r = rows(&["\\\\srv\\s1\\a\\1.jpg", "\\\\srv\\s2\\b\\2.jpg"]);
|
||||
assert_eq!(r, vec![row("//srv/s1", 1, 0), row("a", 1, 1), row("//srv/s2", 1, 0), row("b", 1, 1)]);
|
||||
// the verbatim form Windows APIs give is the same share
|
||||
let r = rows(&["\\\\?\\UNC\\srv\\s1\\a\\1.jpg", "\\\\srv\\s1\\a\\2.jpg"]);
|
||||
assert_eq!(r, vec![row("//srv/s1", 2, 0), row("a", 2, 2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_samba_share_mounted_on_a_mac_is_a_volume_named_after_the_share() {
|
||||
// Finder mounts every share under /Volumes, and a second mount of the same share gets a suffix
|
||||
let r = rows(&["/Volumes/tokyo/Photos/2024/a.jpg", "/Volumes/tokyo/Photos/2025/b.jpg", "/Volumes/tokyo-1/old/c.jpg", "/Volumes/東京 NAS/d.jpg"]);
|
||||
assert_eq!(
|
||||
r,
|
||||
vec![
|
||||
row("tokyo", 2, 0),
|
||||
row("Photos", 2, 0),
|
||||
row("2024", 1, 1),
|
||||
row("2025", 1, 1),
|
||||
row("tokyo-1", 1, 0),
|
||||
row("old", 1, 1),
|
||||
row("東京 NAS", 1, 1)
|
||||
]
|
||||
);
|
||||
let tree = library(&["/Volumes/tokyo/Photos/a.jpg"]).folder_tree();
|
||||
assert_eq!((tree[0].path.as_str(), tree[0].volume), ("/Volumes/tokyo", true), "choosing the share shows everything on it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shares_the_other_systems_mount_are_volumes_too() {
|
||||
// Linux desktops: gvfs mounts per user, with the server and share in the mount's name
|
||||
let gvfs = "/run/user/1000/gvfs/smb-share:server=tokyo,share=photos";
|
||||
let r = rows(&[&format!("{gvfs}/2024/a.jpg"), "/run/user/1000/gvfs/sftp:host=nas/b.jpg"]);
|
||||
assert_eq!(r, vec![row("photos on tokyo", 1, 0), row("2024", 1, 1), row("sftp:host=nas", 1, 1)]);
|
||||
// NFS automounts: Linux /net/<host>, macOS /Network/Servers/<host>
|
||||
let r = rows(&["/net/tokyo/export/a.jpg", "/Network/Servers/osaka/vol/b.jpg"]);
|
||||
assert_eq!(r, vec![row("osaka", 1, 0), row("vol", 1, 1), row("tokyo", 1, 0), row("export", 1, 1)]);
|
||||
// Windows: a share by name or by address, the WSL file system, a mapped drive
|
||||
let r = rows(&[r"\\tokyo.local\photos\a.jpg", r"\\192.168.1.5\scans\b.jpg", r"\\wsl$\Ubuntu\home\me\c.jpg", r"Z:\d.jpg"]);
|
||||
assert_eq!(
|
||||
r,
|
||||
vec![
|
||||
row("//192.168.1.5/scans", 1, 1),
|
||||
row("//tokyo.local/photos", 1, 1),
|
||||
row("//wsl$/Ubuntu", 1, 0),
|
||||
row("home", 1, 0),
|
||||
row("me", 1, 1),
|
||||
row("Z:", 1, 1)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_share_that_is_not_mounted_costs_nothing() {
|
||||
// the tree is made from the catalog's own records: it never looks at a disk, so a share that
|
||||
// is asleep or gone lists (and counts) exactly like one that is there
|
||||
let c = library(&["/Volumes/definitely-not-mounted/a/b.jpg", r"\\unreachable.invalid\share\c.jpg"]);
|
||||
let tree = c.folder_tree();
|
||||
assert_eq!(tree.iter().map(|n| n.count).sum::<usize>(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_photo_in_the_root_of_a_disk_counts_for_the_disk() {
|
||||
assert_eq!(rows(&["/x.jpg", "/d/y.jpg"]), vec![row("/", 2, 1), row("d", 1, 1)]);
|
||||
assert_eq!(rows(&["C:\\x.jpg"]), vec![row("C:", 1, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photos_with_no_folder_are_left_out() {
|
||||
assert!(rows(&["x.jpg", ""]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folders_are_listed_by_name_ignoring_case() {
|
||||
let names: Vec<String> = rows(&["/p/b/1.jpg", "/p/A/2.jpg", "/p/c/3.jpg"]).into_iter().skip(2).map(|r| r.0).collect();
|
||||
assert_eq!(names, ["A", "b", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absurdly_deep_paths_neither_crash_nor_lose_photos() {
|
||||
let deep = format!("/{}/a.jpg", vec!["d"; 5000].join("/"));
|
||||
let tree = library(&[&deep]).folder_tree();
|
||||
assert_eq!(tree.iter().map(|n| n.count).sum::<usize>(), 1);
|
||||
assert!(outline(&tree).len() <= crate::folders::MAX_DEPTH + 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_folder_shows_what_was_imported_from_it_and_below() {
|
||||
let mut c = Catalog::new();
|
||||
let trip = add(&mut c, "/pics/trip/a.jpg");
|
||||
let day = add(&mut c, "/pics/trip/day1/b.jpg");
|
||||
let sibling = add(&mut c, "/pics/trip2/c.jpg"); // shares the name's start, not the folder
|
||||
let browsed = add_with(&mut c, "/pics/trip/d.jpg", |p| p.local = true);
|
||||
let f = Filter { library_folder: Some("/pics//trip/".into()), ..Default::default() };
|
||||
let shown = |id: PhotoId| f.matches(c.photo(id).unwrap(), &c);
|
||||
assert!(shown(trip) && shown(day));
|
||||
assert!(!shown(sibling), "/pics/trip2 is another folder");
|
||||
assert!(!shown(browsed), "browsed photos are not part of the library's folders");
|
||||
assert_eq!(f.describe(), "folder /pics//trip/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recently_deleted_shows_the_deleted_photos_of_the_chosen_folder() {
|
||||
let mut c = Catalog::new();
|
||||
let gone = add_with(&mut c, "/pics/trip/a.jpg", |p| p.deleted = true);
|
||||
let kept = add(&mut c, "/pics/trip/b.jpg");
|
||||
let f = Filter { library_folder: Some("/pics/trip".into()), deleted: true, ..Default::default() };
|
||||
assert!(f.matches(c.photo(gone).unwrap(), &c));
|
||||
assert!(!f.matches(c.photo(kept).unwrap(), &c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_folder_choice_is_no_choice() {
|
||||
let c = library(&["/pics/a.jpg"]);
|
||||
let f = Filter { library_folder: Some(" ".into()), ..Default::default() };
|
||||
assert_eq!(c.query(&f, &Sort::default()).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_row_shows_exactly_as_many_photos_as_it_says() {
|
||||
let c = library(&[
|
||||
"/Users/me/Pictures/2024/a.jpg",
|
||||
"/Users/me/Pictures/2024/b.jpg",
|
||||
"/Users/me/Pictures/2025/c.jpg",
|
||||
"/Users/me/Pictures/d.jpg",
|
||||
"/Volumes/nas/photos/e.jpg",
|
||||
"/Volumes/nas/f.jpg",
|
||||
"/x.jpg",
|
||||
"/a/../b/g.jpg",
|
||||
"D:\\Photos\\2024\\h.jpg",
|
||||
"d:/Photos/i.jpg",
|
||||
"\\\\srv\\s1\\a\\j.jpg",
|
||||
"\\\\?\\UNC\\srv\\s2\\k.jpg",
|
||||
"/pics/trip/l.jpg",
|
||||
"/pics/trip2/m.jpg",
|
||||
"/Volumes/tokyo/Photos/2024/n.jpg",
|
||||
"/Volumes/tokyo/o.jpg",
|
||||
"/run/user/1000/gvfs/smb-share:server=tokyo,share=photos/p.jpg",
|
||||
"/net/osaka/export/q.jpg",
|
||||
r"\\wsl$\Ubuntu\home\me\r.jpg",
|
||||
// odd spellings the tree and the filter must read the same way
|
||||
"//server/share/../other/1.jpg",
|
||||
"//server/share/ok/2.jpg",
|
||||
"//a b//A/f.jpg",
|
||||
r"\\wsl$\Ubuntu\..\a\1.jpg",
|
||||
// a folder that holds other disks as well as photos of its own
|
||||
"/Volumes/1.jpg",
|
||||
"/mnt/2.jpg",
|
||||
"/mnt/disk/3.jpg",
|
||||
]);
|
||||
fn check(c: &Catalog, nodes: &[FolderNode], seen: &mut usize) {
|
||||
for n in nodes {
|
||||
// the startup volume's row only opens: its path "/" would cover every other disk
|
||||
if n.selectable {
|
||||
let f = Filter { library_folder: Some(n.path.clone()), ..Default::default() };
|
||||
assert_eq!(c.query(&f, &Sort::default()).len(), n.count, "{} ({})", n.name, n.path);
|
||||
*seen += 1;
|
||||
}
|
||||
check(c, &n.children, seen);
|
||||
}
|
||||
}
|
||||
let mut seen = 0;
|
||||
check(&c, &c.folder_tree(), &mut seen);
|
||||
assert!(seen > 10, "{seen} rows checked");
|
||||
}
|
||||
|
||||
fn find<'a>(nodes: &'a [FolderNode], path: &str) -> Option<&'a FolderNode> {
|
||||
nodes.iter().find_map(|n| if n.path == path { Some(n) } else { find(&n.children, path) })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_that_would_cover_other_disks_is_not_offered_for_choosing() {
|
||||
let tree = library(&["/Users/me/a.jpg", "/Volumes/1.jpg", "/Volumes/tokyo/x.jpg", "/mnt/2.jpg"]).folder_tree();
|
||||
let sel = |p: &str| find(&tree, p).map(|n| n.selectable);
|
||||
assert_eq!(sel("/"), Some(false), "the startup disk's path covers every disk");
|
||||
assert_eq!(sel("/Volumes"), Some(false), "it holds the tokyo disk");
|
||||
assert_eq!(sel("/Volumes/tokyo"), Some(true));
|
||||
assert_eq!(sel("/Users/me"), Some(true));
|
||||
assert_eq!(sel("/mnt"), Some(true), "no disk is mounted below it in this library");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tree_and_the_filter_read_odd_spellings_the_same_way() {
|
||||
// `..` cannot climb above a share, and repeated separators between server and share are one
|
||||
let tree = library(&["//server/share/../other/1.jpg", "//a b//A/f.jpg"]).folder_tree();
|
||||
assert_eq!(outline(&tree), vec![row("//a b/A", 1, 1), row("//server/share", 1, 0), row("other", 1, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_that_names_no_folder_chooses_nothing() {
|
||||
let c = library(&["/pics/a.jpg", "C:\\b\\c.jpg"]);
|
||||
for dot in [".", "./", "a/..", "../.."] {
|
||||
let f = Filter { library_folder: Some(dot.into()), ..Default::default() };
|
||||
assert_eq!(c.query(&f, &Sort::default()).len(), 0, "{dot:?} is no folder, not everything");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_is_labelled_by_its_last_two_names() {
|
||||
use crate::folders::folder_label;
|
||||
assert_eq!(folder_label("/Volumes/tokyo/photos/travel"), "photos/travel");
|
||||
assert_eq!(folder_label("/Volumes/tokyo/"), "Volumes/tokyo");
|
||||
assert_eq!(folder_label(r"C:\Photos\2024"), "Photos/2024");
|
||||
assert_eq!(folder_label("/"), "/");
|
||||
assert_eq!(folder_label(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_roots_are_told_from_folders() {
|
||||
use crate::folders::{is_disk_root, is_startup_disk};
|
||||
for p in ["/", "C:", "C:\\", r"\\?\C:\", r"\\srv\share", "/Volumes/nas", "/mnt/disk", "/net/host"] {
|
||||
assert!(is_disk_root(p), "{p}");
|
||||
}
|
||||
for p in ["/Volumes", "/Users", "C:\\Photos", r"\\srv\share\x", "/Volumes/nas/p", "."] {
|
||||
assert!(!is_disk_root(p), "{p}");
|
||||
}
|
||||
assert!(is_startup_disk("/") && is_startup_disk("//") && !is_startup_disk("/Volumes/nas") && !is_startup_disk("C:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_name_with_spaces_at_its_edge_is_its_own_folder() {
|
||||
// legal on macOS and Linux: `/p/shoot ` is not `/p/shoot`
|
||||
let c = library(&["/p/shoot /a.jpg", "/p/shoot/b.jpg", "/p/other/c.jpg"]);
|
||||
let tree = c.folder_tree();
|
||||
let shoot_space = find(&tree, "/p/shoot ").expect("its own row");
|
||||
let shoot = find(&tree, "/p/shoot").expect("and the other");
|
||||
for (node, want) in [(shoot_space, "a.jpg"), (shoot, "b.jpg")] {
|
||||
let f = Filter { library_folder: Some(node.path.clone()), ..Default::default() };
|
||||
let ids = c.query(&f, &Sort::default());
|
||||
let names: Vec<&str> = ids.iter().filter_map(|id| c.photo(*id)).map(|p| p.file_name.as_str()).collect();
|
||||
assert_eq!(names, [want], "{:?} shows its own photo", node.path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_photo_never_counts_as_imported_even_when_a_disk_folder_is_also_shown() {
|
||||
let mut c = Catalog::new();
|
||||
let browsed = add_with(&mut c, "/pics/trip/b.jpg", |p| p.local = true);
|
||||
let f = Filter { folder: Some("/pics/trip".into()), library_folder: Some("/pics/trip".into()), ..Default::default() };
|
||||
assert!(!f.matches(c.photo(browsed).unwrap(), &c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_quick_check_agrees_with_the_careful_one() {
|
||||
use crate::query::{folder_key, key_within, photo_in_root};
|
||||
let paths = [
|
||||
"/a/b/c.jpg",
|
||||
"/a/b/d/e.jpg",
|
||||
"/a/bc/x.jpg",
|
||||
"/a/./b/x.jpg",
|
||||
"/a/../a/b/x.jpg",
|
||||
"/a//b/x.jpg",
|
||||
"/a\\b\\x.jpg",
|
||||
"/a/b ",
|
||||
"/a/b /x.jpg",
|
||||
"/.hidden/x.jpg",
|
||||
"/a/.b/x.jpg",
|
||||
"/x.jpg",
|
||||
"//srv/share/x.jpg",
|
||||
"C:\\a\\b\\x.jpg",
|
||||
"relative/a/b/x.jpg",
|
||||
"/Volumes/tokyo/a/x.jpg",
|
||||
"/日本/写真/x.jpg",
|
||||
];
|
||||
let roots = ["/", "/a", "/a/b", "/a/b/", "/a/bc", "/a/b ", "/Volumes/tokyo", "//srv/share", "C:\\a", "/日本", "relative", "/a/../a/b"];
|
||||
for p in paths {
|
||||
let mut c = Catalog::new();
|
||||
let id = add(&mut c, p);
|
||||
let photo = c.photo(id).unwrap();
|
||||
for r in roots {
|
||||
let root = folder_key(r);
|
||||
let slow = crate::local::folder_of(photo).is_some_and(|f| key_within(&f, &root));
|
||||
assert_eq!(photo_in_root(photo, &root), slow, "{p:?} in {r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,12 @@ encode_webp_lossless(&img, &meta)?;
|
||||
encode_avif(&img, 70, 6, &meta)?; // native + feature `avif`
|
||||
```
|
||||
|
||||
Thumbnail fallback decoding limits the source to 64 million pixels by default.
|
||||
Non-JPEG decoders allocate at source resolution before resizing, even for a tiny
|
||||
thumbnail. Use `decode_thumbnail_with` and `ThumbnailOptions::max_pixels` to set
|
||||
a different source budget. Oversized sources return `Error::TooLarge`; embedded
|
||||
JPEG previews can still be used without decoding the full source image.
|
||||
|
||||
`Format::RawTiffLike` (DNG, CR2, NEF, ARW, PEF, ORF, RW2, SRW, …) and `Format::RawOther` (CR3, RAF,
|
||||
CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `decode` returns
|
||||
`Error::Unsupported` for them.
|
||||
@@ -50,6 +56,10 @@ or CMYK profile converted by the CMS straight to linear Rec.2020), `Container` (
|
||||
EXIF `R03` Adobe RGB hint), `Untagged` (assumed sRGB; untagged float data assumed linear),
|
||||
`IccUnsupported` (profile present but unusable → **sRGB fallback**), `Naive` (CMYK without profile).
|
||||
|
||||
`decode_jpeg_with_fallback(bytes, opts, space)` lets an enclosing RAW container supply the colour
|
||||
space of a JPEG with no ICC or EXIF metadata. It applies the transfer curve before linear-light
|
||||
resizing; a JPEG carrying its own metadata retains its existing interpretation.
|
||||
|
||||
ICC profiles are parsed with `moxcms` (v2/v4, `curv`/`para` TRCs, `chad`); sRGB, Display P3,
|
||||
Adobe RGB (1998), ProPhoto (ROMM) and Rec.2020 are recognised by colorants. `icc::write_matrix_trc`
|
||||
emits v4 display profiles (D50 PCS, Bradford `chad`) for export embedding.
|
||||
|
||||
@@ -146,7 +146,19 @@ pub fn write_matrix_trc(space: &RgbSpace, trc: &Trc) -> Vec<u8> {
|
||||
};
|
||||
p.description = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), desc)]));
|
||||
p.copyright = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), "No copyright, use freely".into())]));
|
||||
align_tags(&p.encode().unwrap_or_default())
|
||||
align_tags(&fixed_date(p.encode().unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// Zero the header's creation date/time (ICC.1 §7.2.8, bytes 24..36) so a profile's bytes depend
|
||||
/// only on its colour space and curve. `moxcms` stamps the wall clock there on every `encode`
|
||||
/// (ignoring `ColorProfile::creation_date_time`), which made two exports of one photo differ in
|
||||
/// the embedded profile and nowhere else, so output hashes could not be compared (#180). Readers
|
||||
/// do not interpret the field; all zeros is the conventional "unspecified" value.
|
||||
fn fixed_date(mut b: Vec<u8>) -> Vec<u8> {
|
||||
if let Some(date) = b.get_mut(24..36) {
|
||||
date.fill(0);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Re-lay a profile's tag data on 4-byte boundaries (ICC.1 §7.3.1). The encoder packs tags back to
|
||||
@@ -300,6 +312,23 @@ mod tests {
|
||||
let _ = SRGB;
|
||||
}
|
||||
|
||||
/// Two profiles for one space are byte-identical, and the header date is fixed: exports of the
|
||||
/// same photo with the same settings hash the same (#180).
|
||||
#[test]
|
||||
fn written_profiles_are_deterministic() {
|
||||
for n in NamedSpace::ALL {
|
||||
let a = write_named(n);
|
||||
let b = write_named(n);
|
||||
assert_eq!(a, b, "{n:?}");
|
||||
assert_eq!(&a[24..36], &[0u8; 12], "{n:?} header date/time");
|
||||
// the profile ID is "not computed", not a hash of a timestamp
|
||||
assert_eq!(&a[84..100], &[0u8; 16], "{n:?} profile ID");
|
||||
}
|
||||
let lin = write_matrix_trc(&PROPHOTO, &Trc::Linear);
|
||||
assert_eq!(lin, write_matrix_trc(&PROPHOTO, &Trc::Linear));
|
||||
assert_eq!(&lin[24..36], &[0u8; 12]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_is_none() {
|
||||
assert!(parse(&[]).is_none());
|
||||
|
||||
@@ -105,6 +105,10 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
decode_with_fallback(bytes, opts, None)
|
||||
}
|
||||
|
||||
pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: Option<crate::NamedSpace>) -> Result<Decoded> {
|
||||
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
|
||||
if m.components == 0 {
|
||||
return Err(Error::Malformed(F, "no frame header".into()));
|
||||
@@ -119,7 +123,10 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
} else {
|
||||
decode_zune(bytes, &m)?
|
||||
};
|
||||
let meta = Meta { icc: m.icc, exif: m.exif, xmp: m.xmp, ..Default::default() };
|
||||
// A raw container may carry the colour declaration of an otherwise untagged preview.
|
||||
// Apply it before linearization/resizing; existing JPEG metadata always wins.
|
||||
let hint = fallback.filter(|_| m.icc.is_none() && m.exif.is_none()).map(|space| crate::SourceSpace::named(space, crate::SpaceOrigin::Container));
|
||||
let meta = Meta { icc: m.icc, exif: m.exif, xmp: m.xmp, hint, ..Default::default() };
|
||||
finish(F, raw, meta, (m.width, m.height), opts)
|
||||
}
|
||||
|
||||
@@ -246,6 +253,44 @@ fn mpf_images(m: &Markers) -> Vec<(usize, usize)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn container_fallback_matches_tagged_jpeg_before_resizing_and_keeps_own_metadata() {
|
||||
use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, Samples, SpaceOrigin, encode_jpeg};
|
||||
let pixels: Vec<u8> = (0..16 * 16).flat_map(|i| if i % 2 == 0 { [120, 180, 90] } else { [30, 100, 200] }).collect();
|
||||
let encode = |icc, exif| {
|
||||
encode_jpeg(
|
||||
&EncodeImage::new(16, 16, 3, Samples::U8(&pixels)),
|
||||
100,
|
||||
ChromaSubsampling::S444,
|
||||
&EncodeMeta { icc, exif, ..Default::default() },
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let untagged = encode(None, None);
|
||||
let adobe = crate::icc::write_named(NamedSpace::AdobeRgb);
|
||||
let tagged = encode(Some(&adobe), None);
|
||||
for opts in [DecodeOptions::default(), DecodeOptions::fit(3, 3)] {
|
||||
let with = crate::decode_jpeg_with_fallback(&untagged, opts, NamedSpace::AdobeRgb).unwrap();
|
||||
let expected = crate::decode(&tagged, opts).unwrap();
|
||||
assert_eq!(with.space.origin, SpaceOrigin::Container);
|
||||
let (a, b) = (with.to_working(), expected.to_working());
|
||||
for (p, q) in a.data.iter().zip(&b.data) {
|
||||
assert!(p.iter().zip(q).all(|(a, b)| (a - b).abs() < 0.0001), "{p:?} != {q:?}");
|
||||
}
|
||||
}
|
||||
let srgb = crate::icc::write_named(NamedSpace::Srgb);
|
||||
let exif = crate::exif::minimal_exif(6);
|
||||
for bytes in [encode(Some(&srgb), None), encode(None, Some(&exif)), encode(Some(b"bad ICC"), None)] {
|
||||
let opts = DecodeOptions::fit(5, 5);
|
||||
let with = crate::decode_jpeg_with_fallback(&bytes, opts, NamedSpace::AdobeRgb).unwrap();
|
||||
let expected = crate::decode(&bytes, opts).unwrap();
|
||||
assert_eq!(with.space, expected.space);
|
||||
assert_eq!(with.image, expected.image);
|
||||
assert_eq!(with.orientation, expected.orientation);
|
||||
}
|
||||
assert!(crate::decode_jpeg_with_fallback(b"bad JPEG", DecodeOptions::default(), NamedSpace::AdobeRgb).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_request_covers() {
|
||||
assert_eq!(scaled_request(6000, 4000, 256, 256), (256, 171));
|
||||
|
||||
@@ -156,6 +156,14 @@ pub fn decode(bytes: &[u8], opts: DecodeOptions) -> Result<Decoded> {
|
||||
std::panic::catch_unwind(|| decode_unguarded(bytes, format, &opts)).unwrap_or_else(|_| Err(Error::Malformed(format, "decoder panicked".into())))
|
||||
}
|
||||
|
||||
/// Decode a JPEG preview with its enclosing container's colour space as a fallback.
|
||||
/// A JPEG carrying ICC or EXIF metadata keeps its own interpretation. The fallback is applied
|
||||
/// before transfer decoding and linear-light resizing, never to already-resampled pixels.
|
||||
pub fn decode_jpeg_with_fallback(bytes: &[u8], opts: DecodeOptions, fallback: NamedSpace) -> Result<Decoded> {
|
||||
std::panic::catch_unwind(|| jpeg::decode_with_fallback(bytes, &opts, Some(fallback)))
|
||||
.unwrap_or_else(|_| Err(Error::Malformed(Format::Jpeg, "decoder panicked".into())))
|
||||
}
|
||||
|
||||
/// [`decode`] without the panic guard (for fuzzing our own code paths).
|
||||
#[doc(hidden)]
|
||||
pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Result<Decoded> {
|
||||
@@ -217,11 +225,19 @@ pub struct ThumbnailOptions {
|
||||
/// smaller size). Defaults to `max_edge`, i.e. only previews that need no upscaling. Lower it
|
||||
/// (e.g. 160 for typical EXIF thumbnails) for an instant placeholder.
|
||||
pub min_embedded_edge: u32,
|
||||
/// Maximum source pixels for fallback decoding (default 64 MP). Non-JPEG formats
|
||||
/// decode at source resolution before resizing, so output size does not bound memory.
|
||||
#[serde(default = "thumbnail_max_pixels")]
|
||||
pub max_pixels: u64,
|
||||
}
|
||||
|
||||
fn thumbnail_max_pixels() -> u64 {
|
||||
64_000_000
|
||||
}
|
||||
|
||||
impl ThumbnailOptions {
|
||||
pub fn new(max_edge: u32) -> Self {
|
||||
ThumbnailOptions { max_edge, min_embedded_edge: max_edge }
|
||||
ThumbnailOptions { max_edge, min_embedded_edge: max_edge, max_pixels: thumbnail_max_pixels() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +256,7 @@ pub fn decode_thumbnail_with(bytes: &[u8], opts: &ThumbnailOptions) -> Result<Th
|
||||
{
|
||||
return Ok(t);
|
||||
}
|
||||
let d = decode(bytes, DecodeOptions::fit(max_edge, max_edge))?;
|
||||
let d = decode(bytes, DecodeOptions { max_size: Some((max_edge, max_edge)), max_pixels: opts.max_pixels })?;
|
||||
Ok(Thumbnail {
|
||||
image: d.to_srgb8(),
|
||||
orientation: d.orientation,
|
||||
|
||||
@@ -163,6 +163,9 @@ fn tiff_is_raw(b: &[u8]) -> bool {
|
||||
}
|
||||
let Some(t) = Tiff::new(b) else { return false };
|
||||
let Some(ifd0) = t.first_ifd() else { return false };
|
||||
let sony = t.ifd(ifd0).is_some_and(|(entries, _)| {
|
||||
entries.iter().any(|e| e.tag == 0x010f && t.bytes(e).and_then(|v| v.get(..4)).is_some_and(|v| v.eq_ignore_ascii_case(b"SONY")))
|
||||
});
|
||||
let mut stack = vec![ifd0];
|
||||
let mut seen = 0;
|
||||
while let Some(pos) = stack.pop() {
|
||||
@@ -171,6 +174,12 @@ fn tiff_is_raw(b: &[u8]) -> bool {
|
||||
break;
|
||||
}
|
||||
let Some((entries, _)) = t.ifd(pos) else { continue };
|
||||
// Sony M/S lossless ARWs retain a dummy CFA pattern, but store linear YCbCr.
|
||||
// Requiring the raw pattern plus this layout keeps ordinary Sony TIFFs as TIFFs.
|
||||
let uint = |tag| entries.iter().find(|e| e.tag == tag).and_then(|e| t.uint(e));
|
||||
if sony && entries.iter().any(|e| e.tag == 0x828e) && uint(0x0103) == Some(7) && uint(0x0106) == Some(6) && uint(0x0115) == Some(3) {
|
||||
return true;
|
||||
}
|
||||
for e in &entries {
|
||||
match e.tag {
|
||||
0xC612 => return true, // DNGVersion
|
||||
@@ -233,4 +242,38 @@ mod tests {
|
||||
b.extend_from_slice(&0u32.to_le_bytes());
|
||||
assert_eq!(sniff(&b), Some(Format::RawTiffLike));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sony_linear_ycbcr_subifd_is_raw_but_ordinary_tiff_is_not() {
|
||||
fn fixture(compression: u32, pattern: bool) -> Vec<u8> {
|
||||
let mut b = b"II*\0\x08\0\0\0".to_vec();
|
||||
let entry = |b: &mut Vec<u8>, tag: u16, typ: u16, count: u32, value: u32| {
|
||||
b.extend_from_slice(&tag.to_le_bytes());
|
||||
b.extend_from_slice(&typ.to_le_bytes());
|
||||
b.extend_from_slice(&count.to_le_bytes());
|
||||
b.extend_from_slice(&value.to_le_bytes());
|
||||
};
|
||||
b.extend_from_slice(&2u16.to_le_bytes());
|
||||
entry(&mut b, 0x010f, 2, 5, 38);
|
||||
entry(&mut b, 0x014a, 4, 1, 44);
|
||||
b.extend_from_slice(&0u32.to_le_bytes());
|
||||
b.extend_from_slice(b"SONY\0\0");
|
||||
b.extend_from_slice(&(if pattern { 4u16 } else { 3 }).to_le_bytes());
|
||||
entry(&mut b, 0x0103, 3, 1, compression);
|
||||
entry(&mut b, 0x0106, 3, 1, 6);
|
||||
entry(&mut b, 0x0115, 3, 1, 3);
|
||||
if pattern {
|
||||
entry(&mut b, 0x828e, 1, 4, u32::MAX);
|
||||
}
|
||||
b.extend_from_slice(&0u32.to_le_bytes());
|
||||
b
|
||||
}
|
||||
let mut raw = fixture(7, true);
|
||||
assert_eq!(sniff(&raw), Some(Format::RawTiffLike));
|
||||
assert_eq!(sniff(&fixture(7, false)), Some(Format::Tiff));
|
||||
assert_eq!(sniff(&fixture(1, true)), Some(Format::Tiff));
|
||||
assert_eq!(sniff(&raw[..38]), Some(Format::Tiff));
|
||||
raw[38..42].copy_from_slice(b"TEST");
|
||||
assert_eq!(sniff(&raw), Some(Format::Tiff));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! TIFF decode via the `tiff` crate: 8/16/32-bit integer, 16/32/64-bit float, gray/RGB/CMYK/palette,
|
||||
//! alpha (associated or not), ICC (34675), XMP (700), orientation (274). First image only.
|
||||
//! TIFF decode via the `tiff` crate: 1/2/4/8/16/32/64-bit integer, 16/32/64-bit float, gray/RGB/CMYK,
|
||||
//! alpha (associated or not), ICC (34675), XMP (700), orientation (274). First image only. Palette
|
||||
//! (indexed-colour) images are refused by the `tiff` crate itself, on every read path.
|
||||
|
||||
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
|
||||
use crate::{DecodeOptions, Decoded, Error, Format, Result};
|
||||
@@ -25,7 +26,6 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let icc = tag_bytes(&mut d, Tag::IccProfile);
|
||||
let xmp = tag_bytes(&mut d, Tag::from_u16_exhaustive(700)).map(|v| String::from_utf8_lossy(&v).trim_end_matches('\0').to_string());
|
||||
let orientation = d.get_tag_u32(Tag::Orientation).ok().map(|v| v as u16);
|
||||
let photometric = d.get_tag_u32(Tag::PhotometricInterpretation).ok();
|
||||
let planar = d.get_tag_u32(Tag::PlanarConfiguration).ok() == Some(2);
|
||||
let extra = d.get_tag_u16_vec(Tag::ExtraSamples).ok().unwrap_or_default();
|
||||
let colormap = d.get_tag_u16_vec(Tag::ColorMap).ok();
|
||||
@@ -45,6 +45,9 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth),
|
||||
other => return Err(Error::Unsupported(F, unsupported_name(other))),
|
||||
};
|
||||
if bits < 8 && planar && n_samples > 1 {
|
||||
return Err(Error::Unsupported(F, "planar TIFF with samples narrower than a byte"));
|
||||
}
|
||||
let has_alpha = n_samples > n_color && !matches!(ct, ColorType::Palette(_));
|
||||
let premultiplied = has_alpha && extra.first() == Some(&1);
|
||||
|
||||
@@ -52,6 +55,7 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let (wu, hu) = (w as usize, h as usize);
|
||||
let n = wu * hu;
|
||||
let mut buf = match res {
|
||||
DecodingResult::U8(v) if bits < 8 => Buf::U8(unpack(&v, wu, hu, n_samples, bits)?),
|
||||
DecodingResult::U8(v) => Buf::U8(v),
|
||||
DecodingResult::U16(v) => Buf::U16(v),
|
||||
// Rare integer widths: keep 16 significant bits (plenty for display-referred data).
|
||||
@@ -94,14 +98,8 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
return finish(F, raw, meta, (w, h), opts);
|
||||
}
|
||||
|
||||
// WhiteIsZero → invert.
|
||||
if photometric == Some(0) {
|
||||
match &mut buf {
|
||||
Buf::U8(v) => v.iter_mut().for_each(|x| *x = 255 - *x),
|
||||
Buf::U16(v) => v.iter_mut().for_each(|x| *x = 65535 - *x),
|
||||
Buf::F32(v) => v.iter_mut().for_each(|x| *x = 1.0 - *x),
|
||||
}
|
||||
}
|
||||
// WhiteIsZero needs nothing here: the `tiff` crate inverts those samples as it reads them (and
|
||||
// refuses WhiteIsZero layouts it can't invert), so inverting again would show a negative.
|
||||
|
||||
// Drop extra samples beyond colour + one alpha.
|
||||
let keep = n_color + has_alpha as usize;
|
||||
@@ -140,6 +138,29 @@ fn unsupported_name(ct: ColorType) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Samples narrower than a byte (1-bit bilevel and fax scans, 2- and 4-bit gray) arrive packed, most
|
||||
/// significant bits first, each row padded to a whole byte. Spread them out one per byte, scaled to
|
||||
/// 0–255 like any 8-bit sample.
|
||||
fn unpack(packed: &[u8], width: usize, height: usize, samples: usize, bits: u8) -> Result<Vec<u8>> {
|
||||
if !matches!(bits, 1 | 2 | 4) {
|
||||
return Err(Error::Unsupported(F, "TIFF samples of 3, 5, 6 or 7 bits"));
|
||||
}
|
||||
let bits = bits as usize;
|
||||
let per_row = width.checked_mul(samples).ok_or_else(|| err("row too wide"))?;
|
||||
let row_bytes = per_row.checked_mul(bits).ok_or_else(|| err("row too wide"))?.div_ceil(8);
|
||||
if row_bytes == 0 || packed.len() / row_bytes < height {
|
||||
return Err(err("sample buffer too short"));
|
||||
}
|
||||
let max = (1u8 << bits) - 1;
|
||||
let scale = 255 / max; // 255, 85, 17: exact
|
||||
let mut out = Vec::with_capacity(per_row.saturating_mul(height));
|
||||
for row in packed.chunks_exact(row_bytes).take(height) {
|
||||
let values = row.iter().flat_map(|&b| (1..=8 / bits).map(move |i| (b >> (8 - bits * i)) & max));
|
||||
out.extend(values.take(per_row).map(|v| v * scale));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn interleave(buf: Buf, n: usize, s: usize) -> Buf {
|
||||
fn go<T: Copy + Default>(v: Vec<T>, n: usize, s: usize) -> Vec<T> {
|
||||
if v.len() < n * s {
|
||||
|
||||
@@ -12,6 +12,9 @@ fn err(e: impl std::fmt::Display) -> Error {
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let mut d = image_webp::WebPDecoder::new(std::io::Cursor::new(bytes)).map_err(err)?;
|
||||
// RIFF chunk lengths are untrusted. Metadata cannot exceed the input;
|
||||
// otherwise a tiny truncated file can request gigabytes before read_exact fails.
|
||||
d.set_memory_limit(bytes.len());
|
||||
let (w, h) = d.dimensions();
|
||||
check_size(F, w as u64, h as u64, opts)?;
|
||||
let alpha = d.has_alpha();
|
||||
|
||||
@@ -32,10 +32,46 @@ fn try_all(bytes: &[u8]) {
|
||||
let _ = decode_unguarded(bytes, f, &DecodeOptions { max_size: Some((8, 8)), max_pixels: 1 << 22 });
|
||||
}
|
||||
let _ = decode(bytes, small_opts());
|
||||
let _ = decode_thumbnail(bytes, 16);
|
||||
let _ = decode_thumbnail_with(bytes, &ThumbnailOptions { max_pixels: small_opts().max_pixels, ..ThumbnailOptions::new(16) });
|
||||
let _ = lightcraft_codecs::icc::parse(bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_truncated_metadata_cannot_allocate_declared_chunk_size() {
|
||||
let mut bytes = seeds()[5].clone();
|
||||
let tag = bytes.windows(4).rposition(|w| w == b"XMP ").unwrap();
|
||||
bytes[tag + 4..tag + 8].copy_from_slice(&1_946_157_060u32.to_le_bytes());
|
||||
let decoded = decode(&bytes, small_opts()).unwrap();
|
||||
assert_eq!((decoded.image.width, decoded.image.height), (24, 16));
|
||||
assert!(decoded.xmp.is_none(), "invalid optional metadata is ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnail_rejects_huge_gif_before_allocating_source_pixels() {
|
||||
// A logical screen header without a global palette; previously this reached a
|
||||
// multi-gigabyte output allocation before discovering that no frame follows.
|
||||
let bytes = [
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x8f, 0xa4, 0xb0, 0x3f, 0x57, 0x14, 0xe8, 0x8a, 0xf8, 0xe0, 0xc7, 0x41, 0x9d, 0x0c, 0x10, 0xde, 0x54,
|
||||
0x24, 0xcb, 0xed, 0xff, 0x4a, 0xe7, 0xa0, 0x82, 0x10, 0xa0, 0xbc, 0xac, 0xe5, 0x33, 0x3f, 0xc9, 0x8d, 0x69, 0xf4, 0x24, 0xce, 0x99, 0x4f,
|
||||
0x0d, 0x47, 0xa0, 0xb0, 0xf4, 0xa8, 0x59, 0xad, 0x29, 0xbd, 0x09, 0x48, 0x63, 0xea, 0x59, 0xb7, 0xb7, 0xf2, 0xa1, 0x90, 0xeb, 0x92, 0xc5,
|
||||
0x9c, 0xde, 0xcc, 0x96, 0x67, 0xc4, 0xe5, 0x8c, 0x03, 0x50, 0xb2, 0x30, 0xf8, 0xf4, 0x69, 0x1d, 0xeb, 0xb9, 0xbd, 0x4e, 0xfb, 0xbd, 0xb6,
|
||||
0x02,
|
||||
];
|
||||
let result = decode_thumbnail(&bytes, 16);
|
||||
assert!(matches!(result, Err(Error::TooLarge(42_127, 16_304))), "{result:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnail_respects_configured_source_pixel_limit() {
|
||||
let image = Rgba8::from_fn(3, 2, |_, _| [40, 80, 120, 255]);
|
||||
let bytes = encode_png(&EncodeImage::rgba8(&image), &EncodeMeta::default()).unwrap();
|
||||
let mut opts = ThumbnailOptions::new(1);
|
||||
opts.max_pixels = 5;
|
||||
assert!(matches!(decode_thumbnail_with(&bytes, &opts), Err(Error::TooLarge(3, 2))));
|
||||
opts.max_pixels = 6;
|
||||
assert_eq!(decode_thumbnail_with(&bytes, &opts).unwrap().image.width, 1);
|
||||
}
|
||||
|
||||
fn seeds() -> &'static Vec<Vec<u8>> {
|
||||
static S: OnceLock<Vec<Vec<u8>>> = OnceLock::new();
|
||||
S.get_or_init(|| {
|
||||
|
||||
@@ -245,6 +245,80 @@ fn tiff_cmyk_and_orientation() {
|
||||
assert!(red[0] > 0.99 && red[1] < 0.01 && red[2] < 0.01, "{red:?}");
|
||||
}
|
||||
|
||||
/// A one-strip, uncompressed, single-sample TIFF (little-endian) whose samples are `bits` wide and
|
||||
/// already packed into `strip`, one padded row after another.
|
||||
fn packed_tiff(width: u16, height: u16, bits: u16, photometric: u16, strip: &[u8]) -> Vec<u8> {
|
||||
const STRIP_AT: u32 = 8 + 2 + 9 * 12 + 4;
|
||||
let short = |tag: u16, v: u16| [&tag.to_le_bytes()[..], &3u16.to_le_bytes(), &1u32.to_le_bytes(), &v.to_le_bytes(), &[0, 0]].concat();
|
||||
let long = |tag: u16, v: u32| [&tag.to_le_bytes()[..], &4u16.to_le_bytes(), &1u32.to_le_bytes(), &v.to_le_bytes()].concat();
|
||||
let mut b = b"II*\0".to_vec();
|
||||
b.extend(8u32.to_le_bytes());
|
||||
b.extend(9u16.to_le_bytes());
|
||||
b.extend(short(256, width));
|
||||
b.extend(short(257, height));
|
||||
b.extend(short(258, bits));
|
||||
b.extend(short(259, 1));
|
||||
b.extend(short(262, photometric));
|
||||
b.extend(long(273, STRIP_AT));
|
||||
b.extend(short(277, 1));
|
||||
b.extend(short(278, height));
|
||||
b.extend(long(279, strip.len() as u32));
|
||||
b.extend(0u32.to_le_bytes());
|
||||
assert_eq!(b.len(), STRIP_AT as usize);
|
||||
b.extend_from_slice(strip);
|
||||
b
|
||||
}
|
||||
|
||||
/// WhiteIsZero (photometric 0) gray: the lowest sample is white. The `tiff` crate already inverts these
|
||||
/// samples, and a second inversion here used to show every WhiteIsZero scan as a negative.
|
||||
#[test]
|
||||
fn tiff_white_is_zero_is_inverted_once() {
|
||||
// Two pixels: the lowest sample, then the highest, at 8 and 16 bits.
|
||||
for (bits, strip) in [(8, vec![0u8, 255]), (16, [0u16.to_le_bytes(), 65535u16.to_le_bytes()].concat())] {
|
||||
for (photometric, first) in [(0, 1.0), (1, 0.0)] {
|
||||
let d = decode(&packed_tiff(2, 1, bits, photometric, &strip), DecodeOptions::default()).unwrap();
|
||||
let (a, b) = (d.image.get(0, 0)[1], d.image.get(1, 0)[1]);
|
||||
assert!((a - first).abs() < 1e-6 && (b - (1.0 - first)).abs() < 1e-6, "{bits}-bit, photometric {photometric}: {a} {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 1-, 2- and 4-bit samples (bilevel scans, fax images, low-depth gray) arrive packed eight, four or
|
||||
/// two to a byte with each row padded to a byte; they used to be rejected as "sample buffer too short".
|
||||
#[test]
|
||||
fn tiff_samples_narrower_than_a_byte() {
|
||||
let gray = |d: &Decoded, x: usize, y: usize| d.image.get(x, y)[1];
|
||||
|
||||
// 10 × 2 bilevel: rows are 2 bytes, the last 6 bits of each are padding (set, to prove they are ignored).
|
||||
// Row 0 alternates set/clear bits; row 1 is clear but for its last pixel.
|
||||
let rows = [0b1010_1010, 0b1011_1111, 0b0000_0000, 0b0111_1111];
|
||||
// What a set bit decodes to: white for BlackIsZero (1), black for WhiteIsZero (0).
|
||||
for (photometric, set_bit) in [(1, 1.0), (0, 0.0)] {
|
||||
let d = decode(&packed_tiff(10, 2, 1, photometric, &rows), DecodeOptions::default()).unwrap();
|
||||
assert_eq!((d.image.width, d.image.height, d.bit_depth), (10, 2, 1));
|
||||
assert!(d.grayscale);
|
||||
let clear_bit = 1.0 - set_bit;
|
||||
for x in 0..10 {
|
||||
let want = (if x % 2 == 0 { set_bit } else { clear_bit }, if x == 9 { set_bit } else { clear_bit });
|
||||
assert!((gray(&d, x, 0) - want.0).abs() < 1e-6, "row 0, x {x}, photometric {photometric}");
|
||||
assert!((gray(&d, x, 1) - want.1).abs() < 1e-6, "row 1, x {x}, photometric {photometric}");
|
||||
}
|
||||
}
|
||||
|
||||
// 2-bit: 0, 1, 2, 3 → 0, 85, 170, 255. 4-bit: 0, 15, 8 → 0, 255, 136 (the row's last nibble is padding).
|
||||
let d = decode(&packed_tiff(4, 1, 2, 1, &[0b00_01_10_11]), DecodeOptions::default()).unwrap();
|
||||
for (x, v) in [0, 85, 170, 255].into_iter().enumerate() {
|
||||
assert!((gray(&d, x, 0) - srgb_to_linear(v as f32 / 255.0)).abs() < 1e-6, "2-bit x {x}");
|
||||
}
|
||||
let d = decode(&packed_tiff(3, 1, 4, 1, &[0x0F, 0x8F]), DecodeOptions::default()).unwrap();
|
||||
for (x, v) in [0, 255, 136].into_iter().enumerate() {
|
||||
assert!((gray(&d, x, 0) - srgb_to_linear(v as f32 / 255.0)).abs() < 1e-6, "4-bit x {x}");
|
||||
}
|
||||
|
||||
// A strip shorter than its rows is an error, never a panic.
|
||||
assert!(decode(&packed_tiff(10, 2, 1, 1, &rows[..3]), DecodeOptions::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_lossless_exact() {
|
||||
let img = gradient(31, 19);
|
||||
@@ -461,7 +535,7 @@ fn thumbnail_accepts_smaller_preview_when_asked() {
|
||||
encode_jpeg(&EncodeImage::rgba8(&main), 80, ChromaSubsampling::S420, &EncodeMeta { exif: Some(&exif), ..Default::default() }).unwrap();
|
||||
let t = decode_thumbnail(&bytes, 256).unwrap();
|
||||
assert_eq!(t.source, ThumbnailSource::Scaled);
|
||||
let t = decode_thumbnail_with(&bytes, &ThumbnailOptions { max_edge: 256, min_embedded_edge: 160 }).unwrap();
|
||||
let t = decode_thumbnail_with(&bytes, &ThumbnailOptions { min_embedded_edge: 160, ..ThumbnailOptions::new(256) }).unwrap();
|
||||
assert_eq!(t.source, ThumbnailSource::ExifThumbnail);
|
||||
assert_eq!((t.image.width, t.image.height), (160, 120));
|
||||
}
|
||||
|
||||
+17
-6
@@ -2,7 +2,9 @@
|
||||
//!
|
||||
//! The Planckian locus uses the Kim et al. (2002) cubic approximation (1667–25000 K); outside that
|
||||
//! range it is extended linearly in mired space. Tint is a signed offset perpendicular to the locus in
|
||||
//! CIE 1960 (u, v): **positive tint = magenta** (below the locus), `Duv = −tint / TINT_SCALE`.
|
||||
//! CIE 1960 (u, v). Tint describes the **correction**: positive adds magenta, negative adds green.
|
||||
//! A positive correction neutralises a green illuminant above the locus, so its source white uses
|
||||
//! `Duv = tint / TINT_SCALE`. Bradford adaptation removes that source cast.
|
||||
|
||||
use crate::{Mat3, RgbSpace, Xy, bradford};
|
||||
|
||||
@@ -65,11 +67,12 @@ fn locus_normal(t: f64) -> (f64, f64) {
|
||||
if ny < 0.0 { (-nx, -ny) } else { (nx, ny) }
|
||||
}
|
||||
|
||||
/// Chromaticity of the white point for temperature `t` (K) and `tint`.
|
||||
/// Source illuminant chromaticity for temperature `t` (K) and correction `tint`.
|
||||
/// Positive tint means a green source white, whose correction adds magenta to the image.
|
||||
pub fn temp_tint_to_xy(t: f64, tint: f64) -> Xy {
|
||||
let (u, v) = locus_uv(t);
|
||||
let (nu, nv) = locus_normal(t);
|
||||
let duv = -tint / TINT_SCALE;
|
||||
let duv = tint / TINT_SCALE;
|
||||
uv_to_xy(u + nu * duv, v + nv * duv)
|
||||
}
|
||||
|
||||
@@ -104,7 +107,7 @@ pub fn xy_to_temp_tint(p: Xy) -> (f64, f64) {
|
||||
let (lu, lv) = locus_uv(t);
|
||||
let (nu, nv) = locus_normal(t);
|
||||
let duv = (u - lu) * nu + (v - lv) * nv;
|
||||
(t, -duv * TINT_SCALE)
|
||||
(t, duv * TINT_SCALE)
|
||||
}
|
||||
|
||||
/// Matrix (linear RGB in `space` → same space) that white-balances a scene lit by `src` so that it
|
||||
@@ -141,10 +144,18 @@ mod tests {
|
||||
let warm = temp_tint_to_xy(3000.0, 0.0);
|
||||
let cool = temp_tint_to_xy(9000.0, 0.0);
|
||||
assert!(warm.x > cool.x);
|
||||
// positive tint is magenta: lower v
|
||||
// A positive magenta correction specifies a greener source illuminant: higher v.
|
||||
let (_, v0) = xy_to_uv(temp_tint_to_xy(5000.0, 0.0));
|
||||
let (_, v1) = xy_to_uv(temp_tint_to_xy(5000.0, 50.0));
|
||||
assert!(v1 < v0);
|
||||
assert!(v1 > v0);
|
||||
}
|
||||
|
||||
/// Daylight lies above the Planckian locus, so D65 reads as a small positive tint, as
|
||||
/// Lightroom's Daylight preset (5500 K, +10) has it (#188).
|
||||
#[test]
|
||||
fn daylight_has_a_positive_tint() {
|
||||
let (_, tint) = xy_to_temp_tint(crate::D65);
|
||||
assert!((5.0..15.0).contains(&tint), "{tint}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -13,6 +13,7 @@ lightcraft-geom = { workspace = true }
|
||||
lightcraft-color = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -8,10 +8,12 @@
|
||||
|
||||
pub mod controls;
|
||||
pub mod presets;
|
||||
pub mod segmask;
|
||||
pub mod settings;
|
||||
|
||||
pub use controls::{CONTROLS, ControlSpec, Section, Track};
|
||||
pub use presets::{Preset, SettingsGroup, apply_partial, extract_groups};
|
||||
pub use segmask::SegMask;
|
||||
pub use settings::*;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Model segmentations stored with a mask ([`SegMask`]), so rendering never needs the model.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Logits are stored in steps of 1/`SCALE`, clamped to ±127/`SCALE` (≈ ±15.9: a sigmoid of
|
||||
/// 1 − 1.2e-7, i.e. fully in or out).
|
||||
const SCALE: f32 = 8.0;
|
||||
/// Largest grid a stored segmentation may claim (hostile input caps the allocation).
|
||||
pub const MAX_SIDE: usize = 1024;
|
||||
|
||||
/// A segmentation computed by a model (SAM 3 in `lightcraft-segment`): `side × side` logits
|
||||
/// over the uncropped, oriented image stretched to a square (row `y`, column `x` covers
|
||||
/// normalized `((x + 0.5) / side, (y + 0.5) / side)`), quantized to `i8`, deflated and base64
|
||||
/// encoded. Coverage at a point is the sigmoid of the bilinearly sampled logit, so edges stay
|
||||
/// smooth at any output size.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SegMask {
|
||||
pub side: u32,
|
||||
pub data: String,
|
||||
/// The part of the image the grid covers, normalized `[x0, y0, x1, y1]` (`None` = all of
|
||||
/// it). A zoomed-in pass over one object covers just its surroundings, at a higher
|
||||
/// resolution than a pass over the whole photo.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rect: Option<[f64; 4]>,
|
||||
}
|
||||
|
||||
impl SegMask {
|
||||
/// From `side × side` logits over the whole image.
|
||||
pub fn from_logits(side: usize, logits: &[f32]) -> SegMask {
|
||||
let q: Vec<u8> = logits.iter().take(side * side).map(|l| ((l * SCALE).round().clamp(-127.0, 127.0) as i8) as u8).collect();
|
||||
let z = miniz_oxide::deflate::compress_to_vec(&q, 8);
|
||||
SegMask { side: side as u32, data: base64_encode(&z), rect: None }
|
||||
}
|
||||
|
||||
/// From `side × side` logits over the part `rect` of the image.
|
||||
pub fn from_logits_in(side: usize, logits: &[f32], rect: [f64; 4]) -> SegMask {
|
||||
SegMask { rect: Some(rect), ..SegMask::from_logits(side, logits) }
|
||||
}
|
||||
|
||||
/// The covered part, normalized (the whole image without a `rect`); `None` when damaged.
|
||||
pub fn bounds(&self) -> Option<[f64; 4]> {
|
||||
let r = self.rect.unwrap_or([0.0, 0.0, 1.0, 1.0]);
|
||||
(r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1]).then_some(r)
|
||||
}
|
||||
|
||||
/// The logits, or `None` when the data is damaged or claims an absurd size.
|
||||
pub fn logits(&self) -> Option<Vec<f32>> {
|
||||
let side = self.side as usize;
|
||||
if side == 0 || side > MAX_SIDE {
|
||||
return None;
|
||||
}
|
||||
let z = base64_decode(&self.data)?;
|
||||
let q = miniz_oxide::inflate::decompress_to_vec_with_limit(&z, side * side).ok()?;
|
||||
(q.len() == side * side).then(|| q.iter().map(|b| f32::from(*b as i8) / SCALE).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Most zoomed-in detail patches one AI mask component keeps (the app makes one; each costs a
|
||||
/// decode per render, so a hostile document can't make rendering explode).
|
||||
pub const MAX_DETAIL: usize = 4;
|
||||
|
||||
/// Deserialize a component's detail patches, keeping at most [`MAX_DETAIL`] (the rest are
|
||||
/// skipped without being decoded).
|
||||
pub(crate) fn de_detail<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<SegMask>, D::Error> {
|
||||
struct Capped;
|
||||
impl<'de> serde::de::Visitor<'de> for Capped {
|
||||
type Value = Vec<SegMask>;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a list of segmentations")
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<SegMask>, A::Error> {
|
||||
let mut out = Vec::new();
|
||||
while out.len() < MAX_DETAIL {
|
||||
match seq.next_element::<SegMask>()? {
|
||||
Some(m) => out.push(m),
|
||||
None => return Ok(out),
|
||||
}
|
||||
}
|
||||
while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
d.deserialize_seq(Capped)
|
||||
}
|
||||
|
||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
fn base64_encode(bytes: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||
for chunk in bytes.chunks(3) {
|
||||
let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
|
||||
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
|
||||
for i in 0..4 {
|
||||
if i <= chunk.len() {
|
||||
out.push(char::from(B64[((n >> (18 - 6 * i)) & 63) as usize]));
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn base64_decode(s: &str) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(s.len() / 4 * 3);
|
||||
let (mut acc, mut bits) = (0u32, 0u32);
|
||||
for c in s.bytes().filter(|c| !c.is_ascii_whitespace()) {
|
||||
if c == b'=' {
|
||||
break;
|
||||
}
|
||||
let v = B64.iter().position(|b| *b == c)? as u32;
|
||||
acc = (acc << 6) | v;
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push((acc >> bits) as u8);
|
||||
acc &= (1 << bits) - 1;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base64_round_trips() {
|
||||
for n in 0..10 {
|
||||
let v: Vec<u8> = (0..n).map(|i| (i * 37 + 11) as u8).collect();
|
||||
assert_eq!(base64_decode(&base64_encode(&v)).unwrap(), v);
|
||||
}
|
||||
assert_eq!(base64_encode(b"Man"), "TWFu");
|
||||
assert_eq!(base64_encode(b"Ma"), "TWE=");
|
||||
assert!(base64_decode("TW!u").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logits_round_trip_quantized() {
|
||||
let side = 16;
|
||||
let l: Vec<f32> = (0..side * side).map(|i| (i as f32 - 128.0) / 4.0).collect();
|
||||
let m = SegMask::from_logits(side, &l);
|
||||
let back = m.logits().unwrap();
|
||||
for (a, b) in l.iter().zip(&back) {
|
||||
assert!((a.clamp(-127.0 / SCALE, 127.0 / SCALE) - b).abs() <= 0.5 / SCALE + 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hostile_document_keeps_at_most_max_detail_patches() {
|
||||
let patch = serde_json::to_value(SegMask::from_logits(4, &[1.0; 16])).unwrap();
|
||||
let shape = serde_json::json!({"kind": "object", "hint": [{"x": 0.5, "y": 0.5}], "detail": vec![patch; 10_000]});
|
||||
let s: crate::MaskShape = serde_json::from_value(shape).unwrap();
|
||||
let crate::MaskShape::Object { detail, .. } = s else { panic!("an Object") };
|
||||
assert_eq!(detail.len(), MAX_DETAIL);
|
||||
// and a whole develop document with them still loads
|
||||
let mut d = crate::DevelopSettings::default();
|
||||
d.masks.push(crate::Mask {
|
||||
id: 1,
|
||||
components: vec![crate::MaskComponent {
|
||||
name: None,
|
||||
op: crate::MaskOp::Add,
|
||||
invert: false,
|
||||
shape: crate::MaskShape::Prompt { text: "x".into(), seg: None, detail: vec![SegMask::from_logits(4, &[1.0; 16]); 50], edge: 0.0 },
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
let back: crate::DevelopSettings = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap();
|
||||
let crate::MaskShape::Prompt { detail, .. } = &back.masks[0].components[0].shape else { panic!("a Prompt") };
|
||||
assert_eq!(detail.len(), MAX_DETAIL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_data_is_none() {
|
||||
let mut m = SegMask::from_logits(8, &[1.0; 64]);
|
||||
m.side = 9;
|
||||
assert!(m.logits().is_none(), "size mismatch");
|
||||
m.side = 100_000;
|
||||
assert!(m.logits().is_none(), "absurd size");
|
||||
assert!(SegMask { side: 8, data: "not base64 !".into(), rect: None }.logits().is_none());
|
||||
assert!(SegMask { rect: Some([0.5, 0.0, 0.2, 1.0]), ..SegMask::from_logits(2, &[0.0; 4]) }.bounds().is_none());
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,7 @@ impl WbMode {
|
||||
pub struct WhiteBalance {
|
||||
pub mode: WbMode,
|
||||
pub temp: f64,
|
||||
/// Correction direction: negative adds green, positive adds magenta.
|
||||
pub tint: f64,
|
||||
}
|
||||
|
||||
@@ -539,8 +540,9 @@ impl Default for Optics {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lens corrections embedded in a DNG file (`OpcodeList3`: `WarpRectilinear`, `FixVignetteRadial`), converted
|
||||
/// to the oriented, default-cropped image. This is camera/file data (stored on the photo record, not in the develop
|
||||
/// Lens corrections embedded in the file, converted to the oriented, default-cropped image: a DNG's `OpcodeList3`
|
||||
/// (`WarpRectilinear`, `FixVignetteRadial`), or a raw reader's equivalent of the camera's own correction (Panasonic /
|
||||
/// Leica RW2 distortion, `lightcraft_raw`'s `vendor/rw2.rs`). This is camera/file data (stored on the photo record, not in the develop
|
||||
/// settings); "Enable Profile Corrections" applies it, scaled by the profile distortion/vignetting amounts.
|
||||
/// LightCraft never uses Adobe LCP lens profiles.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -772,8 +774,33 @@ pub enum MaskShape {
|
||||
Subject,
|
||||
Sky,
|
||||
Background,
|
||||
/// One object picked by clicks (SAM 3 point prompts): `hint` holds the clicks that include,
|
||||
/// `exclude` the ones that exclude; `seg` the segmentation computed from them.
|
||||
Object {
|
||||
hint: Vec<Point>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
exclude: Vec<Point>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
seg: Option<crate::SegMask>,
|
||||
/// Zoomed-in passes over parts of the image (higher resolution than `seg`), used
|
||||
/// inside their rectangles.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "crate::segmask::de_detail")]
|
||||
detail: Vec<crate::SegMask>,
|
||||
/// Edge −100..100: below 0 harder (a steeper transition), above 0 softer (feathered).
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
edge: f64,
|
||||
},
|
||||
/// Everything a description names ("sky", "the red car": SAM 3 concept prompts); `seg` is
|
||||
/// the segmentation computed for it.
|
||||
Prompt {
|
||||
text: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
seg: Option<crate::SegMask>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "crate::segmask::de_detail")]
|
||||
detail: Vec<crate::SegMask>,
|
||||
/// Edge −100..100, as for `Object`.
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
edge: f64,
|
||||
},
|
||||
People {
|
||||
person: u32,
|
||||
|
||||
@@ -30,6 +30,13 @@ serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
ab_glyph = { workspace = true }
|
||||
# Apply the font’s vertical substitutions and origins to Japanese watermarks.
|
||||
harfrust = { version = "0.12", default-features = false, features = ["std"] }
|
||||
lightcraft-segment = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
# AI masks with SAM 3 (Object / Describe). Off for the web and CLI builds.
|
||||
sam = ["dep:lightcraft-segment"]
|
||||
|
||||
[dev-dependencies]
|
||||
libc = { workspace = true }
|
||||
+31
-4
@@ -7,16 +7,20 @@
|
||||
//! `.wasm` within the web size budget (Cloudflare serves at most 25 MiB per file); the export
|
||||
//! watermark falls back to it there. Native builds embed every font.
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-changed=build.rs");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
|
||||
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
|
||||
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) {
|
||||
let mut embedded = 0;
|
||||
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from).map(resolve) {
|
||||
match craft_fonts(&dir) {
|
||||
Ok(entries) => src.push_str(&entries),
|
||||
Ok(entries) => {
|
||||
embedded = entries.lines().count();
|
||||
src.push_str(&entries);
|
||||
}
|
||||
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
|
||||
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
|
||||
}
|
||||
@@ -24,14 +28,37 @@ fn main() {
|
||||
}
|
||||
}
|
||||
src.push_str("];\n");
|
||||
// Without them the app still runs and every language still selectable, so Chinese and Japanese
|
||||
// just paint boxes — the one failure a user cannot diagnose from what they see.
|
||||
if embedded == 0 {
|
||||
println!(
|
||||
"cargo::warning=no craft-fonts embedded: Chinese and Japanese text will render as empty boxes. \
|
||||
Clone https://github.com/storytold/craft-fonts next to this repo and rebuild with \
|
||||
CRAFT_FONTS_DIR=../craft-fonts"
|
||||
);
|
||||
}
|
||||
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
|
||||
if let Err(e) = std::fs::write(&out, src) {
|
||||
println!("cargo::error=writing {}: {e}", out.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a relative `CRAFT_FONTS_DIR` against both roots a caller might mean: a build script's
|
||||
/// cwd is the *package* root (`crates/engine`), while the `../craft-fonts` the README and
|
||||
/// craft-fonts' docs use is written relative to the *workspace* root and would otherwise resolve
|
||||
/// to `crates/craft-fonts`.
|
||||
fn resolve(dir: PathBuf) -> PathBuf {
|
||||
if dir.is_absolute() {
|
||||
return dir;
|
||||
}
|
||||
let package = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default());
|
||||
let workspace = package.parent().and_then(Path::parent).unwrap_or(package.as_path());
|
||||
let candidates = [workspace.join(&dir), package.join(&dir)];
|
||||
candidates.iter().find(|candidate| candidate.is_dir()).cloned().unwrap_or_else(|| candidates[0].clone())
|
||||
}
|
||||
|
||||
/// One `CraftFont { .. }` initialiser per manifest line.
|
||||
fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
||||
fn craft_fonts(dir: &Path) -> Result<String, String> {
|
||||
let manifest = dir.join("fonts/manifest.txt");
|
||||
println!("cargo::rerun-if-changed={}", manifest.display());
|
||||
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
|
||||
@@ -93,7 +93,9 @@ fn main() {
|
||||
for path in &args {
|
||||
let bytes = std::fs::read(path).expect("read");
|
||||
println!("{path}");
|
||||
let raw = lightcraft_raw::decode(&bytes).expect("decode");
|
||||
let mut raw = lightcraft_raw::decode(&bytes).expect("decode");
|
||||
// as the loader does: lens corrections are applied by the pipeline, not in the raw develop
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
println!(" {}×{} cpp {} cfa {:?}", raw.width, raw.height, raw.cpp, raw.cfa.as_ref().map(|c| c.name()));
|
||||
println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode"))));
|
||||
println!(" {:<36} {}", "normalize:", best(n, || drop(raw.normalized().expect("norm"))));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Estimate the starting look of a raw without a camera colour matrix (Sony ARW, Nikon NEF) from its
|
||||
//! own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only,
|
||||
//! Estimate the starting look of a raw without a camera colour matrix (Sony ARW, Nikon NEF, Panasonic
|
||||
//! RW2) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only,
|
||||
//! never output pixels or a replacement for RAW editing.
|
||||
//! A global matrix can't follow the camera's hue-dependent rendering (the best matrix rendered a
|
||||
//! lime shirt olive that the camera kept lime): a hue/saturation table fitted to the residuals
|
||||
@@ -31,7 +31,7 @@ pub(crate) const PROFILE_PROXY: usize = 192;
|
||||
/// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the
|
||||
/// same formats by file extension.
|
||||
pub(crate) fn file_local_look(format: RawFormat) -> bool {
|
||||
matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw)
|
||||
matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2)
|
||||
}
|
||||
|
||||
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
|
||||
@@ -60,21 +60,30 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
|
||||
/// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s
|
||||
/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020).
|
||||
fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f)> {
|
||||
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||||
let edge = (2 * size).max(384) as u32;
|
||||
let decoded = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 }).ok()?;
|
||||
let reference = decoded.to_working();
|
||||
let crop = raw.crop.clipped(raw.active_area.width, raw.active_area.height);
|
||||
let decoded = crate::files::decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 })?;
|
||||
let mut reference = decoded.to_working();
|
||||
let (a, crop) = (raw.active_area, 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;
|
||||
let matches = |w: usize, h: usize, rw: usize, rh: usize| (rw as f64 / rh as f64 / (w as f64 / h as f64) - 1.0).abs() <= 0.02;
|
||||
if !matches(crop.width, crop.height, reference.width, reference.height) {
|
||||
// Panasonic previews show the whole active area while the default crop is the in-camera aspect ratio
|
||||
if !matches(a.width, a.height, reference.width, reference.height) {
|
||||
return None;
|
||||
}
|
||||
let (sx, sy) = (reference.width as f64 / a.width as f64, reference.height as f64 / a.height as f64);
|
||||
let (x, y) = ((crop.x as f64 * sx).round() as usize, (crop.y as f64 * sy).round() as usize);
|
||||
let (w, h) = ((crop.width as f64 * sx).round() as usize, (crop.height as f64 * sy).round() as usize);
|
||||
if w < 16 || h < 16 || x + w > reference.width || y + h > reference.height {
|
||||
return None;
|
||||
}
|
||||
reference = reference.into_crop(x, y, w, h);
|
||||
}
|
||||
// Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution.
|
||||
let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2;
|
||||
let sensor = raw.develop_binned(k, 0.99).ok()??;
|
||||
let sensor = sensor_proxy(raw, k, edge as usize)?;
|
||||
let mut sensor = fit(&sensor, size, size, Filter::Box);
|
||||
let reference = fit(&reference, sensor.width, sensor.height, Filter::Box);
|
||||
let gain = 2f32.powf(transform.baseline_exposure as f32);
|
||||
@@ -92,7 +101,7 @@ pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option<Vec<([f64; 3
|
||||
let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw));
|
||||
let (sensor, reference) = proxies(raw, bytes, &transform, PROFILE_PROXY)?;
|
||||
let to_camera = transform.matrix.inverse()?;
|
||||
let (pairs, _) = collect_pairs(&sensor, &reference)?;
|
||||
let (pairs, _) = collect_pairs(&sensor, &reference, None)?;
|
||||
Some(pairs.into_iter().map(|(x, y)| (to_camera.apply(x), y)).collect())
|
||||
}
|
||||
|
||||
@@ -103,6 +112,14 @@ pub(crate) fn fit_profile(pairs: &[([f64; 3], [f64; 3])]) -> Option<(Mat3, Optio
|
||||
Some((matrix, fit_hue_sat(pairs, &matrix)))
|
||||
}
|
||||
|
||||
fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option<Rgb32f> {
|
||||
Some(match raw.develop_binned(k, 0.99).ok()? {
|
||||
Some(sensor) => sensor,
|
||||
None if raw.cpp == 3 && raw.cfa.is_none() => fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box),
|
||||
None => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -132,10 +149,31 @@ fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
|
||||
fit_pairs_with(sensor, reference, None)
|
||||
}
|
||||
|
||||
/// Largest luminance ratio within the 3×3 neighbourhood of pixel `i` (1 on flat areas, large on edges).
|
||||
fn local_contrast(image: &Rgb32f, i: usize) -> f32 {
|
||||
let (w, h) = (image.width, image.height);
|
||||
if w == 0 {
|
||||
return f32::INFINITY;
|
||||
}
|
||||
let (x, y) = (i % w, i / w);
|
||||
let (mut lo, mut hi) = (f32::INFINITY, 0f32);
|
||||
for row in y.saturating_sub(1)..(y + 2).min(h) {
|
||||
for col in x.saturating_sub(1)..(x + 2).min(w) {
|
||||
let l = image.data.get(row * w + col).map_or(0.0, |p| luminance_2020(*p));
|
||||
lo = lo.min(l);
|
||||
hi = hi.max(l);
|
||||
}
|
||||
}
|
||||
if hi.is_finite() { hi / lo.max(1e-4) } else { f32::INFINITY }
|
||||
}
|
||||
|
||||
/// Training pairs (sensor → JPEG, unclipped midtones) and the wider set including highlights
|
||||
/// (for the chroma curve); `None` when too few, or the photo has too little colour.
|
||||
/// (for the chroma curve); `None` when too few, or the photo has too little colour. With
|
||||
/// `edge_limit`, pixels whose 3×3 neighbourhood in either proxy spans a larger luminance ratio
|
||||
/// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated
|
||||
/// colours (see [`EDGE_CONTRAST`]).
|
||||
type Pairs = Vec<([f64; 3], [f64; 3])>;
|
||||
fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<(Pairs, Pairs)> {
|
||||
fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>) -> Option<(Pairs, Pairs)> {
|
||||
if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() {
|
||||
return None;
|
||||
}
|
||||
@@ -143,7 +181,10 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<(Pairs, Pairs)>
|
||||
// Highlights too (camera JPEGs bleach colours toward white there), for the chroma curve only.
|
||||
let mut bright = Vec::new();
|
||||
let mut colour = 0;
|
||||
for (input, output) in sensor.data.iter().zip(&reference.data) {
|
||||
for (i, (input, output)) in sensor.data.iter().zip(&reference.data).enumerate() {
|
||||
if edge_limit.is_some_and(|limit| local_contrast(sensor, i) > limit || local_contrast(reference, i) > limit) {
|
||||
continue;
|
||||
}
|
||||
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.0) {
|
||||
continue;
|
||||
@@ -197,11 +238,25 @@ fn fit_matrix(pairs: &[([f64; 3], [f64; 3])]) -> Option<Mat3> {
|
||||
matrix.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0).then_some(matrix)
|
||||
}
|
||||
|
||||
/// Neighbourhood luminance ratio above which a pixel counts as an edge for the second attempt
|
||||
/// of [`fit_pairs_with`]. Camera JPEGs are often lens-corrected (Sony "Distortion Comp.: Auto",
|
||||
/// compacts and kit zooms): on a 51 mm ILCE-7RM2 shot of a glass façade the JPEG is up to 15 px
|
||||
/// of 1440 (about one proxy pixel) off the raw, and the fit failed the gate on the mismatched
|
||||
/// window frames alone (held-out RMS 0.111; 0.059 for the same shot with distortion correction
|
||||
/// off). Away from edges: 0.049. Issue #232.
|
||||
const EDGE_CONTRAST: f32 = 3.0;
|
||||
|
||||
/// The photo's look: its own matrix (with and without a hue/saturation table), or the given
|
||||
/// colour model (a camera profile's, in the sensor proxy's space), each completed with a tone
|
||||
/// and chroma curve fitted to this photo.
|
||||
/// and chroma curve fitted to this photo. When the fit on all pixels fails the acceptance gates,
|
||||
/// it is tried once more on the pixels away from edges (same gates): there colour pairs stay
|
||||
/// valid when the camera JPEG's geometry differs slightly from the raw's.
|
||||
fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
|
||||
let (pairs, bright) = collect_pairs(sensor, reference)?;
|
||||
fit_pairs_on(sensor, reference, colour.clone(), None).or_else(|| fit_pairs_on(sensor, reference, colour, Some(EDGE_CONTRAST)))
|
||||
}
|
||||
|
||||
fn fit_pairs_on(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>, edge_limit: Option<f32>) -> Option<CameraLook> {
|
||||
let (pairs, bright) = collect_pairs(sensor, reference, edge_limit)?;
|
||||
let candidates = match colour {
|
||||
Some(given) => vec![given],
|
||||
None => {
|
||||
@@ -252,9 +307,10 @@ fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt
|
||||
.sum();
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!(
|
||||
"[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels)",
|
||||
"[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels{})",
|
||||
(before / samples as f64).sqrt(),
|
||||
(after / samples as f64).sqrt()
|
||||
(after / samples as f64).sqrt(),
|
||||
if edge_limit.is_some() { ", away from edges" } else { "" }
|
||||
);
|
||||
}
|
||||
if samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) {
|
||||
@@ -511,6 +567,35 @@ fn fit_tone(mut pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn linear_arw_gets_a_sensor_proxy_without_demosaicing() {
|
||||
use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, RawFormat, Rect};
|
||||
let mut raw = RawImage {
|
||||
format: RawFormat::Arw,
|
||||
width: 32,
|
||||
height: 32,
|
||||
cpp: 3,
|
||||
data: RawData::F32([0.2, 0.3, 0.4].repeat(32 * 32)),
|
||||
cfa: None,
|
||||
bits: 16,
|
||||
black: BlackLevel::uniform(0.0),
|
||||
white: vec![1.0],
|
||||
active_area: Rect::new(0, 0, 32, 32),
|
||||
crop: Rect::new(0, 0, 32, 32),
|
||||
orientation: Orientation::from_exif(1),
|
||||
color: ColorData::default(),
|
||||
wb_multipliers: Some([1.0; 3]),
|
||||
linearized: true,
|
||||
opcodes: OpcodeLists::default(),
|
||||
metadata: lightcraft_meta::Metadata::default(),
|
||||
};
|
||||
let proxy = sensor_proxy(&raw, 2, 384).unwrap();
|
||||
assert_eq!((proxy.width, proxy.height), (32, 32));
|
||||
assert_eq!(proxy.data[0], [0.2, 0.3, 0.4]);
|
||||
raw.data = RawData::F32(Vec::new());
|
||||
assert!(sensor_proxy(&raw, 2, 384).is_none());
|
||||
}
|
||||
#[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]]);
|
||||
@@ -693,7 +778,7 @@ mod tests {
|
||||
let mut pool = Vec::new();
|
||||
for seed in 0..4 {
|
||||
let (sensor, reference) = photo(seed, 2.0 + seed as f32 * 0.5);
|
||||
pool.extend(collect_pairs(&sensor, &reference).unwrap().0);
|
||||
pool.extend(collect_pairs(&sensor, &reference, None).unwrap().0);
|
||||
}
|
||||
let (matrix, _) = fit_profile(&pool).unwrap();
|
||||
// the camera's colours (luminance-normalised: the tone curve sets brightness)
|
||||
@@ -731,8 +816,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sony_and_nikon_raws_get_a_file_local_look() {
|
||||
assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw].into_iter().all(file_local_look));
|
||||
fn sony_nikon_and_panasonic_raws_get_a_file_local_look() {
|
||||
assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2].into_iter().all(file_local_look));
|
||||
assert!(![RawFormat::Dng, RawFormat::Cr2, RawFormat::Raf].into_iter().any(file_local_look));
|
||||
}
|
||||
|
||||
@@ -753,6 +838,86 @@ mod tests {
|
||||
assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0);
|
||||
}
|
||||
|
||||
/// A public DC-FZ1000 II RW2 shot at 4:3 on its 3:2 sensor (skipped without the corpus): the default crop is 4:3
|
||||
/// while the embedded JPEG shows the whole sensor; the look is still fitted, against the matching part of it.
|
||||
#[test]
|
||||
fn corpus_rw2_with_an_in_camera_crop_gets_a_camera_look() {
|
||||
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw/rw2-panasonic-fz1000m2-4x3.rw2");
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
eprintln!("skip: {} absent", path.display());
|
||||
return;
|
||||
};
|
||||
let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
assert_eq!((img.width, img.height), (400, 300), "framed in the in-camera aspect ratio");
|
||||
assert!(info.camera_tone.is_some(), "no camera look fitted");
|
||||
assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0);
|
||||
}
|
||||
|
||||
/// Issue #232 (skipped without the corpus): the public ILCE-7RM2 sample's camera JPEG is
|
||||
/// distortion-corrected, and on its glass façade the fit on all pixels misses the gate
|
||||
/// (held-out RMS 0.113): it used to open grey with the neutral fallback.
|
||||
#[test]
|
||||
fn corpus_arw_with_a_lens_corrected_preview_gets_a_camera_look() {
|
||||
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw/arw-sony-a7rm2-12bit-uncompressed.arw");
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
eprintln!("skip: {} absent", path.display());
|
||||
return;
|
||||
};
|
||||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
assert!(info.camera_tone.is_some(), "no camera look fitted");
|
||||
}
|
||||
|
||||
/// Issue #232: an ILCE-7RM2 camera JPEG is lens-corrected ("Distortion Comp.: Auto"), so on a
|
||||
/// façade of window frames the raw and its JPEG are a pixel or two apart. The colours agree
|
||||
/// everywhere away from the edges, and the look must still be found there.
|
||||
#[test]
|
||||
fn fits_a_slightly_misaligned_camera_jpeg_away_from_edges() {
|
||||
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
|
||||
let (w, h, block, shift) = (96, 64, 8, 2);
|
||||
// Patches of 8×8 pixels, alternately dark and bright (frames and panes), varied in colour.
|
||||
let scene = |x: usize, y: usize| -> [f32; 3] {
|
||||
let (bx, by) = (x / block, y / block);
|
||||
let ev = if (bx + by) % 2 == 0 { 0.04 + (bx % 3) as f32 * 0.01 } else { 0.3 + (by % 4) as f32 * 0.08 };
|
||||
[ev * (0.7 + ((bx * 7 + by * 3) % 11) as f32 * 0.05), ev, ev * (0.7 + ((bx * 5 + by) % 13) as f32 * 0.04)]
|
||||
};
|
||||
let camera = |p: [f32; 3]| {
|
||||
let q = known.apply_f32(p);
|
||||
let y = luminance_2020(q);
|
||||
q.map(|v| v * (1.0 - (-2.5 * y).exp()) / y)
|
||||
};
|
||||
let mut sensor = Rgb32f::new(w, h);
|
||||
let mut reference = sensor.clone();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
sensor.data[y * w + x] = scene(x, y);
|
||||
// The JPEG shows the scene `shift` pixels to the right of where the raw has it.
|
||||
reference.data[y * w + x] = camera(scene(x.saturating_sub(shift), y));
|
||||
}
|
||||
}
|
||||
let fit = fit_pairs(&sensor, &reference).expect("look fitted away from the misaligned edges");
|
||||
// The look itself is right: on the aligned scene it reproduces the camera.
|
||||
let tone = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0);
|
||||
let (mut error, mut n) = (0.0, 0);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let p = displayed(fit.matrix.apply(scene(x, y).map(f64::from)), &tone);
|
||||
let t = camera(scene(x, y));
|
||||
error += (0..3).map(|c| (p[c] - f64::from(t[c])).powi(2)).sum::<f64>();
|
||||
n += 3;
|
||||
}
|
||||
}
|
||||
assert!((error / n as f64).sqrt() < 0.03, "{}", (error / n as f64).sqrt());
|
||||
// Unrelated colours stay rejected away from edges too (covered below); a flat JPEG as well.
|
||||
reference.data.fill([0.2; 3]);
|
||||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_monochrome_invalid_and_unrelated_previews() {
|
||||
let mut sensor = Rgb32f::new(32, 32);
|
||||
|
||||
@@ -199,7 +199,9 @@ impl Pool {
|
||||
/// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW or
|
||||
/// NEF without colour matrices, no usable camera JPEG, too little colour).
|
||||
pub fn add(&mut self, bytes: &[u8]) -> Result<Option<String>, String> {
|
||||
let raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
|
||||
let mut raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
|
||||
// colour only: geometric lens corrections would stop the sensor proxy from binning
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let Some(model) = raw.metadata.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) else { return Ok(None) };
|
||||
let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes) else { return Ok(None) };
|
||||
let step = pairs.len().div_ceil(PAIRS_PER_FILE).max(1);
|
||||
|
||||
@@ -251,6 +251,9 @@ pub(crate) fn rename_folder_on_disk(from: &str, to: &str) -> std::result::Result
|
||||
|
||||
/// 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) {
|
||||
// a library folder chosen at or below it follows too, as a source or as a filter
|
||||
follow_library_folder(&mut s.library_folder, from, to);
|
||||
follow_library_folder(&mut s.filter.library_folder, from, to);
|
||||
if let Some(b) = &mut s.browse
|
||||
&& let Ok(rel) = Path::new(&b.path).strip_prefix(from)
|
||||
{
|
||||
@@ -260,6 +263,21 @@ pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A chosen library folder at or below `from` becomes the same place below `to`. Compared the
|
||||
/// way the filter does, by folder identity, so a differently spelled choice is not widened to
|
||||
/// the folder itself.
|
||||
fn follow_library_folder(chosen: &mut Option<String>, from: &str, to: &str) {
|
||||
let Some(c) = chosen.as_deref() else { return };
|
||||
let (ck, fk) = (lightcraft_catalog::query::folder_key(c), lightcraft_catalog::query::folder_key(from));
|
||||
if fk.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(rest) = ck.strip_prefix(fk.as_str()).filter(|r| r.is_empty() || r.starts_with('/') || fk.ends_with('/')) {
|
||||
let rest = rest.trim_start_matches('/');
|
||||
*chosen = Some(if rest.is_empty() { to.to_string() } else { format!("{}/{rest}", to.trim_end_matches(['/', '\\'])) });
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId, Version};
|
||||
use lightcraft_develop::{DevelopSettings, Preset, Section, SettingsGroup, Treatment, Upright, WbMode, controls};
|
||||
use lightcraft_geom::{CropGeometry, Point, Rect, crop_fit_angle};
|
||||
use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, ok, str_param};
|
||||
@@ -37,6 +37,32 @@ fn crop_dims(s: &Session, id: PhotoId) -> (f64, f64) {
|
||||
if o.swaps_axes() { (h, w) } else { (w, h) }
|
||||
}
|
||||
|
||||
/// `v` as a list of numbers; `None` unless it is an array of nothing but numbers.
|
||||
fn numbers(v: Option<&Value>) -> Option<Vec<f64>> {
|
||||
v?.as_array()?.iter().map(Value::as_f64).collect()
|
||||
}
|
||||
|
||||
/// A width × height lock stored as hundredths. Rejects non-finite, non-positive and
|
||||
/// extreme (beyond 20:1) shapes, which would give an empty or unusable crop.
|
||||
fn aspect_tuple(c: &str, x: f64, y: f64) -> Result<(u32, u32)> {
|
||||
let ratio = x / y;
|
||||
if !(x.is_finite()
|
||||
&& y.is_finite()
|
||||
&& x > 0.0
|
||||
&& y > 0.0
|
||||
&& ratio.is_finite()
|
||||
&& (1.0 / lightcraft_geom::MAX_RATIO..=lightcraft_geom::MAX_RATIO).contains(&ratio))
|
||||
{
|
||||
return Err(bad(c, "aspect needs two positive numbers with a ratio between 1:20 and 20:1"));
|
||||
}
|
||||
let to = |v: f64| (v * 100.0).round();
|
||||
let (a, b) = (to(x), to(y));
|
||||
if a < 1.0 || b < 1.0 || a > f64::from(u32::MAX) || b > f64::from(u32::MAX) {
|
||||
return Err(bad(c, "aspect numbers are out of range"));
|
||||
}
|
||||
Ok((a as u32, b as u32))
|
||||
}
|
||||
|
||||
fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>) {
|
||||
d.crop.aspect = aspect;
|
||||
let a = aspect.map(|(aw, ah)| {
|
||||
@@ -395,6 +421,46 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
cmd!(
|
||||
"crop.drag",
|
||||
"Drag Crop Handle",
|
||||
[],
|
||||
None,
|
||||
"{handle: topLeft|topRight|bottomRight|bottomLeft|top|right|bottom|left|move | 0..8, from: [x,y], to: [x,y] (normalized, straightened frame), start?: [x0,y0,x1,y1] (default: the current crop)} — drags a crop handle; stops at the image edge, keeps the opposite edges, honours the aspect lock",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let id = active(s, "crop.drag")?;
|
||||
let (w, h) = crop_dims(s, id);
|
||||
let handle = match p.get("handle") {
|
||||
Some(Value::String(n)) => CropHandle::from_name(n),
|
||||
Some(v) => v.as_u64().and_then(|i| u8::try_from(i).ok()).and_then(CropHandle::from_index),
|
||||
None => None,
|
||||
}
|
||||
.ok_or_else(|| bad("crop.drag", "unknown handle"))?;
|
||||
let point = |k: &str| -> Result<Point> {
|
||||
match numbers(p.get(k)).as_deref() {
|
||||
Some([x, y]) if x.is_finite() && y.is_finite() => Ok(Point::new(*x, *y)),
|
||||
_ => Err(bad("crop.drag", format!("`{k}` needs [x, y]"))),
|
||||
}
|
||||
};
|
||||
let (from, to) = (point("from")?, point("to")?);
|
||||
let given = match (p.get("start"), numbers(p.get("start")).as_deref()) {
|
||||
(None | Some(Value::Null), _) => None,
|
||||
(_, Some(&[x0, y0, x1, y1])) if [x0, y0, x1, y1].iter().all(|v| v.is_finite()) => {
|
||||
Some(Rect::new(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)))
|
||||
}
|
||||
_ => return Err(bad("crop.drag", "`start` needs 4 numbers")),
|
||||
};
|
||||
edit(s, "crop.drag", "Crop", |d| {
|
||||
let start = CropGeometry { rect: given.unwrap_or(d.crop.geometry.rect), angle: d.crop.geometry.angle };
|
||||
// A locked crop keeps the shape it has: the lock stores a width:height tuple whose
|
||||
// orientation is reinterpreted per image, so the frame itself is the truth.
|
||||
let ratio = d.crop.aspect.and(Some(start.rect_px(w, h).aspect())).filter(|a| a.is_finite() && *a > 0.0);
|
||||
d.crop.geometry = drag_crop(start, handle, from, to, w, h, ratio);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"crop.straighten",
|
||||
"Straighten",
|
||||
@@ -470,11 +536,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let (x, y) = a.split_once('x').ok_or_else(|| bad("crop.aspect", "use WxH"))?;
|
||||
let (x, y): (f64, f64) =
|
||||
(x.parse().map_err(|_| bad("crop.aspect", "bad number"))?, y.parse().map_err(|_| bad("crop.aspect", "bad number"))?);
|
||||
Some(((x * 100.0) as u32, (y * 100.0) as u32))
|
||||
}
|
||||
Some(Value::Array(a)) if a.len() == 2 => {
|
||||
Some(((a[0].as_f64().unwrap_or(1.0) * 100.0) as u32, (a[1].as_f64().unwrap_or(1.0) * 100.0) as u32))
|
||||
Some(aspect_tuple("crop.aspect", x, y)?)
|
||||
}
|
||||
Some(v @ Value::Array(_)) => match numbers(Some(v)).as_deref() {
|
||||
Some(&[x, y]) => Some(aspect_tuple("crop.aspect", x, y)?),
|
||||
_ => return Err(bad("crop.aspect", "use [w, h] with two numbers")),
|
||||
},
|
||||
_ => return Err(bad("crop.aspect", "missing aspect")),
|
||||
};
|
||||
edit(s, "crop.aspect", "Crop Aspect", |d| {
|
||||
|
||||
@@ -23,7 +23,8 @@ impl Session {
|
||||
.into_iter()
|
||||
.find(|(x, _)| x.name.eq_ignore_ascii_case(name.trim()))
|
||||
.ok_or_else(|| format!("unknown export preset `{name}` (see export.presets)"))?;
|
||||
let mut out = preset.params;
|
||||
// a preset saved by another version may hold keys this one doesn't know: not the caller's typo
|
||||
let mut out = crate::export::ExportOptions::known_keys_only(&preset.params);
|
||||
if let (Some(o), Some(own)) = (out.as_object_mut(), p.as_object()) {
|
||||
for (k, v) in own.iter().filter(|(k, _)| *k != "preset") {
|
||||
o.insert(k.clone(), v.clone());
|
||||
@@ -54,6 +55,8 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.retain(|k, _| !NOT_SETTINGS.contains(&k.as_str()));
|
||||
}
|
||||
// a preset that app.export would refuse is refused here, where the typo is on screen
|
||||
crate::export::ExportOptions::validate(ID, ¶ms)?;
|
||||
let preset = ExportPreset { name: name.to_string(), params };
|
||||
match s.export_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) {
|
||||
Some(x) => *x = preset,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Library commands: view source, filter/sort, selection, ratings/flags/labels, rotate, delete,
|
||||
//! metadata, albums, import.
|
||||
|
||||
use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Flag, GroupBy, Op, PhotoId, Sort, SortKey};
|
||||
use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Filter, Flag, GroupBy, Op, PhotoId, Sort, SortKey};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param};
|
||||
@@ -66,7 +66,14 @@ pub fn import_params(s: &Session, p: &Value) -> Result<ImportRequest> {
|
||||
{
|
||||
return Err(bad("library.import", format!("unknown metadata preset `{n}`")));
|
||||
}
|
||||
let on_deleted = match str_param(p, "onDeleted") {
|
||||
Some(v) => {
|
||||
crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (skip|restore|fresh)")))?
|
||||
}
|
||||
None => Default::default(),
|
||||
};
|
||||
let opts = crate::import::ImportOptions {
|
||||
on_deleted,
|
||||
mode,
|
||||
preset,
|
||||
keywords: strs(p, "keywords"),
|
||||
@@ -87,7 +94,9 @@ pub fn import_params(s: &Session, p: &Value) -> Result<ImportRequest> {
|
||||
/// `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();
|
||||
// photos an import restored from Recently Deleted take part like new ones (album, selection)
|
||||
let imported: Vec<u64> =
|
||||
["imported", "restored"].iter().flat_map(|k| report[*k].as_array().into_iter().flatten().filter_map(Value::as_u64)).collect();
|
||||
if album.is_none()
|
||||
&& let Some(name) = album_name
|
||||
&& !imported.is_empty()
|
||||
@@ -224,6 +233,25 @@ fn select_by(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"selected": n}))
|
||||
}
|
||||
|
||||
/// Seeds stay exactly representable as a JSON double, so web and agent clients read back what they wrote.
|
||||
const MAX_SEED: u64 = 1 << 53;
|
||||
|
||||
/// `seed` as an integer in `0..2^53`, or `default` when absent or null; anything else is an error.
|
||||
fn seed_param(p: &Value, cmd: &str, default: u64) -> Result<u64> {
|
||||
match p.get("seed") {
|
||||
None | Some(Value::Null) => Ok(default),
|
||||
Some(v) => v.as_u64().filter(|n| *n < MAX_SEED).ok_or_else(|| bad(cmd, "seed must be an integer from 0 to 2^53 - 1")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The seed after `prev` at time `now`: a mix of `prev` and the clock text, so repeated reshuffles
|
||||
/// within one clock tick still differ (barring a 2^-53 collision). Kept below 2^53 so JSON
|
||||
/// clients that read numbers as doubles (web, MCP agents) get the same seed back.
|
||||
fn next_seed(prev: u64, now: &str) -> u64 {
|
||||
let t = now.bytes().fold(0u64, |h, b| lightcraft_catalog::mix64(h ^ u64::from(b)));
|
||||
(lightcraft_catalog::mix64(prev.wrapping_add(1)) ^ t) & (MAX_SEED - 1)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
// ---- view source / filter / sort
|
||||
@@ -232,7 +260,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Show Source",
|
||||
[],
|
||||
None,
|
||||
"{kind: all|recentlyAdded|album|recentlyDeleted|picks|missing, id?: albumId}",
|
||||
"{kind: all|recentlyAdded|album|recentlyDeleted|picks|missing|libraryFolder, id?: albumId, path?: a path from library.folders (for libraryFolder)}",
|
||||
always,
|
||||
|s, p| {
|
||||
let kind = str_param(p, "kind").unwrap_or("all");
|
||||
@@ -249,6 +277,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
LibrarySource::Album(a)
|
||||
}
|
||||
"libraryFolder" => {
|
||||
let path = str_param(p, "path").filter(|d| !d.trim().is_empty() && !lightcraft_catalog::query::folder_key(d).is_empty());
|
||||
let path = path.ok_or_else(|| bad("library.source", "libraryFolder needs the `path` of a folder from library.folders"))?;
|
||||
if lightcraft_catalog::folders::is_startup_disk(path) {
|
||||
return Err(bad("library.source", "the startup disk's path covers every disk: choose a folder in it"));
|
||||
}
|
||||
let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
|
||||
let ids = s.catalog.query(&f, &Sort::default());
|
||||
if ids.is_empty() {
|
||||
return Err(bad("library.source", format!("{path}: no photo in the library was imported from it")));
|
||||
}
|
||||
if covers_other_disks(s, path, &ids) {
|
||||
return Err(bad("library.source", format!("{path} holds other disks as well: choose a disk or a folder on one")));
|
||||
}
|
||||
s.library_folder = Some(path.to_string());
|
||||
// one folder at a time: a folder filter left over would be ignored
|
||||
s.filter.library_folder = None;
|
||||
LibrarySource::LibraryFolder
|
||||
}
|
||||
other => return Err(bad("library.source", format!("unknown source `{other}`"))),
|
||||
};
|
||||
let vis = s.visible_cloned();
|
||||
@@ -263,15 +310,75 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Filter",
|
||||
[],
|
||||
None,
|
||||
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, keyword?, person?, camera?}",
|
||||
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, libraryFolder?: a path from library.folders, keyword?, person?, camera?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let mut v = serde_json::to_value(&s.filter).unwrap_or_default();
|
||||
lightcraft_develop::presets::deep_merge(&mut v, p);
|
||||
s.filter = serde_json::from_value(v).map_err(|e| bad("library.filter", e.to_string()))?;
|
||||
let f: Filter = serde_json::from_value(v).map_err(|e| bad("library.filter", e.to_string()))?;
|
||||
let mut f = f;
|
||||
// a blank folder is no folder: no hidden "filters active" state
|
||||
f.library_folder = f.library_folder.filter(|d| !d.trim().is_empty());
|
||||
if f.library_folder.is_some() && s.source == LibrarySource::LibraryFolder {
|
||||
return Err(bad("library.filter", "a folder is already shown (library.source): show another source first"));
|
||||
}
|
||||
s.filter = f;
|
||||
Ok(json!({"count": s.visible().len()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
query "library.folders",
|
||||
"Library Folders",
|
||||
[],
|
||||
None,
|
||||
"{} → [{name, path, count, own, volume, selectable, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)",
|
||||
always,
|
||||
|s, _| Ok(serde_json::to_value(s.catalog.folder_tree()).unwrap_or_default())
|
||||
),
|
||||
cmd!(
|
||||
"library.removeFolder",
|
||||
"Remove Folder from Library",
|
||||
[],
|
||||
None,
|
||||
"{path, disk?: bool} — move the library photos imported from this folder (and the folders inside it) to Recently Deleted; one undo step, no file is touched. A whole disk or share (`/Volumes/nas`, `C:\\`, `\\\\srv\\share`) goes only with `disk: true`, the startup disk never → {removed}",
|
||||
always,
|
||||
|s, p| {
|
||||
const C: &str = "library.removeFolder";
|
||||
let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
if lightcraft_catalog::query::folder_key(path).is_empty() {
|
||||
return Err(bad(C, format!("{path}: not a folder")));
|
||||
}
|
||||
if lightcraft_catalog::folders::is_startup_disk(path) {
|
||||
return Err(bad(C, "choose a folder, not the whole startup disk"));
|
||||
}
|
||||
if lightcraft_catalog::folders::is_disk_root(path) && !bool_or(p, "disk", false) {
|
||||
return Err(bad(C, format!("{path} is a whole disk or share: pass `disk: true` to remove everything on it")));
|
||||
}
|
||||
let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
|
||||
let ids = s.catalog.query(&f, &Sort::default());
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, format!("{path}: no photo in the library was imported from it")));
|
||||
}
|
||||
if covers_other_disks(s, path, &ids) && !bool_or(p, "disk", false) {
|
||||
return Err(bad(C, format!("{path} holds whole disks: pass `disk: true` to remove everything on them")));
|
||||
}
|
||||
let ops = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect();
|
||||
s.commit("Remove Folder from Library", Op::Batch { ops })?;
|
||||
if s.filter.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) {
|
||||
s.filter.library_folder = None;
|
||||
}
|
||||
// the folder being shown is gone: back to everything, as for a deleted album
|
||||
if s.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) {
|
||||
s.library_folder = None;
|
||||
if s.source == LibrarySource::LibraryFolder {
|
||||
s.source = LibrarySource::All;
|
||||
}
|
||||
}
|
||||
let vis = s.visible_cloned();
|
||||
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
|
||||
Ok(json!({"removed": ids.len()}))
|
||||
}
|
||||
),
|
||||
cmd!("library.clearFilter", "Clear Filters", ["View"], None, "{}", always, |s, _| {
|
||||
s.filter = Default::default();
|
||||
Ok(json!({"count": s.visible().len()}))
|
||||
@@ -281,7 +388,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Sort",
|
||||
["View", "Sort"],
|
||||
None,
|
||||
"{key?: captureDate|importDate|editDate|fileName|rating|fileSize, ascending?: bool, group?: auto|none|day|month|year}",
|
||||
"{key?: captureDate|importDate|editDate|fileName|rating|fileSize|random, ascending?: bool, group?: auto|none|day|month|year, seed?: u64 (the shuffle `random` gives)}",
|
||||
always,
|
||||
|s, p| {
|
||||
let key: SortKey = match p.get("key") {
|
||||
@@ -292,7 +399,31 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Some(g) => GroupBy::parse(g).ok_or_else(|| bad("library.sort", "group must be auto|none|day|month|year"))?,
|
||||
None => s.sort.group,
|
||||
};
|
||||
s.sort = Sort { key, ascending: bool_or(p, "ascending", s.sort.ascending), group };
|
||||
let mut seed = seed_param(p, "library.sort", s.sort.seed)?;
|
||||
let explicit = p.get("seed").is_some_and(|v| !v.is_null());
|
||||
if key == SortKey::Random && s.sort.key != SortKey::Random && !explicit {
|
||||
// switching to Random is a fresh shuffle, not whatever seed was left behind
|
||||
seed = next_seed(seed, &(s.clock)());
|
||||
}
|
||||
s.sort = Sort { key, ascending: bool_or(p, "ascending", s.sort.ascending), group, seed };
|
||||
ok()
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"library.shuffle",
|
||||
"Reshuffle",
|
||||
["View", "Sort"],
|
||||
None,
|
||||
"{seed?: 0..2^53-1} — sort at random; without `seed` a new shuffle each time",
|
||||
always,
|
||||
|s, p| {
|
||||
let seed = match seed_param(p, "library.shuffle", s.sort.seed)? {
|
||||
given if p.get("seed").is_some_and(|v| !v.is_null()) => given,
|
||||
// derived from the previous seed and the session clock: reproducible under a test
|
||||
// clock, and no RNG needed
|
||||
prev => next_seed(prev, &(s.clock)()),
|
||||
};
|
||||
s.sort = Sort { key: SortKey::Random, seed, ..s.sort };
|
||||
ok()
|
||||
}
|
||||
),
|
||||
@@ -313,36 +444,50 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
// ---- selection
|
||||
cmd!("library.select", "Select Photos", [], None, "{ids: [id], active?: id, mode?: replace|add|toggle|range}", always, |s, p| {
|
||||
let ids = ids_param(p).unwrap_or_default();
|
||||
let mode = str_param(p, "mode").unwrap_or("replace");
|
||||
s.end_interaction()?;
|
||||
match mode {
|
||||
"replace" => {
|
||||
s.selection =
|
||||
Selection { ids: ids.clone(), active: p.get("active").and_then(Value::as_u64).map(PhotoId).or(ids.first().copied()) };
|
||||
cmd!(
|
||||
"library.select",
|
||||
"Select Photos",
|
||||
[],
|
||||
None,
|
||||
"{ids: [id], active?: id, mode?: replace|add|toggle|range} — every id must be a photo of the library",
|
||||
always,
|
||||
|s, p| {
|
||||
let ids = ids_param(p).unwrap_or_default();
|
||||
let active = p.get("active").and_then(Value::as_u64).map(PhotoId);
|
||||
let mode = str_param(p, "mode").unwrap_or("replace");
|
||||
// An id that is not in the library is an error, and the selection stays as it was:
|
||||
// selecting it would otherwise report success and leave the session with no usable
|
||||
// active photo (#182).
|
||||
if let Some(id) = ids.iter().chain(active.as_ref()).find(|id| s.catalog.photo(**id).is_none()) {
|
||||
return Err(bad("library.select", format!("no such photo {}", id.0)));
|
||||
}
|
||||
"add" => {
|
||||
for id in ids {
|
||||
if !s.selection.contains(id) {
|
||||
s.selection.ids.push(id);
|
||||
s.end_interaction()?;
|
||||
match mode {
|
||||
"replace" => {
|
||||
s.selection = Selection { ids: ids.clone(), active: active.or(ids.first().copied()) };
|
||||
}
|
||||
"add" => {
|
||||
for id in ids {
|
||||
if !s.selection.contains(id) {
|
||||
s.selection.ids.push(id);
|
||||
}
|
||||
s.selection.active = Some(id);
|
||||
}
|
||||
s.selection.active = Some(id);
|
||||
}
|
||||
}
|
||||
"toggle" => ids.into_iter().for_each(|id| s.selection.toggle(id)),
|
||||
"range" => {
|
||||
let vis = s.visible_cloned();
|
||||
if let Some(id) = ids.first() {
|
||||
s.selection.extend_to(*id, &vis);
|
||||
"toggle" => ids.into_iter().for_each(|id| s.selection.toggle(id)),
|
||||
"range" => {
|
||||
let vis = s.visible_cloned();
|
||||
if let Some(id) = ids.first() {
|
||||
s.selection.extend_to(*id, &vis);
|
||||
}
|
||||
}
|
||||
other => return Err(bad("library.select", format!("unknown mode `{other}`"))),
|
||||
}
|
||||
other => return Err(bad("library.select", format!("unknown mode `{other}`"))),
|
||||
s.active_mask = None;
|
||||
s.active_spot = None;
|
||||
Ok(json!({"selected": s.selection.ids.len()}))
|
||||
}
|
||||
s.active_mask = None;
|
||||
s.active_spot = None;
|
||||
Ok(json!({"selected": s.selection.ids.len()}))
|
||||
}),
|
||||
),
|
||||
cmd!("library.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, |s, _| {
|
||||
let vis = s.visible_cloned();
|
||||
s.selection = Selection { active: s.selection.active.filter(|a| vis.contains(a)).or(vis.first().copied()), ids: vis };
|
||||
@@ -517,10 +662,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
id,
|
||||
deleted: false
|
||||
}))),
|
||||
cmd!(
|
||||
"library.emptyRecentlyDeleted",
|
||||
"Empty Recently Deleted",
|
||||
["Photo"],
|
||||
None,
|
||||
"{} → {deleted} — removes every photo in Recently Deleted from the library (files on disk stay)",
|
||||
always,
|
||||
|s, _| {
|
||||
let ids: Vec<PhotoId> = s.catalog.photos().filter(|p| p.deleted).map(|p| p.id).collect();
|
||||
if ids.is_empty() {
|
||||
return Ok(json!({"deleted": 0}));
|
||||
}
|
||||
let op = s.catalog.delete_photos_permanently_ops(&ids);
|
||||
s.commit("Empty Recently Deleted", op)?;
|
||||
s.selection = Selection::default();
|
||||
Ok(json!({"deleted": ids.len()}))
|
||||
}
|
||||
),
|
||||
cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| {
|
||||
let t = s.targets(p);
|
||||
let ops = t.iter().map(|id| s.catalog.delete_permanently_ops(*id)).collect::<Vec<_>>();
|
||||
s.commit("Delete Permanently", Op::Batch { ops })?;
|
||||
let op = s.catalog.delete_photos_permanently_ops(&t);
|
||||
s.commit("Delete Permanently", op)?;
|
||||
s.selection = Selection::default();
|
||||
Ok(json!({"deleted": t.len()}))
|
||||
}),
|
||||
@@ -682,8 +845,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let rules = if bool_or(p, "fromView", false) {
|
||||
view_rules(s)
|
||||
} else {
|
||||
let base = if bool_or(p, "replace", false) { Default::default() } else { cur };
|
||||
merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules")?
|
||||
let replace = bool_or(p, "replace", false);
|
||||
let folder = cur.library_folder.clone();
|
||||
let base = if replace { Default::default() } else { cur };
|
||||
let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules")?;
|
||||
// the rules dialog has no folder field: replacing its rules keeps the folder
|
||||
// unless the call says (`libraryFolder: null`) to drop it
|
||||
if replace && p.get("rules").and_then(|r| r.get("libraryFolder")).is_none() {
|
||||
r.library_folder = folder;
|
||||
}
|
||||
r
|
||||
};
|
||||
s.commit("Edit Smart Album", Op::SetAlbumRules { id, rules: Box::new(rules) })?;
|
||||
Ok(json!({"count": s.catalog.album_count(id)}))
|
||||
@@ -913,7 +1084,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Import Photos",
|
||||
["File"],
|
||||
Some("Cmd+Shift+I"),
|
||||
"{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?}",
|
||||
"{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?: [..], onDeleted?: skip|restore|fresh (a file that is in Recently Deleted: skip = leave it there and say so, restore = bring the photo back with its edits, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let req = import_params(s, p)?;
|
||||
@@ -924,9 +1095,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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 ids_of = |k: &str| -> Vec<u64> { report[k].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default() };
|
||||
let (imported, restored) = (ids_of("imported"), ids_of("restored"));
|
||||
let n = s.undo.len().saturating_sub(undo0);
|
||||
s.merge_undo(n, &format!("Add {} Photo{}", imported.len(), if imported.len() == 1 { "" } else { "s" }));
|
||||
let (verb, count) = if imported.is_empty() && !restored.is_empty() { ("Restore", restored.len()) } else { ("Add", imported.len()) };
|
||||
s.merge_undo(n, &format!("{verb} {count} Photo{}", if count == 1 { "" } else { "s" }));
|
||||
let imported = if imported.is_empty() { restored } else { imported };
|
||||
if let Some(f) = imported.first() {
|
||||
s.selection = Selection::single(PhotoId(*f));
|
||||
}
|
||||
@@ -991,6 +1165,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
cmd!("library.clearPreviews", "Clear Preview Cache", ["File"], None, "{}", always, |s, _| {
|
||||
s.media.rendered.clear();
|
||||
s.media.clear_sources();
|
||||
ok()
|
||||
}),
|
||||
]
|
||||
@@ -1007,6 +1182,16 @@ fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Res
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// Whether `path` lies above a disk: some of its `ids` photos are on a disk (not the startup
|
||||
/// disk) that the folder does not lie in. Such a folder (`/Volumes`, `/mnt`…) is not a folder of
|
||||
/// one disk but a way to reach several.
|
||||
fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool {
|
||||
ids.iter()
|
||||
.filter_map(|id| s.catalog.photo(*id))
|
||||
.filter_map(|p| lightcraft_catalog::folders::volume_of(p))
|
||||
.any(|v| v != "/" && !lightcraft_catalog::query::folder_within(path, &v))
|
||||
}
|
||||
|
||||
/// The current view (source + filter) as smart-album rules. Viewing a smart album starts from
|
||||
/// its rules with the filter bar's settings on top.
|
||||
fn view_rules(s: &Session) -> lightcraft_catalog::Filter {
|
||||
@@ -1033,6 +1218,9 @@ fn view_rules(s: &Session) -> lightcraft_catalog::Filter {
|
||||
None => {
|
||||
let mut f = s.source.to_filter(&s.filter, &s.catalog);
|
||||
f.deleted = false;
|
||||
if s.source == LibrarySource::LibraryFolder {
|
||||
f.library_folder = s.library_folder.clone();
|
||||
}
|
||||
f
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use lightcraft_geom::Point;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, bool_or, cmd, f64_or, has_active, ok, point, str_param};
|
||||
use crate::segment::{Click, MAX_CLICKS};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn shape_from(kind: &str, p: &Value, c: &str) -> Result<MaskShape> {
|
||||
@@ -38,10 +39,100 @@ fn shape_from(kind: &str, p: &Value, c: &str) -> Result<MaskShape> {
|
||||
.unwrap_or_default();
|
||||
MaskShape::ColorRange { samples, refine: f64_or(p, "refine", 50.0) }
|
||||
}
|
||||
other => return Err(bad(c, format!("unknown mask kind `{other}` (brush|linear|radial|sky|subject|background|luminanceRange|colorRange)"))),
|
||||
"object" => MaskShape::Object { hint: points(p, "points"), exclude: points(p, "exclude"), seg: seg_param(p), detail: vec![], edge: 0.0 },
|
||||
"prompt" => {
|
||||
MaskShape::Prompt { text: str_param(p, "text").unwrap_or_default().trim().to_string(), seg: seg_param(p), detail: vec![], edge: 0.0 }
|
||||
}
|
||||
other => {
|
||||
return Err(bad(
|
||||
c,
|
||||
format!("unknown mask kind `{other}` (brush|linear|radial|sky|subject|background|luminanceRange|colorRange|object|prompt)"),
|
||||
));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `[[x, y], …]` normalized points under `key`.
|
||||
fn points(p: &Value, key: &str) -> Vec<Point> {
|
||||
p.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A segmentation passed in (`seg`: a stored or replayed AI mask; the model isn't needed).
|
||||
fn seg_param(p: &Value) -> Option<lightcraft_develop::SegMask> {
|
||||
p.get("seg").and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
}
|
||||
|
||||
/// What a new AI shape still needs once it is added.
|
||||
enum Later {
|
||||
Nothing,
|
||||
/// Background mode: these Object clicks are segmented on the worker.
|
||||
Clicks(Vec<Point>, Vec<Point>),
|
||||
/// Background mode: the Describe selection is computed first; the mask is added when found.
|
||||
Text(String),
|
||||
}
|
||||
|
||||
/// Compute the segmentation of a new AI shape (Object with clicks, Prompt) — or, in background
|
||||
/// mode, say what to queue. An Object without clicks stays empty until the photo is clicked
|
||||
/// (the photo is analyzed meanwhile); a Prompt that matches nothing is an error. Shapes given
|
||||
/// with their `seg` need no model.
|
||||
fn resolve_ai(s: &mut Session, shape: &mut MaskShape, c: &str) -> Result<Later> {
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let background = s.segmenter.background;
|
||||
match shape {
|
||||
MaskShape::Object { seg: Some(_), .. } | MaskShape::Prompt { seg: Some(_), .. } => {}
|
||||
MaskShape::Object { hint, exclude, .. } if !hint.is_empty() && background => {
|
||||
s.segmenter.model_dir().map_err(ai_err)?;
|
||||
return Ok(Later::Clicks(std::mem::take(hint), std::mem::take(exclude)));
|
||||
}
|
||||
MaskShape::Object { hint, exclude, seg, .. } if !hint.is_empty() => {
|
||||
*seg = Some(s.segment_clicks(id, &clicks_of(hint, exclude)).map_err(ai_err)?);
|
||||
}
|
||||
MaskShape::Object { .. } => {
|
||||
// the clicks come later: refuse an Object mask that could never be computed (no
|
||||
// model) rather than leave an empty one; in the app, analyze the photo meanwhile
|
||||
s.segmenter.model_dir().map_err(ai_err)?;
|
||||
if background {
|
||||
s.segment_prepare(id).map_err(ai_err)?;
|
||||
}
|
||||
}
|
||||
MaskShape::Prompt { text, .. } if background => {
|
||||
s.segmenter.model_dir().map_err(ai_err)?;
|
||||
if text.trim().is_empty() {
|
||||
return Err(ai_err("describe what to select"));
|
||||
}
|
||||
return Ok(Later::Text(text.clone()));
|
||||
}
|
||||
MaskShape::Prompt { text, seg, .. } => {
|
||||
let found = s.segment_text(id, text).map_err(ai_err)?;
|
||||
*seg = Some(found.ok_or_else(|| ai_err(format!("nothing matching “{text}” was found in this photo")))?);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Later::Nothing)
|
||||
}
|
||||
|
||||
/// Queue what a new AI component (component `comp` of mask `mask`) still needs.
|
||||
fn queue_later(s: &mut Session, later: Later, mask: u32, comp: usize, mut out: Value) -> Result<Value> {
|
||||
if let Later::Clicks(hint, exclude) = later {
|
||||
let id = s.active().ok_or_else(|| ai_err("no active photo"))?;
|
||||
s.segment_clicks_later(id, mask, comp, hint, exclude).map_err(ai_err)?;
|
||||
out["pending"] = json!(true);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// AI mask failures are shown as they are (not as "invalid parameters").
|
||||
fn ai_err(msg: impl Into<String>) -> crate::EngineError {
|
||||
crate::EngineError::Other(msg.into())
|
||||
}
|
||||
|
||||
pub(crate) fn clicks_of(include: &[Point], exclude: &[Point]) -> Vec<Click> {
|
||||
include.iter().map(|p| Click { at: *p, include: true }).chain(exclude.iter().map(|p| Click { at: *p, include: false })).collect()
|
||||
}
|
||||
|
||||
fn masks_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec<Mask>, &mut Option<u32>) -> Result<()>) -> Result<Value> {
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
@@ -67,14 +158,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create New Mask",
|
||||
[],
|
||||
None,
|
||||
"{kind: brush|linear|radial|sky|subject|background|luminanceRange|colorRange, ...shape params (start/end, center/rx/ry/angle/feather, lo/hi…), name?}",
|
||||
"{kind: brush|linear|radial|sky|subject|background|luminanceRange|colorRange|object|prompt, ...shape params (start/end, center/rx/ry/angle/feather, lo/hi…; object: points/exclude [[x,y],…]; prompt: text; object/prompt: seg? a stored segmentation, no model needed), name?} — AI kinds need the SAM 3 model; in the app a prompt returns {pending} and the mask appears when found",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let kind = str_param(p, "kind").unwrap_or("radial").to_string();
|
||||
let shape = shape_from(&kind, p, "mask.add")?;
|
||||
let mut shape = shape_from(&kind, p, "mask.add")?;
|
||||
let later = resolve_ai(s, &mut shape, "mask.add")?;
|
||||
let name = str_param(p, "name").map(str::to_string);
|
||||
if let Later::Text(text) = &later {
|
||||
let id = s.active().ok_or_else(|| bad("mask.add", "no active photo"))?;
|
||||
s.segment_text_later(id, None, "add", name, text).map_err(ai_err)?;
|
||||
return Ok(json!({"pending": true}));
|
||||
}
|
||||
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
|
||||
masks_edit(s, "mask.add", "Add Mask", |masks, active| {
|
||||
let out = masks_edit(s, "mask.add", "Add Mask", |masks, active| {
|
||||
masks.push(Mask {
|
||||
id: next,
|
||||
name: name.unwrap_or_else(|| format!("Mask {next}")),
|
||||
@@ -83,7 +180,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
});
|
||||
*active = Some(next);
|
||||
Ok(())
|
||||
})
|
||||
})?;
|
||||
queue_later(s, later, next, 0, out)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
@@ -95,16 +193,29 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_active,
|
||||
|s, p| {
|
||||
let kind = str_param(p, "kind").unwrap_or("brush").to_string();
|
||||
let shape = shape_from(&kind, p, "mask.addComponent")?;
|
||||
let op: MaskOp =
|
||||
serde_json::from_value(p.get("op").cloned().unwrap_or(json!("add"))).map_err(|e| bad("mask.addComponent", e.to_string()))?;
|
||||
let active = s.active_mask;
|
||||
let mid = mask_id(p, active, "mask.addComponent")?;
|
||||
masks_edit(s, "mask.addComponent", "Edit Mask", |masks, _| {
|
||||
let id = s.active().ok_or_else(|| bad("mask.addComponent", "no active photo"))?;
|
||||
if !s.develop_of(id).is_some_and(|d| d.masks.iter().any(|m| m.id == mid)) {
|
||||
return Err(bad("mask.addComponent", format!("no mask {mid}")));
|
||||
}
|
||||
let mut shape = shape_from(&kind, p, "mask.addComponent")?;
|
||||
let later = resolve_ai(s, &mut shape, "mask.addComponent")?;
|
||||
if let Later::Text(text) = &later {
|
||||
let op = p.get("op").and_then(Value::as_str).unwrap_or("add");
|
||||
s.segment_text_later(id, Some(mid), op, None, text).map_err(ai_err)?;
|
||||
return Ok(json!({"pending": true}));
|
||||
}
|
||||
let mut comp = 0;
|
||||
let out = masks_edit(s, "mask.addComponent", "Edit Mask", |masks, _| {
|
||||
let i = find(masks, mid, "mask.addComponent")?;
|
||||
masks[i].components.push(MaskComponent { name: None, op, invert: bool_or(p, "invert", false), shape });
|
||||
comp = masks[i].components.len() - 1;
|
||||
Ok(())
|
||||
})
|
||||
})?;
|
||||
queue_later(s, later, mid, comp, out)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
@@ -213,6 +324,158 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"samples": out}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"mask.objectPoint",
|
||||
"Add Object Click",
|
||||
[],
|
||||
None,
|
||||
"{x, y: normalized image point, exclude?: bool (⌥-click: leave this part out), id?: maskId} — a click on the selected mask's Object selection (its last Object component); SAM 3 re-segments the object → {include, exclude}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "mask.objectPoint";
|
||||
let at = Point::new(super::f64_req(p, "x", c)?, super::f64_req(p, "y", c)?);
|
||||
if !(0.0..=1.0).contains(&at.x) || !(0.0..=1.0).contains(&at.y) {
|
||||
return Err(bad(c, "the point is outside the photo"));
|
||||
}
|
||||
let exclude = bool_or(p, "exclude", false);
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let mid = mask_id(p, s.active_mask, c)?;
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let m = d.masks.iter().find(|m| m.id == mid).ok_or_else(|| bad(c, format!("no mask {mid}")))?;
|
||||
let k = m
|
||||
.components
|
||||
.iter()
|
||||
.rposition(|x| matches!(x.shape, MaskShape::Object { .. }))
|
||||
.ok_or_else(|| bad(c, "the mask has no Object selection (add one first)"))?;
|
||||
let Some(MaskShape::Object { hint, exclude: ex, edge, .. }) = m.components.get(k).map(|x| &x.shape) else {
|
||||
return Err(bad(c, "not an Object component"));
|
||||
};
|
||||
let (mut hint, mut ex, edge) = (hint.clone(), ex.clone(), *edge);
|
||||
// clicks still on their way to the model count too
|
||||
if let Some(pending) = s.segmenter.pending_clicks().filter(|q| (q.photo, q.mask, q.comp) == (id, mid, k)) {
|
||||
(hint, ex) = (pending.hint.clone(), pending.exclude.clone());
|
||||
}
|
||||
if hint.len() + ex.len() >= MAX_CLICKS {
|
||||
return Err(ai_err(format!("an Object selection takes at most {MAX_CLICKS} clicks; start a new one")));
|
||||
}
|
||||
if exclude {
|
||||
ex.push(at)
|
||||
} else {
|
||||
hint.push(at)
|
||||
}
|
||||
if s.segmenter.background && !hint.is_empty() {
|
||||
let out = json!({"include": hint.len(), "exclude": ex.len(), "pending": true});
|
||||
s.segment_clicks_later(id, mid, k, hint, ex).map_err(ai_err)?;
|
||||
return Ok(out);
|
||||
}
|
||||
let seg = if hint.is_empty() { None } else { Some(s.segment_clicks(id, &clicks_of(&hint, &ex)).map_err(ai_err)?) };
|
||||
let out = json!({"include": hint.len(), "exclude": ex.len()});
|
||||
masks_edit(s, c, "Object Mask", |masks, _| {
|
||||
let i = find(masks, mid, c)?;
|
||||
if let Some(comp) = masks[i].components.get_mut(k) {
|
||||
comp.shape = MaskShape::Object { hint, exclude: ex, seg, detail: vec![], edge };
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(out)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"mask.refineDetail",
|
||||
"Refine AI Mask Detail",
|
||||
[],
|
||||
None,
|
||||
"{id?: maskId, component?} — a zoomed-in SAM 3 pass over an Object/Describe selection (default: the mask's last one): the photo around it is analyzed again at a higher resolution, in the background; the mask updates when it's done → {started}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "mask.refineDetail";
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let mid = mask_id(p, s.active_mask, c)?;
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let m = d.masks.iter().find(|m| m.id == mid).ok_or_else(|| bad(c, format!("no mask {mid}")))?;
|
||||
let k = match p.get("component").and_then(Value::as_u64) {
|
||||
Some(k) => k as usize,
|
||||
None => m
|
||||
.components
|
||||
.iter()
|
||||
.rposition(|x| matches!(x.shape, MaskShape::Object { .. } | MaskShape::Prompt { .. }))
|
||||
.ok_or_else(|| bad(c, "the mask has no Object or Describe selection"))?,
|
||||
};
|
||||
let started = s.segment_detail(id, mid, k).map_err(ai_err)?;
|
||||
Ok(json!({"started": started}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
query "segment.prepare",
|
||||
"Prepare AI Masks",
|
||||
[],
|
||||
None,
|
||||
"{} — load SAM 3 and analyze the active photo in the background, so Object clicks are instant → {busy}",
|
||||
has_active,
|
||||
|s, _| {
|
||||
let c = "segment.prepare";
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
s.segment_prepare(id).map_err(ai_err)?;
|
||||
Ok(json!({"busy": s.segmenter.busy()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
query "segment.model.status",
|
||||
"AI Mask Model Status",
|
||||
[],
|
||||
None,
|
||||
"{} — whether this build has AI masks, whether the SAM 3 model is installed (and where), loaded, busy, and the download's progress → {available, installed, dir, loaded, busy, analyzing, sizeBytes, license, licenseUrl, mirrors, download: {running, done, total, file, error, finished}}",
|
||||
super::always,
|
||||
|s, _| {
|
||||
let g = &s.segmenter;
|
||||
Ok(json!({
|
||||
"available": crate::segment::Segmenter::AVAILABLE,
|
||||
"installed": g.installed(),
|
||||
"dir": g.dir.as_ref().map(|d| d.display().to_string()),
|
||||
"loaded": g.loaded(),
|
||||
"busy": g.busy(),
|
||||
"analyzing": g.analyzing(),
|
||||
"sizeBytes": crate::segment::MODEL_BYTES,
|
||||
"license": crate::segment::LICENSE_NAME,
|
||||
"licenseUrl": crate::segment::LICENSE_URL,
|
||||
"mirrors": g.mirrors().len(),
|
||||
"download": g.download_status(),
|
||||
}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
query "segment.model.download",
|
||||
"Download AI Mask Model",
|
||||
[],
|
||||
None,
|
||||
"{acknowledged: true} — download the SAM 3 model (about 3.4 GB, Meta's SAM License, not LightCraft's) in the background, from the configured mirrors; only after the user agreed to it. Watch segment.model.status; segment.model.cancel stops it (it resumes later) → {started, installed, downloading}",
|
||||
super::always,
|
||||
|s, p| {
|
||||
let c = "segment.model.download";
|
||||
if p.get("acknowledged").and_then(Value::as_bool) != Some(true) {
|
||||
return Err(bad(
|
||||
c,
|
||||
format!(
|
||||
"the SAM 3 model is a {:.1} GB download under Meta's {} ({}): pass `acknowledged: true` once the user has agreed to download it",
|
||||
crate::segment::MODEL_BYTES as f64 / 1e9,
|
||||
crate::segment::LICENSE_NAME,
|
||||
crate::segment::LICENSE_URL
|
||||
),
|
||||
));
|
||||
}
|
||||
let started = s.segmenter.start_download().map_err(ai_err)?;
|
||||
Ok(json!({"started": started, "installed": s.segmenter.installed(), "downloading": s.segmenter.download_status().running}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
query "segment.model.cancel",
|
||||
"Cancel AI Mask Model Download",
|
||||
[],
|
||||
None,
|
||||
"{} — stop the SAM 3 download (what has arrived is kept, and a new download resumes from it) → {cancelled}",
|
||||
super::always,
|
||||
|s, _| Ok(json!({"cancelled": s.segmenter.cancel_download()}))
|
||||
),
|
||||
cmd!(
|
||||
"mask.refine",
|
||||
"Refine Mask Edges",
|
||||
|
||||
@@ -5,7 +5,7 @@ use lightcraft_develop::{CONTROLS, controls};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, has_active};
|
||||
use crate::Session;
|
||||
use crate::{LibrarySource, Session};
|
||||
|
||||
pub fn photo_summary(p: &Photo) -> Value {
|
||||
json!({
|
||||
@@ -48,8 +48,13 @@ fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog) -> Va
|
||||
v
|
||||
}
|
||||
|
||||
/// The photo a query is about: `id` when given (which must be in the library), else the active one.
|
||||
fn photo_arg(s: &Session, p: &Value, c: &str) -> crate::Result<PhotoId> {
|
||||
p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad(c, "no photo"))
|
||||
match p.get("id").and_then(Value::as_u64).map(PhotoId) {
|
||||
Some(id) if s.catalog.photo(id).is_some() => Ok(id),
|
||||
Some(id) => Err(bad(c, format!("no such photo {}", id.0))),
|
||||
None => s.active().ok_or_else(|| bad(c, "no photo")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -81,10 +86,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}))
|
||||
}),
|
||||
cmd!(query "library.state", "Library State", [], None, "{}", always, |s, _| {
|
||||
let label = s.source.label(&s.catalog);
|
||||
let label = match (s.source, s.library_folder.as_deref()) {
|
||||
(LibrarySource::LibraryFolder, Some(path)) => lightcraft_catalog::folders::folder_label(path),
|
||||
_ => s.source.label(&s.catalog),
|
||||
};
|
||||
let n = s.visible().len();
|
||||
Ok(json!({
|
||||
"source": s.source,
|
||||
"libraryFolder": s.library_folder.as_ref().filter(|_| s.source == LibrarySource::LibraryFolder),
|
||||
"sourceLabel": label,
|
||||
"filter": s.filter,
|
||||
"sort": s.sort,
|
||||
|
||||
@@ -258,6 +258,35 @@ fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec<ab_glyph::F
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The font's vertical form, positioned from its vertical origin within the watermark cell.
|
||||
/// Unlike Unicode presentation-form characters, GSUB works with BIZ UD fonts too.
|
||||
fn vertical_watermark_glyph(
|
||||
font: &ab_glyph::FontRef<'_>,
|
||||
data: &harfrust::ShaperData,
|
||||
ch: char,
|
||||
px: f32,
|
||||
left: f32,
|
||||
top: f32,
|
||||
) -> Option<ab_glyph::Glyph> {
|
||||
use ab_glyph::{Font, ScaleFont};
|
||||
let face = harfrust::FontRef::new(font.font_data()).ok()?;
|
||||
let mut buf = harfrust::UnicodeBuffer::new();
|
||||
buf.add(ch, 0);
|
||||
buf.set_direction(harfrust::Direction::TopToBottom);
|
||||
buf.guess_segment_properties();
|
||||
let shaped = data.shaper(&face).build().shape(buf, harfrust::ShapeOptions::new());
|
||||
if shaped.glyph_infos().len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let gid = u16::try_from(shaped.glyph_infos().first()?.glyph_id).ok().filter(|g| *g != 0)?;
|
||||
let pos = shaped.glyph_positions().first()?;
|
||||
let sf = font.as_scaled(px);
|
||||
let (sx, sy) = (sf.h_scale_factor(), sf.v_scale_factor());
|
||||
let height = -(pos.y_advance as f32) * sy;
|
||||
let pen = ab_glyph::point(left + px / 2.0 + pos.x_offset as f32 * sx, top + (px - height) / 2.0 - pos.y_offset as f32 * sy);
|
||||
Some(ab_glyph::GlyphId(gid).with_scale_and_position(px, pen))
|
||||
}
|
||||
|
||||
/// Draw `wm` onto `img` (straight alpha blending of the encoded values).
|
||||
pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) {
|
||||
let width = img.width;
|
||||
@@ -312,6 +341,11 @@ fn watermark_coverage(
|
||||
}
|
||||
let fonts = watermark_fonts(craft);
|
||||
let Some(latin) = fonts.first() else { return };
|
||||
let vertical_data: Vec<_> = if wm.vertical {
|
||||
fonts.iter().map(|font| harfrust::FontRef::new(font.font_data()).ok().map(|face| harfrust::ShaperData::new(&face))).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let short = width.min(height) as f32;
|
||||
let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0);
|
||||
let mut glyphs = Vec::new();
|
||||
@@ -335,6 +369,21 @@ fn watermark_coverage(
|
||||
prev = None;
|
||||
continue;
|
||||
}
|
||||
if wm.vertical
|
||||
&& matches!(original as u32, 0x3000..=0x30FF | 0x3400..=0x9FFF | 0xFF01..=0xFF60)
|
||||
&& let Some(face) = fonts.iter().position(|font| font.glyph_id(original).0 != 0)
|
||||
&& let (Some(font), Some(Some(data))) = (fonts.get(face), vertical_data.get(face))
|
||||
{
|
||||
let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px;
|
||||
if let Some(glyph) = vertical_watermark_glyph(font, data, original, px, left, y)
|
||||
&& (glyph.id != font.glyph_id(original)
|
||||
|| !matches!(original, '、' | '。' | 'ー' | '(' | ')' | '「' | '」' | '『' | '』' | '【' | '】'))
|
||||
{
|
||||
glyphs.push((face, glyph, None));
|
||||
y += px;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let vertical_form = match original {
|
||||
'、' => '︑',
|
||||
'。' => '︒',
|
||||
@@ -562,12 +611,264 @@ impl Default for ExportOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `app.export` params that are not export options: which photos, where to, a preset to start
|
||||
/// from, and the desktop app's background flag.
|
||||
pub const TARGET_PARAMS: &[&str] = &["id", "ids", "path", "dir", "preset", "background"];
|
||||
|
||||
/// The export option params [`ExportOptions::from_json`] reads (and [`ExportOptions::to_json`] writes).
|
||||
pub const OPTION_PARAMS: &[&str] = &[
|
||||
"format",
|
||||
"quality",
|
||||
"resize",
|
||||
"longEdge",
|
||||
"shortEdge",
|
||||
"width",
|
||||
"height",
|
||||
"megapixels",
|
||||
"percent",
|
||||
"dontEnlarge",
|
||||
"ppi",
|
||||
"limitKb",
|
||||
"sharpen",
|
||||
"sharpenAmount",
|
||||
"naming",
|
||||
"startNumber",
|
||||
"subfolder",
|
||||
"conflict",
|
||||
"tiffCompression",
|
||||
"dngCompression",
|
||||
"metadata",
|
||||
"removeLocation",
|
||||
"watermark",
|
||||
"colorSpace",
|
||||
"bitDepth",
|
||||
];
|
||||
|
||||
/// The keys of a `watermark` object ([`Watermark`], camelCase).
|
||||
pub const WATERMARK_PARAMS: &[&str] = &["text", "vertical", "size", "opacity", "anchor", "inset", "color", "shadow", "image", "imageWidth"];
|
||||
|
||||
/// Text height as a fraction of the short edge: the range the renderer draws without clamping.
|
||||
pub const WATERMARK_SIZE_RANGE: (f32, f32) = (0.005, 0.5);
|
||||
/// Margin as a fraction of the short edge.
|
||||
pub const WATERMARK_INSET_RANGE: (f32, f32) = (0.0, 0.4);
|
||||
/// A graphic's width as a fraction of the photo's width.
|
||||
pub const WATERMARK_IMAGE_WIDTH_RANGE: (f32, f32) = (0.01, 1.0);
|
||||
|
||||
/// Levenshtein distance, for "did you mean" on a misspelled key (both inputs are short).
|
||||
fn edit_distance(a: &str, b: &str) -> usize {
|
||||
let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
|
||||
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
||||
for (i, ca) in a.iter().enumerate() {
|
||||
let mut cur = vec![i + 1; b.len() + 1];
|
||||
for (j, cb) in b.iter().enumerate() {
|
||||
let sub = prev.get(j).copied().unwrap_or(0) + usize::from(ca != cb);
|
||||
let del = prev.get(j + 1).copied().unwrap_or(0) + 1;
|
||||
let ins = cur.get(j).copied().unwrap_or(0) + 1;
|
||||
if let Some(c) = cur.get_mut(j + 1) {
|
||||
*c = sub.min(del).min(ins);
|
||||
}
|
||||
}
|
||||
prev = cur;
|
||||
}
|
||||
prev.last().copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `unknown parameter \`k\``, naming the closest known key when one is near.
|
||||
fn unknown_key(what: &str, k: &str, known: &[&str]) -> String {
|
||||
let near = known.iter().map(|n| (edit_distance(&k.to_ascii_lowercase(), &n.to_ascii_lowercase()), *n)).filter(|(d, _)| *d <= 2).min();
|
||||
match near {
|
||||
Some((_, n)) => format!("unknown {what} `{k}` (did you mean `{n}`?)"),
|
||||
None => format!("unknown {what} `{k}` (one of {})", known.join(", ")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A JSON integer (`92`; never `92.5` or `"92"`), as [`ExportOptions::from_json`] reads them.
|
||||
fn whole(v: &serde_json::Value) -> Option<i64> {
|
||||
v.as_i64()
|
||||
}
|
||||
|
||||
impl ExportOptions {
|
||||
/// Stored export settings (a saved preset, `prefs.json ▸ lastExport`) with the keys an older or
|
||||
/// newer version may have written and this one doesn't know dropped, also inside `watermark`.
|
||||
/// [`ExportOptions::validate`] is strict about what a caller types; settings saved on disk are
|
||||
/// not the caller's typo, and refusing them would make a preset or Export with Previous fail
|
||||
/// for good after an update.
|
||||
pub fn known_keys_only(p: &serde_json::Value) -> serde_json::Value {
|
||||
let mut p = p.clone();
|
||||
if let Some(o) = p.as_object_mut() {
|
||||
o.retain(|k, _| OPTION_PARAMS.contains(&k.as_str()) || TARGET_PARAMS.contains(&k.as_str()));
|
||||
if let Some(w) = o.get_mut("watermark").and_then(serde_json::Value::as_object_mut) {
|
||||
w.retain(|k, _| WATERMARK_PARAMS.contains(&k.as_str()));
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Strict [`ExportOptions::from_json`] for a command that is about to export (issue #181): an
|
||||
/// unknown parameter or a value that cannot be read as what it is for is an error, never a
|
||||
/// silent default. `app.export` targets (`id`, `ids`, `path`, `dir`, `preset`, `background`)
|
||||
/// are accepted and left to the caller.
|
||||
pub fn from_params(p: &serde_json::Value) -> crate::Result<Self> {
|
||||
Self::validate("app.export", p)?;
|
||||
Ok(Self::from_json(p))
|
||||
}
|
||||
|
||||
/// Check `app.export` params for `cmd` (the command named in the error): every key must be an
|
||||
/// export option ([`OPTION_PARAMS`]) or a target ([`TARGET_PARAMS`]), every value must be the
|
||||
/// kind of thing its key takes, and a `watermark` object must hold only [`WATERMARK_PARAMS`]
|
||||
/// with its sizes in range (issue #183). `null` means "not given", as a dropped key does.
|
||||
pub fn validate(cmd: &str, p: &serde_json::Value) -> crate::Result<()> {
|
||||
use serde_json::Value;
|
||||
let bad = |msg: String| crate::cmd::bad(cmd, msg);
|
||||
let Some(obj) = p.as_object() else { return Err(bad("params must be an object".into())) };
|
||||
let known: Vec<&str> = OPTION_PARAMS.iter().chain(TARGET_PARAMS).copied().collect();
|
||||
let int = |k: &str, v: &Value, lo: i64, hi: i64| -> crate::Result<i64> {
|
||||
whole(v).filter(|i| (lo..=hi).contains(i)).ok_or_else(|| bad(format!("`{k}` must be an integer {lo}..{hi}")))
|
||||
};
|
||||
let num = |k: &str, v: &Value, lo: f64, hi: f64| -> crate::Result<f64> {
|
||||
v.as_f64().filter(|f| f.is_finite() && (lo..=hi).contains(f)).ok_or_else(|| bad(format!("`{k}` must be a number {lo}..{hi}")))
|
||||
};
|
||||
fn string<'a>(cmd: &str, k: &str, v: &'a Value) -> crate::Result<&'a str> {
|
||||
v.as_str().ok_or_else(|| crate::cmd::bad(cmd, format!("`{k}` must be a string")))
|
||||
}
|
||||
let boolean = |k: &str, v: &Value| -> crate::Result<bool> { v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false"))) };
|
||||
let one_of = |k: &str, v: &Value, options: &[&str]| -> crate::Result<()> {
|
||||
let s = string(cmd, k, v)?;
|
||||
if options.iter().any(|o| o.eq_ignore_ascii_case(s)) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) }
|
||||
};
|
||||
for (k, v) in obj {
|
||||
if v.is_null() {
|
||||
continue;
|
||||
}
|
||||
match k.as_str() {
|
||||
"format" => {
|
||||
if ExportFormat::parse(string(cmd, k, v)?).is_none() {
|
||||
return Err(bad(format!("`{k}` must be one of jpeg|png|tiff|webp|avif|dng|original")));
|
||||
}
|
||||
}
|
||||
"quality" => {
|
||||
int(k, v, 1, 100)?;
|
||||
}
|
||||
"ppi" => {
|
||||
int(k, v, 1, u16::MAX as i64)?;
|
||||
}
|
||||
"limitKb" => {
|
||||
int(k, v, 0, u32::MAX as i64)?;
|
||||
}
|
||||
"startNumber" => {
|
||||
int(k, v, 0, 999_999)?;
|
||||
}
|
||||
"bitDepth" => {
|
||||
if !matches!(whole(v), Some(8 | 10 | 16 | 32)) {
|
||||
return Err(bad(format!("`{k}` must be 8, 10, 16 or 32")));
|
||||
}
|
||||
}
|
||||
"longEdge" | "shortEdge" | "width" | "height" | "megapixels" | "percent" => {
|
||||
num(k, v, 0.0, 1.0e9)?;
|
||||
}
|
||||
"resize" => {
|
||||
let Some(r) = v.as_object() else { return Err(bad(format!("`{k}` must be an object {{mode, value, height?, dontEnlarge?}}"))) };
|
||||
if let Some(x) = r.keys().find(|x| !matches!(x.as_str(), "mode" | "value" | "height" | "dontEnlarge")) {
|
||||
return Err(bad(unknown_key("resize key", x, &["mode", "value", "height", "dontEnlarge"])));
|
||||
}
|
||||
serde_json::from_value::<Resize>(v.clone()).map_err(|e| bad(format!("`{k}`: {e}")))?;
|
||||
}
|
||||
"dontEnlarge" | "removeLocation" | "background" => {
|
||||
boolean(k, v)?;
|
||||
}
|
||||
"naming" | "subfolder" | "path" | "dir" | "preset" => {
|
||||
string(cmd, k, v)?;
|
||||
}
|
||||
"sharpen" => one_of(k, v, &["none", "screen", "matte", "glossy"])?,
|
||||
"sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?,
|
||||
"conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?,
|
||||
"tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?,
|
||||
"dngCompression" => {
|
||||
serde_json::from_value::<DngCompression>(v.clone())
|
||||
.map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed")))?;
|
||||
}
|
||||
"metadata" => one_of(k, v, &["all", "allExceptCamera", "copyright", "none"])?,
|
||||
"colorSpace" => {
|
||||
if OutputSpace::parse(string(cmd, k, v)?).is_none() {
|
||||
return Err(bad(format!("`{k}` must be one of srgb|displayP3|adobeRgb|proPhoto|rec2020")));
|
||||
}
|
||||
}
|
||||
"watermark" => Self::validate_watermark(cmd, v)?,
|
||||
"id" => {
|
||||
int(k, v, 0, i64::MAX)?;
|
||||
}
|
||||
"ids" => {
|
||||
let ok = v.as_array().is_some_and(|a| a.iter().all(|x| x.is_u64()));
|
||||
if !ok {
|
||||
return Err(bad(format!("`{k}` must be an array of photo ids")));
|
||||
}
|
||||
}
|
||||
_ => return Err(bad(unknown_key("parameter", k, &known))),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A `watermark` value: text, or an object of [`WATERMARK_PARAMS`] (issue #183).
|
||||
fn validate_watermark(cmd: &str, v: &serde_json::Value) -> crate::Result<()> {
|
||||
use serde_json::Value;
|
||||
let bad = |msg: String| crate::cmd::bad(cmd, msg);
|
||||
let obj = match v {
|
||||
Value::String(_) => return Ok(()),
|
||||
Value::Object(o) => o,
|
||||
_ => {
|
||||
return Err(bad(
|
||||
"`watermark` must be text or an object {text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let frac = |k: &str, v: &Value, (lo, hi): (f32, f32), unit: &str| -> crate::Result<()> {
|
||||
match v.as_f64() {
|
||||
Some(f) if f.is_finite() && (f64::from(lo)..=f64::from(hi)).contains(&f) => Ok(()),
|
||||
_ => Err(bad(format!("`watermark.{k}` must be a number {lo}..{hi} ({unit})"))),
|
||||
}
|
||||
};
|
||||
for (k, v) in obj {
|
||||
if v.is_null() {
|
||||
continue;
|
||||
}
|
||||
match k.as_str() {
|
||||
"text" | "image" => {
|
||||
v.as_str().ok_or_else(|| bad(format!("`watermark.{k}` must be a string")))?;
|
||||
}
|
||||
"vertical" | "shadow" => {
|
||||
v.as_bool().ok_or_else(|| bad(format!("`watermark.{k}` must be true or false")))?;
|
||||
}
|
||||
"size" => frac(k, v, WATERMARK_SIZE_RANGE, "fraction of the short edge")?,
|
||||
"opacity" => frac(k, v, (0.0, 1.0), "0 = invisible, 1 = solid")?,
|
||||
"inset" => frac(k, v, WATERMARK_INSET_RANGE, "fraction of the short edge")?,
|
||||
"imageWidth" => frac(k, v, WATERMARK_IMAGE_WIDTH_RANGE, "fraction of the photo's width")?,
|
||||
"anchor" => {
|
||||
serde_json::from_value::<Anchor>(v.clone()).map_err(|_| {
|
||||
bad("`watermark.anchor` must be one of topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight".into())
|
||||
})?;
|
||||
}
|
||||
"color" => {
|
||||
let ok = v.as_array().is_some_and(|a| a.len() == 3 && a.iter().all(|c| matches!(whole(c), Some(0..=255))));
|
||||
if !ok {
|
||||
return Err(bad("`watermark.color` must be [r, g, b] with 0..255 each (sRGB)".into()));
|
||||
}
|
||||
}
|
||||
_ => return Err(bad(unknown_key("watermark key", k, WATERMARK_PARAMS))),
|
||||
}
|
||||
}
|
||||
serde_json::from_value::<Watermark>(v.clone()).map(|_| ()).map_err(|e| bad(format!("`watermark`: {e}")))
|
||||
}
|
||||
|
||||
/// Read options from command params (`format`, `quality`, `limitKb`, `sharpen`, `sharpenAmount`,
|
||||
/// `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`, `ppi`) and the
|
||||
/// size: one of `longEdge`, `shortEdge`, `width`, `height` (both = fit inside W × H),
|
||||
/// `megapixels`, `percent` (absent or 0 = full size), or a [`Resize`] object as `resize`; plus
|
||||
/// `dontEnlarge` (default true).
|
||||
///
|
||||
/// Lenient: anything unknown or unreadable falls back to the default, which suits stored
|
||||
/// prefs and dialog prefill. A command that is about to export uses
|
||||
/// [`ExportOptions::from_params`], which validates first.
|
||||
pub fn from_json(p: &serde_json::Value) -> Self {
|
||||
use serde_json::Value;
|
||||
let d = Self::default();
|
||||
@@ -1392,6 +1693,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn biz_ud_vertical_watermarks_place_punctuation_at_the_top_right() {
|
||||
use ab_glyph::Font;
|
||||
let Some(entry) = crate::fonts::CRAFT_FONTS.iter().find(|f| f.family == "BIZ UDMincho") else {
|
||||
eprintln!("skipped: built without BIZ UDMincho from craft-fonts");
|
||||
return;
|
||||
};
|
||||
let font = ab_glyph::FontRef::try_from_slice(entry.bytes).unwrap();
|
||||
// BIZ UDMincho has no Unicode presentation-form characters: the old fallback drew 、。.
|
||||
assert_eq!(font.glyph_id('︑').0, 0);
|
||||
assert_eq!(font.glyph_id('︒').0, 0);
|
||||
let data = harfrust::ShaperData::new(&harfrust::FontRef::new(entry.bytes).unwrap());
|
||||
for ch in ['、', '。'] {
|
||||
let glyph = vertical_watermark_glyph(&font, &data, ch, 100.0, 0.0, 0.0).unwrap();
|
||||
assert_ne!(glyph.id, font.glyph_id(ch), "the font's vertical alternate for {ch}");
|
||||
let rect = font.outline_glyph(glyph).unwrap().px_bounds();
|
||||
assert!(rect.min.x > 50.0 && rect.max.y < 50.0, "{ch} in the upper right: {rect:?}");
|
||||
}
|
||||
let craft = Box::leak(
|
||||
vec![crate::fonts::CraftFont { family: entry.family, style: entry.style, scripts: entry.scripts, bytes: entry.bytes }].into_boxed_slice(),
|
||||
);
|
||||
let wm = Watermark {
|
||||
text: "、。".into(),
|
||||
vertical: true,
|
||||
size: 0.1,
|
||||
anchor: Anchor::TopLeft,
|
||||
inset: 0.0,
|
||||
shadow: false,
|
||||
opacity: 1.0,
|
||||
..Default::default()
|
||||
};
|
||||
let mut pixels = Vec::new();
|
||||
watermark_coverage(400, 300, &wm, craft, |x, y, k, _| {
|
||||
if k > 0.5 {
|
||||
pixels.push((x, y));
|
||||
}
|
||||
});
|
||||
assert!(!pixels.is_empty());
|
||||
for (x, y) in pixels {
|
||||
assert!(x > 15 && y % 30 < 15, "actual export coverage at the upper right of the 30 px cell: {x}, {y}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_watermarks_support_vertical_columns() {
|
||||
use ab_glyph::Font;
|
||||
@@ -1720,4 +2064,99 @@ mod tests {
|
||||
Err(e) => assert!(e.contains("not available"), "{e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #181: a command about to export refuses what `from_json` would silently default —
|
||||
/// unknown keys (naming the nearest known one) and values of the wrong kind or range.
|
||||
#[test]
|
||||
fn export_params_are_validated() {
|
||||
use serde_json::json;
|
||||
let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
let e = err(json!({"longEdgee": 400}));
|
||||
assert!(e.starts_with("invalid parameters for `app.export`: unknown parameter `longEdgee` (did you mean `longEdge`?)"), "{e}");
|
||||
assert!(err(json!({"qualty": 5})).contains("did you mean `quality`"));
|
||||
assert!(err(json!({"longEdge": "banana"})).contains("`longEdge` must be a number"));
|
||||
assert!(err(json!({"quality": 0})).contains("`quality` must be an integer 1..100"));
|
||||
assert!(err(json!({"quality": "5"})).contains("`quality` must be an integer"));
|
||||
assert!(err(json!({"quality": 92.5})).contains("`quality` must be an integer"));
|
||||
assert!(err(json!({"sharpen": "lots"})).contains("`sharpen` must be one of none|screen|matte|glossy"));
|
||||
assert!(err(json!({"format": "bmp"})).contains("`format` must be one of"));
|
||||
assert!(err(json!({"bitDepth": 12})).contains("`bitDepth` must be 8, 10, 16 or 32"));
|
||||
assert!(err(json!({"colorSpace": "cmyk"})).contains("`colorSpace` must be one of"));
|
||||
assert!(err(json!({"dontEnlarge": "no"})).contains("`dontEnlarge` must be true or false"));
|
||||
assert!(err(json!({"resize": {"mode": "longEdge", "valeu": 10}})).contains("did you mean `value`"));
|
||||
assert!(err(json!({"resize": {"mode": "diagonal", "value": 10}})).contains("`resize`:"));
|
||||
assert!(err(json!({"ids": [1, "x"]})).contains("`ids` must be an array of photo ids"));
|
||||
assert!(err(json!("jpeg")).contains("params must be an object"));
|
||||
assert!(
|
||||
ExportOptions::validate("export.savePreset", &json!({"qualty": 5}))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.starts_with("invalid parameters for `export.savePreset`")
|
||||
);
|
||||
// everything the presets, the dialog, Export with Previous and `render --opt` send passes
|
||||
for p in builtin_presets() {
|
||||
ExportOptions::from_params(&p.params).unwrap_or_else(|e| panic!("{}: {e}", p.name));
|
||||
}
|
||||
let full = ExportOptions {
|
||||
resize: Some(Resize::default()),
|
||||
limit_kb: Some(500),
|
||||
watermark: Some(Watermark { text: "©".into(), ..Default::default() }),
|
||||
bit_depth: Some(16),
|
||||
..Default::default()
|
||||
};
|
||||
let mut j = full.to_json();
|
||||
for (k, v) in [
|
||||
("dir", json!("/tmp/out")),
|
||||
("background", json!(true)),
|
||||
("preset", json!("x")),
|
||||
("ids", json!([1, 2])),
|
||||
("id", json!(1)),
|
||||
("path", json!("a.jpg")),
|
||||
] {
|
||||
j[k] = v;
|
||||
}
|
||||
assert_eq!(ExportOptions::from_params(&j).unwrap(), full);
|
||||
assert_eq!(ExportOptions::from_params(&ExportOptions::default().to_json()).unwrap(), ExportOptions::default());
|
||||
// null is "not given", as a dropped key is
|
||||
ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap();
|
||||
}
|
||||
|
||||
/// Issue #183: the `watermark` object's keys, kinds and ranges are checked (and documented in
|
||||
/// the `app.export` param string), instead of unknown keys passing and sizes clamping silently.
|
||||
#[test]
|
||||
fn watermark_params_are_validated() {
|
||||
use serde_json::json;
|
||||
let err = |w: serde_json::Value| ExportOptions::from_params(&json!({"watermark": w})).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(
|
||||
err(json!(5))
|
||||
.contains("`watermark` must be text or an object {text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}")
|
||||
);
|
||||
assert!(
|
||||
err(json!({"text": "x", "bogus": 1}))
|
||||
.contains("unknown watermark key `bogus` (one of text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth)")
|
||||
);
|
||||
assert!(err(json!({"text": "x", "sizee": 0.1})).contains("did you mean `size`"));
|
||||
for size in [json!(3), json!(0.5001), json!(0), json!("big")] {
|
||||
let e = err(json!({"text": "x", "size": size}));
|
||||
assert!(e.contains("`watermark.size` must be a number 0.005..0.5 (fraction of the short edge)"), "{size}: {e}");
|
||||
}
|
||||
assert!(err(json!({"text": "x", "opacity": 1.5})).contains("`watermark.opacity` must be a number 0..1"));
|
||||
assert!(err(json!({"text": "x", "inset": -0.1})).contains("`watermark.inset` must be a number 0..0.4"));
|
||||
assert!(err(json!({"image": "logo.png", "imageWidth": 2})).contains("`watermark.imageWidth` must be a number 0.01..1"));
|
||||
assert!(err(json!({"text": "x", "anchor": "middle"})).contains("`watermark.anchor` must be one of"));
|
||||
assert!(err(json!({"text": "x", "color": [255, 255]})).contains("`watermark.color` must be [r, g, b]"));
|
||||
assert!(err(json!({"text": "x", "color": [255, 255, 256]})).contains("`watermark.color`"));
|
||||
assert!(err(json!({"text": "x", "vertical": "yes"})).contains("`watermark.vertical` must be true or false"));
|
||||
assert!(err(json!({"text": 7})).contains("`watermark.text` must be a string"));
|
||||
// the documented shapes pass and read as before
|
||||
let o = ExportOptions::from_params(&json!({"watermark": "© me"})).unwrap();
|
||||
assert_eq!(o.watermark.map(|w| w.text).as_deref(), Some("© me"));
|
||||
let w = json!({"text": "日本語", "vertical": true, "size": 0.1, "opacity": 0.5, "anchor": "topLeft", "inset": 0.05, "color": [0, 0, 0], "shadow": false, "image": "", "imageWidth": 0.3});
|
||||
let wm = ExportOptions::from_params(&json!({"watermark": w})).unwrap().watermark.unwrap();
|
||||
assert!((wm.size - 0.1).abs() < 1e-6 && wm.vertical && wm.anchor == Anchor::TopLeft && wm.color == [0, 0, 0] && !wm.shadow);
|
||||
assert_eq!(ExportOptions::from_params(&json!({"watermark": {"text": "x", "size": 0.5}})).unwrap().watermark.map(|w| w.size), Some(0.5));
|
||||
// the Export dialog's sliders (1–15 % size, 5–100 % opacity, 2–100 % width) stay inside the accepted ranges
|
||||
assert!(WATERMARK_SIZE_RANGE.0 <= 0.01 && WATERMARK_SIZE_RANGE.1 >= 0.15);
|
||||
assert!(WATERMARK_IMAGE_WIDTH_RANGE.0 <= 0.02 && WATERMARK_IMAGE_WIDTH_RANGE.1 >= 1.0);
|
||||
}
|
||||
}
|
||||
+150
-14
@@ -43,8 +43,9 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
|
||||
(meta, m.capture_time.as_ref().map(|d| d.to_iso()))
|
||||
}
|
||||
|
||||
/// Lens corrections embedded in a DNG's `OpcodeList3` (`WarpRectilinear`, `FixVignetteRadial`), re-expressed for
|
||||
/// the default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies.
|
||||
/// Lens corrections embedded in a raw's `OpcodeList3` (`WarpRectilinear`, `FixVignetteRadial`: a DNG's own, or the
|
||||
/// raw reader's equivalent of the camera's correction, e.g. Panasonic / Leica RW2 distortion), re-expressed for the
|
||||
/// default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies.
|
||||
pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option<lightcraft_develop::EmbeddedLens> {
|
||||
use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp};
|
||||
use lightcraft_geom::Point;
|
||||
@@ -87,10 +88,6 @@ pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option<lightcraft_develop
|
||||
Some(lightcraft_pipeline::optics::reorient_lens(&lens, raw.orientation, cw, ch))
|
||||
}
|
||||
|
||||
fn is_lens_opcode(op: &lightcraft_raw::Opcode) -> bool {
|
||||
matches!(op, lightcraft_raw::Opcode::WarpRectilinear { .. } | lightcraft_raw::Opcode::FixVignetteRadial { .. })
|
||||
}
|
||||
|
||||
fn ext_upper(name: &str) -> String {
|
||||
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default()
|
||||
}
|
||||
@@ -224,16 +221,17 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in
|
||||
// (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`).
|
||||
let lens = embedded_lens(&raw.info());
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
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());
|
||||
raw.opcodes.list3.retain(|op| !is_lens_opcode(op));
|
||||
// Previews and thumbnails bin the mosaic straight to (about) the size they need; only
|
||||
// larger levels (exports, 1:1) demosaic the whole sensor.
|
||||
let t0 = std::time::Instant::now();
|
||||
let t0 = web_time::Instant::now();
|
||||
let binned = match bin_factor(&raw, max_edge) {
|
||||
Some(k) => raw.develop_binned(k, HIGHLIGHT_CLIP).map_err(|e| e.to_string())?,
|
||||
None => None,
|
||||
@@ -320,6 +318,21 @@ fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation {
|
||||
lightcraft_meta::extract(raw_bytes).orientation.unwrap_or(Orientation::Normal)
|
||||
}
|
||||
|
||||
/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting.
|
||||
pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOptions) -> Option<lightcraft_codecs::Decoded> {
|
||||
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||||
match lightcraft_raw::embedded_preview_color_space(bytes) {
|
||||
Some(space) => {
|
||||
let space = match space {
|
||||
lightcraft_raw::PreviewColorSpace::Srgb => lightcraft_codecs::NamedSpace::Srgb,
|
||||
lightcraft_raw::PreviewColorSpace::AdobeRgb => lightcraft_codecs::NamedSpace::AdobeRgb,
|
||||
};
|
||||
lightcraft_codecs::decode_jpeg_with_fallback(&jpeg, opts, space).ok()
|
||||
}
|
||||
None => lightcraft_codecs::decode(&jpeg, opts).ok(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Oriented size of the embedded preview of a raw file.
|
||||
fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||||
@@ -333,8 +346,7 @@ fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
|
||||
/// The embedded preview of a raw file as a working-space image no larger than `max_edge`, oriented.
|
||||
pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, SourceInfo)> {
|
||||
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||||
let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).ok()?;
|
||||
let d = decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32))?;
|
||||
let img = d.to_working();
|
||||
let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img };
|
||||
Some((img.oriented(preview_orientation(bytes, d.orientation)), SourceInfo::default()))
|
||||
@@ -343,8 +355,7 @@ pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, S
|
||||
/// The embedded preview of a raw file for display (sRGB, oriented, no larger than `max_edge`): the
|
||||
/// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]).
|
||||
pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
|
||||
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||||
let mut d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).ok()?;
|
||||
let mut d = decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32))?;
|
||||
if d.image.width.max(d.image.height) > max_edge {
|
||||
d.image = fit(&d.image, max_edge, max_edge, Filter::Box);
|
||||
d.alpha = None;
|
||||
@@ -402,6 +413,131 @@ mod tests {
|
||||
use super::*;
|
||||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
|
||||
|
||||
/// Luminance (from linear RGB) minus its 9 × 9 local mean: tone differences between a render and a camera JPEG
|
||||
/// mostly cancel, edges remain.
|
||||
fn detail(w: usize, h: usize, lum: impl Fn(usize, usize) -> f32) -> Vec<f32> {
|
||||
let l: Vec<f32> = (0..w * h).map(|i| lum(i % w, i / w)).collect();
|
||||
let mut out = vec![0.0; w * h];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (x0, x1, y0, y1) = (x.saturating_sub(4), (x + 5).min(w), y.saturating_sub(4), (y + 5).min(h));
|
||||
let m = (y0..y1).flat_map(|yy| (x0..x1).map(move |xx| (xx, yy))).map(|(xx, yy)| l[yy * w + xx]).sum::<f32>();
|
||||
out[y * w + x] = l[y * w + x] - m / ((x1 - x0) * (y1 - y0)) as f32;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The integer shift (within ±`r` px) of the `p × p` patch of `a` at `(x, y)` that best matches `b`, with its
|
||||
/// normalised cross-correlation, or `None` when the patch has too little detail to tell.
|
||||
fn patch_shift(a: &[f32], b: &[f32], w: usize, (x, y): (usize, usize), p: usize, r: i64) -> Option<((i64, i64), f32)> {
|
||||
let pa: Vec<f32> = (0..p * p).map(|i| a[(y + i / p) * w + x + i % p]).collect();
|
||||
let na = pa.iter().map(|v| v * v).sum::<f32>().sqrt();
|
||||
if na < 1e-3 * p as f32 {
|
||||
return None;
|
||||
}
|
||||
let mut best = (f32::MIN, (0, 0));
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let (bx, by) = (x as i64 + dx, y as i64 + dy);
|
||||
let (mut dot, mut nb) = (0.0f32, 0.0f32);
|
||||
for i in 0..p {
|
||||
for j in 0..p {
|
||||
let v = b[(by as usize + i) * w + bx as usize + j];
|
||||
dot += pa[i * p + j] * v;
|
||||
nb += v * v;
|
||||
}
|
||||
}
|
||||
let c = dot / (na * nb.sqrt()).max(1e-12);
|
||||
if c > best.0 {
|
||||
best = (c, (dx, dy));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((best.1, best.0))
|
||||
}
|
||||
|
||||
/// Issue #256: public Panasonic / Leica raws (skipped without the corpus) are corrected for distortion the way the
|
||||
/// camera corrected its own JPEG (tag 0x0119): at the corners of the frame, the render with "Enable Profile
|
||||
/// Corrections" lines up with the embedded JPEG within 2 px at 640 px, while the uncorrected render of the wide
|
||||
/// lenses is far off. A file shot with the camera's correction off (DMC-GH1) carries no correction.
|
||||
#[test]
|
||||
fn corpus_rw2_distortion_matches_the_camera_jpeg() {
|
||||
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw");
|
||||
let Ok(gh1) = std::fs::read(dir.join("rw2-panasonic-gh1.rw2")) else {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
return;
|
||||
};
|
||||
assert_eq!(probe_bytes("gh1.rw2", &gh1).unwrap().embedded_lens, None, "correction off: nothing to apply");
|
||||
assert_eq!(load_bytes(&gh1, 320).unwrap().1.lens, None);
|
||||
|
||||
// (file, how far off (px) the uncorrected corners are at the least, if they match within ±R at all)
|
||||
let files = [
|
||||
("rw2-panasonic-fz1000m2-4x3.rw2", 8), // 9.1 mm, ~13 % at the corners
|
||||
("rwl-leica-dlux7.rwl", 8), // 10.9 mm, ~13 %
|
||||
("rw2-panasonic-gx80.rw2", 8), // 14 mm, ~8 %
|
||||
("rw2-panasonic-gh6.rw2", 3), // 20 mm, ~1.6 %
|
||||
("rw2-panasonic-s9.rw2", 3), // 45 mm, 3 % zoom after the correction
|
||||
("rw2-panasonic-g9.rw2", 0), // 40 mm, ~0.5 %
|
||||
];
|
||||
const EDGE: usize = 640;
|
||||
const P: usize = 64;
|
||||
const R: i64 = 12;
|
||||
for (name, off_at_least) in files {
|
||||
let bytes = std::fs::read(dir.join(name)).unwrap();
|
||||
let probed = probe_bytes(name, &bytes).unwrap();
|
||||
assert!(probed.embedded_lens.is_some_and(|l| l.warp.is_some()), "{name}: no distortion correction read");
|
||||
let (src, info) = load_bytes(&bytes, 2 * EDGE).unwrap();
|
||||
assert_eq!(info.lens, probed.embedded_lens, "{name}: probe and decode disagree");
|
||||
// Convert to DNG keeps it: the same correction, as the DNG's own `WarpRectilinear`
|
||||
let dng = lightcraft_raw::write_dng(&lightcraft_raw::decode(&bytes).unwrap(), &Default::default()).unwrap();
|
||||
assert_eq!(probe_bytes("x.dng", &dng).unwrap().embedded_lens, probed.embedded_lens, "{name}: lost in the DNG");
|
||||
let mut s = lightcraft_develop::DevelopSettings::default();
|
||||
let render = |s: &lightcraft_develop::DevelopSettings| {
|
||||
lightcraft_pipeline::render(&src, &info, s, &lightcraft_pipeline::RenderRequest::fit(EDGE, EDGE)).image
|
||||
};
|
||||
let before = render(&s);
|
||||
s.optics.lens_profile = true;
|
||||
let after = render(&s);
|
||||
let (w, h) = (after.width, after.height);
|
||||
assert_eq!((before.width, before.height), (w, h));
|
||||
// the camera JPEG shows the whole sensor; a render in an in-camera aspect ratio is its centre
|
||||
let (cam, _) = load_embedded_preview(&bytes, 4 * EDGE).unwrap();
|
||||
let (cw, ch) = (cam.width as f64, cam.height as f64);
|
||||
let (sw, sh) = if cw / ch > w as f64 / h as f64 { (ch * w as f64 / h as f64, ch) } else { (cw, cw * h as f64 / w as f64) };
|
||||
let cam = cam.crop(((cw - sw) / 2.0).round() as usize, ((ch - sh) / 2.0).round() as usize, sw.round() as usize, sh.round() as usize);
|
||||
let cam = lightcraft_raster::resample::resize(&cam, w, h, Filter::Mitchell);
|
||||
let lin = |v: u8| (v as f32 / 255.0).powf(2.2);
|
||||
let lum8 = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| {
|
||||
let p = im.data[y * im.width + x];
|
||||
0.3 * lin(p[0]) + 0.6 * lin(p[1]) + 0.1 * lin(p[2])
|
||||
};
|
||||
let reference = detail(w, h, |x, y| {
|
||||
let p = cam.data[y * w + x];
|
||||
0.3 * p[0] + 0.6 * p[1] + 0.1 * p[2]
|
||||
});
|
||||
let (db, da) = (detail(w, h, |x, y| lum8(&before, x, y)), detail(w, h, |x, y| lum8(&after, x, y)));
|
||||
let m = R as usize + 4;
|
||||
let corners = [(m, m), (w - m - P, m), (m, h - m - P), (w - m - P, h - m - P)];
|
||||
let mut measured = 0;
|
||||
for c in corners {
|
||||
let Some(((dx, dy), ca)) = patch_shift(&da, &reference, w, c, P, R) else { continue };
|
||||
measured += 1;
|
||||
assert!(dx.abs() <= 2 && dy.abs() <= 2 && ca >= 0.5, "{name}: corrected corner {c:?} off by ({dx}, {dy}) px, ncc {ca}");
|
||||
if off_at_least > 0
|
||||
&& let Some(((bx, by), cb)) = patch_shift(&db, &reference, w, c, P, R)
|
||||
{
|
||||
let off = bx.abs().max(by.abs()) >= off_at_least || cb < 0.3;
|
||||
assert!(off, "{name}: uncorrected corner {c:?} already within ({bx}, {by}) px, ncc {cb}");
|
||||
}
|
||||
}
|
||||
assert!(measured >= 2, "{name}: only {measured} corners with detail");
|
||||
}
|
||||
}
|
||||
|
||||
/// A CR3-shaped file (not decodable yet) whose only content is a `PRVW` preview box.
|
||||
fn cr3_with_preview(w: u32, h: u32) -> Vec<u8> {
|
||||
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
|
||||
|
||||
@@ -32,8 +32,8 @@ use crate::media::ProbeInfo;
|
||||
|
||||
/// File extensions LightCraft imports (lower case).
|
||||
pub const EXTENSIONS: &[&str] = &[
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "pef", "psd", "jxl", "gif", "bmp",
|
||||
"heic", "avif",
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl",
|
||||
"gif", "bmp", "heic", "avif",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
@@ -48,9 +48,34 @@ pub enum ImportMode {
|
||||
Move,
|
||||
}
|
||||
|
||||
/// What an import does with a file the library already has in Recently Deleted.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum OnDeleted {
|
||||
/// Leave it there; the report says so ([`Duplicate::existing_deleted`]).
|
||||
#[default]
|
||||
Skip,
|
||||
/// Bring the trashed photo back, with its edits ([`ImportReport::restored`]).
|
||||
Restore,
|
||||
/// Delete the trashed record permanently and import the file as a new photo.
|
||||
Fresh,
|
||||
}
|
||||
|
||||
impl OnDeleted {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"skip" => Some(Self::Skip),
|
||||
"restore" => Some(Self::Restore),
|
||||
"fresh" => Some(Self::Fresh),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What an import does besides adding the photos.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ImportOptions {
|
||||
/// A file that is in Recently Deleted: skip (default), restore, or import afresh.
|
||||
pub on_deleted: OnDeleted,
|
||||
pub mode: ImportMode,
|
||||
/// Applied to every imported photo (one History entry).
|
||||
pub preset: Option<lightcraft_develop::Preset>,
|
||||
@@ -147,12 +172,18 @@ pub struct Duplicate {
|
||||
pub existing: Option<u64>,
|
||||
/// `"path"` (already imported from there) or `"content"` (same bytes elsewhere).
|
||||
pub reason: &'static str,
|
||||
/// That photo is in Recently Deleted (see [`OnDeleted`]).
|
||||
#[serde(rename = "existingDeleted", skip_serializing_if = "std::ops::Not::not")]
|
||||
pub existing_deleted: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ImportReport {
|
||||
pub imported: Vec<u64>,
|
||||
pub duplicates: Vec<Duplicate>,
|
||||
/// [`OnDeleted::Restore`]: photos brought back from Recently Deleted instead of imported.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub restored: Vec<u64>,
|
||||
/// Move: the originals that were moved (and removed from the source).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub moved: Vec<Moved>,
|
||||
@@ -393,7 +424,7 @@ pub struct ScanInput {
|
||||
probe: Option<crate::media::FileProbe>,
|
||||
skip: Option<PathBuf>,
|
||||
/// path → (photo, its summary) for files already in the library.
|
||||
by_path: HashMap<String, (PhotoId, ImportCandidate)>,
|
||||
by_path: HashMap<PathBuf, (PhotoId, ImportCandidate)>,
|
||||
by_hash: HashMap<String, PhotoId>,
|
||||
cache: HashMap<String, ProbeInfo>,
|
||||
}
|
||||
@@ -432,7 +463,7 @@ impl ScanInput {
|
||||
existing: Some(p.id.0),
|
||||
..Default::default()
|
||||
};
|
||||
by_path.insert(path.clone(), (p.id, c));
|
||||
by_path.insert(PathBuf::from(path), (p.id, c));
|
||||
}
|
||||
if let Some(h) = &p.content_hash {
|
||||
by_hash.insert(h.clone(), p.id);
|
||||
@@ -458,7 +489,7 @@ pub fn scan(s: &mut Session, paths: &[String]) -> Vec<ImportCandidate> {
|
||||
pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let files = expand(paths, input.skip.as_deref());
|
||||
let todo: Vec<String> = files.iter().filter(|f| !input.by_path.contains_key(f.as_str())).cloned().collect();
|
||||
let todo: Vec<String> = files.iter().filter(|f| !input.by_path.contains_key(Path::new(f))).cloned().collect();
|
||||
progress.total.store(todo.len(), Relaxed);
|
||||
// probes from a preceding `scan` are reused when the file is unchanged (same size)
|
||||
let cached: Vec<Option<ProbeInfo>> = todo
|
||||
@@ -480,7 +511,7 @@ pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress
|
||||
let mut out = Vec::with_capacity(files.len());
|
||||
let mut kept = HashMap::new();
|
||||
for f in files {
|
||||
if let Some((_, c)) = input.by_path.get(&f) {
|
||||
if let Some((_, c)) = input.by_path.get(Path::new(&f)) {
|
||||
let mut c = c.clone();
|
||||
c.name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone());
|
||||
out.push(c);
|
||||
@@ -638,6 +669,10 @@ impl ImportJob {
|
||||
let mut by_path = HashMap::new();
|
||||
let mut by_hash = HashMap::new();
|
||||
for p in s.catalog.photos() {
|
||||
// a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate
|
||||
if p.deleted && opts.on_deleted == OnDeleted::Fresh {
|
||||
continue;
|
||||
}
|
||||
if let Source::File { path } = &p.source {
|
||||
by_path.insert(path.clone(), (p.id, p.local));
|
||||
}
|
||||
@@ -840,6 +875,20 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare
|
||||
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();
|
||||
// Fresh: trashed photos a new import replaces (by content or by file), each removed once
|
||||
let (mut trash_hash, mut trash_path): (HashMap<String, PhotoId>, HashMap<String, PhotoId>) = Default::default();
|
||||
if opts.on_deleted == OnDeleted::Fresh {
|
||||
for p in s.catalog.photos().filter(|p| p.deleted) {
|
||||
if let Some(h) = &p.content_hash {
|
||||
trash_hash.insert(h.clone(), p.id);
|
||||
}
|
||||
if let Source::File { path } = &p.source {
|
||||
trash_path.insert(path.clone(), p.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut purge: Vec<PhotoId> = Vec::new();
|
||||
let mut purged: std::collections::HashSet<PhotoId> = Default::default();
|
||||
for it in prepared.items {
|
||||
match it {
|
||||
PreparedItem::Promote(id) => {
|
||||
@@ -853,7 +902,16 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare
|
||||
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))
|
||||
});
|
||||
report.duplicates.push(Duplicate { path, existing: existing.map(|i| i.0), reason });
|
||||
let trashed = existing.filter(|id| s.catalog.photo(*id).is_some_and(|p| p.deleted));
|
||||
match trashed {
|
||||
Some(id) if opts.on_deleted == OnDeleted::Restore => {
|
||||
if !report.restored.contains(&id.0) {
|
||||
ops.push(Op::SetDeleted { id, deleted: false });
|
||||
report.restored.push(id.0);
|
||||
}
|
||||
}
|
||||
_ => report.duplicates.push(Duplicate { path, existing: existing.map(|i| i.0), reason, existing_deleted: trashed.is_some() }),
|
||||
}
|
||||
}
|
||||
PreparedItem::Failed(path, e) => report.failed.push((path, e)),
|
||||
PreparedItem::Kept(k) => report.kept.push(k),
|
||||
@@ -864,6 +922,11 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare
|
||||
if let Some(h) = &info.content_hash {
|
||||
new_hash.insert(h.clone(), id);
|
||||
}
|
||||
for old in info.content_hash.as_ref().and_then(|h| trash_hash.get(h)).into_iter().chain(trash_path.get(&stored)) {
|
||||
if purged.insert(*old) {
|
||||
purge.push(*old);
|
||||
}
|
||||
}
|
||||
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());
|
||||
@@ -907,9 +970,18 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare
|
||||
}
|
||||
}
|
||||
}
|
||||
// (built against the catalog as it is: one op list for all of them, ahead of the additions)
|
||||
if !purge.is_empty() {
|
||||
ops.insert(0, s.catalog.delete_photos_permanently_ops(&purge));
|
||||
}
|
||||
let log0 = s.pending_log.len();
|
||||
let (verb, n) = if report.imported.is_empty() && !report.restored.is_empty() {
|
||||
("Restore", report.restored.len())
|
||||
} else {
|
||||
("Add", report.imported.len().max(1))
|
||||
};
|
||||
if !ops.is_empty()
|
||||
&& let Err(e) = s.commit(&format!("Add {} Photo{}", ops.len(), if ops.len() == 1 { "" } else { "s" }), Op::Batch { ops })
|
||||
&& let Err(e) = s.commit(&format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }), Op::Batch { ops })
|
||||
{
|
||||
// nothing was catalogued: take the moves back (their sources were never touched)
|
||||
placed.iter().for_each(crate::import_move::rollback);
|
||||
@@ -993,6 +1065,9 @@ impl Session {
|
||||
let source = self.media.origin_ref(&p.source, level.max_edge());
|
||||
let key = lightcraft_preview::Hasher128::new().str(&c.path).u64(c.file_size).u64(edge as u64).finish().0 as u64;
|
||||
let small = crate::media::RenderJob {
|
||||
request_id: 0,
|
||||
cache_generation: self.media.rendered.generation(),
|
||||
source_key: None,
|
||||
photo: id,
|
||||
level,
|
||||
source,
|
||||
@@ -1009,7 +1084,7 @@ impl Session {
|
||||
(Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)),
|
||||
_ => None,
|
||||
};
|
||||
Some(crate::media::QuickJob { photo: id, key, cached: Vec::new(), embedded, small: Some(Box::new(small)) })
|
||||
Some(crate::media::QuickJob { request_id: 0, photo: id, key, cached: Vec::new(), embedded, small: Some(Box::new(small)) })
|
||||
}
|
||||
|
||||
/// Use the system clock for import/edit times (native hosts).
|
||||
|
||||
@@ -25,6 +25,7 @@ pub mod guard;
|
||||
pub mod import;
|
||||
mod import_move;
|
||||
pub mod library;
|
||||
pub mod logging;
|
||||
pub mod media;
|
||||
pub mod memory;
|
||||
pub mod merge;
|
||||
@@ -33,6 +34,7 @@ pub mod preset_import;
|
||||
pub mod preset_luminar;
|
||||
pub mod presets;
|
||||
pub mod rename;
|
||||
pub mod segment;
|
||||
pub mod sidecar;
|
||||
pub mod smart;
|
||||
mod view;
|
||||
@@ -45,7 +47,7 @@ use lightcraft_catalog::{Catalog, Filter, Op, PhotoId, Sort};
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
pub use media::{RenderJob, SourceLevel};
|
||||
use serde_json::Value;
|
||||
pub use view::{Browse, FilterChip, LibrarySource, Selection, filter_chips};
|
||||
pub use view::{Browse, FilterChip, LibrarySource, Selection, SelectionState, filter_chips};
|
||||
pub use {lightcraft_catalog as catalog, lightcraft_develop as develop, lightcraft_gpu as gpu, lightcraft_pipeline as pipeline};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -133,6 +135,9 @@ pub struct Session {
|
||||
pub clipboard: Option<Value>,
|
||||
/// The folder on disk the [`LibrarySource::Folder`] view browses.
|
||||
pub browse: Option<Browse>,
|
||||
/// The folder the [`LibrarySource::LibraryFolder`] view shows: the library's photos imported
|
||||
/// from it and from the folders inside it.
|
||||
pub library_folder: Option<String>,
|
||||
/// Copied metadata (`photo.copyMetadata`): photo.setMeta params.
|
||||
pub meta_clipboard: Option<Value>,
|
||||
/// The photo that was active before the current one (Paste Settings from Previous).
|
||||
@@ -154,6 +159,8 @@ pub struct Session {
|
||||
depth: u32,
|
||||
/// Selected mask (Masking panel), by mask id.
|
||||
pub active_mask: Option<u32>,
|
||||
/// AI masks (SAM 3): the model and the last photo prepared for it.
|
||||
pub segmenter: segment::Segmenter,
|
||||
/// Selected spot (Remove panel), by index into the active photo's spots.
|
||||
pub active_spot: Option<usize>,
|
||||
/// The persistent library this session writes to (`None` = in-memory only).
|
||||
@@ -231,6 +238,7 @@ impl Session {
|
||||
clipboard: None,
|
||||
meta_clipboard: None,
|
||||
browse: None,
|
||||
library_folder: None,
|
||||
previous_active: None,
|
||||
copy_groups: lightcraft_develop::SettingsGroup::default_copy(),
|
||||
presets: presets::builtin(),
|
||||
@@ -242,6 +250,7 @@ impl Session {
|
||||
clock: Box::new(|| "2026-09-30T12:00:00".to_string()),
|
||||
depth: 0,
|
||||
active_mask: None,
|
||||
segmenter: segment::Segmenter::default(),
|
||||
active_spot: None,
|
||||
library: None,
|
||||
xmp: sidecar::XmpPrefs::default(),
|
||||
@@ -586,7 +595,8 @@ impl Session {
|
||||
|
||||
/// Photos shown in the grid/filmstrip for the current source, filter and sort.
|
||||
pub fn visible(&mut self) -> &[PhotoId] {
|
||||
let mut 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, self.library_folder));
|
||||
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()));
|
||||
@@ -601,7 +611,18 @@ impl Session {
|
||||
f.folder = Some(b.path);
|
||||
f.subfolders = b.subfolders;
|
||||
}
|
||||
if self.source == LibrarySource::LibraryFolder {
|
||||
// no folder chosen: nothing (`.` names no folder)
|
||||
f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into()));
|
||||
}
|
||||
let mut visible = self.catalog.query(&f, &self.sort);
|
||||
if visible.is_empty() && self.source == LibrarySource::LibraryFolder && !self.folder_holds_photos() {
|
||||
// the shown folder lost its last photo (deleted, moved, removed): everything, not
|
||||
// an empty grid under the name of a folder that is gone from the sidebar
|
||||
self.source = LibrarySource::All;
|
||||
self.library_folder = None;
|
||||
return self.visible();
|
||||
}
|
||||
if matches!(self.source, LibrarySource::Album(_))
|
||||
&& self.sort.key == lightcraft_catalog::SortKey::CaptureDate
|
||||
&& let LibrarySource::Album(a) = self.source
|
||||
@@ -638,13 +659,20 @@ impl Session {
|
||||
&self.visible
|
||||
}
|
||||
|
||||
/// Whether the library still holds a photo imported from the folder a `LibraryFolder` source
|
||||
/// shows.
|
||||
fn folder_holds_photos(&self) -> bool {
|
||||
let f = Filter { library_folder: self.library_folder.clone(), ..Default::default() };
|
||||
self.library_folder.is_some() && !self.catalog.query(&f, &Sort::default()).is_empty()
|
||||
}
|
||||
|
||||
/// Photos in the current source (folder, album, …) before the filter bar and search narrow
|
||||
/// them; `None` where that is not a separate number (Missing Photos).
|
||||
pub fn source_total(&mut self) -> Option<usize> {
|
||||
if self.source == LibrarySource::Missing {
|
||||
return None;
|
||||
}
|
||||
let key = (self.catalog.revision, format!("{:?}|{:?}", self.source, self.browse));
|
||||
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}", self.source, self.browse, self.library_folder));
|
||||
if self.total.as_ref().map(|t| &t.0) != Some(&key) {
|
||||
let mut f = self.source.to_filter(&Filter::default(), &self.catalog);
|
||||
if self.source == LibrarySource::Folder {
|
||||
@@ -652,6 +680,9 @@ impl Session {
|
||||
f.folder = Some(b.path);
|
||||
f.subfolders = b.subfolders;
|
||||
}
|
||||
if self.source == LibrarySource::LibraryFolder {
|
||||
f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into()));
|
||||
}
|
||||
let n = self.catalog.query(&f, &self.sort).len();
|
||||
self.total = Some((key, n));
|
||||
}
|
||||
@@ -705,6 +736,8 @@ mod tests_color;
|
||||
#[cfg(test)]
|
||||
mod tests_export;
|
||||
#[cfg(test)]
|
||||
mod tests_folders;
|
||||
#[cfg(test)]
|
||||
mod tests_forget_local;
|
||||
#[cfg(test)]
|
||||
mod tests_import;
|
||||
@@ -723,6 +756,8 @@ mod tests_persist;
|
||||
#[cfg(test)]
|
||||
mod tests_prefs;
|
||||
#[cfg(test)]
|
||||
mod tests_segment;
|
||||
#[cfg(test)]
|
||||
mod tests_settings_files;
|
||||
#[cfg(test)]
|
||||
mod tests_spots;
|
||||
|
||||
@@ -102,6 +102,8 @@ struct PresetsFile {
|
||||
struct ViewFile {
|
||||
source: LibrarySource,
|
||||
browse: Option<crate::Browse>,
|
||||
/// The folder a `libraryFolder` source shows.
|
||||
library_folder: Option<String>,
|
||||
// No filter: a library opens unfiltered. A date, keyword or person left over from the last session
|
||||
// would silently hide photos, with only a small badge to say so.
|
||||
sort: lightcraft_catalog::Sort,
|
||||
@@ -295,7 +297,13 @@ impl Session {
|
||||
{
|
||||
log::error!("library: {e}");
|
||||
}
|
||||
let (mut journal, catalog, report) = Journal::open(catalog)?;
|
||||
let (mut journal, mut catalog, report) = Journal::open(catalog)?;
|
||||
// A loaded catalog counts revisions from 0, like every other one. Caches (sidebar counts,
|
||||
// keyword tree, the grid's list…) are keyed on the revision, so give each library loaded
|
||||
// in this process its own range: otherwise an empty library opened after one that was
|
||||
// never edited this session would look unchanged and show the old library's numbers.
|
||||
static LOADS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
catalog.revision = LOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1) << 32;
|
||||
self.catalog = catalog;
|
||||
self.undo.clear();
|
||||
self.redo.clear();
|
||||
@@ -303,25 +311,12 @@ impl Session {
|
||||
self.pending_log.clear();
|
||||
self.selection = Selection::default();
|
||||
self.source = LibrarySource::All;
|
||||
self.library_folder = None;
|
||||
if report.created && seed_demo {
|
||||
crate::demo::load(self);
|
||||
journal.snapshot(&self.catalog)?;
|
||||
}
|
||||
let mut settings = SettingsLoad::default();
|
||||
// presets
|
||||
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);
|
||||
}
|
||||
for u in f.user {
|
||||
if !self.presets.iter().any(|p| p.id == u.id) {
|
||||
self.presets.push(u);
|
||||
}
|
||||
}
|
||||
let known = |id: &String| crate::presets::profile(id).is_some();
|
||||
self.profile_favorites = f.profile_favorites.into_iter().filter(known).collect();
|
||||
self.profile_recent = f.profile_recent.into_iter().filter(known).take(crate::presets::RECENT_PROFILES).collect();
|
||||
}
|
||||
// preferences
|
||||
let prefs = settings.read::<PrefsFile>(files.as_mut(), "prefs.json").unwrap_or_default();
|
||||
self.xmp = prefs.xmp;
|
||||
@@ -343,10 +338,34 @@ impl Session {
|
||||
if let Some(d) = &self.smart_previews_dir {
|
||||
self.media.smart_dir = Some(d.clone());
|
||||
}
|
||||
// presets
|
||||
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);
|
||||
}
|
||||
for u in f.user {
|
||||
if !self.presets.iter().any(|p| p.id == u.id) {
|
||||
self.presets.push(u);
|
||||
}
|
||||
}
|
||||
let known = |id: &String| crate::presets::profile(id).is_some() || self.lut_profiles.iter().any(|p| &p.id == id);
|
||||
self.profile_favorites = f.profile_favorites.into_iter().filter(known).collect();
|
||||
self.profile_recent = f.profile_recent.into_iter().filter(known).take(crate::presets::RECENT_PROFILES).collect();
|
||||
}
|
||||
// view state
|
||||
if let Some(v) = settings.read::<ViewFile>(files.as_mut(), "view.json") {
|
||||
self.source = v.source;
|
||||
self.browse = v.browse;
|
||||
self.library_folder = v.library_folder.filter(|f| !f.trim().is_empty());
|
||||
if self.source == LibrarySource::LibraryFolder {
|
||||
// a folder that is gone (or none): everything, not an empty grid
|
||||
let f = lightcraft_catalog::Filter { library_folder: self.library_folder.clone(), ..Default::default() };
|
||||
let any = self.library_folder.is_some() && !self.catalog.query(&f, &lightcraft_catalog::Sort::default()).is_empty();
|
||||
if !any {
|
||||
self.source = LibrarySource::All;
|
||||
self.library_folder = None;
|
||||
}
|
||||
}
|
||||
self.sort = v.sort;
|
||||
self.selection = v.selection;
|
||||
self.selection.ids.retain(|id| self.catalog.photo(*id).is_some());
|
||||
@@ -494,7 +513,14 @@ impl Session {
|
||||
}
|
||||
|
||||
fn view_json(&self) -> Vec<u8> {
|
||||
let view = ViewFile { source: self.source, browse: self.browse.clone(), sort: self.sort, selection: self.selection.clone() };
|
||||
let view = ViewFile {
|
||||
source: self.source,
|
||||
browse: self.browse.clone(),
|
||||
// only while it is shown: a leftover would be a stale choice nobody made
|
||||
library_folder: self.library_folder.clone().filter(|_| self.source == LibrarySource::LibraryFolder),
|
||||
sort: self.sort,
|
||||
selection: self.selection.clone(),
|
||||
};
|
||||
serde_json::to_vec_pretty(&view).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -557,13 +583,14 @@ impl Session {
|
||||
if self.cache_mb == 0 { crate::media::DISK_CACHE_BYTES } else { u64::from(self.cache_mb) << 20 }
|
||||
}
|
||||
|
||||
/// Change the thumbnail disk cache budget (MB, 0 = default): re-attaches the cache, which
|
||||
/// trims it to the new size. Saved with the library preferences.
|
||||
/// Change the thumbnail disk cache budget (MB, 0 = default) in place: the cached thumbnails
|
||||
/// stay (the next write trims to the new size), and so do the textures shown from them.
|
||||
/// Saved with the library preferences.
|
||||
pub fn set_cache_mb(&mut self, mb: u32) -> Result<()> {
|
||||
self.cache_mb = mb;
|
||||
if let Some(lib) = self.library.as_ref().filter(|l| l.on_disk) {
|
||||
let dir = lib.thumbs_dir();
|
||||
self.media.attach_disk_cache(&dir, self.cache_bytes());
|
||||
self.media.set_disk_cache_bytes(&dir, self.cache_bytes());
|
||||
}
|
||||
self.save_prefs()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! `log` records on stderr for the native desktop app and CLI (#168). The web build installs
|
||||
//! eframe's `WebLogger` instead.
|
||||
//!
|
||||
//! Warnings and errors are shown by default. `LIGHTCRAFT_LOG` or `RUST_LOG` picks another level
|
||||
//! (`off`, `error`, `warn`, `info`, `debug`, `trace`); `LIGHTCRAFT_LOG` wins when both are set.
|
||||
//! `RUST_LOG` also accepts per-target directives (`lightcraft=debug,wgpu=warn`): a bare level
|
||||
//! applies to everything, and the most verbose `lightcraft…=level` directive sets LightCraft's.
|
||||
//! Below warnings only LightCraft's own records are shown, so `info` and `debug` aren't drowned
|
||||
//! out by the GPU and windowing libraries.
|
||||
|
||||
use log::{Level, LevelFilter, Metadata, Record};
|
||||
|
||||
/// Records shown at a level, written to stderr as `<prefix>: LEVEL target: message`.
|
||||
pub struct StderrLog {
|
||||
prefix: &'static str,
|
||||
level: LevelFilter,
|
||||
}
|
||||
|
||||
impl StderrLog {
|
||||
pub fn new(prefix: &'static str, level: LevelFilter) -> Self {
|
||||
Self { prefix, level }
|
||||
}
|
||||
}
|
||||
|
||||
impl log::Log for StderrLog {
|
||||
fn enabled(&self, m: &Metadata) -> bool {
|
||||
m.level() <= self.level && (m.level() <= Level::Warn || m.target().starts_with("lightcraft"))
|
||||
}
|
||||
fn log(&self, r: &Record) {
|
||||
if self.enabled(r.metadata()) {
|
||||
eprintln!("{}: {} {}: {}", self.prefix, r.level(), r.target(), r.args());
|
||||
}
|
||||
}
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
fn parse_level(s: &str) -> Option<LevelFilter> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"off" | "none" => Some(LevelFilter::Off),
|
||||
"error" => Some(LevelFilter::Error),
|
||||
"warn" | "warning" => Some(LevelFilter::Warn),
|
||||
"info" => Some(LevelFilter::Info),
|
||||
"debug" => Some(LevelFilter::Debug),
|
||||
"trace" => Some(LevelFilter::Trace),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The level that `LIGHTCRAFT_LOG` / `RUST_LOG` ask for; warnings when neither names one.
|
||||
pub fn level_from(lightcraft_log: Option<&str>, rust_log: Option<&str>) -> LevelFilter {
|
||||
if let Some(l) = lightcraft_log.and_then(parse_level) {
|
||||
return l;
|
||||
}
|
||||
let Some(rust_log) = rust_log else { return LevelFilter::Warn };
|
||||
let mut bare = None;
|
||||
let mut ours: Option<LevelFilter> = None;
|
||||
for directive in rust_log.split(',') {
|
||||
match directive.split_once('=') {
|
||||
None => bare = parse_level(directive).or(bare),
|
||||
Some((target, level)) if target.trim().starts_with("lightcraft") => {
|
||||
if let Some(l) = parse_level(level) {
|
||||
ours = Some(ours.map_or(l, |o| o.max(l)));
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
ours.or(bare).unwrap_or(LevelFilter::Warn)
|
||||
}
|
||||
|
||||
/// Install the stderr logger at the level the environment asks for. Does nothing when a logger is
|
||||
/// already installed.
|
||||
pub fn install(prefix: &'static str) {
|
||||
let level = level_from(std::env::var("LIGHTCRAFT_LOG").ok().as_deref(), std::env::var("RUST_LOG").ok().as_deref());
|
||||
static LOGGER: std::sync::OnceLock<StderrLog> = std::sync::OnceLock::new();
|
||||
if log::set_logger(LOGGER.get_or_init(|| StderrLog::new(prefix, level))).is_ok() {
|
||||
log::set_max_level(level);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use log::Log;
|
||||
|
||||
#[test]
|
||||
fn warnings_by_default_and_either_variable_picks_the_level() {
|
||||
assert_eq!(level_from(None, None), LevelFilter::Warn);
|
||||
assert_eq!(level_from(None, Some("")), LevelFilter::Warn);
|
||||
assert_eq!(level_from(None, Some("warn")), LevelFilter::Warn);
|
||||
assert_eq!(level_from(None, Some("TRACE")), LevelFilter::Trace);
|
||||
assert_eq!(level_from(None, Some("off")), LevelFilter::Off);
|
||||
assert_eq!(level_from(Some("info"), None), LevelFilter::Info);
|
||||
assert_eq!(level_from(Some("error"), Some("debug")), LevelFilter::Error, "LIGHTCRAFT_LOG wins");
|
||||
assert_eq!(level_from(Some("nonsense"), Some("debug")), LevelFilter::Debug, "an unknown LIGHTCRAFT_LOG is ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_log_directives_set_lightcraft_s_level() {
|
||||
assert_eq!(level_from(None, Some("lightcraft=debug,wgpu=warn")), LevelFilter::Debug);
|
||||
assert_eq!(level_from(None, Some("wgpu_core=trace")), LevelFilter::Warn, "other crates' directives don't apply");
|
||||
assert_eq!(level_from(None, Some("error,lightcraft_gpu=info")), LevelFilter::Info);
|
||||
assert_eq!(level_from(None, Some("lightcraft_gpu=info,lightcraft_engine=trace")), LevelFilter::Trace);
|
||||
assert_eq!(level_from(None, Some("info,naga=off")), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_lightcraft_records_below_warnings() {
|
||||
let log = StderrLog::new("test", LevelFilter::Debug);
|
||||
let meta = |level, target| Metadata::builder().level(level).target(target).build();
|
||||
assert!(log.enabled(&meta(Level::Warn, "wgpu_core::device")));
|
||||
assert!(log.enabled(&meta(Level::Debug, "lightcraft_gpu::ctx")));
|
||||
assert!(!log.enabled(&meta(Level::Info, "wgpu_core::device")));
|
||||
assert!(!log.enabled(&meta(Level::Trace, "lightcraft_gpu::ctx")));
|
||||
let quiet = StderrLog::new("test", LevelFilter::Off);
|
||||
assert!(!quiet.enabled(&meta(Level::Error, "lightcraft_engine")));
|
||||
}
|
||||
}
|
||||
+214
-16
@@ -16,7 +16,8 @@
|
||||
//! embedded JPEG for an unedited raw, else a thumbnail-level render — while the real render is
|
||||
//! prepared. Grid thumbnails of unedited raws likewise start from the embedded preview.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use lightcraft_catalog::{MediaKind, Photo, PhotoId, Source};
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
@@ -25,12 +26,51 @@ use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache};
|
||||
use lightcraft_raster::{Histogram, Rgb32f, Rgba8};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const SETTINGS_HASH_ENTRIES: usize = 1024;
|
||||
|
||||
#[derive(Default)]
|
||||
struct SettingsHashes {
|
||||
entries: HashMap<PhotoId, (Weak<DevelopSettings>, u64)>,
|
||||
order: VecDeque<PhotoId>,
|
||||
#[cfg(test)]
|
||||
computations: usize,
|
||||
}
|
||||
|
||||
impl SettingsHashes {
|
||||
fn get(&mut self, id: PhotoId, settings: &Arc<DevelopSettings>) -> u64 {
|
||||
if let Some((old, hash)) = self.entries.get(&id)
|
||||
&& old.as_ptr() == Arc::as_ptr(settings)
|
||||
{
|
||||
return *hash;
|
||||
}
|
||||
let hash = settings.hash64();
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.computations += 1;
|
||||
}
|
||||
if !self.entries.contains_key(&id) {
|
||||
if self.entries.len() >= SETTINGS_HASH_ENTRIES
|
||||
&& let Some(oldest) = self.order.pop_front()
|
||||
{
|
||||
self.entries.remove(&oldest);
|
||||
}
|
||||
self.order.push_back(id);
|
||||
}
|
||||
self.entries.insert(id, (Arc::downgrade(settings), hash));
|
||||
hash
|
||||
}
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 11;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 12;
|
||||
|
||||
/// 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];
|
||||
|
||||
pub fn thumb_bucket(long_edge: usize) -> usize {
|
||||
THUMB_SIZES.iter().copied().find(|s| *s >= long_edge).unwrap_or(512)
|
||||
}
|
||||
|
||||
/// Memory budgets.
|
||||
const THUMB_SOURCE_BYTES: usize = 384 << 20;
|
||||
const RENDERED_MEM_BYTES: usize = 128 << 20;
|
||||
@@ -145,6 +185,7 @@ impl SourceRef {
|
||||
}
|
||||
|
||||
pub struct MediaCache {
|
||||
settings_hashes: SettingsHashes,
|
||||
/// Decoded thumbnail-level sources (LRU by bytes).
|
||||
thumbs: Lru<PhotoId, DecodedSource>,
|
||||
/// Decoded preview-level sources with their last use ([`lightcraft_preview::next_tick`]).
|
||||
@@ -175,6 +216,7 @@ impl Default for MediaCache {
|
||||
fn default() -> Self {
|
||||
let budget = crate::memory::cache_share(crate::memory::budget());
|
||||
MediaCache {
|
||||
settings_hashes: SettingsHashes::default(),
|
||||
thumbs: Lru::new(THUMB_SOURCE_BYTES.min(budget)),
|
||||
previews: Vec::new(),
|
||||
full: None,
|
||||
@@ -202,13 +244,27 @@ fn source_bytes(img: &Rgb32f) -> usize {
|
||||
}
|
||||
|
||||
impl MediaCache {
|
||||
/// Keep rendered thumbnails on disk in `dir` as well.
|
||||
/// Keep rendered thumbnails on disk in `dir` as well. The cache this replaces is retired
|
||||
/// (its files stay): render jobs still holding it can't write through it any more, even
|
||||
/// into a directory a later clear emptied (`dir` may be the same one).
|
||||
pub fn attach_disk_cache(&mut self, dir: &std::path::Path, disk_bytes: u64) {
|
||||
self.rendered.retire();
|
||||
self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, disk_bytes));
|
||||
}
|
||||
|
||||
/// Change the disk budget of the cache in `dir`: in place when that cache is attached (its
|
||||
/// thumbnails are keyed by content, so they and the textures shown from them stay valid,
|
||||
/// and the same cache object keeps its generation), else attach it.
|
||||
pub fn set_disk_cache_bytes(&mut self, dir: &std::path::Path, disk_bytes: u64) {
|
||||
match self.rendered.disk().filter(|d| d.dir() == dir) {
|
||||
Some(d) => d.set_budget(disk_bytes),
|
||||
None => self.attach_disk_cache(dir, disk_bytes),
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget every decoded source (photo ids changed meaning, e.g. another library was opened).
|
||||
pub fn clear_sources(&mut self) {
|
||||
self.settings_hashes = SettingsHashes::default();
|
||||
self.thumbs = Lru::new(THUMB_SOURCE_BYTES.min(self.budget));
|
||||
self.previews.clear();
|
||||
self.full = None;
|
||||
@@ -391,6 +447,11 @@ impl MediaCache {
|
||||
/// Everything needed to render one photo, detached from the session.
|
||||
#[derive(Clone)]
|
||||
pub struct RenderJob {
|
||||
/// Assigned by a frontend to distinguish late completions, even for an identical render key.
|
||||
pub request_id: u64,
|
||||
pub cache_generation: u64,
|
||||
/// Content identity for validating a decoded source returned after a reload.
|
||||
pub source_key: Option<Hash128>,
|
||||
pub photo: PhotoId,
|
||||
pub level: SourceLevel,
|
||||
pub source: SourceRef,
|
||||
@@ -412,6 +473,8 @@ pub struct RenderJob {
|
||||
}
|
||||
|
||||
pub struct RenderResult {
|
||||
pub request_id: u64,
|
||||
pub source_key: Option<Hash128>,
|
||||
pub photo: PhotoId,
|
||||
pub level: SourceLevel,
|
||||
pub key: u64,
|
||||
@@ -469,11 +532,13 @@ impl RenderJob {
|
||||
|
||||
pub fn run(self) -> RenderResult {
|
||||
if let Some((cache, key)) = &self.cache
|
||||
&& let Some(img) = cache.get(*key)
|
||||
&& let Some(img) = cache.get_at(self.cache_generation, *key)
|
||||
{
|
||||
let image = Arc::unwrap_or_clone(img);
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
return RenderResult {
|
||||
request_id: self.request_id,
|
||||
source_key: self.source_key,
|
||||
photo: self.photo,
|
||||
level: self.level,
|
||||
key: self.key,
|
||||
@@ -492,14 +557,16 @@ impl RenderJob {
|
||||
let gpu = self.cache.is_none();
|
||||
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()));
|
||||
cache.put_at(self.cache_generation, *key, Arc::new(rendered.image.clone()));
|
||||
}
|
||||
if let Some((cache, key)) = &self.view_cache
|
||||
&& self.request.quality == Quality::Full
|
||||
{
|
||||
cache.put_deferred(*key, Arc::new(rendered.image.clone()));
|
||||
cache.put_deferred_at(self.cache_generation, *key, Arc::new(rendered.image.clone()));
|
||||
}
|
||||
RenderResult {
|
||||
request_id: self.request_id,
|
||||
source_key: self.source_key,
|
||||
photo: self.photo,
|
||||
level: self.level,
|
||||
key: self.key,
|
||||
@@ -508,7 +575,16 @@ impl RenderJob {
|
||||
quick: None,
|
||||
}
|
||||
}
|
||||
Err(e) => RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Err(e), loaded: None, quick: None },
|
||||
Err(e) => RenderResult {
|
||||
request_id: self.request_id,
|
||||
source_key: self.source_key,
|
||||
photo: self.photo,
|
||||
level: self.level,
|
||||
key: self.key,
|
||||
rendered: Err(e),
|
||||
loaded: None,
|
||||
quick: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -548,6 +624,7 @@ pub enum QuickSource {
|
||||
/// renders, the embedded preview of an unedited raw, a thumbnail-level render.
|
||||
#[derive(Clone)]
|
||||
pub struct QuickJob {
|
||||
pub request_id: u64,
|
||||
pub photo: PhotoId,
|
||||
pub key: u64,
|
||||
/// Cached renders to try first, in order: (cache, key).
|
||||
@@ -561,7 +638,17 @@ pub struct QuickJob {
|
||||
impl QuickJob {
|
||||
pub fn run(self) -> RenderResult {
|
||||
let (photo, key) = (self.photo, self.key);
|
||||
let done = |rendered: Result<Rendered, String>, quick| RenderResult { photo, level: SourceLevel::Thumb, key, rendered, loaded: None, quick };
|
||||
let request_id = self.request_id;
|
||||
let done = |rendered: Result<Rendered, String>, quick| RenderResult {
|
||||
request_id,
|
||||
source_key: None,
|
||||
photo,
|
||||
level: SourceLevel::Thumb,
|
||||
key,
|
||||
rendered,
|
||||
loaded: None,
|
||||
quick,
|
||||
};
|
||||
let rendered = |image: Rgba8| {
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
Ok(Rendered { image, histogram, deep: None })
|
||||
@@ -578,7 +665,7 @@ impl QuickJob {
|
||||
}
|
||||
if let Some(job) = self.small {
|
||||
let r = job.run();
|
||||
return RenderResult { key, quick: Some(QuickSource::Small), ..r };
|
||||
return RenderResult { request_id, key, quick: Some(QuickSource::Small), ..r };
|
||||
}
|
||||
done(Err("no quick preview".into()), None)
|
||||
}
|
||||
@@ -617,7 +704,7 @@ impl crate::Session {
|
||||
/// A grid/filmstrip thumbnail job: the long edge is rounded up to one of [`THUMB_SIZES`]
|
||||
/// (≥ `long_edge`) and the result comes from / goes to the thumbnail cache (memory + disk).
|
||||
pub fn thumb_job(&mut self, id: PhotoId, long_edge: usize) -> Option<RenderJob> {
|
||||
let b = THUMB_SIZES.iter().copied().find(|s| *s >= long_edge).unwrap_or(THUMB_SIZES[THUMB_SIZES.len() - 1]);
|
||||
let b = thumb_bucket(long_edge);
|
||||
self.build_job(id, b, b, false, true, Some(b))
|
||||
}
|
||||
|
||||
@@ -638,8 +725,12 @@ impl crate::Session {
|
||||
// the photo id is part of the key: two photos with the same settings and size must not
|
||||
// share a result (a view slot showing photo A would otherwise look current for photo B)
|
||||
// …and so is the file's content: a file changed on disk (Reload) renders afresh
|
||||
let content = Hasher128::new().str(&content_key(&p)).finish().0 as u64;
|
||||
let key = settings.hash64()
|
||||
let content_key = content_key(&p);
|
||||
let source_key = Hasher128::new().str(&content_key).finish();
|
||||
let content = source_key.0 as u64;
|
||||
// Temporary "before" settings must not replace the photo's cached develop hash.
|
||||
let settings_hash = if before { settings.hash64() } else { self.media.settings_hashes.get(id, &settings) };
|
||||
let key = settings_hash
|
||||
^ ((max_w as u64) << 40)
|
||||
^ ((max_h as u64) << 20)
|
||||
^ (apply_crop as u64)
|
||||
@@ -648,8 +739,8 @@ impl crate::Session {
|
||||
^ content.rotate_left(17);
|
||||
let cache = thumb_bucket.map(|b| {
|
||||
let k = Hasher128::new()
|
||||
.str(&content_key(&p))
|
||||
.u64(settings.hash64())
|
||||
.str(&content_key)
|
||||
.u64(settings_hash)
|
||||
.u64(b as u64)
|
||||
.u64(RENDER_CACHE_VERSION)
|
||||
.u64(crate::camera_profiles::cache_key())
|
||||
@@ -657,6 +748,9 @@ impl crate::Session {
|
||||
(self.media.rendered.clone(), k)
|
||||
});
|
||||
Some(RenderJob {
|
||||
request_id: 0,
|
||||
cache_generation: self.media.rendered.generation(),
|
||||
source_key: Some(source_key),
|
||||
photo: id,
|
||||
level,
|
||||
source,
|
||||
@@ -704,6 +798,9 @@ impl crate::Session {
|
||||
let source = self.media.source_ref(&p, level);
|
||||
let ck = Self::variant_key(&p, settings, edge);
|
||||
Some(RenderJob {
|
||||
request_id: 0,
|
||||
cache_generation: self.media.rendered.generation(),
|
||||
source_key: Some(Hasher128::new().str(&content_key(&p)).finish()),
|
||||
photo: id,
|
||||
level,
|
||||
source,
|
||||
@@ -746,6 +843,9 @@ impl crate::Session {
|
||||
let source = self.media.source_ref(&p, level);
|
||||
let ck = Self::variant_key(&p, &settings, edge);
|
||||
Some(RenderJob {
|
||||
request_id: 0,
|
||||
cache_generation: self.media.rendered.generation(),
|
||||
source_key: Some(Hasher128::new().str(&content_key(&p)).finish()),
|
||||
photo: id,
|
||||
level,
|
||||
source,
|
||||
@@ -799,7 +899,7 @@ impl crate::Session {
|
||||
let h = Hasher128::new().str(&content_key(&p)).str("quick").u64(p.develop.hash64()).u64(apply_crop as u64).finish();
|
||||
let key = h.0 as u64;
|
||||
let embedded = self.embedded_of(&p).map(|(path, l)| (path, l, max_edge.clamp(1, SourceLevel::Preview.max_edge())));
|
||||
Some(QuickJob { photo: id, key, cached, embedded, small: apply_crop.then(|| Box::new(small)) })
|
||||
Some(QuickJob { request_id: 0, photo: id, key, cached, embedded, small: apply_crop.then(|| Box::new(small)) })
|
||||
}
|
||||
|
||||
/// A first grid thumbnail for an unedited raw: the cached render for `job` (the real
|
||||
@@ -811,6 +911,7 @@ impl crate::Session {
|
||||
let (path, l) = self.embedded_of(&p)?;
|
||||
let edge = job.request.max_w.max(job.request.max_h);
|
||||
Some(QuickJob {
|
||||
request_id: 0,
|
||||
photo: job.photo,
|
||||
key: job.key,
|
||||
cached: job.cache.clone().into_iter().collect(),
|
||||
@@ -821,7 +922,9 @@ 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 {
|
||||
if let Some(src) = &r.loaded
|
||||
&& r.source_key.is_none_or(|key| self.catalog.photo(r.photo).is_some_and(|p| Hasher128::new().str(&content_key(p)).finish() == key))
|
||||
{
|
||||
self.media.insert_source(r.photo, r.level, src.clone());
|
||||
}
|
||||
}
|
||||
@@ -907,6 +1010,101 @@ pub struct ProbeInfo {
|
||||
|
||||
pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod thumbnail_hash_tests {
|
||||
use super::*;
|
||||
use lightcraft_catalog::Op;
|
||||
|
||||
fn session() -> crate::Session {
|
||||
let mut s = crate::Session::new();
|
||||
let p = Photo::new(PhotoId(1), Source::File { path: "hash-fixture.jpg".into() }, "fixture.jpg", "JPEG", 6000, 4000, "2026-01-01");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
s
|
||||
}
|
||||
#[test]
|
||||
fn hashes_match_uncached_keys_and_are_not_recomputed_for_sizes_metadata_or_before() {
|
||||
let mut s = session();
|
||||
for edge in [256, 384, 512, 256] {
|
||||
let p = s.catalog.photo(PhotoId(1)).unwrap().clone();
|
||||
let b = thumb_bucket(edge);
|
||||
// Independent original formula: never asks the new hash cache for expected values.
|
||||
let settings = p.develop.hash64();
|
||||
let content = Hasher128::new().str(&content_key(&p)).finish().0 as u64;
|
||||
let expected =
|
||||
settings ^ ((b as u64) << 40) ^ ((b as u64) << 20) ^ 1 ^ p.id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ content.rotate_left(17);
|
||||
let disk = Hasher128::new()
|
||||
.str(&content_key(&p))
|
||||
.u64(settings)
|
||||
.u64(b as u64)
|
||||
.u64(RENDER_CACHE_VERSION)
|
||||
.u64(crate::camera_profiles::cache_key())
|
||||
.finish();
|
||||
let job = s.thumb_job(p.id, edge).unwrap();
|
||||
assert_eq!(job.key, expected);
|
||||
assert_eq!(job.cache.unwrap().1, disk);
|
||||
}
|
||||
s.catalog.apply(Op::SetRating { id: PhotoId(1), rating: 4 }).unwrap();
|
||||
s.thumb_job(PhotoId(1), 256).unwrap();
|
||||
s.render_job(PhotoId(1), 256, 256, true, true).unwrap();
|
||||
s.thumb_job(PhotoId(1), 256).unwrap();
|
||||
assert_eq!(s.media.settings_hashes.computations, 1);
|
||||
let mut edit = DevelopSettings::default();
|
||||
edit.light.exposure = 1.0;
|
||||
let undo = s.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap();
|
||||
s.thumb_job(PhotoId(1), 256).unwrap();
|
||||
s.thumb_job(PhotoId(1), 384).unwrap();
|
||||
assert_eq!(s.media.settings_hashes.computations, 2);
|
||||
s.catalog.apply(undo).unwrap();
|
||||
s.thumb_job(PhotoId(1), 256).unwrap();
|
||||
assert_eq!(s.media.settings_hashes.computations, 3);
|
||||
}
|
||||
#[test]
|
||||
fn hash_cache_is_bounded_weak_and_shared_settings_remain_independent() {
|
||||
let mut hashes = SettingsHashes::default();
|
||||
let shared = Arc::new(DevelopSettings::default());
|
||||
for id in 1..=SETTINGS_HASH_ENTRIES as u64 * 3 {
|
||||
assert_eq!(hashes.get(PhotoId(id), &shared), shared.hash64());
|
||||
}
|
||||
assert_eq!(hashes.entries.len(), SETTINGS_HASH_ENTRIES);
|
||||
assert_eq!(hashes.order.len(), SETTINGS_HASH_ENTRIES);
|
||||
assert_eq!(Arc::strong_count(&shared), 1);
|
||||
let mut edit = (*shared).clone();
|
||||
edit.light.exposure = 1.0;
|
||||
let edit = Arc::new(edit);
|
||||
let a = PhotoId(10000);
|
||||
let b = PhotoId(10001);
|
||||
let initial = hashes.get(a, &shared);
|
||||
assert_eq!(hashes.get(b, &shared), initial);
|
||||
assert_ne!(hashes.get(a, &edit), initial);
|
||||
assert_eq!(hashes.get(b, &shared), initial);
|
||||
let mut s = session();
|
||||
s.thumb_job(PhotoId(1), 256).unwrap();
|
||||
s.media.clear_sources();
|
||||
assert!(s.media.settings_hashes.entries.is_empty());
|
||||
assert!(s.media.settings_hashes.order.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn source_from_before_content_reload_is_not_accepted() {
|
||||
let mut s = session();
|
||||
s.media.file_loader = Some(Arc::new(|_, _| Ok((Rgb32f::new(8, 8), SourceInfo::default()))));
|
||||
let old = s.thumb_job(PhotoId(1), 256).unwrap().run();
|
||||
assert!(old.loaded.is_some());
|
||||
s.catalog
|
||||
.apply(Op::SetContent {
|
||||
id: PhotoId(1),
|
||||
width: 6000,
|
||||
height: 4000,
|
||||
file_size: 99,
|
||||
content_hash: Some("replacement".into()),
|
||||
preview_only: None,
|
||||
})
|
||||
.unwrap();
|
||||
s.media.forget(PhotoId(1));
|
||||
s.accept(&old);
|
||||
assert_eq!(s.media.source_usage().0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -446,6 +446,14 @@ pub fn profile(id: &str) -> Option<&'static ProfileInfo> {
|
||||
pub const RECENT_PROFILES: usize = 5;
|
||||
|
||||
impl Session {
|
||||
/// A profile by id, looking up built-in profiles first and imported LUT profiles second.
|
||||
pub fn profile_info(&self, id: &str) -> Option<(&str, &str)> {
|
||||
if let Some(p) = profile(id) {
|
||||
return Some((p.name, p.group));
|
||||
}
|
||||
self.lut_profiles.iter().find(|p| p.id == id).map(|p| (p.name.as_str(), p.group.as_str()))
|
||||
}
|
||||
|
||||
/// Remember `id` as the most recently applied profile.
|
||||
pub fn note_profile_used(&mut self, id: &str) {
|
||||
self.profile_recent.retain(|p| p != id);
|
||||
|
||||
+75
-18
@@ -395,7 +395,12 @@ impl MoveFs for RealFs {
|
||||
/// `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)
|
||||
from != to && folded_path(&from.to_string_lossy()) == folded_path(&to.to_string_lossy()) && fs.exists(to) && fs.same_file(from, to)
|
||||
}
|
||||
|
||||
/// Windows accepts both separators; spelling differences must not hide file identity.
|
||||
fn folded_path(path: &str) -> String {
|
||||
if cfg!(windows) { path.replace('\\', "/").to_lowercase() } else { path.to_lowercase() }
|
||||
}
|
||||
|
||||
/// Rename `a` to `b` without ever replacing a file; a change of letter case only goes through a
|
||||
@@ -439,7 +444,7 @@ pub fn move_file(from: &str, to: &str) -> std::result::Result<(), MoveError> {
|
||||
|
||||
/// [`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 {
|
||||
if Path::new(from) == Path::new(to) {
|
||||
return Ok(());
|
||||
}
|
||||
let fail = |message: String| MoveError { message, moved: false };
|
||||
@@ -590,11 +595,11 @@ fn plan_rename_core(
|
||||
loop {
|
||||
let name = candidate(k);
|
||||
let tp = dir.join(&name).to_string_lossy().to_string();
|
||||
let key = tp.to_lowercase();
|
||||
let key = folded_path(&tp);
|
||||
// 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));
|
||||
let same = from == to || (key == folded_path(path) && 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)) {
|
||||
@@ -656,6 +661,7 @@ impl Session {
|
||||
};
|
||||
let Some(plan) = plan else { continue };
|
||||
let source = match &plan.to_path {
|
||||
Some(t) if matches!(&p.source, Source::File { path } if Path::new(path) == Path::new(t)) => p.source.clone(),
|
||||
Some(t) => Source::File { path: t.clone() },
|
||||
None => p.source.clone(),
|
||||
};
|
||||
@@ -668,7 +674,7 @@ impl Session {
|
||||
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())),
|
||||
(Some(a), Some(b)) if Path::new(a) != Path::new(b) => Some((a.clone(), b.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@@ -732,7 +738,7 @@ impl Session {
|
||||
Op::Batch { ops } => ops.iter().for_each(|o| rec(s, o, v)),
|
||||
Op::SetFile { id, source: Source::File { path: to }, .. } => {
|
||||
if let Some(Source::File { path: from }) = s.catalog.photo(*id).map(|p| &p.source)
|
||||
&& from != to
|
||||
&& Path::new(from) != Path::new(to)
|
||||
&& !v.iter().any(|(f, _)| f == from)
|
||||
{
|
||||
v.push((from.clone(), to.clone()));
|
||||
@@ -768,13 +774,17 @@ mod tests {
|
||||
fail: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
/// The model volume spells paths with `/`; the code under test joins them with the host's separator.
|
||||
fn norm(p: &Path) -> String {
|
||||
p.to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
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 })
|
||||
let p = Self::norm(p);
|
||||
self.files.borrow().iter().position(|(f, _)| if self.ci { folded_path(f) == folded_path(&p) } 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();
|
||||
let mut v: Vec<_> = self.files.borrow().iter().map(|(p, c)| (p.replace('\\', "/"), String::from_utf8_lossy(c).to_string())).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
@@ -791,14 +801,14 @@ mod tests {
|
||||
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())) {
|
||||
if self.fail.borrow().iter().any(|f| Self::norm(a).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();
|
||||
self.files.borrow_mut()[i].0 = Self::norm(b);
|
||||
Ok(())
|
||||
}
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
@@ -807,7 +817,7 @@ mod tests {
|
||||
}
|
||||
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));
|
||||
self.files.borrow_mut().push((Self::norm(b), c));
|
||||
Ok(())
|
||||
}
|
||||
fn remove_file(&self, p: &Path) -> std::io::Result<()> {
|
||||
@@ -816,17 +826,64 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
fn stem_sibling(&self, sidecar: &Path) -> Option<String> {
|
||||
let stem = sidecar.with_extension("").to_string_lossy().to_string();
|
||||
let stem = Self::norm(&sidecar.with_extension(""));
|
||||
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"))
|
||||
Self::norm(&p.with_extension("")) == 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")
|
||||
// the host's separator, so `dir.join(name)` in the planner spells the same path the same way
|
||||
let path = path.replace('/', std::path::MAIN_SEPARATOR_STR);
|
||||
Photo::new(PhotoId(id), Source::File { path }, &name, "JPG", 1, 1, "2026-01-01T00:00:00")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn mixed_separator_noop_rename_preserves_files_catalog_and_undo() {
|
||||
let fs = FakeFs::new(true, &[("N:/photos/IMG.jpg", "image"), ("N:/photos/IMG.xmp", "edits"), ("N:/photos/other.jpg", "other")]);
|
||||
// Any attempt to move the unchanged photo (including rollback) must fail.
|
||||
fs.fail.borrow_mut().push("IMG".into());
|
||||
let mut s = Session::new();
|
||||
// spelled with `/` on purpose (not `file_photo`, which uses the host's separator)
|
||||
for (id, path) in [(1, "N:/photos/IMG.jpg"), (2, "N:/photos/other.jpg")] {
|
||||
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let photo = Photo::new(PhotoId(id), Source::File { path: path.into() }, &name, "JPG", 1, 1, "2026-01-01T00:00:00");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
|
||||
}
|
||||
let before = fs.listing();
|
||||
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "{name}", 1);
|
||||
assert_ne!(plans[0].from_path, plans[0].to_path, "fixture exercises different separator spelling");
|
||||
assert_eq!(s.apply_rename_with(&fs, &plans).unwrap(), 0);
|
||||
assert_eq!(fs.listing(), before);
|
||||
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "N:/photos/IMG.jpg".into() });
|
||||
assert!(s.undo.is_empty());
|
||||
move_file_with(&fs, "N:/photos/IMG.jpg", "N:/photos\\IMG.jpg").unwrap();
|
||||
let separator_only = Op::SetFile { id: PhotoId(1), file_name: "IMG.jpg".into(), source: Source::File { path: "N:/photos\\IMG.jpg".into() } };
|
||||
assert!(s.file_moves(&separator_only).is_empty());
|
||||
|
||||
// A batch may combine that no-op with a genuine rename, without touching IMG or its sidecar.
|
||||
let mut batch = plans;
|
||||
batch.extend(s.plan_rename_with(&fs, &[PhotoId(2)], "renamed", 1));
|
||||
assert_eq!(s.apply_rename_with(&fs, &batch).unwrap(), 1);
|
||||
assert_eq!(
|
||||
fs.listing(),
|
||||
vec![
|
||||
("N:/photos/IMG.jpg".into(), "image".into()),
|
||||
("N:/photos/IMG.xmp".into(), "edits".into()),
|
||||
("N:/photos/renamed.jpg".into(), "other".into())
|
||||
]
|
||||
);
|
||||
let undo = &s.undo.last().unwrap().op;
|
||||
let moves = s.file_moves(undo);
|
||||
assert_eq!(moves.len(), 1);
|
||||
assert_eq!(Path::new(&moves[0].1), Path::new("N:/photos/other.jpg"));
|
||||
move_all(&fs, &moves).unwrap();
|
||||
s.catalog.apply(undo.clone()).unwrap();
|
||||
assert_eq!(fs.listing(), before);
|
||||
}
|
||||
|
||||
/// Issue #95: a case-only rename is a real change on a case-insensitive volume and never
|
||||
@@ -884,7 +941,7 @@ mod tests {
|
||||
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();
|
||||
let err = s.apply_rename_with(&fs, &plans).unwrap_err().to_string().replace('\\', "/");
|
||||
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}");
|
||||
@@ -899,7 +956,7 @@ mod tests {
|
||||
);
|
||||
// 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::File { path } => path.replace('\\', "/"),
|
||||
Source::Demo { .. } => String::new(),
|
||||
};
|
||||
assert_eq!(path(&s, 1), "/p/Trip-1.jpg");
|
||||
@@ -913,7 +970,7 @@ mod tests {
|
||||
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!(e.message.replace('\\', "/").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")]);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! The model download as a background task the session starts, watches and cancels without
|
||||
//! ever blocking: progress lives in atomics, messages behind a mutex only ever `try_lock`ed
|
||||
//! by the session (the download thread holds it for a few instructions at a time).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use lightcraft_segment::fetch::{self, Options, Progress};
|
||||
|
||||
/// What the session sees of a download.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct Status {
|
||||
pub running: bool,
|
||||
pub done: u64,
|
||||
pub total: u64,
|
||||
/// The file being fetched.
|
||||
pub file: String,
|
||||
/// The last download's error (cleared when a new one starts).
|
||||
pub error: Option<String>,
|
||||
/// The last download finished and the files are in place.
|
||||
pub finished: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Text {
|
||||
file: String,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Shared {
|
||||
running: AtomicBool,
|
||||
cancel: AtomicBool,
|
||||
finished: AtomicBool,
|
||||
done: AtomicU64,
|
||||
total: AtomicU64,
|
||||
text: Mutex<Text>,
|
||||
}
|
||||
|
||||
/// Clears `running` when the download thread ends, however it ends.
|
||||
struct Running(Arc<Shared>);
|
||||
|
||||
impl Drop for Running {
|
||||
fn drop(&mut self) {
|
||||
self.0.running.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Downloader {
|
||||
shared: Arc<Shared>,
|
||||
/// The last text seen (when the download thread held the lock at that moment).
|
||||
last: Mutex<Text>,
|
||||
}
|
||||
|
||||
fn lock(m: &Mutex<Text>) -> std::sync::MutexGuard<'_, Text> {
|
||||
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
impl Downloader {
|
||||
pub fn status(&self) -> Status {
|
||||
let s = &self.shared;
|
||||
if let Ok(t) = s.text.try_lock()
|
||||
&& let Ok(mut last) = self.last.try_lock()
|
||||
{
|
||||
last.file.clone_from(&t.file);
|
||||
last.error.clone_from(&t.error);
|
||||
}
|
||||
// (only the session's thread locks `last`)
|
||||
let (file, error) = self.last.try_lock().map(|l| (l.file.clone(), l.error.clone())).unwrap_or_default();
|
||||
Status {
|
||||
running: s.running.load(Ordering::SeqCst),
|
||||
done: s.done.load(Ordering::SeqCst),
|
||||
total: s.total.load(Ordering::SeqCst),
|
||||
file,
|
||||
error,
|
||||
finished: s.finished.load(Ordering::SeqCst),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn running(&self) -> bool {
|
||||
self.shared.running.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Ask a running download to stop (it keeps its partial files to resume later).
|
||||
pub fn cancel(&self) -> bool {
|
||||
let running = self.running();
|
||||
if running {
|
||||
self.shared.cancel.store(true, Ordering::SeqCst);
|
||||
}
|
||||
running
|
||||
}
|
||||
|
||||
/// Start downloading `files` from `mirrors` into `dir` on a background thread. False when
|
||||
/// one is already running.
|
||||
pub fn start(&self, files: &'static [fetch::FileSpec], mirrors: Vec<String>, dir: PathBuf, opts: Options) -> Result<bool, String> {
|
||||
let s = self.shared.clone();
|
||||
if s.running.swap(true, Ordering::SeqCst) {
|
||||
return Ok(false);
|
||||
}
|
||||
s.cancel.store(false, Ordering::SeqCst);
|
||||
s.finished.store(false, Ordering::SeqCst);
|
||||
s.done.store(0, Ordering::SeqCst);
|
||||
s.total.store(files.iter().filter_map(|f| f.size).sum(), Ordering::SeqCst);
|
||||
// nothing else runs while `running` was false
|
||||
*lock(&s.text) = Text::default();
|
||||
*lock(&self.last) = Text::default();
|
||||
let guard = Running(s.clone());
|
||||
let spawned = std::thread::Builder::new().name("sam3-download".into()).spawn(move || {
|
||||
let s = guard.0.clone();
|
||||
let mut last_file = String::new();
|
||||
let mut on_progress = |p: &Progress| {
|
||||
s.done.store(p.done, Ordering::SeqCst);
|
||||
s.total.store(p.total, Ordering::SeqCst);
|
||||
if p.file != last_file {
|
||||
last_file.clone_from(&p.file);
|
||||
lock(&s.text).file.clone_from(&p.file);
|
||||
}
|
||||
};
|
||||
let r =
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fetch::download(files, &mirrors, &dir, &opts, &s.cancel, &mut on_progress)));
|
||||
let error = match r {
|
||||
Ok(Ok(())) => {
|
||||
s.finished.store(true, Ordering::SeqCst);
|
||||
log::info!("SAM 3 model downloaded to {}", dir.display());
|
||||
None
|
||||
}
|
||||
Ok(Err(e)) => Some(e.to_string()),
|
||||
Err(_) => Some("the download failed unexpectedly".to_string()),
|
||||
};
|
||||
if let Some(e) = &error {
|
||||
log::warn!("SAM 3 download: {e}");
|
||||
}
|
||||
lock(&s.text).error = error;
|
||||
drop(guard);
|
||||
});
|
||||
match spawned {
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => {
|
||||
// the closure (and its guard) was dropped: `running` is false again
|
||||
Err(format!("could not start the download: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
//! AI masks: Object (clicks) and Describe (text) selections computed with SAM 3
|
||||
//! (`lightcraft-segment`, cargo feature `sam`; the desktop app enables it).
|
||||
//!
|
||||
//! **Nothing requires the model.** It is not part of LightCraft (SAM License); the user
|
||||
//! downloads it when they first use an AI mask and agree to (`segment.model.download`), or
|
||||
//! puts the files in the model folder themselves. Without it, AI mask requests fail with a
|
||||
//! clear "not installed" error; renders, exports and every other feature never touch it
|
||||
//! (stored segmentations are plain data in the develop settings).
|
||||
//!
|
||||
//! The model sees the photo as developed, uncropped and without masks, at 1008 px — the frame
|
||||
//! mask coordinates live in — so a stored segmentation lines up with the photo at any size.
|
||||
//! Everything that touches the model (loading it, rendering and encoding the photo, clicks,
|
||||
//! descriptions, detail passes) runs on one worker thread ([`worker`]); the session talks to it
|
||||
//! over channels and never waits on a lock. With [`Segmenter::background`] (the desktop app)
|
||||
//! commands return at once and [`Session::segment_poll`] applies the results; without it (CLI,
|
||||
//! MCP, tests) they wait for the worker.
|
||||
|
||||
#[cfg(feature = "sam")]
|
||||
mod download;
|
||||
#[cfg(feature = "sam")]
|
||||
mod worker;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_develop::{MaskShape, SegMask};
|
||||
use lightcraft_geom::Point;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Long edge of the image the model sees.
|
||||
pub const INPUT_EDGE: usize = 1008;
|
||||
/// Download size of the model (`model.safetensors`; the tokenizer files add ~2 MB).
|
||||
pub const MODEL_BYTES: u64 = 3_439_938_512;
|
||||
/// The model's licence (not LightCraft's): shown before downloading.
|
||||
pub const LICENSE_NAME: &str = "SAM License (Meta)";
|
||||
pub const LICENSE_URL: &str = "https://github.com/facebookresearch/sam3/blob/main/LICENSE";
|
||||
/// How errors about a missing model start (the UI offers the download on it).
|
||||
pub const NOT_INSTALLED: &str = "The SAM 3 model is not installed";
|
||||
/// Longest a waiting command (CLI, MCP) waits for the model (loading on a CPU can take minutes).
|
||||
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
|
||||
const WAIT: std::time::Duration = std::time::Duration::from_secs(15 * 60);
|
||||
/// Most clicks one Object selection takes (the model's prompt grows with each).
|
||||
pub const MAX_CLICKS: usize = 64;
|
||||
|
||||
/// One click of an Object selection (normalized image coordinates).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Click {
|
||||
pub at: Point,
|
||||
pub include: bool,
|
||||
}
|
||||
|
||||
/// What a request's result is for (how [`Session::segment_poll`] applies it).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
|
||||
pub(crate) enum Tag {
|
||||
/// A command waiting for the result itself.
|
||||
Wait,
|
||||
/// Load the model and encode the photo ahead of the first click.
|
||||
Prepare,
|
||||
/// Object clicks on component `comp` of mask `mask`.
|
||||
Clicks { photo: PhotoId, mask: u32, comp: usize, hint: Vec<Point>, exclude: Vec<Point> },
|
||||
/// A Describe selection: a new mask (`mask: None`) or a component combined by `op`.
|
||||
NewPrompt { photo: PhotoId, mask: Option<u32>, op: String, name: Option<String>, text: String },
|
||||
/// A zoomed-in pass for a component, computed from `shape` (without its detail).
|
||||
Detail { photo: PhotoId, mask: u32, comp: usize, shape: MaskShape },
|
||||
}
|
||||
|
||||
/// The model input: 8-bit RGB.
|
||||
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
|
||||
pub(crate) struct Input {
|
||||
rgb: Vec<u8>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
}
|
||||
|
||||
/// What a detail pass re-runs.
|
||||
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
|
||||
pub(crate) enum Prompt {
|
||||
Clicks(Vec<Click>),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
/// Object clicks sent to the worker and not applied yet (background mode): further clicks add
|
||||
/// to these, and the loupe shows them.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PendingClicks {
|
||||
pub photo: PhotoId,
|
||||
pub mask: u32,
|
||||
pub comp: usize,
|
||||
pub hint: Vec<Point>,
|
||||
pub exclude: Vec<Point>,
|
||||
}
|
||||
|
||||
/// What [`Session::segment_poll`] did.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Polled {
|
||||
/// A mask changed.
|
||||
pub changed: bool,
|
||||
/// Errors and notices from background work, for the user.
|
||||
pub messages: Vec<String>,
|
||||
/// A mask that got a new selection (worth a detail pass).
|
||||
pub refine: Option<u32>,
|
||||
}
|
||||
|
||||
/// The model download (`segment.model.*`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct DownloadStatus {
|
||||
pub running: bool,
|
||||
pub done: u64,
|
||||
pub total: u64,
|
||||
pub file: String,
|
||||
pub error: Option<String>,
|
||||
pub finished: bool,
|
||||
}
|
||||
|
||||
/// The model (on its worker thread), the download, and AI mask requests in flight.
|
||||
pub struct Segmenter {
|
||||
/// Where the `facebook/sam3` files live (set by the app; `None`: no AI masks).
|
||||
pub dir: Option<PathBuf>,
|
||||
/// The user's list of download mirrors (one base URL per line), if any.
|
||||
pub mirrors_file: Option<PathBuf>,
|
||||
/// Run requests in the background and apply them in [`Session::segment_poll`] (the
|
||||
/// desktop app); otherwise commands wait for their result (CLI, MCP, tests).
|
||||
pub background: bool,
|
||||
pending: Option<PendingClicks>,
|
||||
messages: Vec<String>,
|
||||
#[cfg(feature = "sam")]
|
||||
worker: worker::Worker,
|
||||
#[cfg(feature = "sam")]
|
||||
download: download::Downloader,
|
||||
#[cfg(feature = "sam")]
|
||||
results: (std::sync::mpsc::Sender<worker::Outcome>, std::sync::mpsc::Receiver<worker::Outcome>),
|
||||
}
|
||||
|
||||
// (derivable only without the `sam` feature: the results channel has no Default)
|
||||
#[allow(clippy::derivable_impls)]
|
||||
impl Default for Segmenter {
|
||||
fn default() -> Self {
|
||||
Segmenter {
|
||||
dir: None,
|
||||
mirrors_file: None,
|
||||
background: false,
|
||||
pending: None,
|
||||
messages: Vec::new(),
|
||||
#[cfg(feature = "sam")]
|
||||
worker: worker::Worker::default(),
|
||||
#[cfg(feature = "sam")]
|
||||
download: download::Downloader::default(),
|
||||
#[cfg(feature = "sam")]
|
||||
results: std::sync::mpsc::channel(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Segmenter {
|
||||
/// Whether this build can compute AI masks at all.
|
||||
pub const AVAILABLE: bool = cfg!(feature = "sam");
|
||||
|
||||
/// Whether the model's files are in place.
|
||||
pub fn installed(&self) -> bool {
|
||||
#[cfg(feature = "sam")]
|
||||
if let Some(dir) = &self.dir {
|
||||
return lightcraft_segment::is_model_dir(dir);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether requests are queued or running (loading, analyzing the photo, a click…).
|
||||
pub fn busy(&self) -> bool {
|
||||
#[cfg(feature = "sam")]
|
||||
return self.worker.pending() > 0;
|
||||
#[cfg(not(feature = "sam"))]
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether the slow part runs now: loading the model or analyzing a photo.
|
||||
pub fn analyzing(&self) -> bool {
|
||||
#[cfg(feature = "sam")]
|
||||
return self.worker.encoding();
|
||||
#[cfg(not(feature = "sam"))]
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether a zoomed-in detail pass is queued or running.
|
||||
pub fn detail_busy(&self) -> bool {
|
||||
#[cfg(feature = "sam")]
|
||||
return self.worker.detail() > 0;
|
||||
#[cfg(not(feature = "sam"))]
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether the model is in memory (it is unloaded after a while without use).
|
||||
pub fn loaded(&self) -> bool {
|
||||
#[cfg(feature = "sam")]
|
||||
return self.worker.loaded();
|
||||
#[cfg(not(feature = "sam"))]
|
||||
false
|
||||
}
|
||||
|
||||
/// Clicks waiting for their segmentation.
|
||||
pub fn pending_clicks(&self) -> Option<&PendingClicks> {
|
||||
self.pending.as_ref()
|
||||
}
|
||||
|
||||
/// The model download's state.
|
||||
pub fn download_status(&self) -> DownloadStatus {
|
||||
#[cfg(feature = "sam")]
|
||||
{
|
||||
let s = self.download.status();
|
||||
DownloadStatus { running: s.running, done: s.done, total: s.total, file: s.file, error: s.error, finished: s.finished }
|
||||
}
|
||||
#[cfg(not(feature = "sam"))]
|
||||
DownloadStatus::default()
|
||||
}
|
||||
|
||||
/// Where the model is downloaded from, in order (the user's mirrors first).
|
||||
pub fn mirrors(&self) -> Vec<String> {
|
||||
#[cfg(feature = "sam")]
|
||||
{
|
||||
let env = std::env::var(lightcraft_segment::fetch::MIRRORS_ENV).ok();
|
||||
lightcraft_segment::fetch::mirrors(env.as_deref(), self.mirrors_file.as_deref())
|
||||
}
|
||||
#[cfg(not(feature = "sam"))]
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// The model folder, if the model is there; otherwise an error that says what to do.
|
||||
pub fn model_dir(&self) -> Result<PathBuf, String> {
|
||||
if !Self::AVAILABLE {
|
||||
return Err("AI masks are not available in this build".into());
|
||||
}
|
||||
let dir = self.dir.clone().ok_or("no folder is set for the SAM 3 model")?;
|
||||
if self.installed() {
|
||||
return Ok(dir);
|
||||
}
|
||||
let d = self.download_status();
|
||||
if d.running {
|
||||
let pct = d.done.saturating_mul(100).checked_div(d.total).unwrap_or(0);
|
||||
return Err(format!("{NOT_INSTALLED} yet: it is downloading ({pct} %)."));
|
||||
}
|
||||
Err(format!(
|
||||
"{NOT_INSTALLED}. Download it (about {:.1} GB, {LICENSE_NAME}) when LightCraft offers it, with `segment.model.download {{\"acknowledged\": true}}`, or put model.safetensors, vocab.json and merges.txt in {}.",
|
||||
MODEL_BYTES as f64 / 1e9,
|
||||
dir.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// Start downloading the model (on a background thread; see `segment.model.download`).
|
||||
/// `Ok(false)` when it is installed or downloading already.
|
||||
pub fn start_download(&self) -> Result<bool, String> {
|
||||
if !Self::AVAILABLE {
|
||||
return Err("AI masks are not available in this build".into());
|
||||
}
|
||||
let dir = self.dir.clone().ok_or("no folder is set for the SAM 3 model")?;
|
||||
if self.installed() {
|
||||
return Ok(false);
|
||||
}
|
||||
#[cfg(feature = "sam")]
|
||||
{
|
||||
let mirrors = self.mirrors();
|
||||
if mirrors.is_empty() {
|
||||
return Err(lightcraft_segment::fetch::no_mirrors_message());
|
||||
}
|
||||
self.download.start(lightcraft_segment::fetch::SAM3_FILES, mirrors, dir, lightcraft_segment::fetch::options())
|
||||
}
|
||||
#[cfg(not(feature = "sam"))]
|
||||
{
|
||||
let _ = dir;
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop a running download (its partial files stay, to resume). False when none runs.
|
||||
pub fn cancel_download(&self) -> bool {
|
||||
#[cfg(feature = "sam")]
|
||||
return self.download.cancel();
|
||||
#[cfg(not(feature = "sam"))]
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Mix `v` into a 64-bit hash.
|
||||
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
|
||||
fn mix(h: u64, v: u64) -> u64 {
|
||||
(h ^ v).wrapping_mul(0x0100_0000_01b3).rotate_left(29)
|
||||
}
|
||||
|
||||
/// Objects covering more than this much of the photo gain little from a zoomed pass.
|
||||
const DETAIL_MAX_AREA: f64 = 0.4;
|
||||
|
||||
/// The part of the photo to zoom into for `seg`'s selection: its bounding box with a margin of
|
||||
/// a quarter of its size, at least 12 % of the long edge, at most 2:1, inside the photo
|
||||
/// (`aspect` = width / height). `None` when nothing is selected or it is most of the photo.
|
||||
pub fn detail_region(seg: &SegMask, aspect: f64) -> Option<[f64; 4]> {
|
||||
let logits = seg.logits()?;
|
||||
let r = seg.bounds()?;
|
||||
let side = seg.side as usize;
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0, 0);
|
||||
for (i, l) in logits.iter().enumerate() {
|
||||
if *l > 0.0 {
|
||||
let (x, y) = (i % side, i / side);
|
||||
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1));
|
||||
}
|
||||
}
|
||||
if x0 == usize::MAX {
|
||||
return None;
|
||||
}
|
||||
let (rw, rh) = (r[2] - r[0], r[3] - r[1]);
|
||||
let nx = |c: usize| r[0] + c as f64 / side as f64 * rw;
|
||||
let ny = |c: usize| r[1] + c as f64 / side as f64 * rh;
|
||||
let (bx0, by0, bx1, by1) = (nx(x0), ny(y0), nx(x1), ny(y1));
|
||||
if (bx1 - bx0) * (by1 - by0) > DETAIL_MAX_AREA {
|
||||
return None;
|
||||
}
|
||||
// in units of the photo's height: x × aspect
|
||||
let a = if aspect.is_finite() { aspect.clamp(0.1, 10.0) } else { 1.0 };
|
||||
let (cx, cy) = ((bx0 + bx1) / 2.0 * a, (by0 + by1) / 2.0);
|
||||
let size = ((bx1 - bx0) * a).max(by1 - by0);
|
||||
let m = 0.25 * size;
|
||||
let mut w = ((bx1 - bx0) * a + 2.0 * m).max(0.12 * a.max(1.0));
|
||||
let mut h = ((by1 - by0) + 2.0 * m).max(0.12 * a.max(1.0));
|
||||
w = w.max(h / 2.0).min(a);
|
||||
h = h.max(w / 2.0).min(1.0);
|
||||
let x = (cx - w / 2.0).clamp(0.0, (a - w).max(0.0));
|
||||
let y = (cy - h / 2.0).clamp(0.0, (1.0 - h).max(0.0));
|
||||
let out = [x / a, y, (x + w) / a, y + h];
|
||||
// a region nearly the whole photo isn't worth a second pass
|
||||
((out[2] - out[0]) * (out[3] - out[1]) < 0.8).then_some(out)
|
||||
}
|
||||
|
||||
/// `shape` without its detail patches (what a detail pass was computed from)
|
||||
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
|
||||
fn strip_detail(mut shape: MaskShape) -> MaskShape {
|
||||
// (nor its edge setting: moving the slider meanwhile keeps the pass)
|
||||
if let MaskShape::Object { detail, edge, .. } | MaskShape::Prompt { detail, edge, .. } = &mut shape {
|
||||
detail.clear();
|
||||
*edge = 0.0;
|
||||
}
|
||||
shape
|
||||
}
|
||||
|
||||
fn clicks_of(include: &[Point], exclude: &[Point]) -> Vec<Click> {
|
||||
include.iter().map(|p| Click { at: *p, include: true }).chain(exclude.iter().map(|p| Click { at: *p, include: false })).collect()
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The key and settings of photo `id` as the model sees it: its look without masks, uncropped.
|
||||
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
|
||||
fn segment_key(&self, id: PhotoId) -> Option<(u64, lightcraft_develop::DevelopSettings)> {
|
||||
let p = self.catalog.photo(id)?;
|
||||
let mut d = (*p.develop).clone();
|
||||
d.masks.clear();
|
||||
let content = crate::media::content_key(p);
|
||||
let mut h = mix(0xcbf2_9ce4_8422_2325, d.hash64());
|
||||
h = mix(h, id.0);
|
||||
for b in content.bytes() {
|
||||
h = mix(h, u64::from(b));
|
||||
}
|
||||
Some((h, d))
|
||||
}
|
||||
|
||||
/// Clicks (in normalized photo coordinates) for the model.
|
||||
#[cfg(feature = "sam")]
|
||||
fn model_clicks(clicks: &[Click]) -> Vec<lightcraft_segment::Click> {
|
||||
clicks.iter().map(|c| lightcraft_segment::Click { x: c.at.x as f32, y: c.at.y as f32, positive: c.include }).collect()
|
||||
}
|
||||
|
||||
/// A worker request about photo `id` (the render job for its model input goes along; the
|
||||
/// worker runs it only when its cache doesn't hold the photo). `region` renders that part
|
||||
/// of the photo, from a source sharp enough for it (detail passes).
|
||||
#[cfg(feature = "sam")]
|
||||
fn segment_job(
|
||||
&mut self,
|
||||
id: PhotoId,
|
||||
kind: worker::Kind,
|
||||
tag: Tag,
|
||||
reply: std::sync::mpsc::Sender<worker::Outcome>,
|
||||
region: Option<[f64; 4]>,
|
||||
) -> Result<worker::Job, String> {
|
||||
let dir = self.segmenter.model_dir()?;
|
||||
let (key, mut d) = self.segment_key(id).ok_or("no such photo")?;
|
||||
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
|
||||
let missing = match &p.source {
|
||||
lightcraft_catalog::Source::File { path } if !std::path::Path::new(path).exists() => Some(path.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let render = match region {
|
||||
None => self.preview_job(id, INPUT_EDGE, INPUT_EDGE, false, &d).ok_or("no such photo")?,
|
||||
Some(region) => {
|
||||
d.crop.geometry =
|
||||
lightcraft_geom::CropGeometry { rect: lightcraft_geom::Rect::new(region[0], region[1], region[2], region[3]), angle: 0.0 };
|
||||
d.crop.flip_h = false;
|
||||
d.crop.flip_v = false;
|
||||
d.geometry.constrain_crop = false;
|
||||
let mut job = self.preview_job(id, INPUT_EDGE, INPUT_EDGE, true, &d).ok_or("no such photo")?;
|
||||
let frac = (region[2] - region[0]).max(region[3] - region[1]).max(0.01);
|
||||
job.level = crate::media::SourceLevel::for_size((INPUT_EDGE as f64 / frac) as usize);
|
||||
job.source = self.media.source_ref(&p, job.level);
|
||||
job
|
||||
}
|
||||
};
|
||||
Ok(worker::Job { dir, key: if region.is_some() { 0 } else { key }, render: Some(render), missing, kind, tag, reply })
|
||||
}
|
||||
|
||||
/// Send a request and wait for its result (CLI, MCP, tests; or when not in background mode).
|
||||
#[cfg(feature = "sam")]
|
||||
fn segment_wait(&mut self, id: PhotoId, kind: worker::Kind, region: Option<[f64; 4]>) -> Result<Option<SegMask>, String> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let job = self.segment_job(id, kind, Tag::Wait, tx, region)?;
|
||||
self.segmenter.worker.submit(job)?;
|
||||
match rx.recv_timeout(WAIT) {
|
||||
Ok(o) => o.result,
|
||||
Err(_) => Err("the AI model took too long; try again".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a request whose result [`Session::segment_poll`] applies.
|
||||
#[cfg(feature = "sam")]
|
||||
fn segment_queue(&mut self, id: PhotoId, kind: worker::Kind, tag: Tag, region: Option<[f64; 4]>) -> Result<(), String> {
|
||||
let reply = self.segmenter.results.0.clone();
|
||||
let job = self.segment_job(id, kind, tag, reply, region)?;
|
||||
self.segmenter.worker.submit(job)
|
||||
}
|
||||
|
||||
/// Start preparing photo `id` for AI masks (load the model, encode the image), so the first
|
||||
/// click is fast. In background mode it returns at once.
|
||||
pub fn segment_prepare(&mut self, id: PhotoId) -> Result<(), String> {
|
||||
self.segmenter.model_dir()?;
|
||||
#[cfg(feature = "sam")]
|
||||
{
|
||||
if self.segmenter.background {
|
||||
return self.segment_queue(id, worker::Kind::Prepare, Tag::Prepare, None);
|
||||
}
|
||||
self.segment_wait(id, worker::Kind::Prepare, None)?;
|
||||
}
|
||||
#[cfg(not(feature = "sam"))]
|
||||
let _ = id;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Segment the object `clicks` pick on photo `id`, waiting for the model.
|
||||
pub fn segment_clicks(&mut self, id: PhotoId, clicks: &[Click]) -> Result<SegMask, String> {
|
||||
self.segmenter.model_dir()?;
|
||||
if !clicks.iter().any(|c| c.include) {
|
||||
return Err("click the object to include it first".into());
|
||||
}
|
||||
#[cfg(feature = "sam")]
|
||||
{
|
||||
self.segment_wait(id, worker::Kind::Clicks(Self::model_clicks(clicks)), None)?.ok_or_else(|| "nothing was selected".to_string())
|
||||
}
|
||||
#[cfg(not(feature = "sam"))]
|
||||
{
|
||||
let _ = id;
|
||||
Err("AI masks are not available in this build".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Segment everything `text` describes on photo `id`, waiting for the model; `Ok(None)`
|
||||
/// when nothing matches.
|
||||
pub fn segment_text(&mut self, id: PhotoId, text: &str) -> Result<Option<SegMask>, String> {
|
||||
self.segmenter.model_dir()?;
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Err("describe what to select".into());
|
||||
}
|
||||
#[cfg(feature = "sam")]
|
||||
{
|
||||
self.segment_wait(id, worker::Kind::Text(text.to_string()), None)
|
||||
}
|
||||
#[cfg(not(feature = "sam"))]
|
||||
{
|
||||
let _ = id;
|
||||
Err("AI masks are not available in this build".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Background mode: queue Object clicks for component `comp` of mask `mask`; the result
|
||||
/// (clicks and segmentation, one undo step) is applied by [`Session::segment_poll`].
|
||||
pub(crate) fn segment_clicks_later(&mut self, id: PhotoId, mask: u32, comp: usize, hint: Vec<Point>, exclude: Vec<Point>) -> Result<(), String> {
|
||||
self.segmenter.model_dir()?;
|
||||
if hint.is_empty() {
|
||||
return Err("click the object to include it first".into());
|
||||
}
|
||||
#[cfg(feature = "sam")]
|
||||
{
|
||||
let clicks = Self::model_clicks(&clicks_of(&hint, &exclude));
|
||||
let tag = Tag::Clicks { photo: id, mask, comp, hint: hint.clone(), exclude: exclude.clone() };
|
||||
self.segment_queue(id, worker::Kind::Clicks(clicks), tag, None)?;
|
||||
self.segmenter.pending = Some(PendingClicks { photo: id, mask, comp, hint, exclude });
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(feature = "sam"))]
|
||||
{
|
||||
let _ = (id, mask, comp, exclude);
|
||||
Err("AI masks are not available in this build".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Background mode: queue a Describe selection; the mask (or component, combined by `op`
|
||||
/// into mask `mask`) is created by [`Session::segment_poll`] when something matches.
|
||||
pub(crate) fn segment_text_later(&mut self, id: PhotoId, mask: Option<u32>, op: &str, name: Option<String>, text: &str) -> Result<(), String> {
|
||||
self.segmenter.model_dir()?;
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Err("describe what to select".into());
|
||||
}
|
||||
#[cfg(feature = "sam")]
|
||||
{
|
||||
let tag = Tag::NewPrompt { photo: id, mask, op: op.to_string(), name, text: text.to_string() };
|
||||
self.segment_queue(id, worker::Kind::Text(text.to_string()), tag, None)
|
||||
}
|
||||
#[cfg(not(feature = "sam"))]
|
||||
{
|
||||
let _ = (id, mask, op, name);
|
||||
Err("AI masks are not available in this build".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a zoomed-in pass for component `comp` of mask `mask` on photo `id` (an Object or
|
||||
/// Describe selection): the photo around the selection is rendered at a higher resolution
|
||||
/// and segmented again. In background mode [`Session::segment_poll`] applies the result;
|
||||
/// otherwise it is applied before this returns. `Ok(false)` when there is nothing to
|
||||
/// refine (no selection yet, or it is most of the photo) or a pass is running already.
|
||||
pub fn segment_detail(&mut self, id: PhotoId, mask: u32, comp: usize) -> Result<bool, String> {
|
||||
self.segmenter.model_dir()?;
|
||||
let d = self.develop_of(id).ok_or("no such photo")?;
|
||||
let shape = d.masks.iter().find(|m| m.id == mask).and_then(|m| m.components.get(comp)).map(|c| c.shape.clone()).ok_or("no such mask")?;
|
||||
let (seg, prompt) = match &shape {
|
||||
// (a document with absurdly many clicks isn't run through the model)
|
||||
MaskShape::Object { hint, exclude, seg: Some(seg), .. } if !hint.is_empty() && hint.len() + exclude.len() <= MAX_CLICKS => {
|
||||
(seg.clone(), Prompt::Clicks(clicks_of(hint, exclude)))
|
||||
}
|
||||
MaskShape::Prompt { text, seg: Some(seg), .. } => (seg.clone(), Prompt::Text(text.clone())),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
let p = self.catalog.photo(id).ok_or("no such photo")?;
|
||||
let (pw, ph) = (f64::from(p.width.max(1)), f64::from(p.height.max(1)));
|
||||
let aspect = if d.orientation.swaps_axes() { ph / pw } else { pw / ph };
|
||||
let Some(region) = detail_region(&seg, aspect) else { return Ok(false) };
|
||||
#[cfg(feature = "sam")]
|
||||
{
|
||||
let kind = worker::Kind::Detail { prompt, region };
|
||||
let tag = Tag::Detail { photo: id, mask, comp, shape: strip_detail(shape) };
|
||||
if self.segmenter.background {
|
||||
if self.segmenter.detail_busy() {
|
||||
return Ok(false);
|
||||
}
|
||||
self.segment_queue(id, kind, tag, Some(region))?;
|
||||
} else {
|
||||
let patch = self.segment_wait(id, kind, Some(region))?;
|
||||
let outcome = worker::Outcome { tag, result: Ok(patch), superseded: false };
|
||||
let mut polled = Polled::default();
|
||||
self.apply_outcome(outcome, &mut polled);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
#[cfg(not(feature = "sam"))]
|
||||
{
|
||||
let _ = (prompt, region, mask);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply finished background requests (to masks that haven't changed since) and collect
|
||||
/// their messages. Cheap when nothing is pending: call it every frame.
|
||||
pub fn segment_poll(&mut self) -> Polled {
|
||||
#[cfg_attr(not(feature = "sam"), allow(unused_mut))]
|
||||
let mut polled = Polled { messages: std::mem::take(&mut self.segmenter.messages), ..Default::default() };
|
||||
#[cfg(feature = "sam")]
|
||||
{
|
||||
// (before draining: a worker that is idle now has sent everything)
|
||||
let idle = !self.segmenter.busy();
|
||||
let done: Vec<worker::Outcome> = self.segmenter.results.1.try_iter().collect();
|
||||
for o in done {
|
||||
self.apply_outcome(o, &mut polled);
|
||||
}
|
||||
// clicks whose request was lost (the worker restarted) aren't pending forever
|
||||
if idle {
|
||||
self.segmenter.pending = None;
|
||||
}
|
||||
}
|
||||
polled
|
||||
}
|
||||
|
||||
#[cfg(feature = "sam")]
|
||||
fn apply_outcome(&mut self, o: worker::Outcome, polled: &mut Polled) {
|
||||
if o.superseded {
|
||||
return;
|
||||
}
|
||||
match o.tag {
|
||||
Tag::Wait => {}
|
||||
Tag::Prepare => {
|
||||
if let Err(e) = o.result {
|
||||
polled.messages.push(e);
|
||||
}
|
||||
}
|
||||
Tag::Clicks { photo, mask, comp, hint, exclude } => {
|
||||
let latest = PendingClicks { photo, mask, comp, hint, exclude };
|
||||
if self.segmenter.pending.as_ref() != Some(&latest) {
|
||||
return; // newer clicks are on their way
|
||||
}
|
||||
self.segmenter.pending = None;
|
||||
let seg = match o.result {
|
||||
Ok(Some(seg)) => seg,
|
||||
Ok(None) => return,
|
||||
Err(e) => return polled.messages.push(e),
|
||||
};
|
||||
let Some(d) = self.develop_of(photo) else { return };
|
||||
let mut d = (*d).clone();
|
||||
let Some(c) = d.masks.iter_mut().find(|m| m.id == mask).and_then(|m| m.components.get_mut(comp)) else { return };
|
||||
let MaskShape::Object { edge, .. } = c.shape else { return };
|
||||
c.shape = MaskShape::Object { hint: latest.hint, exclude: latest.exclude, seg: Some(seg), detail: vec![], edge };
|
||||
match self.set_develop(photo, d, "Object Mask") {
|
||||
Ok(()) => {
|
||||
polled.changed = true;
|
||||
polled.refine = Some(mask);
|
||||
}
|
||||
Err(e) => polled.messages.push(e.to_string()),
|
||||
}
|
||||
}
|
||||
Tag::NewPrompt { photo, mask, op, name, text } => {
|
||||
let seg = match o.result {
|
||||
Ok(Some(seg)) => seg,
|
||||
Ok(None) => return polled.messages.push(format!("Nothing matching “{text}” was found in this photo.")),
|
||||
Err(e) => return polled.messages.push(e),
|
||||
};
|
||||
if self.active() != Some(photo) {
|
||||
return polled.messages.push(format!("The photo changed before “{text}” was found; try again."));
|
||||
}
|
||||
let Ok(seg) = serde_json::to_value(seg) else { return };
|
||||
let mut p = serde_json::json!({"kind": "prompt", "text": text, "seg": seg});
|
||||
if let Some(n) = name {
|
||||
p["name"] = serde_json::json!(n);
|
||||
}
|
||||
let r = match mask {
|
||||
None => self.execute("mask.add", &p),
|
||||
Some(m) => {
|
||||
if !self.develop_of(photo).is_some_and(|d| d.masks.iter().any(|x| x.id == m)) {
|
||||
return polled.messages.push("The mask was removed before the selection was found.".into());
|
||||
}
|
||||
self.active_mask = Some(m);
|
||||
p["op"] = serde_json::json!(op);
|
||||
self.execute("mask.addComponent", &p)
|
||||
}
|
||||
};
|
||||
match r {
|
||||
Ok(_) => {
|
||||
polled.changed = true;
|
||||
polled.refine = self.active_mask;
|
||||
}
|
||||
Err(e) => polled.messages.push(e.to_string()),
|
||||
}
|
||||
}
|
||||
Tag::Detail { photo, mask, comp, shape } => {
|
||||
let patch = match o.result {
|
||||
Ok(Some(p)) => p,
|
||||
Ok(None) => return,
|
||||
Err(e) => return log::warn!("SAM 3 detail pass: {e}"),
|
||||
};
|
||||
let Some(d) = self.develop_of(photo) else { return };
|
||||
let mut d = (*d).clone();
|
||||
let Some(c) = d.masks.iter_mut().find(|m| m.id == mask).and_then(|m| m.components.get_mut(comp)) else { return };
|
||||
if strip_detail(c.shape.clone()) != shape {
|
||||
return; // clicked again meanwhile: a newer pass will follow
|
||||
}
|
||||
match &mut c.shape {
|
||||
MaskShape::Object { detail, .. } | MaskShape::Prompt { detail, .. } => *detail = vec![patch],
|
||||
_ => return,
|
||||
}
|
||||
match self.set_develop(photo, d, "AI Mask Detail") {
|
||||
Ok(()) => polled.changed = true,
|
||||
Err(e) => log::warn!("AI mask detail: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn seg_box(side: usize, x0: usize, y0: usize, x1: usize, y1: usize) -> SegMask {
|
||||
let l: Vec<f32> =
|
||||
(0..side * side).map(|i| if (x0..x1).contains(&(i % side)) && (y0..y1).contains(&(i / side)) { 8.0 } else { -8.0 }).collect();
|
||||
SegMask::from_logits(side, &l)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_region_frames_the_selection_with_a_margin() {
|
||||
// a small object in the middle of a square photo
|
||||
let r = detail_region(&seg_box(100, 40, 45, 50, 55), 1.0).unwrap();
|
||||
assert!(r[0] < 0.4 && r[2] > 0.5 && r[1] < 0.45 && r[3] > 0.55, "contains it: {r:?}");
|
||||
assert!(r[2] - r[0] < 0.25 && r[3] - r[1] < 0.25, "zoomed in: {r:?}");
|
||||
assert!(r.iter().all(|v| (0.0..=1.0).contains(v)));
|
||||
// at the corner: shifted inside the photo, not cut
|
||||
let r = detail_region(&seg_box(100, 0, 0, 5, 5), 1.5).unwrap();
|
||||
assert!(r[0] >= 0.0 && r[1] >= 0.0 && r[0] < 1e-9 && r[1] < 1e-9);
|
||||
// a thin object stays at most 2:1 (in photo pixels, 1.5:1 photo)
|
||||
let r = detail_region(&seg_box(100, 10, 50, 90, 52), 1.5).unwrap();
|
||||
let (w, h) = ((r[2] - r[0]) * 1.5, r[3] - r[1]);
|
||||
assert!(w / h <= 2.0 + 1e-9, "{w} × {h}");
|
||||
// a hostile aspect ratio stays sane
|
||||
assert!(detail_region(&seg_box(100, 40, 45, 50, 55), f64::NAN).is_some_and(|r| r.iter().all(|v| v.is_finite())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_region_for_nothing_or_most_of_the_photo() {
|
||||
assert!(detail_region(&seg_box(50, 0, 0, 0, 0), 1.0).is_none());
|
||||
assert!(detail_region(&seg_box(50, 2, 2, 48, 48), 1.0).is_none());
|
||||
let damaged = SegMask { side: 50, data: "?".into(), rect: None };
|
||||
assert!(detail_region(&damaged, 1.0).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
//! The SAM 3 worker thread: it owns the model and the last encoded photo, and runs every
|
||||
//! request (load, render the model input, encode, clicks, descriptions, detail passes) in
|
||||
//! turn. The session only sends jobs and receives outcomes over channels, so no thread ever
|
||||
//! waits on a lock the model holds, and the UI thread never runs the model.
|
||||
//!
|
||||
//! Every job runs under `catch_unwind`: a panic inside candle becomes an error outcome, drops
|
||||
//! the (possibly inconsistent) model and leaves the worker ready for the next job. The busy
|
||||
//! counters are decremented by drop guards, so they can't stay stuck. The model is unloaded
|
||||
//! after [`IDLE_UNLOAD`] without requests (it holds several GB).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{self, RecvTimeoutError};
|
||||
use std::time::Duration;
|
||||
|
||||
use lightcraft_develop::SegMask;
|
||||
use lightcraft_segment::{Encoded, Sam3};
|
||||
|
||||
use super::{Input, Prompt, Tag};
|
||||
use crate::media::RenderJob;
|
||||
|
||||
/// Unload the model after this long without a request.
|
||||
pub const IDLE_UNLOAD: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
/// What to compute.
|
||||
pub enum Kind {
|
||||
/// Load the model and encode the photo (so the first click is fast).
|
||||
Prepare,
|
||||
Clicks(Vec<lightcraft_segment::Click>),
|
||||
Text(String),
|
||||
/// A zoomed-in pass over `region` (normalized) of the photo, rendered by the job's `render`.
|
||||
Detail {
|
||||
prompt: Prompt,
|
||||
region: [f64; 4],
|
||||
},
|
||||
/// Panics (tests of the worker's recovery).
|
||||
#[cfg(test)]
|
||||
Panic,
|
||||
}
|
||||
|
||||
/// One request.
|
||||
pub struct Job {
|
||||
/// The model folder.
|
||||
pub dir: PathBuf,
|
||||
/// Which photo and look the request is about (the encode cache's key).
|
||||
pub key: u64,
|
||||
/// Renders the model input when the cache doesn't hold `key` (always for a detail pass).
|
||||
pub render: Option<RenderJob>,
|
||||
/// The photo's file, when it is missing on disk (for a clearer error if rendering fails).
|
||||
pub missing: Option<String>,
|
||||
pub kind: Kind,
|
||||
pub tag: Tag,
|
||||
pub reply: mpsc::Sender<Outcome>,
|
||||
}
|
||||
|
||||
/// A finished request: the segmentation (`None`: nothing selected / nothing to report).
|
||||
pub struct Outcome {
|
||||
pub tag: Tag,
|
||||
pub result: Result<Option<SegMask>, String>,
|
||||
/// A newer request for the same selection came in; this one was skipped.
|
||||
pub superseded: bool,
|
||||
}
|
||||
|
||||
/// Counters the session reads without locking.
|
||||
#[derive(Default)]
|
||||
pub struct Shared {
|
||||
/// Requests other than detail passes, queued or running.
|
||||
pub pending: AtomicUsize,
|
||||
/// Detail passes queued or running.
|
||||
pub detail: AtomicUsize,
|
||||
/// The model is in memory.
|
||||
pub loaded: AtomicBool,
|
||||
/// Loading the model or encoding a photo right now (the slow parts).
|
||||
pub encoding: AtomicBool,
|
||||
}
|
||||
|
||||
/// Decrements a counter when dropped (also when unwinding).
|
||||
struct Done<'a>(&'a AtomicUsize);
|
||||
|
||||
impl Drop for Done<'_> {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.try_update(Ordering::SeqCst, Ordering::SeqCst, |n| Some(n.saturating_sub(1)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets a flag while alive.
|
||||
struct Flag<'a>(&'a AtomicBool);
|
||||
|
||||
impl<'a> Flag<'a> {
|
||||
fn raise(f: &'a AtomicBool) -> Self {
|
||||
f.store(true, Ordering::SeqCst);
|
||||
Flag(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Flag<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// The session's handle on the worker thread (started on the first request, restarted if it
|
||||
/// ever ends).
|
||||
#[derive(Default)]
|
||||
pub struct Worker {
|
||||
tx: Option<mpsc::Sender<Job>>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
pub shared: Arc<Shared>,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
fn counter(&self, job: &Job) -> &AtomicUsize {
|
||||
if matches!(job.kind, Kind::Detail { .. }) { &self.shared.detail } else { &self.shared.pending }
|
||||
}
|
||||
|
||||
/// Whether the thread has ended (it never should); its counters are then meaningless.
|
||||
fn dead(&self) -> bool {
|
||||
self.thread.as_ref().is_some_and(|t| t.is_finished())
|
||||
}
|
||||
|
||||
pub fn pending(&self) -> usize {
|
||||
if self.dead() { 0 } else { self.shared.pending.load(Ordering::SeqCst) }
|
||||
}
|
||||
|
||||
pub fn detail(&self) -> usize {
|
||||
if self.dead() { 0 } else { self.shared.detail.load(Ordering::SeqCst) }
|
||||
}
|
||||
|
||||
pub fn encoding(&self) -> bool {
|
||||
!self.dead() && self.shared.encoding.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn loaded(&self) -> bool {
|
||||
!self.dead() && self.shared.loaded.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Queue `job`; returns at once.
|
||||
pub fn submit(&mut self, job: Job) -> Result<(), String> {
|
||||
if self.dead() {
|
||||
// a thread that ended took its counters' meaning with it
|
||||
self.tx = None;
|
||||
self.thread = None;
|
||||
self.shared = Arc::new(Shared::default());
|
||||
}
|
||||
self.counter(&job).fetch_add(1, Ordering::SeqCst);
|
||||
let job = match &self.tx {
|
||||
Some(tx) => match tx.send(job) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(mpsc::SendError(job)) => job,
|
||||
},
|
||||
None => job,
|
||||
};
|
||||
// (re)start the thread
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let shared = self.shared.clone();
|
||||
match std::thread::Builder::new().name("sam3".into()).spawn(move || run(&rx, &shared, IDLE_UNLOAD)) {
|
||||
Ok(t) => {
|
||||
self.thread = Some(t);
|
||||
self.tx = Some(tx.clone());
|
||||
tx.send(job).map_err(|_| "the AI mask worker stopped".to_string())
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = self.counter(&job).try_update(Ordering::SeqCst, Ordering::SeqCst, |n| Some(n.saturating_sub(1)));
|
||||
Err(format!("could not start the AI mask worker: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The model and the last encoded photo.
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
model: Option<(PathBuf, Sam3)>,
|
||||
cache: Option<(u64, Encoded)>,
|
||||
}
|
||||
|
||||
/// Whether `later` makes `job` pointless (it asks for the same selection again).
|
||||
fn supersedes(later: &Job, job: &Job) -> bool {
|
||||
match (&later.tag, &job.tag) {
|
||||
(Tag::Prepare, Tag::Prepare) => later.key == job.key,
|
||||
(Tag::Clicks { photo: a, mask: am, comp: ac, .. }, Tag::Clicks { photo: b, mask: bm, comp: bc, .. }) => (a, am, ac) == (b, bm, bc),
|
||||
(Tag::Detail { photo: a, mask: am, comp: ac, .. }, Tag::Detail { photo: b, mask: bm, comp: bc, .. }) => (a, am, ac) == (b, bm, bc),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration) {
|
||||
let mut state = State::default();
|
||||
let mut queue: VecDeque<Job> = VecDeque::new();
|
||||
loop {
|
||||
if queue.is_empty() {
|
||||
match rx.recv_timeout(idle) {
|
||||
Ok(j) => queue.push_back(j),
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
if state.model.is_some() {
|
||||
state = State::default();
|
||||
shared.loaded.store(false, Ordering::SeqCst);
|
||||
log::info!("SAM 3 unloaded after {} minutes without use", idle.as_secs() / 60);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => return,
|
||||
}
|
||||
}
|
||||
while let Ok(j) = rx.try_recv() {
|
||||
queue.push_back(j);
|
||||
}
|
||||
let Some(mut job) = queue.pop_front() else { continue };
|
||||
let is_detail = matches!(job.kind, Kind::Detail { .. });
|
||||
let _done = Done(if is_detail { &shared.detail } else { &shared.pending });
|
||||
if queue.iter().any(|later| supersedes(later, &job)) {
|
||||
let _ = job.reply.send(Outcome { tag: job.tag, result: Ok(None), superseded: true });
|
||||
continue;
|
||||
}
|
||||
let result = match std::panic::catch_unwind(AssertUnwindSafe(|| run_job(&mut state, shared, &mut job))) {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
let why = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
log::error!("SAM 3 panicked: {why}");
|
||||
// don't trust what the model kept: load it again next time
|
||||
state = State::default();
|
||||
Err("the AI model failed unexpectedly (it will be reloaded on the next try)".to_string())
|
||||
}
|
||||
};
|
||||
if result.is_err() && !is_detail {
|
||||
state.cache = None;
|
||||
}
|
||||
shared.loaded.store(state.model.is_some(), Ordering::SeqCst);
|
||||
let _ = job.reply.send(Outcome { tag: job.tag, result, superseded: false });
|
||||
}
|
||||
}
|
||||
|
||||
/// The model for `dir`, loading it when needed.
|
||||
fn model<'a>(state: &'a mut State, shared: &Shared, dir: &std::path::Path) -> Result<&'a mut Sam3, String> {
|
||||
if state.model.as_ref().is_none_or(|(d, _)| d != dir) {
|
||||
state.model = None;
|
||||
state.cache = None;
|
||||
let _busy = Flag::raise(&shared.encoding);
|
||||
let t = web_time::Instant::now();
|
||||
let m = Sam3::load(dir).map_err(|e| e.to_string())?;
|
||||
log::info!("SAM 3 loaded in {:?}", t.elapsed());
|
||||
state.model = Some((dir.to_path_buf(), m));
|
||||
shared.loaded.store(true, Ordering::SeqCst);
|
||||
}
|
||||
state.model.as_mut().map(|(_, m)| m).ok_or_else(|| "the model did not load".to_string())
|
||||
}
|
||||
|
||||
/// Render the model input.
|
||||
fn render(job: &mut Job) -> Result<Input, String> {
|
||||
let r = job.render.take().ok_or("the photo is not available")?.run();
|
||||
let img = match r.rendered {
|
||||
Ok(r) => r.image,
|
||||
Err(e) => {
|
||||
return Err(match &job.missing {
|
||||
Some(path) => format!(
|
||||
"This photo's file is missing (moved or deleted), so it can't be analyzed for AI masks: {path}. Library ▸ Find Missing Photos can relink it."
|
||||
),
|
||||
None => format!("couldn't render the photo for AI masks: {e}"),
|
||||
});
|
||||
}
|
||||
};
|
||||
let rgb = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
Ok(Input { rgb, w: img.width, h: img.height })
|
||||
}
|
||||
|
||||
/// The model and the encoded photo `job.key` (rendering and encoding it when needed).
|
||||
fn encoded<'a>(state: &'a mut State, shared: &Shared, job: &mut Job) -> Result<(&'a mut Sam3, &'a mut Encoded), String> {
|
||||
model(state, shared, &job.dir)?;
|
||||
if state.cache.as_ref().is_none_or(|(k, _)| *k != job.key) {
|
||||
state.cache = None;
|
||||
let input = render(job)?;
|
||||
let _busy = Flag::raise(&shared.encoding);
|
||||
let m = state.model.as_mut().map(|(_, m)| m).ok_or("the model did not load")?;
|
||||
let t = web_time::Instant::now();
|
||||
let mut enc = m.encode(&input.rgb, input.w, input.h).map_err(|e| e.to_string())?;
|
||||
m.prepare_clicks(&mut enc).map_err(|e| e.to_string())?;
|
||||
log::info!("SAM 3 encoded {}×{} in {:?}", input.w, input.h, t.elapsed());
|
||||
state.cache = Some((job.key, enc));
|
||||
}
|
||||
let State { model, cache } = state;
|
||||
match (model.as_mut(), cache.as_mut()) {
|
||||
(Some((_, m)), Some((_, e))) => Ok((m, e)),
|
||||
_ => Err("the model is not ready".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every phrase of `text` ("car, road"), merged (the per-pixel maximum); `None` when nothing
|
||||
/// matches.
|
||||
fn text_logits(model: &mut Sam3, enc: &mut Encoded, text: &str) -> Result<Option<Vec<f32>>, String> {
|
||||
let mut merged: Option<Vec<f32>> = None;
|
||||
for phrase in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) {
|
||||
if let Some(p) = model.segment_text(enc, phrase, 0.5).map_err(|e| e.to_string())? {
|
||||
merged = Some(match merged {
|
||||
None => p.logits,
|
||||
Some(m) => m.iter().zip(&p.logits).map(|(a, b)| a.max(*b)).collect(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
fn run_job(state: &mut State, shared: &Shared, job: &mut Job) -> Result<Option<SegMask>, String> {
|
||||
let side = lightcraft_segment::MASK_SIDE;
|
||||
match std::mem::replace(&mut job.kind, Kind::Prepare) {
|
||||
Kind::Prepare => {
|
||||
encoded(state, shared, job)?;
|
||||
Ok(None)
|
||||
}
|
||||
Kind::Clicks(clicks) => {
|
||||
let (m, enc) = encoded(state, shared, job)?;
|
||||
let p = m.segment_clicks(enc, &clicks).map_err(|e| e.to_string())?;
|
||||
Ok(Some(SegMask::from_logits(side, &p.logits)))
|
||||
}
|
||||
Kind::Text(text) => {
|
||||
let (m, enc) = encoded(state, shared, job)?;
|
||||
Ok(text_logits(m, enc, &text)?.map(|l| SegMask::from_logits(side, &l)))
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::panic)]
|
||||
Kind::Panic => panic!("a test panic"),
|
||||
Kind::Detail { prompt, region } => {
|
||||
let input = render(job)?;
|
||||
let m = model(state, shared, &job.dir)?;
|
||||
let t = web_time::Instant::now();
|
||||
let mut enc = m.encode(&input.rgb, input.w, input.h).map_err(|e| e.to_string())?;
|
||||
let (rw, rh) = (region[2] - region[0], region[3] - region[1]);
|
||||
if !(rw > 0.0 && rh > 0.0) {
|
||||
return Ok(None);
|
||||
}
|
||||
let logits = match prompt {
|
||||
Prompt::Clicks(clicks) => {
|
||||
let inside: Vec<lightcraft_segment::Click> = clicks
|
||||
.iter()
|
||||
.map(|c| ((c.at.x - region[0]) / rw, (c.at.y - region[1]) / rh, c.include))
|
||||
.filter(|(x, y, _)| (0.0..=1.0).contains(x) && (0.0..=1.0).contains(y))
|
||||
.map(|(x, y, positive)| lightcraft_segment::Click { x: x as f32, y: y as f32, positive })
|
||||
.collect();
|
||||
if !inside.iter().any(|c| c.positive) {
|
||||
return Ok(None);
|
||||
}
|
||||
Some(m.segment_clicks(&mut enc, &inside).map_err(|e| e.to_string())?.logits)
|
||||
}
|
||||
Prompt::Text(text) => text_logits(m, &mut enc, &text)?,
|
||||
};
|
||||
log::info!("SAM 3 detail pass ({}×{}) in {:?}", input.w, input.h, t.elapsed());
|
||||
Ok(logits.map(|l| SegMask::from_logits_in(side, &l, region)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn job(tag: Tag, reply: &mpsc::Sender<Outcome>) -> Job {
|
||||
// an empty folder: loading fails with an error (no model files)
|
||||
let dir = std::env::temp_dir().join(format!("lc-sam3-worker-none-{}", std::process::id()));
|
||||
Job { dir, key: 1, render: None, missing: None, kind: Kind::Prepare, tag, reply: reply.clone() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_come_back_and_the_counters_return_to_zero() {
|
||||
let mut w = Worker::default();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
for _ in 0..3 {
|
||||
w.submit(job(Tag::Wait, &tx)).unwrap();
|
||||
}
|
||||
for _ in 0..3 {
|
||||
let o = rx.recv_timeout(Duration::from_secs(30)).unwrap();
|
||||
assert!(o.result.unwrap_err().contains("not found"));
|
||||
}
|
||||
let t = web_time::Instant::now();
|
||||
while w.pending() > 0 && t.elapsed() < Duration::from_secs(5) {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert_eq!(w.pending(), 0);
|
||||
assert!(!w.encoding() && !w.loaded());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_in_a_job_is_an_error_and_the_worker_carries_on() {
|
||||
let shared = Shared::default();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let (jtx, jrx) = mpsc::channel();
|
||||
let mut j = job(Tag::Wait, &tx);
|
||||
j.kind = Kind::Panic;
|
||||
jtx.send(j).unwrap();
|
||||
jtx.send(job(Tag::Wait, &tx)).unwrap();
|
||||
shared.pending.store(2, Ordering::SeqCst);
|
||||
drop(jtx);
|
||||
run(&jrx, &shared, Duration::from_secs(60));
|
||||
let first = rx.recv().unwrap();
|
||||
assert!(first.result.unwrap_err().contains("failed unexpectedly"));
|
||||
let second = rx.recv().unwrap();
|
||||
assert!(second.result.is_err(), "the next job still runs");
|
||||
assert_eq!(shared.pending.load(Ordering::SeqCst), 0, "counters reset");
|
||||
assert!(!shared.encoding.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_request_for_the_same_selection_supersedes_a_queued_one() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let (jtx, jrx) = mpsc::channel();
|
||||
let clicks = |n: usize| Tag::Clicks {
|
||||
photo: lightcraft_catalog::PhotoId(1),
|
||||
mask: 1,
|
||||
comp: 0,
|
||||
hint: vec![lightcraft_geom::Point::new(0.5, 0.5); n],
|
||||
exclude: vec![],
|
||||
};
|
||||
for n in 1..=3 {
|
||||
jtx.send(job(clicks(n), &tx)).unwrap();
|
||||
}
|
||||
drop(jtx);
|
||||
let shared = Shared::default();
|
||||
shared.pending.store(3, Ordering::SeqCst);
|
||||
run(&jrx, &shared, Duration::from_secs(60));
|
||||
let out: Vec<Outcome> = rx.try_iter().collect();
|
||||
assert_eq!(out.len(), 3);
|
||||
assert!(out[0].superseded && out[1].superseded && !out[2].superseded);
|
||||
assert_eq!(shared.pending.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
}
|
||||
@@ -382,6 +382,61 @@ fn crop_aspect_lock_current_and_toggle() {
|
||||
assert!(active_dev(&s).crop.aspect.is_none(), "toggle unlocks");
|
||||
}
|
||||
|
||||
// Feature: dragging crop handles (issue 295)
|
||||
#[test]
|
||||
fn crop_drag_stops_at_the_image_edge_and_keeps_the_anchor() {
|
||||
let mut s = demo();
|
||||
s.execute("crop.set", &json!({"rect": [0.2, 0.2, 0.8, 0.8]})).unwrap();
|
||||
s.execute("crop.drag", &json!({"handle": "bottomRight", "from": [0.8, 0.8], "to": [1.4, 0.7]})).unwrap();
|
||||
let r = active_dev(&s).crop.geometry.rect;
|
||||
assert!((r.x0 - 0.2).abs() < 1e-6 && (r.y0 - 0.2).abs() < 1e-6, "anchor moved: {r:?}");
|
||||
assert!((r.x1 - 1.0).abs() < 1e-6 && (r.y1 - 0.7).abs() < 1e-6, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_drag_keeps_a_locked_ratio_on_every_handle() {
|
||||
let mut s = demo();
|
||||
s.execute("crop.aspect", &json!({"aspect": "16x9"})).unwrap();
|
||||
let d = active_dev(&s);
|
||||
assert!(d.crop.aspect.is_some());
|
||||
let want = d.crop.geometry.rect.aspect();
|
||||
let r = d.crop.geometry.rect;
|
||||
for (handle, to) in [("left", [r.x0 + 0.1, 0.5]), ("top", [0.5, r.y0 + 0.05]), ("bottomRight", [1.3, 1.3]), ("right", [0.9, 0.5])] {
|
||||
s.execute("crop.drag", &json!({"handle": handle, "from": [0.5, 0.5], "to": to, "start": [r.x0, r.y0, r.x1, r.y1]})).unwrap();
|
||||
let g = active_dev(&s).crop.geometry.rect;
|
||||
assert!((g.aspect() - want).abs() < 1e-6, "{handle}: {} vs {want}", g.aspect());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_drag_rejects_unknown_handles_and_bad_points() {
|
||||
let mut s = demo();
|
||||
assert!(s.execute("crop.drag", &json!({"handle": "sideways", "from": [0.5, 0.5], "to": [0.6, 0.6]})).is_err());
|
||||
assert!(s.execute("crop.drag", &json!({"handle": "move", "from": [0.5], "to": [0.6, 0.6]})).is_err());
|
||||
assert!(s.execute("crop.drag", &json!({"handle": "move", "to": [0.6, 0.6]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_aspect_rejects_degenerate_custom_ratios() {
|
||||
let mut s = demo();
|
||||
let before = active_dev(&s).crop;
|
||||
for bad in [json!([0.004, 3]), json!([3, 0]), json!([-1, 2]), json!([1000, 0.01]), json!(["a", 2]), json!([1, 2, 3])] {
|
||||
assert!(s.execute("crop.aspect", &json!({"aspect": bad.clone()})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.execute("crop.aspect", &json!({"aspect": "0x5"})).is_err());
|
||||
let after = active_dev(&s).crop;
|
||||
assert_eq!(before.geometry, after.geometry, "a rejected ratio leaves the crop alone");
|
||||
s.execute("crop.aspect", &json!({"aspect": [2.5, 1]})).unwrap();
|
||||
assert!(!active_dev(&s).crop.geometry.rect.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_drag_rejects_non_numeric_points() {
|
||||
let mut s = demo();
|
||||
assert!(s.execute("crop.drag", &json!({"handle": "move", "from": ["a", 0.5, 0.5], "to": [0.6, 0.6]})).is_err());
|
||||
assert!(s.execute("crop.drag", &json!({"handle": "move", "from": [0.5, 0.5], "to": [0.6, 0.6], "start": [0.1, "x", 0.5, 0.5]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_from_previous_and_copy_paste_metadata() {
|
||||
let mut s = demo();
|
||||
@@ -728,6 +783,40 @@ fn curve_reset_by_channel_and_whole() {
|
||||
assert!(s.execute("curve.reset", &json!({"channel": "alpha"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_masks_without_the_model_explain_themselves() {
|
||||
let mut s = demo();
|
||||
let id = s.active().unwrap();
|
||||
// an Object mask that could never be computed is refused, not left empty
|
||||
let e = s.execute("mask.add", &json!({"kind": "object"})).unwrap_err().to_string();
|
||||
assert!(e.contains("SAM 3") || e.contains("not available"), "{e}");
|
||||
assert!(!e.contains("invalid parameters"), "shown as is: {e}");
|
||||
assert!(s.develop_of(id).unwrap().masks.is_empty());
|
||||
// an empty Object component (as an older document may hold) still takes clicks
|
||||
let shape = lightcraft_develop::MaskShape::Object { hint: vec![], exclude: vec![], seg: None, detail: vec![], edge: 0.0 };
|
||||
let mut d = (*s.develop_of(id).unwrap()).clone();
|
||||
d.masks.push(lightcraft_develop::Mask {
|
||||
id: 1,
|
||||
components: vec![lightcraft_develop::MaskComponent { name: None, op: lightcraft_develop::MaskOp::Add, invert: false, shape }],
|
||||
..Default::default()
|
||||
});
|
||||
s.set_develop(id, d, "test").unwrap();
|
||||
s.active_mask = Some(1);
|
||||
// clicks and descriptions need the model: an error that says what's missing, nothing changed
|
||||
let e = s.execute("mask.objectPoint", &json!({"x": 0.5, "y": 0.5})).unwrap_err().to_string();
|
||||
assert!(e.contains("SAM 3") || e.contains("not available"), "{e}");
|
||||
let e = s.execute("mask.add", &json!({"kind": "prompt", "text": "sky"})).unwrap_err().to_string();
|
||||
assert!(e.contains("SAM 3") || e.contains("not available"), "{e}");
|
||||
let empty = std::env::temp_dir().join(format!("lc-no-sam3-{}", std::process::id()));
|
||||
s.segmenter.dir = Some(empty.clone());
|
||||
let e = s.execute("mask.objectPoint", &json!({"x": 0.5, "y": 0.5})).unwrap_err().to_string();
|
||||
assert!(e.contains(&empty.display().to_string()) || e.contains("not available"), "names the folder: {e}");
|
||||
assert_eq!(s.develop_of(id).unwrap().masks.len(), 1);
|
||||
// bad input is an error, not a crash
|
||||
assert!(s.execute("mask.objectPoint", &json!({"x": 1.5, "y": 0.5})).is_err());
|
||||
assert!(s.execute("mask.add", &json!({"kind": "prompt", "text": " "})).is_err());
|
||||
}
|
||||
|
||||
/// A People card's close-up is a square (in pixels) inside the photo, around the face, whatever the
|
||||
/// face rectangle says: corners, oversized and degenerate rectangles never leave the frame or panic.
|
||||
#[test]
|
||||
@@ -783,3 +872,53 @@ fn face_job_follows_rotate_right() {
|
||||
let (pw, ph) = ((r.x1 - r.x0) * h, (r.y1 - r.y0) * w);
|
||||
assert!((pw - ph).abs() < 1e-6 * w.max(h), "square in rotated pixels: {pw} × {ph}");
|
||||
}
|
||||
|
||||
/// Selecting an id that is not in the library is an error, and the selection stays as it was
|
||||
/// (#182: `select_photos {"ids": [9999]}` answered `selected: 1` and the session lost its photo).
|
||||
#[test]
|
||||
fn selecting_an_unknown_photo_is_an_error_and_keeps_the_selection() {
|
||||
let mut s = demo();
|
||||
let vis = s.visible_cloned();
|
||||
s.execute("library.select", &json!({"ids": [vis[0].0]})).unwrap();
|
||||
let before = s.selection.clone();
|
||||
for p in [
|
||||
json!({"ids": [9999]}),
|
||||
json!({"ids": [9999], "active": 9999}),
|
||||
json!({"ids": [vis[1].0], "active": 9999}),
|
||||
json!({"ids": [vis[1].0, 9999]}),
|
||||
json!({"ids": [9999], "mode": "add"}),
|
||||
json!({"ids": [9999], "mode": "toggle"}),
|
||||
json!({"ids": [9999], "mode": "range"}),
|
||||
] {
|
||||
let e = s.execute("library.select", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("no such photo 9999"), "{p}: {e}");
|
||||
assert_eq!(s.selection, before, "{p}");
|
||||
}
|
||||
assert_eq!(s.active(), Some(vis[0]));
|
||||
assert!(s.execute("develop.get", &json!({})).is_ok());
|
||||
// queries about an explicit id check it too
|
||||
for c in ["develop.get", "photo.inspect", "photo.allMetadata", "history.list"] {
|
||||
let e = s.execute(c, &json!({"id": 9999})).unwrap_err().to_string();
|
||||
assert!(e.contains("no such photo 9999"), "{c}: {e}");
|
||||
assert!(s.execute(c, &json!({"id": vis[1].0})).is_ok(), "{c}");
|
||||
}
|
||||
// an empty selection is still allowed
|
||||
assert_eq!(s.execute("library.select", &json!({"ids": []})).unwrap()["selected"], 0);
|
||||
}
|
||||
|
||||
/// A preset saved by another version may hold keys this one doesn't know: exporting with it keeps
|
||||
/// working (the stale keys are dropped), while a typo the caller makes is still refused (#181).
|
||||
#[test]
|
||||
fn saved_presets_with_unknown_keys_still_export() {
|
||||
use crate::export::{ExportOptions, ExportPreset};
|
||||
let mut s = demo();
|
||||
s.export_presets.push(ExportPreset {
|
||||
name: "Old".into(),
|
||||
params: json!({"format": "png", "retiredOption": 3, "watermark": {"text": "©", "retiredKey": true}}),
|
||||
});
|
||||
let p = s.export_params(&json!({"preset": "old", "quality": 80})).unwrap();
|
||||
let o = ExportOptions::from_params(&p).unwrap();
|
||||
assert_eq!(o.format, crate::export::ExportFormat::Png);
|
||||
assert_eq!(o.watermark.map(|w| w.text), Some("©".to_string()));
|
||||
assert!(ExportOptions::from_params(&s.export_params(&json!({"preset": "old", "qualty": 80})).unwrap()).is_err());
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
//! Exploring the library by folder: `library.folders` lists where the imported photos live and
|
||||
//! `library.filter {libraryFolder}` shows one folder's photos (see `lightcraft_catalog::folders`);
|
||||
//! `library.removeFolder` takes a folder's photos out of the library.
|
||||
//!
|
||||
//! * Given photos imported from two folders, an agent can list them with counts, then choose one
|
||||
//! and see exactly its photos; the filter shows as a chip that clears just that choice.
|
||||
//! * A path that is not a library folder shows nothing rather than everything.
|
||||
//! * Removing a folder from the library moves its photos to Recently Deleted (one undo step) and
|
||||
//! leaves every file where it is.
|
||||
//! * Renaming or moving a folder on disk keeps the chosen folder chosen.
|
||||
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{LibrarySource, Session, filter_chips};
|
||||
|
||||
fn source_folder(s: &mut Session, path: &str) -> serde_json::Value {
|
||||
s.execute("library.source", &json!({"kind": "libraryFolder", "path": path})).unwrap()
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, path: &str) {
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let p = Photo::new(id, Source::File { path: path.into() }, "x.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
|
||||
/// A scratch folder that goes away with the test, however it ends.
|
||||
struct Scratch(std::path::PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(tag: &str) -> Scratch {
|
||||
let dir = std::env::temp_dir().join(format!("lc-folders-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Scratch(dir)
|
||||
}
|
||||
fn path(&self, rel: &str) -> String {
|
||||
self.0.join(rel).to_string_lossy().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
for path in ["/pics/trip/a.jpg", "/pics/trip/b.jpg", "/pics/home/c.jpg"] {
|
||||
add(&mut s, path);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_folders_command_lists_where_the_photos_were_imported_from() {
|
||||
let mut s = session();
|
||||
let r = s.execute("library.folders", &json!({})).unwrap();
|
||||
assert_eq!((r[0]["name"].as_str(), r[0]["volume"].as_bool(), r[0]["count"].as_u64()), (Some("/"), Some(true), Some(3)));
|
||||
let pics = &r[0]["children"][0];
|
||||
assert_eq!(pics["name"], "pics");
|
||||
let kids: Vec<(&str, u64)> =
|
||||
pics["children"].as_array().unwrap().iter().map(|c| (c["name"].as_str().unwrap(), c["count"].as_u64().unwrap())).collect();
|
||||
assert_eq!(kids, [("home", 1), ("trip", 2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_folder_shows_its_photos_and_a_chip_that_clears_it() {
|
||||
let mut s = session();
|
||||
assert_eq!(s.visible().len(), 3);
|
||||
let r = s.execute("library.filter", &json!({"libraryFolder": "/pics/trip"})).unwrap();
|
||||
assert_eq!(r["count"], 2);
|
||||
let chips = filter_chips(&s.filter, &s.catalog);
|
||||
assert_eq!(chips.len(), 1);
|
||||
assert_eq!(chips[0].label, "Folder: pics/trip", "two names, so two folders called trip are told apart");
|
||||
s.execute("library.filter", &chips[0].clear).unwrap();
|
||||
assert_eq!(s.visible().len(), 3, "clearing the chip shows everything again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_that_is_not_a_library_folder_shows_nothing() {
|
||||
let mut s = session();
|
||||
let r = s.execute("library.filter", &json!({"libraryFolder": "/elsewhere"})).unwrap();
|
||||
assert_eq!(r["count"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_folder_moves_its_photos_to_recently_deleted_and_undo_brings_them_back() {
|
||||
let mut s = session();
|
||||
let r = s.execute("library.removeFolder", &json!({"path": "/pics//trip/"})).unwrap();
|
||||
assert_eq!(r["removed"], 2);
|
||||
assert_eq!(s.visible().len(), 1, "only /pics/home is left");
|
||||
let tree = s.execute("library.folders", &json!({})).unwrap();
|
||||
assert_eq!(tree[0]["count"], 1);
|
||||
s.source = LibrarySource::RecentlyDeleted;
|
||||
assert_eq!(s.visible().len(), 2, "they wait in Recently Deleted");
|
||||
s.source = LibrarySource::All;
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.visible().len(), 3, "one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_folder_that_holds_nothing_or_a_whole_disk_is_refused() {
|
||||
let mut s = session();
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": "/elsewhere"})).is_err());
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": "/"})).is_err(), "choose a folder, not everything");
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": " "})).is_err());
|
||||
assert!(s.execute("library.removeFolder", &json!({})).is_err());
|
||||
assert_eq!(s.visible().len(), 3, "nothing changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_folder_leaves_browsed_and_already_deleted_photos_alone() {
|
||||
let mut s = session();
|
||||
for (path, local, deleted) in [("/pics/trip/browsed.jpg", true, false), ("/pics/trip/gone.jpg", false, true)] {
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::File { path: path.into() }, "x.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00");
|
||||
(p.local, p.deleted) = (local, deleted);
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
let r = s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap();
|
||||
assert_eq!(r["removed"], 2, "only the two library photos of the folder");
|
||||
let state: Vec<(bool, bool)> = s.catalog.photos().filter(|p| p.local || p.deleted).map(|p| (p.local, p.deleted)).collect();
|
||||
assert!(state.contains(&(true, false)) && state.contains(&(false, true)), "browsed stays browsed, the old deletion stays: {state:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_the_folder_that_is_chosen_clears_the_choice_but_a_parent_stays() {
|
||||
let mut s = session();
|
||||
s.execute("library.filter", &json!({"libraryFolder": "/pics/trip"})).unwrap();
|
||||
s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap();
|
||||
assert_eq!(s.filter.library_folder, None, "no empty grid under a chip for a folder that is gone");
|
||||
let mut s = session();
|
||||
s.execute("library.filter", &json!({"libraryFolder": "/pics"})).unwrap();
|
||||
s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap();
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some("/pics"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_that_names_no_folder_removes_nothing() {
|
||||
let mut s = session();
|
||||
for path in [".", "./", "a/..", "../..", "x/../.."] {
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": path})).is_err(), "{path:?}");
|
||||
}
|
||||
assert_eq!(s.visible().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_disk_goes_only_when_asked_for_by_name_and_never_the_startup_disk() {
|
||||
let mut s = Session::new();
|
||||
for p in ["/Volumes/nas/a/1.jpg", "/Volumes/nas/2.jpg", "/Users/me/3.jpg", r"C:\x\4.jpg", r"\\srv\share\5.jpg"] {
|
||||
add(&mut s, p);
|
||||
}
|
||||
for path in ["/Volumes/nas", "C:", "C:\\", r"\\?\C:\", "C:\\..", r"\\srv\share"] {
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": path})).is_err(), "{path:?} is a whole disk");
|
||||
}
|
||||
for path in ["/", "//"] {
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": path, "disk": true})).is_err(), "{path:?}: never the startup disk");
|
||||
}
|
||||
assert_eq!(s.visible().len(), 5, "nothing changed");
|
||||
let r = s.execute("library.removeFolder", &json!({"path": "/Volumes/nas", "disk": true})).unwrap();
|
||||
assert_eq!(r["removed"], 2, "everything on the nas, nothing elsewhere");
|
||||
assert_eq!(s.visible().len(), 3);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.visible().len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_that_spans_several_disks_is_removed_only_when_asked_for_by_name() {
|
||||
let mut s = Session::new();
|
||||
for p in ["/Volumes/nas/a/1.jpg", "/Volumes/tokyo/2.jpg", "/Volumes/3.jpg"] {
|
||||
add(&mut s, p);
|
||||
}
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": "/Volumes"})).is_err(), "it would take two disks' photos at once");
|
||||
assert_eq!(s.visible().len(), 3);
|
||||
let r = s.execute("library.removeFolder", &json!({"path": "/Volumes", "disk": true})).unwrap();
|
||||
assert_eq!(r["removed"], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_folder_filter_takes_only_a_text_path() {
|
||||
let mut s = session();
|
||||
assert!(s.execute("library.filter", &json!({"libraryFolder": 5})).is_err());
|
||||
assert_eq!(s.filter.library_folder, None, "refused, nothing changed");
|
||||
s.execute("library.filter", &json!({"libraryFolder": " "})).unwrap();
|
||||
assert_eq!(s.visible().len(), 3, "a blank path is no choice");
|
||||
s.execute("library.filter", &json!({"libraryFolder": "."})).unwrap();
|
||||
assert_eq!(s.visible().len(), 0, "a path that names no folder shows nothing, not everything");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renamed_folder_stays_the_chosen_one_and_undo_follows_it_back() {
|
||||
let dir = Scratch::new("rename");
|
||||
std::fs::create_dir_all(dir.0.join("trip/day1")).unwrap();
|
||||
let (trip, renamed) = (dir.path("trip"), dir.path("holiday"));
|
||||
let mut s = Session::new();
|
||||
add(&mut s, &format!("{trip}/a.jpg"));
|
||||
add(&mut s, &format!("{trip}/day1/b.jpg"));
|
||||
s.execute("library.filter", &json!({"libraryFolder": trip})).unwrap();
|
||||
assert_eq!(s.visible().len(), 2);
|
||||
s.execute("folder.rename", &json!({"path": trip, "name": "holiday"})).unwrap();
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some(renamed.as_str()), "the choice follows the folder");
|
||||
assert_eq!(s.visible().len(), 2, "and still shows its photos");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some(trip.as_str()));
|
||||
assert_eq!(s.visible().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moved_folder_keeps_a_chosen_subfolder_chosen() {
|
||||
let dir = Scratch::new("move");
|
||||
std::fs::create_dir_all(dir.0.join("trip/day1")).unwrap();
|
||||
std::fs::create_dir_all(dir.0.join("archive")).unwrap();
|
||||
let (day1, moved) = (dir.path("trip/day1"), dir.path("archive/trip/day1"));
|
||||
let mut s = Session::new();
|
||||
add(&mut s, &format!("{day1}/b.jpg"));
|
||||
s.execute("library.filter", &json!({"libraryFolder": day1})).unwrap();
|
||||
s.execute("folder.move", &json!({"path": dir.path("trip"), "into": dir.path("archive")})).unwrap();
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some(moved.as_str()), "the subfolder follows its parent");
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some(day1.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn following_a_folder_reads_both_spellings_the_same_way() {
|
||||
// the choice was typed with a `..` in it; the folder that moved was not
|
||||
let mut s = Session::new();
|
||||
s.filter.library_folder = Some("/a/x/../b/sub".into());
|
||||
crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c");
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some("/a/c/sub"), "a subfolder stays a subfolder, never widens to its parent");
|
||||
s.filter.library_folder = Some("/elsewhere/b".into());
|
||||
crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c");
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some("/elsewhere/b"), "other folders are left alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_folder_replaces_the_source_the_way_an_album_does() {
|
||||
let mut s = session();
|
||||
s.execute("library.source", &json!({"kind": "picks"})).unwrap();
|
||||
assert_eq!(s.visible().len(), 0, "no picks");
|
||||
let r = source_folder(&mut s, "/pics/trip");
|
||||
assert_eq!(r["count"], 2, "the folder's photos, whatever was shown before");
|
||||
assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::LibraryFolder, Some("/pics/trip")));
|
||||
assert!(filter_chips(&s.filter, &s.catalog).is_empty(), "a source is not a filter chip");
|
||||
let state = s.execute("library.state", &json!({})).unwrap();
|
||||
assert_eq!((state["sourceLabel"].as_str(), state["libraryFolder"].as_str()), (Some("pics/trip"), Some("/pics/trip")), "agents see which folder");
|
||||
// the filter bar narrows it further, as it does an album
|
||||
s.execute("library.filter", &json!({"rating": 1})).unwrap();
|
||||
assert_eq!(s.visible().len(), 0);
|
||||
s.execute("library.clearFilter", &json!({})).unwrap();
|
||||
assert_eq!(s.visible().len(), 2);
|
||||
s.execute("library.source", &json!({"kind": "all"})).unwrap();
|
||||
assert_eq!(s.visible().len(), 3, "back to everything");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_folder_source_takes_a_library_folder_path_only() {
|
||||
let mut s = session();
|
||||
for params in [
|
||||
json!({"kind": "libraryFolder"}),
|
||||
json!({"kind": "libraryFolder", "path": 5}),
|
||||
json!({"kind": "libraryFolder", "path": " "}),
|
||||
json!({"kind": "libraryFolder", "path": "."}),
|
||||
] {
|
||||
assert!(s.execute("library.source", ¶ms).is_err(), "{params}");
|
||||
}
|
||||
assert_eq!(s.source, LibrarySource::All, "refused, nothing changed");
|
||||
assert!(s.execute("library.source", &json!({"kind": "libraryFolder", "path": "/elsewhere"})).is_err(), "no photo was imported from it");
|
||||
assert_eq!(s.source, LibrarySource::All);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_the_folder_being_shown_returns_to_all_photos() {
|
||||
let mut s = session();
|
||||
source_folder(&mut s, "/pics/trip");
|
||||
s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap();
|
||||
assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::All, None));
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
// removing another folder leaves the source alone
|
||||
let mut s = session();
|
||||
source_folder(&mut s, "/pics/home");
|
||||
s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap();
|
||||
assert_eq!(s.source, LibrarySource::LibraryFolder);
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renamed_folder_stays_the_one_shown_and_undo_follows_it_back() {
|
||||
let dir = Scratch::new("source-rename");
|
||||
std::fs::create_dir_all(dir.0.join("trip/day1")).unwrap();
|
||||
let (trip, day1, renamed) = (dir.path("trip"), dir.path("trip/day1"), dir.path("holiday"));
|
||||
let mut s = Session::new();
|
||||
add(&mut s, &format!("{trip}/a.jpg"));
|
||||
add(&mut s, &format!("{day1}/b.jpg"));
|
||||
source_folder(&mut s, &day1);
|
||||
s.execute("folder.rename", &json!({"path": trip, "name": "holiday"})).unwrap();
|
||||
assert_eq!(s.library_folder.as_deref(), Some(format!("{renamed}/day1").as_str()), "the subfolder follows its parent");
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.library_folder.as_deref(), Some(day1.as_str()));
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shown_folder_follows_reading_both_spellings_the_same_way() {
|
||||
let mut s = Session::new();
|
||||
s.library_folder = Some("/a/x/../b/sub".into());
|
||||
crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c");
|
||||
assert_eq!(s.library_folder.as_deref(), Some("/a/c/sub"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_from_one_folder_to_another_changes_what_is_shown() {
|
||||
let mut s = session();
|
||||
assert_eq!(source_folder(&mut s, "/pics/trip")["count"], 2);
|
||||
assert_eq!(s.source_total(), Some(2));
|
||||
assert_eq!(source_folder(&mut s, "/pics/home")["count"], 1, "same source, another folder");
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
assert_eq!(s.source_total(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_smart_album_made_from_a_folder_view_keeps_the_folder() {
|
||||
let mut s = session();
|
||||
source_folder(&mut s, "/pics/trip");
|
||||
assert_eq!(s.visible().len(), 2);
|
||||
let r = s.execute("album.createSmart", &json!({"name": "Trip"})).unwrap();
|
||||
assert_eq!(r["count"], 2, "the album matches the folder's photos, not the whole library: {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_source_and_a_folder_filter_never_disagree() {
|
||||
let mut s = session();
|
||||
s.execute("library.filter", &json!({"libraryFolder": "/pics/home"})).unwrap();
|
||||
source_folder(&mut s, "/pics/trip");
|
||||
assert_eq!(s.filter.library_folder, None, "choosing a folder drops a folder filter left over");
|
||||
assert!(s.execute("library.filter", &json!({"libraryFolder": "/pics/home"})).is_err(), "one folder at a time");
|
||||
assert_eq!(s.visible().len(), 2);
|
||||
s.execute("library.filter", &json!({"libraryFolder": null, "rating": 0})).unwrap();
|
||||
s.execute("library.source", &json!({"kind": "all"})).unwrap();
|
||||
assert!(s.execute("library.filter", &json!({"libraryFolder": "/pics/home"})).is_ok(), "fine while no folder is the source");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_source_with_no_folder_falls_back_to_all_photos() {
|
||||
let mut s = session();
|
||||
s.source = LibrarySource::LibraryFolder;
|
||||
s.library_folder = None;
|
||||
assert_eq!(s.visible().len(), 3, "everything, not an empty grid under no name");
|
||||
assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::All, None));
|
||||
assert_eq!(s.source_total(), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_folder_that_shows_exactly_its_own_photos_can_be_the_source() {
|
||||
let mut s = Session::new();
|
||||
for p in ["/Volumes/nas/a/1.jpg", "/Users/me/2.jpg", "/Volumes/3.jpg"] {
|
||||
add(&mut s, p);
|
||||
}
|
||||
for path in ["/", "//", "/elsewhere", "/Volumes"] {
|
||||
assert!(s.execute("library.source", &json!({"kind": "libraryFolder", "path": path})).is_err(), "{path:?}");
|
||||
}
|
||||
assert_eq!(s.source, LibrarySource::All, "refused, nothing changed");
|
||||
assert_eq!(source_folder(&mut s, "/Volumes/nas")["count"], 1, "a disk is a fine source");
|
||||
assert_eq!(source_folder(&mut s, "/Users/me")["count"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_folder_above_a_disk_needs_asking_by_name_even_with_one_disk() {
|
||||
let mut s = Session::new();
|
||||
for p in ["/Volumes/nas/a/1.jpg", "/Users/me/2.jpg"] {
|
||||
add(&mut s, p);
|
||||
}
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": "/Volumes"})).is_err(), "it would take the whole nas");
|
||||
assert_eq!(s.visible().len(), 2);
|
||||
assert_eq!(s.execute("library.removeFolder", &json!({"path": "/Volumes/nas/a"})).unwrap()["removed"], 1, "a folder on the disk is just a folder");
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_name_with_spaces_at_its_edge_is_never_mistaken_for_its_neighbour() {
|
||||
let mut s = Session::new();
|
||||
for p in ["/p/shoot /a.jpg", "/p/shoot/b.jpg", "/p/other/c.jpg"] {
|
||||
add(&mut s, p);
|
||||
}
|
||||
assert_eq!(source_folder(&mut s, "/p/shoot ")["count"], 1);
|
||||
let shown: Vec<String> = s.visible().to_vec().iter().filter_map(|id| s.catalog.photo(*id)).map(|p| p.file_name.clone()).collect();
|
||||
assert_eq!(s.library_folder.as_deref(), Some("/p/shoot "), "kept as the tree gave it");
|
||||
assert_eq!(shown.len(), 1);
|
||||
let before: Vec<_> = s.catalog.photos().filter(|p| p.deleted).map(|p| p.id).collect();
|
||||
assert!(before.is_empty());
|
||||
let r = s.execute("library.removeFolder", &json!({"path": "/p/shoot "})).unwrap();
|
||||
assert_eq!(r["removed"], 1);
|
||||
let gone: Vec<String> = s
|
||||
.catalog
|
||||
.photos()
|
||||
.filter(|p| p.deleted)
|
||||
.map(|p| match &p.source {
|
||||
Source::File { path } => path.clone(),
|
||||
Source::Demo { .. } => String::new(),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(gone, ["/p/shoot /a.jpg"], "the folder named, not its neighbour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_a_smart_album_made_from_a_folder_view_keeps_the_folder() {
|
||||
let mut s = session();
|
||||
source_folder(&mut s, "/pics/trip");
|
||||
let r = s.execute("album.createSmart", &json!({"name": "Trip"})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
// the rules dialog saves what it shows (no folder field) with `replace`
|
||||
let r = s.execute("album.setRules", &json!({"id": id, "replace": true, "rules": {"ruleSet": {"rules": []}}})).unwrap();
|
||||
assert_eq!(r["count"], 2, "the folder is not something the dialog can show, so it is kept: {r}");
|
||||
let r = s.execute("album.setRules", &json!({"id": id, "replace": true, "rules": {"libraryFolder": null}})).unwrap();
|
||||
assert_eq!(r["count"], 3, "and can be dropped on purpose");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shown_folder_that_loses_its_last_photo_gives_way_to_all_photos() {
|
||||
let mut s = session();
|
||||
source_folder(&mut s, "/pics/home");
|
||||
s.execute("photo.delete", &json!({})).unwrap();
|
||||
assert_eq!(s.visible().len(), 2, "back to everything that is left");
|
||||
assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::All, None));
|
||||
// a folder whose photos are only hidden by the filter bar stays
|
||||
let mut s = session();
|
||||
source_folder(&mut s, "/pics/trip");
|
||||
s.execute("library.filter", &json!({"rating": 5})).unwrap();
|
||||
assert_eq!(s.visible().len(), 0);
|
||||
assert_eq!(s.source, LibrarySource::LibraryFolder, "the filters hide them, the folder is still there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_state_names_a_folder_only_while_it_is_shown() {
|
||||
let mut s = session();
|
||||
source_folder(&mut s, "/pics/trip");
|
||||
assert_eq!(s.execute("library.state", &json!({})).unwrap()["libraryFolder"], "/pics/trip");
|
||||
s.execute("library.source", &json!({"kind": "all"})).unwrap();
|
||||
assert!(s.execute("library.state", &json!({})).unwrap()["libraryFolder"].is_null(), "not a stale leftover");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_folder_filter_is_no_filter() {
|
||||
let mut s = session();
|
||||
s.execute("library.filter", &json!({"libraryFolder": " "})).unwrap();
|
||||
assert_eq!(s.filter, lightcraft_catalog::Filter::default(), "no hidden 'filters active' state");
|
||||
}
|
||||
@@ -181,8 +181,12 @@ fn find_missing_matches_renamed_files_by_content() {
|
||||
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!(to("a.png"), Some((dir.join("moved").join("Trip-001.png").to_string_lossy().to_string(), json!("content"))));
|
||||
assert_eq!(
|
||||
to("b.png"),
|
||||
Some((dir.join("moved").join("real").join("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);
|
||||
@@ -541,12 +545,17 @@ fn cube_luts_become_profiles() {
|
||||
);
|
||||
s.execute("develop.profile", &json!({"id": id, "amount": 0})).unwrap();
|
||||
assert_eq!(s.render_now(photo, 48, 32).unwrap().image.data, base.data, "Amount 0 = the photo as it was");
|
||||
assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks")));
|
||||
s.execute("profile.favorite", &json!({"id": id})).unwrap();
|
||||
assert!(s.profile_favorites.contains(&id));
|
||||
// the library keeps it
|
||||
drop(s);
|
||||
lightcraft_pipeline::lut::unregister(&id);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert!(s.lut_profiles.iter().any(|p| p.id == id));
|
||||
assert!(s.profile_favorites.contains(&id), "imported LUT favorite survives reopen");
|
||||
assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks")));
|
||||
assert!(lightcraft_pipeline::lut::get(&id).is_some(), "registered again on open");
|
||||
s.execute("profile.deleteImported", &json!({"id": id})).unwrap();
|
||||
assert!(lightcraft_pipeline::lut::get(&id).is_none());
|
||||
@@ -582,7 +591,7 @@ fn folders_rename_and_move_with_their_photos() {
|
||||
assert!(s.execute("folder.move", &json!({"path": b.to_string_lossy(), "into": b.join("deeper").to_string_lossy()})).is_err(), "not into itself");
|
||||
let r = s.execute("folder.move", &json!({"path": b.to_string_lossy(), "into": root.join("Archive").to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["relinked"], 1);
|
||||
assert_eq!(path(&s), root.join("Archive/Italy 2026/one.png").to_string_lossy());
|
||||
assert_eq!(path(&s), root.join("Archive").join("Italy 2026").join("one.png").to_string_lossy());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -854,3 +863,147 @@ fn missing_photos_skip_local_browse_records() {
|
||||
s.execute("library.source", &json!({"kind": "missing"})).unwrap();
|
||||
assert_eq!(s.visible_cloned().len(), 4, "the view lists what the count counts");
|
||||
}
|
||||
|
||||
/// Import a file that is in Recently Deleted (issue #298): by default it is skipped but the
|
||||
/// report says it is in the trash; `onDeleted: restore` brings it back with its edits;
|
||||
/// `onDeleted: fresh` replaces the trashed record with a new one.
|
||||
fn trashed_photo(tag: &str) -> (Session, std::path::PathBuf, u64) {
|
||||
let src = temp_dir(tag);
|
||||
write_png(&src.join("a.png"), 1);
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let id = r["imported"][0].as_u64().unwrap();
|
||||
s.execute("photo.rate", &json!({"ids": [id], "rating": 4})).unwrap();
|
||||
s.execute("photo.delete", &json!({"ids": [id]})).unwrap();
|
||||
(s, src, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_in_recently_deleted_is_reported_as_such() {
|
||||
let (mut s, src, id) = trashed_photo("trash-report");
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(ids(&r, "imported"), 0, "{r}");
|
||||
assert_eq!(r["duplicates"][0]["existing"], id, "{r}");
|
||||
assert_eq!(r["duplicates"][0]["existingDeleted"], true, "{r}");
|
||||
assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().deleted, "default: left in the trash");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_can_restore_a_duplicate_from_recently_deleted() {
|
||||
let (mut s, src, id) = trashed_photo("trash-restore");
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "restore"})).unwrap();
|
||||
assert_eq!(r["restored"], json!([id]), "{r}");
|
||||
assert_eq!(ids(&r, "duplicates"), 0, "{r}");
|
||||
let p = s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap();
|
||||
assert!(!p.deleted && p.rating == 4, "restored with its edits");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_can_replace_a_duplicate_from_recently_deleted() {
|
||||
let (mut s, src, id) = trashed_photo("trash-fresh");
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "fresh"})).unwrap();
|
||||
assert_eq!(ids(&r, "imported"), 1, "{r}");
|
||||
let new = r["imported"][0].as_u64().unwrap();
|
||||
assert_ne!(new, id);
|
||||
assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).is_none(), "the trashed record is gone");
|
||||
let p = s.catalog.photo(lightcraft_catalog::PhotoId(new)).unwrap();
|
||||
assert!(!p.deleted && p.rating == 0, "a new photo without the old edits");
|
||||
assert_eq!(s.catalog.len(), 1);
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_on_deleted_is_an_error() {
|
||||
let mut s = Session::new().with_fs();
|
||||
let e = s.execute("library.import", &json!({"paths": [], "onDeleted": "bogus"})).unwrap_err().to_string();
|
||||
assert!(e.contains("onDeleted"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_recently_deleted_removes_only_trashed_photos() {
|
||||
let (mut s, src, id) = trashed_photo("trash-empty");
|
||||
write_png(&src.join("b.png"), 2);
|
||||
let r = s.execute("library.import", &json!({"paths": [src.join("b.png").to_string_lossy()]})).unwrap();
|
||||
let keep = r["imported"][0].as_u64().unwrap();
|
||||
let r = s.execute("library.emptyRecentlyDeleted", &json!({})).unwrap();
|
||||
assert_eq!(r["deleted"], 1, "{r}");
|
||||
assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).is_none());
|
||||
assert!(s.catalog.photo(lightcraft_catalog::PhotoId(keep)).is_some());
|
||||
// nothing to empty is not an error
|
||||
assert_eq!(s.execute("library.emptyRecentlyDeleted", &json!({})).unwrap()["deleted"], 0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// Two trashed photos that share an album and a two-photo stack: emptying (or permanently
|
||||
/// deleting) them together must not fail or leave dangling references.
|
||||
fn two_trashed_in_album_and_stack(tag: &str) -> (Session, std::path::PathBuf, [u64; 2], u64) {
|
||||
let src = temp_dir(tag);
|
||||
write_png(&src.join("a.png"), 1);
|
||||
write_png(&src.join("b.png"), 2);
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let ids = [r["imported"][0].as_u64().unwrap(), r["imported"][1].as_u64().unwrap()];
|
||||
let al = s.execute("album.create", &json!({"name": "Trip"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("album.addPhotos", &json!({"id": al, "ids": ids})).unwrap();
|
||||
s.execute("library.select", &json!({"ids": ids})).unwrap();
|
||||
s.execute("stack.group", &json!({"ids": ids})).unwrap();
|
||||
s.execute("photo.delete", &json!({"ids": ids})).unwrap();
|
||||
(s, src, ids, al)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emptying_trashed_photos_of_one_album_and_stack_leaves_nothing_dangling() {
|
||||
let (mut s, src, ids, al) = two_trashed_in_album_and_stack("trash-empty-refs");
|
||||
let r = s.execute("library.emptyRecentlyDeleted", &json!({})).unwrap();
|
||||
assert_eq!(r["deleted"], 2, "{r}");
|
||||
assert_eq!(s.catalog.len(), 0);
|
||||
assert!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.is_empty(), "album still lists a removed photo");
|
||||
assert!(ids.iter().all(|i| s.catalog.stack_of(lightcraft_catalog::PhotoId(*i)).is_none()));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.len(), 2, "one undo step brings both back");
|
||||
assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.len(), 2);
|
||||
assert!(s.catalog.stack_of(lightcraft_catalog::PhotoId(ids[0])).is_some());
|
||||
assert!(s.catalog.photos().all(|p| p.deleted), "back in Recently Deleted");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_several_photos_permanently_leaves_nothing_dangling() {
|
||||
let (mut s, src, ids, al) = two_trashed_in_album_and_stack("trash-perm-refs");
|
||||
s.execute("library.select", &json!({"ids": ids})).unwrap();
|
||||
s.execute("photo.deletePermanently", &json!({"ids": ids})).unwrap();
|
||||
assert!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.is_empty());
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_import_of_two_trashed_photos_in_one_album_and_stack_works() {
|
||||
let (mut s, src, ids, al) = two_trashed_in_album_and_stack("trash-fresh-refs");
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "fresh"})).unwrap();
|
||||
assert_eq!(ids_len(&r), 2, "{r}");
|
||||
assert!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.is_empty());
|
||||
assert!(ids.iter().all(|i| s.catalog.photo(lightcraft_catalog::PhotoId(*i)).is_none()));
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
fn ids_len(r: &Value) -> usize {
|
||||
ids(r, "imported")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_import_applies_the_album_to_restored_photos() {
|
||||
let (mut s, src, ids, _) = trashed_photo_ids("trash-restore-album");
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "restore", "albumName": "Back"})).unwrap();
|
||||
assert_eq!(r["restored"], json!(ids), "{r}");
|
||||
let album = r["album"].as_u64().expect("album created for the restored photos");
|
||||
assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos.len(), 1);
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
fn trashed_photo_ids(tag: &str) -> (Session, std::path::PathBuf, [u64; 1], u64) {
|
||||
let (s, src, id) = trashed_photo(tag);
|
||||
(s, src, [id], 0)
|
||||
}
|
||||
@@ -273,7 +273,7 @@ fn import_review_preview_and_options() {
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
// c.png is already in the library
|
||||
s.execute("library.import", &json!({"paths": [src.join("sub/c.png").to_string_lossy()]})).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [src.join("sub").join("c.png").to_string_lossy()]})).unwrap();
|
||||
let n0 = s.catalog.len();
|
||||
let undo0 = s.undo.len();
|
||||
let pv = s.execute("library.importPreview", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
@@ -466,3 +466,88 @@ fn tracklog_auto_tag() {
|
||||
assert!(s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [ids[0]], "offset": "noon"})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// ---- random sort: Given the library, When the user picks Random, Then the grid is a stable shuffle
|
||||
|
||||
#[test]
|
||||
fn random_sort_is_stable_until_reshuffled() {
|
||||
use lightcraft_catalog::SortKey;
|
||||
let mut s = Session::with_demo();
|
||||
let dated = s.visible_cloned();
|
||||
s.execute("library.sort", &json!({"key": "random", "seed": 11})).unwrap();
|
||||
assert_eq!((s.sort.key, s.sort.seed), (SortKey::Random, 11));
|
||||
let a = s.visible_cloned();
|
||||
assert_ne!(a, dated);
|
||||
let mut sorted = a.clone();
|
||||
sorted.sort();
|
||||
let mut all = dated.clone();
|
||||
all.sort();
|
||||
assert_eq!(sorted, all, "a permutation of the same photos");
|
||||
// an unrelated catalog change must not move anything
|
||||
let first = a[0];
|
||||
s.execute("photo.rate", &json!({"rating": 4, "ids": [first.0]})).unwrap();
|
||||
assert_eq!(s.visible_cloned(), a, "same seed, same grid after an edit");
|
||||
assert_eq!(s.execute("library.groups", &json!({})).unwrap(), json!([]), "no date headers for a shuffle");
|
||||
// Reshuffle picks a new seed (and works even with a frozen clock)
|
||||
s.execute("library.shuffle", &json!({})).unwrap();
|
||||
let seed1 = s.sort.seed;
|
||||
assert_eq!(s.sort.key, SortKey::Random);
|
||||
assert_ne!(seed1, 11);
|
||||
assert_ne!(s.visible_cloned(), a);
|
||||
s.execute("library.shuffle", &json!({"seed": null})).unwrap();
|
||||
assert_ne!(s.sort.seed, seed1, "every reshuffle is new, and a null seed means none given");
|
||||
assert!(s.sort.seed < 1 << 53, "seeds survive a round trip through a JSON double");
|
||||
// an explicit seed is reproducible
|
||||
s.execute("library.shuffle", &json!({"seed": 11})).unwrap();
|
||||
assert_eq!(s.visible_cloned(), a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_sort_rejects_bad_params() {
|
||||
let mut s = Session::with_demo();
|
||||
let before = s.sort;
|
||||
assert!(s.execute("library.sort", &json!({"seed": "x"})).is_err());
|
||||
assert!(s.execute("library.sort", &json!({"seed": -1})).is_err());
|
||||
assert!(s.execute("library.shuffle", &json!({"seed": 1.5})).is_err());
|
||||
assert!(s.execute("library.sort", &json!({"seed": 1u64 << 53})).is_err(), "beyond what a JSON double holds exactly");
|
||||
assert!(s.execute("library.sort", &json!({"seed": (1u64 << 53) - 1})).is_ok());
|
||||
assert_eq!(s.sort.key, before.key, "a rejected command leaves the sort alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_sort_applies_to_albums_too() {
|
||||
let mut s = Session::with_demo();
|
||||
s.execute("library.sort", &json!({"key": "random", "seed": 5})).unwrap();
|
||||
let all = s.visible_cloned();
|
||||
// manual order shows newest-first (descending), i.e. the reverse of this insertion order, so it
|
||||
// differs from the shuffle unless Random really overrides it
|
||||
let ids: Vec<u64> = all.iter().take(12).map(|i| i.0).collect();
|
||||
let album = s.execute("album.create", &json!({"name": "Shuffled"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("album.addPhotos", &json!({"id": album, "ids": ids})).unwrap();
|
||||
s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap();
|
||||
let in_album = s.visible_cloned();
|
||||
assert_eq!(in_album.len(), 12);
|
||||
let expect: Vec<_> = all.iter().filter(|i| in_album.contains(i)).copied().collect();
|
||||
assert_eq!(in_album, expect, "the album shows its photos in the shuffle's order, not manual order");
|
||||
}
|
||||
|
||||
/// Choosing Random from another sort is a fresh shuffle (not the fixed seed-0 order); picking it
|
||||
/// again, or changing only the direction, keeps the shuffle on screen; an explicit seed wins.
|
||||
#[test]
|
||||
fn choosing_random_starts_a_fresh_shuffle() {
|
||||
let mut s = Session::with_demo();
|
||||
assert_eq!(s.sort.seed, 0);
|
||||
s.execute("library.sort", &json!({"key": "random"})).unwrap();
|
||||
let first = s.sort.seed;
|
||||
assert_ne!(first, 0, "the first Random is not the fixed seed-0 order");
|
||||
s.execute("library.sort", &json!({"key": "random"})).unwrap();
|
||||
s.execute("library.sort", &json!({"ascending": true})).unwrap();
|
||||
assert_eq!(s.sort.seed, first, "already random: the shuffle stays");
|
||||
s.execute("library.sort", &json!({"key": "fileName"})).unwrap();
|
||||
s.execute("library.sort", &json!({"key": "random"})).unwrap();
|
||||
assert_ne!(s.sort.seed, first, "coming back to Random reshuffles");
|
||||
s.execute("library.sort", &json!({"key": "rating"})).unwrap();
|
||||
s.execute("library.sort", &json!({"key": "random", "seed": 42})).unwrap();
|
||||
assert_eq!(s.sort.seed, 42, "an explicit seed is honoured");
|
||||
assert!(s.sort.seed < 1 << 53);
|
||||
}
|
||||
@@ -212,6 +212,39 @@ fn profile_favorites_and_recent_survive_reopen() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shown_library_folder_survives_reopen() {
|
||||
let dir = temp_dir("libfolder");
|
||||
let mut s = open(&dir, true);
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let p = lightcraft_catalog::Photo::new(
|
||||
id,
|
||||
lightcraft_catalog::Source::File { path: "/pics/trip/a.jpg".into() },
|
||||
"a.jpg",
|
||||
"JPEG",
|
||||
60,
|
||||
40,
|
||||
"2026-01-01T10:00:00",
|
||||
);
|
||||
s.commit("Add", lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
s.execute("library.source", &json!({"kind": "libraryFolder", "path": "/pics/trip"})).unwrap();
|
||||
s.save_view();
|
||||
drop(s);
|
||||
let s = open(&dir, false);
|
||||
assert_eq!((s.source, s.library_folder.as_deref()), (crate::LibrarySource::LibraryFolder, Some("/pics/trip")));
|
||||
drop(s);
|
||||
// a folder that holds none of the library's photos any more opens on everything too
|
||||
std::fs::write(dir.join("view.json"), br#"{"source": {"kind": "libraryFolder"}, "library_folder": "/no/such/folder"}"#).unwrap();
|
||||
let s = open(&dir, false);
|
||||
assert_eq!((s.source, s.library_folder.clone()), (crate::LibrarySource::All, None));
|
||||
drop(s);
|
||||
// a view file that names the source but no folder opens on everything, never an empty grid
|
||||
std::fs::write(dir.join("view.json"), br#"{"source": {"kind": "libraryFolder"}}"#).unwrap();
|
||||
let s = open(&dir, false);
|
||||
assert_eq!((s.source, s.library_folder), (crate::LibrarySource::All, None));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Smart albums with a rule set: all / any / none, nested groups, validation, live updates.
|
||||
#[test]
|
||||
fn smart_album_rule_sets() {
|
||||
|
||||
@@ -242,3 +242,17 @@ fn filter_chips_clear_one_filter_and_total_ignores_the_filter() {
|
||||
}
|
||||
assert_eq!(s.filter, Default::default());
|
||||
}
|
||||
|
||||
/// A shuffle is part of the saved view: the same seed (and so the same grid) is back on the next launch.
|
||||
#[test]
|
||||
fn random_sort_persists_with_the_view() {
|
||||
let dir = temp_dir("random-view");
|
||||
let mut s = open(&dir);
|
||||
s.execute("library.sort", &json!({"key": "random", "seed": 123_456_789})).unwrap();
|
||||
s.close_library().unwrap();
|
||||
drop(s);
|
||||
let s2 = open(&dir);
|
||||
assert_eq!(s2.sort.key, lightcraft_catalog::SortKey::Random);
|
||||
assert_eq!(s2.sort.seed, 123_456_789);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! persistence in prefs.json.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_catalog::MediaKind;
|
||||
use lightcraft_develop::Preset;
|
||||
@@ -263,3 +264,56 @@ fn curve_presets_save_apply_export_import_and_persist() {
|
||||
assert_eq!(names, ["Faded Red", "Linear (imported)"]);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_the_disk_cache_keeps_the_cache_and_its_thumbnails() {
|
||||
let lib = temp_dir("resize");
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
let cache = s.media.rendered.clone();
|
||||
let key = lightcraft_preview::hash_bytes(b"thumbnail");
|
||||
let img = lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[10, 20, 30, 255]; 16] };
|
||||
cache.put(key, Arc::new(img));
|
||||
let generation = cache.generation();
|
||||
s.execute("library.preferences", &json!({"cacheMb": 300})).unwrap();
|
||||
// the same cache object (the UI keeps its textures), the same generation, the new budget
|
||||
assert!(Arc::ptr_eq(&cache, &s.media.rendered));
|
||||
assert_eq!(s.media.rendered.generation(), generation);
|
||||
assert_eq!(s.media.rendered.disk().unwrap().budget(), 300 << 20);
|
||||
assert!(s.media.rendered.get(key).is_some());
|
||||
// an explicit clear still invalidates
|
||||
s.execute("library.clearPreviews", &json!({})).unwrap();
|
||||
assert_ne!(s.media.rendered.generation(), generation);
|
||||
assert!(s.media.rendered.get(key).is_none());
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopening_a_library_retires_the_old_cache_so_its_jobs_cannot_write_after_a_clear() {
|
||||
let lib = temp_dir("reopen-writer");
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, true).unwrap();
|
||||
let id = s.catalog.photos().next().unwrap().id;
|
||||
// a thumbnail job still holding the first open's cache object
|
||||
let job = s.thumb_job(id, 128).unwrap();
|
||||
let (old, key) = job.cache.clone().unwrap();
|
||||
// a thumbnail written before the reopen is still valid afterwards
|
||||
let kept = lightcraft_preview::hash_bytes(b"kept thumbnail");
|
||||
let img = lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[10, 20, 30, 255]; 16] };
|
||||
old.put(kept, Arc::new(img.clone()));
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert!(!Arc::ptr_eq(&old, &s.media.rendered));
|
||||
assert!(s.media.rendered.get(kept).is_some(), "a plain reopen keeps the disk cache");
|
||||
s.execute("library.clearPreviews", &json!({})).unwrap();
|
||||
// the old job finishes: its render must not land in the cleared directory
|
||||
let r = job.run();
|
||||
assert!(r.rendered.is_ok());
|
||||
old.put(kept, Arc::new(img));
|
||||
let fresh = lightcraft_preview::PreviewCache::with_disk(1 << 20, &lib.join("thumbs"), 1 << 30);
|
||||
assert!(fresh.get(key).is_none(), "stale thumbnail written through the replaced cache");
|
||||
assert!(fresh.get(kept).is_none(), "stale put through the replaced cache");
|
||||
assert!(s.media.rendered.get(key).is_none());
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! AI masks without the SAM 3 model (it is never required): every request is a clear error that
|
||||
//! comes back at once, nothing waits on the network or the model, a damaged model is an error
|
||||
//! (not a crash or a stuck "busy"), and the download command needs consent and fails cleanly.
|
||||
//! No test touches the internet: downloads go to a local server.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::segment::{NOT_INSTALLED, Segmenter};
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-sam3-engine-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
/// A folder that looks installed but holds no usable model.
|
||||
fn damaged_model(name: &str) -> std::path::PathBuf {
|
||||
let d = tmp(name);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
std::fs::write(d.join("model.safetensors"), b"\x10\x00\x00\x00\x00\x00\x00\x00{not json at all}").unwrap();
|
||||
std::fs::write(d.join("vocab.json"), b"{}").unwrap();
|
||||
std::fs::write(d.join("merges.txt"), b"#version: 0.2\n").unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Poll until the worker is idle (or `limit` passes), collecting messages.
|
||||
fn settle(s: &mut Session, limit: Duration) -> Vec<String> {
|
||||
let t = Instant::now();
|
||||
let mut messages = Vec::new();
|
||||
loop {
|
||||
let p = s.segment_poll();
|
||||
messages.extend(p.messages);
|
||||
if !s.segmenter.busy() && !s.segmenter.detail_busy() {
|
||||
messages.extend(s.segment_poll().messages);
|
||||
return messages;
|
||||
}
|
||||
assert!(t.elapsed() < limit, "the worker never finished");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_and_model_commands_without_the_model() {
|
||||
let mut s = Session::with_demo();
|
||||
let st = s.execute("segment.model.status", &json!({})).unwrap();
|
||||
assert_eq!(st["available"], Segmenter::AVAILABLE);
|
||||
assert_eq!(st["installed"], false);
|
||||
assert_eq!(st["download"]["running"], false);
|
||||
assert!(st["license"].as_str().unwrap().contains("SAM License"));
|
||||
// downloading needs the user's consent, and names the size and the licence
|
||||
let e = s.execute("segment.model.download", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("acknowledged") && e.contains("SAM License") && e.contains("GB"), "{e}");
|
||||
let e = s.execute("segment.model.download", &json!({"acknowledged": "yes"})).unwrap_err().to_string();
|
||||
assert!(e.contains("acknowledged"), "{e}");
|
||||
assert_eq!(s.execute("segment.model.cancel", &json!({})).unwrap()["cancelled"], false);
|
||||
if !Segmenter::AVAILABLE {
|
||||
assert!(s.execute("segment.model.download", &json!({"acknowledged": true})).is_err());
|
||||
return;
|
||||
}
|
||||
// no folder, then no download location: clear errors, nothing started
|
||||
let e = s.execute("segment.model.download", &json!({"acknowledged": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("no folder"), "{e}");
|
||||
let dir = tmp("nomirrors");
|
||||
s.segmenter.dir = Some(dir.clone());
|
||||
s.segmenter.mirrors_file = Some(dir.join("none.txt"));
|
||||
if s.segmenter.mirrors().is_empty() {
|
||||
let e = s.execute("segment.model.download", &json!({"acknowledged": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("LIGHTCRAFT_SAM3_MIRRORS"), "{e}");
|
||||
}
|
||||
assert!(!s.segmenter.download_status().running);
|
||||
}
|
||||
|
||||
#[cfg(feature = "sam")]
|
||||
#[test]
|
||||
fn a_failing_download_ends_with_an_error_and_never_blocks() {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
// a local mirror that has nothing
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base = format!("http://{}/sam3", l.local_addr().unwrap());
|
||||
std::thread::spawn(move || {
|
||||
for mut c in l.incoming().flatten() {
|
||||
let mut r = BufReader::new(c.try_clone().unwrap());
|
||||
let mut line = String::new();
|
||||
while r.read_line(&mut line).unwrap_or(0) > 2 {
|
||||
line.clear();
|
||||
}
|
||||
let _ = c.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 13\r\nConnection: close\r\n\r\n<h1>404</h1>\n");
|
||||
}
|
||||
});
|
||||
let dir = tmp("download404");
|
||||
let mirrors = dir.with_extension("mirrors.txt");
|
||||
std::fs::write(&mirrors, format!("# test mirror\n{base}\n")).unwrap();
|
||||
let mut s = Session::with_demo();
|
||||
s.segmenter.dir = Some(dir.clone());
|
||||
s.segmenter.mirrors_file = Some(mirrors.clone());
|
||||
let t = Instant::now();
|
||||
let r = s.execute("segment.model.download", &json!({"acknowledged": true})).unwrap();
|
||||
assert_eq!(r["started"], true);
|
||||
assert!(t.elapsed() < Duration::from_secs(1), "starting a download returns at once");
|
||||
// a second request while it runs starts nothing
|
||||
let again = s.execute("segment.model.download", &json!({"acknowledged": true})).unwrap();
|
||||
assert!(again["started"] == false || again["downloading"] == false);
|
||||
let t = Instant::now();
|
||||
while s.segmenter.download_status().running {
|
||||
assert!(t.elapsed() < Duration::from_secs(30), "the download never ended");
|
||||
// AI mask requests meanwhile: an error at once, never a wait
|
||||
let q = Instant::now();
|
||||
let e = s.execute("mask.add", &json!({"kind": "prompt", "text": "sky"})).unwrap_err().to_string();
|
||||
assert!(e.contains(NOT_INSTALLED), "{e}");
|
||||
assert!(q.elapsed() < Duration::from_millis(500));
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let st = s.segmenter.download_status();
|
||||
assert!(!st.finished);
|
||||
let e = st.error.unwrap();
|
||||
assert!(e.contains("not found"), "{e}");
|
||||
assert!(!s.segmenter.installed());
|
||||
// nothing was left behind under a real name, and no error page was saved
|
||||
for f in ["model.safetensors", "vocab.json", "merges.txt", "vocab.json.part"] {
|
||||
assert!(!dir.join(f).exists(), "{f}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_file(&mirrors);
|
||||
}
|
||||
|
||||
/// The app (background mode) never waits: with the model missing every request fails at once;
|
||||
/// with a damaged model the requests are queued, fail on the worker, and come back as messages.
|
||||
#[test]
|
||||
fn background_requests_never_wait_and_failures_come_back_as_messages() {
|
||||
let mut s = Session::with_demo();
|
||||
s.segmenter.background = true;
|
||||
let id = s.active().unwrap();
|
||||
s.segmenter.dir = Some(tmp("missing"));
|
||||
for (cmd, p) in [("mask.add", json!({"kind": "object"})), ("mask.add", json!({"kind": "prompt", "text": "sky"})), ("segment.prepare", json!({}))]
|
||||
{
|
||||
let t = Instant::now();
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(t.elapsed() < Duration::from_millis(500), "{cmd}");
|
||||
if Segmenter::AVAILABLE {
|
||||
assert!(e.starts_with(NOT_INSTALLED), "{cmd}: {e}");
|
||||
} else {
|
||||
assert!(e.contains("not available"), "{cmd}: {e}");
|
||||
}
|
||||
}
|
||||
assert!(s.develop_of(id).unwrap().masks.is_empty());
|
||||
if !Segmenter::AVAILABLE {
|
||||
return;
|
||||
}
|
||||
// a model folder whose weights are damaged
|
||||
let dir = damaged_model("damaged");
|
||||
s.segmenter.dir = Some(dir.clone());
|
||||
assert!(s.segmenter.installed());
|
||||
let t = Instant::now();
|
||||
s.execute("mask.add", &json!({"kind": "object"})).unwrap();
|
||||
let r = s.execute("mask.objectPoint", &json!({"x": 0.5, "y": 0.5})).unwrap();
|
||||
assert_eq!(r["pending"], true);
|
||||
assert!(s.segmenter.pending_clicks().is_some_and(|p| p.hint.len() == 1));
|
||||
// a second click while the first is on its way builds on it
|
||||
let r = s.execute("mask.objectPoint", &json!({"x": 0.6, "y": 0.5, "exclude": true})).unwrap();
|
||||
assert_eq!((r["include"].as_u64(), r["exclude"].as_u64()), (Some(1), Some(1)));
|
||||
let r = s.execute("mask.add", &json!({"kind": "prompt", "text": "the sky"})).unwrap();
|
||||
assert_eq!(r["pending"], true);
|
||||
assert!(t.elapsed() < Duration::from_secs(1), "nothing waited for the model: {:?}", t.elapsed());
|
||||
let messages = settle(&mut s, Duration::from_secs(60));
|
||||
assert!(!messages.is_empty() && messages.iter().all(|m| !m.is_empty()), "{messages:?}");
|
||||
assert!(messages.iter().any(|m| m.contains("model.safetensors") || m.contains("SAM 3")), "{messages:?}");
|
||||
assert!(s.segmenter.pending_clicks().is_none());
|
||||
// the Object mask is there, without a selection; no Describe mask was made from nothing
|
||||
let d = s.develop_of(id).unwrap();
|
||||
assert_eq!(d.masks.len(), 1);
|
||||
assert!(matches!(&d.masks[0].components[0].shape, lightcraft_develop::MaskShape::Object { seg: None, .. }));
|
||||
assert!(!s.segmenter.busy() && !s.segmenter.loaded());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// CLI / MCP (waiting mode): a damaged model is an error from the command, quickly.
|
||||
#[test]
|
||||
fn waiting_requests_with_a_damaged_model_are_errors() {
|
||||
if !Segmenter::AVAILABLE {
|
||||
return;
|
||||
}
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
let dir = damaged_model("damaged-wait");
|
||||
s.segmenter.dir = Some(dir.clone());
|
||||
let e = s.execute("mask.add", &json!({"kind": "prompt", "text": "sky"})).unwrap_err().to_string();
|
||||
assert!(e.contains("model.safetensors") || e.contains("SAM 3"), "{e}");
|
||||
let e = s.execute("mask.add", &json!({"kind": "object", "points": [[0.5, 0.5]]})).unwrap_err().to_string();
|
||||
assert!(!e.is_empty());
|
||||
assert!(s.develop_of(id).unwrap().masks.is_empty());
|
||||
assert!(!s.segmenter.busy());
|
||||
// a stored segmentation needs no model at all (replayed or pasted masks)
|
||||
let seg = serde_json::to_value(lightcraft_develop::SegMask::from_logits(4, &[5.0; 16])).unwrap();
|
||||
s.segmenter.dir = None;
|
||||
s.execute("mask.add", &json!({"kind": "prompt", "text": "sky", "seg": seg})).unwrap();
|
||||
assert_eq!(s.develop_of(id).unwrap().masks.len(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Too many clicks on one Object selection are refused, not sent to the model.
|
||||
#[test]
|
||||
fn object_clicks_are_capped() {
|
||||
if !Segmenter::AVAILABLE {
|
||||
return;
|
||||
}
|
||||
let mut s = Session::with_demo();
|
||||
s.segmenter.background = true;
|
||||
let dir = damaged_model("clicks");
|
||||
s.segmenter.dir = Some(dir.clone());
|
||||
let id = s.active().unwrap();
|
||||
let mut d = (*s.develop_of(id).unwrap()).clone();
|
||||
let many = vec![lightcraft_geom::Point::new(0.5, 0.5); crate::segment::MAX_CLICKS];
|
||||
d.masks.push(lightcraft_develop::Mask {
|
||||
id: 1,
|
||||
components: vec![lightcraft_develop::MaskComponent {
|
||||
name: None,
|
||||
op: lightcraft_develop::MaskOp::Add,
|
||||
invert: false,
|
||||
shape: lightcraft_develop::MaskShape::Object { hint: many, exclude: vec![], seg: None, detail: vec![], edge: 0.0 },
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
s.set_develop(id, d, "test").unwrap();
|
||||
s.active_mask = Some(1);
|
||||
let e = s.execute("mask.objectPoint", &json!({"x": 0.5, "y": 0.5})).unwrap_err().to_string();
|
||||
assert!(e.contains("at most"), "{e}");
|
||||
assert!(!s.segmenter.busy());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -19,6 +19,9 @@ pub enum LibrarySource {
|
||||
Folder,
|
||||
/// Photos whose original file can't be found (`library.missing`).
|
||||
Missing,
|
||||
/// A folder the library's photos were imported from ([`crate::Session::library_folder`]),
|
||||
/// and the folders inside it (`library.folders`).
|
||||
LibraryFolder,
|
||||
}
|
||||
|
||||
/// The folder a [`LibrarySource::Folder`] view shows.
|
||||
@@ -49,7 +52,7 @@ impl LibrarySource {
|
||||
LibrarySource::RecentlyDeleted => f.deleted = true,
|
||||
LibrarySource::Picks => f.flag = Some(lightcraft_catalog::Flag::Pick),
|
||||
// the folder itself is filled in by the session (it holds the path)
|
||||
LibrarySource::Folder | LibrarySource::Missing => {}
|
||||
LibrarySource::Folder | LibrarySource::Missing | LibrarySource::LibraryFolder => {}
|
||||
}
|
||||
f
|
||||
}
|
||||
@@ -62,6 +65,7 @@ impl LibrarySource {
|
||||
LibrarySource::RecentlyDeleted => "Recently Deleted".into(),
|
||||
LibrarySource::Picks => "Picks".into(),
|
||||
LibrarySource::Folder => "Folder".into(),
|
||||
LibrarySource::LibraryFolder => "Folder".into(),
|
||||
LibrarySource::Missing => "Missing Photos".into(),
|
||||
}
|
||||
}
|
||||
@@ -74,7 +78,27 @@ pub struct Selection {
|
||||
pub active: Option<PhotoId>,
|
||||
}
|
||||
|
||||
/// How a photo relates to the selection: what every view draws (grid cell, filmstrip cell…).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SelectionState {
|
||||
NotSelected,
|
||||
/// Selected, but not the photo the loupe and panels act on.
|
||||
Selected,
|
||||
/// The most-selected photo.
|
||||
Active,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
/// What views draw for `id`: active, selected or neither (one answer for grid, filmstrip, …).
|
||||
pub fn state_of(&self, id: PhotoId) -> SelectionState {
|
||||
if self.active == Some(id) {
|
||||
SelectionState::Active
|
||||
} else if self.contains(id) {
|
||||
SelectionState::Selected
|
||||
} else {
|
||||
SelectionState::NotSelected
|
||||
}
|
||||
}
|
||||
pub fn single(id: PhotoId) -> Selection {
|
||||
Selection { ids: vec![id], active: Some(id) }
|
||||
}
|
||||
@@ -173,6 +197,11 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec<FilterChip> {
|
||||
if let Some(d) = &f.date {
|
||||
add(format!("Date: {}", date_label(d)), json!({"date": Null}));
|
||||
}
|
||||
if let Some(d) = f.library_folder.as_deref().filter(|d| !d.trim().is_empty()) {
|
||||
// the last two names, so two folders called "Pictures" are told apart
|
||||
let name = lightcraft_catalog::folders::folder_label(d);
|
||||
add(format!("Folder: {name}"), json!({"libraryFolder": Null}));
|
||||
}
|
||||
if let Some(d) = &f.imported {
|
||||
add(format!("Imported: {}", date_label(d)), json!({"imported": Null}));
|
||||
}
|
||||
@@ -203,3 +232,18 @@ fn date_label(d: &str) -> String {
|
||||
_ => d.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod selection_state_tests {
|
||||
use super::*;
|
||||
use lightcraft_catalog::PhotoId;
|
||||
|
||||
#[test]
|
||||
fn select_all_marks_the_others_selected_and_one_active() {
|
||||
let ids: Vec<PhotoId> = (1..=3).map(PhotoId).collect();
|
||||
let s = Selection { ids: ids.clone(), active: Some(ids[0]) };
|
||||
assert_eq!(s.state_of(ids[0]), SelectionState::Active);
|
||||
assert_eq!(s.state_of(ids[1]), SelectionState::Selected);
|
||||
assert_eq!(s.state_of(PhotoId(9)), SelectionState::NotSelected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "lightcraft-fetch"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license = "Apache-2.0"
|
||||
description = "LightCraft model downloads: mirrors, resume, pinned SHA-256, over HTTPS in pure Rust (L0)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
# downloads run natively only (the web build fetches no models)
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
log = { workspace = true }
|
||||
rustls = { version = "0.23", default-features = false, features = ["std", "tls12"] }
|
||||
rustls-rustcrypto = "0.0.2-alpha"
|
||||
webpki-roots = "1"
|
||||
sha2 = { version = "0.10", default-features = false, features = ["std"] }
|
||||
@@ -0,0 +1,7 @@
|
||||
# lightcraft-fetch
|
||||
|
||||
Native model downloads in pure Rust (L0, Apache-2.0): HTTP/1.1 over std sockets and rustls with the RustCrypto provider, without ring, aws-lc, C or assembly build dependencies. The wasm32 crate is empty.
|
||||
|
||||
Call `download` with bounded file specifications and ordered mirrors, or `download_url` for an exact catalog URL (including its query). Each file has a local filename independent of the URL, a size cap and optional pinned size/SHA-256. Transfers resume pinned `.part` files, verify before renaming, report progress and check cancellation during reads. Run on a worker thread. No proxy support; DNS resolution uses the platform resolver. Tokens are off by default and never forwarded to a different redirect host.
|
||||
|
||||
Tests use local synthetic HTTP servers for redirects, resume, truncation, cancellation, stalls, hashes, size limits and hostile specifications; they make no internet requests.
|
||||
@@ -0,0 +1,550 @@
|
||||
//! A small HTTP/1.1 GET client for model downloads, in pure Rust: `std::net` sockets and, for
|
||||
//! `https://`, rustls with the RustCrypto provider (`rustls-rustcrypto`; no `ring` or
|
||||
//! `aws-lc-rs`, so nothing is compiled from C or assembly) and the Mozilla root certificates
|
||||
//! (`webpki-roots`).
|
||||
//!
|
||||
//! Only what a large-file download needs: one request per connection (`Connection: close`),
|
||||
//! `Range` requests for resuming, redirects (followed by the caller), `Content-Length`,
|
||||
//! chunked and read-to-close bodies. Every read waits at most `stall` for data and checks the
|
||||
//! cancel flag at least twice a second, so a dead server or a cancelled download never hangs
|
||||
//! the downloading thread. No proxies (see docs/ai-masks.md).
|
||||
|
||||
use std::io::{ErrorKind, Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Why a request failed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HttpError {
|
||||
/// The URL can't be used (scheme, host, characters).
|
||||
BadUrl(String),
|
||||
/// Name resolution or connecting failed (or took longer than the connect timeout).
|
||||
Connect(String),
|
||||
/// No data arrived for the stall timeout.
|
||||
Stalled,
|
||||
/// The cancel flag was raised.
|
||||
Cancelled,
|
||||
/// TLS failed (certificate, handshake).
|
||||
Tls(String),
|
||||
/// The server's response could not be understood.
|
||||
Protocol(String),
|
||||
/// Any other I/O error.
|
||||
Io(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HttpError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
HttpError::BadUrl(e) => write!(f, "unusable URL: {e}"),
|
||||
HttpError::Connect(e) => write!(f, "could not connect: {e}"),
|
||||
HttpError::Stalled => write!(f, "the server stopped sending data"),
|
||||
HttpError::Cancelled => write!(f, "cancelled"),
|
||||
HttpError::Tls(e) => write!(f, "secure connection failed: {e}"),
|
||||
HttpError::Protocol(e) => write!(f, "unexpected response: {e}"),
|
||||
HttpError::Io(e) => write!(f, "{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed `http://` or `https://` URL.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Url {
|
||||
pub tls: bool,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
/// Path and query, starting with `/`.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
impl Url {
|
||||
pub fn parse(s: &str) -> Result<Url, HttpError> {
|
||||
let bad = |why: &str| HttpError::BadUrl(why.to_string());
|
||||
if s.len() > 16 * 1024 {
|
||||
return Err(bad("URL exceeds 16 KiB"));
|
||||
}
|
||||
let s = s.trim();
|
||||
if s.chars().any(|c| c.is_control() || c == ' ') {
|
||||
return Err(bad("contains spaces or control characters"));
|
||||
}
|
||||
let (tls, rest) = if let Some(r) = s.strip_prefix("https://") {
|
||||
(true, r)
|
||||
} else if let Some(r) = s.strip_prefix("http://") {
|
||||
(false, r)
|
||||
} else {
|
||||
return Err(bad("only http:// and https:// are supported"));
|
||||
};
|
||||
let rest = rest.split('#').next().unwrap_or_default();
|
||||
let (authority, path) = match rest.find(['/', '?']) {
|
||||
Some(i) => (rest.get(..i).unwrap_or_default(), rest.get(i..).unwrap_or_default()),
|
||||
None => (rest, ""),
|
||||
};
|
||||
let path = if path.starts_with('/') { path.to_string() } else { format!("/{path}") };
|
||||
if authority.contains('@') {
|
||||
return Err(bad("user names and passwords in URLs are not supported"));
|
||||
}
|
||||
let (host, port) = if let Some(v6) = authority.strip_prefix('[') {
|
||||
let end = v6.find(']').ok_or_else(|| bad("unterminated IPv6 address"))?;
|
||||
let host = v6.get(..end).unwrap_or_default();
|
||||
let after = v6.get(end + 1..).unwrap_or_default();
|
||||
(host.to_string(), after.strip_prefix(':'))
|
||||
} else {
|
||||
match authority.rsplit_once(':') {
|
||||
Some((h, p)) => (h.to_string(), Some(p)),
|
||||
None => (authority.to_string(), None),
|
||||
}
|
||||
};
|
||||
let port = match port {
|
||||
Some(p) => p.parse::<u16>().ok().filter(|p| *p != 0).ok_or_else(|| bad("bad port"))?,
|
||||
None if tls => 443,
|
||||
None => 80,
|
||||
};
|
||||
if host.is_empty() || !host.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | ':' | '_')) {
|
||||
return Err(bad("bad host name"));
|
||||
}
|
||||
Ok(Url { tls, host: host.to_ascii_lowercase(), port, path })
|
||||
}
|
||||
|
||||
/// `Location` of a redirect, resolved against this URL.
|
||||
pub fn join(&self, location: &str) -> Result<Url, HttpError> {
|
||||
let l = location.trim();
|
||||
if l.starts_with("http://") || l.starts_with("https://") {
|
||||
return Url::parse(l);
|
||||
}
|
||||
// another scheme (`ftp:`, `file:`, `data:`…)
|
||||
if let Some((scheme, _)) = l.split_once(':')
|
||||
&& !scheme.is_empty()
|
||||
&& scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
|
||||
{
|
||||
return Err(HttpError::BadUrl(format!("redirect to an unsupported location `{scheme}:`")));
|
||||
}
|
||||
let scheme = if self.tls { "https" } else { "http" };
|
||||
let host = if self.host.contains(':') { format!("[{}]", self.host) } else { self.host.clone() };
|
||||
let base = format!("{scheme}://{host}:{}", self.port);
|
||||
if let Some(rest) = l.strip_prefix("//") {
|
||||
return Url::parse(&format!("{scheme}://{rest}"));
|
||||
}
|
||||
if l.starts_with('/') {
|
||||
return Url::parse(&format!("{base}{l}"));
|
||||
}
|
||||
// relative to the current directory
|
||||
let dir = self.path.split('?').next().unwrap_or("/");
|
||||
let dir = dir.rsplit_once('/').map_or("", |(d, _)| d);
|
||||
Url::parse(&format!("{base}{dir}/{l}"))
|
||||
}
|
||||
|
||||
fn host_header(&self) -> String {
|
||||
let host = if self.host.contains(':') { format!("[{}]", self.host) } else { self.host.clone() };
|
||||
let default = if self.tls { 443 } else { 80 };
|
||||
if self.port == default { host } else { format!("{host}:{}", self.port) }
|
||||
}
|
||||
|
||||
/// The URL without its query (for messages: a query may hold a signed token).
|
||||
pub fn display(&self) -> String {
|
||||
let scheme = if self.tls { "https" } else { "http" };
|
||||
format!("{scheme}://{}{}", self.host_header(), self.path.split('?').next().unwrap_or("/"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Timeouts and the cancel flag for one request.
|
||||
pub struct Limits<'a> {
|
||||
pub connect: Duration,
|
||||
pub stall: Duration,
|
||||
pub cancel: &'a AtomicBool,
|
||||
}
|
||||
|
||||
/// How often a blocked read wakes up to check the cancel flag.
|
||||
const TICK: Duration = Duration::from_millis(250);
|
||||
/// Longest status line + headers accepted.
|
||||
const MAX_HEAD: usize = 64 * 1024;
|
||||
|
||||
enum Stream {
|
||||
Plain(TcpStream),
|
||||
Tls(Box<rustls::ClientConnection>, TcpStream),
|
||||
}
|
||||
|
||||
fn is_timeout(e: &std::io::Error) -> bool {
|
||||
matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::Interrupted)
|
||||
}
|
||||
|
||||
fn tls_config() -> Result<Arc<rustls::ClientConfig>, HttpError> {
|
||||
let roots = rustls::RootCertStore { roots: webpki_roots::TLS_SERVER_ROOTS.to_vec() };
|
||||
let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls_rustcrypto::provider()))
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(|e| HttpError::Tls(e.to_string()))?
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
Ok(Arc::new(config))
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
fn connect(url: &Url, limits: &Limits) -> Result<Stream, HttpError> {
|
||||
let addrs: Vec<_> = (url.host.as_str(), url.port).to_socket_addrs().map_err(|e| HttpError::Connect(format!("{}: {e}", url.host)))?.collect();
|
||||
if addrs.is_empty() {
|
||||
return Err(HttpError::Connect(format!("{}: no address", url.host)));
|
||||
}
|
||||
let mut last = String::new();
|
||||
let mut sock = None;
|
||||
for a in addrs {
|
||||
if limits.cancel.load(Ordering::Relaxed) {
|
||||
return Err(HttpError::Cancelled);
|
||||
}
|
||||
match TcpStream::connect_timeout(&a, limits.connect) {
|
||||
Ok(s) => {
|
||||
sock = Some(s);
|
||||
break;
|
||||
}
|
||||
Err(e) => last = format!("{}: {e}", url.host),
|
||||
}
|
||||
}
|
||||
let sock = sock.ok_or(HttpError::Connect(last))?;
|
||||
sock.set_read_timeout(Some(TICK)).map_err(|e| HttpError::Io(e.to_string()))?;
|
||||
sock.set_write_timeout(Some(TICK)).map_err(|e| HttpError::Io(e.to_string()))?;
|
||||
let _ = sock.set_nodelay(true);
|
||||
if !url.tls {
|
||||
return Ok(Stream::Plain(sock));
|
||||
}
|
||||
let name = rustls::pki_types::ServerName::try_from(url.host.clone()).map_err(|e| HttpError::Tls(format!("{}: {e}", url.host)))?;
|
||||
let conn = rustls::ClientConnection::new(tls_config()?, name).map_err(|e| HttpError::Tls(e.to_string()))?;
|
||||
let mut s = Stream::Tls(Box::new(conn), sock);
|
||||
s.handshake(limits)?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Wait for `f` to make progress, retrying timeouts until `stall` passes without any.
|
||||
fn retry<T>(limits: &Limits, mut f: impl FnMut() -> std::io::Result<T>) -> Result<T, HttpError> {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if limits.cancel.load(Ordering::Relaxed) {
|
||||
return Err(HttpError::Cancelled);
|
||||
}
|
||||
match f() {
|
||||
Ok(v) => return Ok(v),
|
||||
Err(e) if is_timeout(&e) => {
|
||||
if start.elapsed() >= limits.stall {
|
||||
return Err(HttpError::Stalled);
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == ErrorKind::InvalidData => return Err(HttpError::Tls(e.to_string())),
|
||||
Err(e) => return Err(HttpError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handshake(&mut self, limits: &Limits) -> Result<(), HttpError> {
|
||||
let Stream::Tls(conn, sock) = self else { return Ok(()) };
|
||||
while conn.is_handshaking() {
|
||||
Self::retry(limits, || conn.complete_io(sock))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_all(&mut self, data: &[u8], limits: &Limits) -> Result<(), HttpError> {
|
||||
match self {
|
||||
Stream::Plain(sock) => {
|
||||
let mut rest = data;
|
||||
while !rest.is_empty() {
|
||||
let n = Self::retry(limits, || sock.write(rest))?;
|
||||
if n == 0 {
|
||||
return Err(HttpError::Io("connection closed while sending the request".into()));
|
||||
}
|
||||
rest = rest.get(n..).unwrap_or_default();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Stream::Tls(conn, sock) => {
|
||||
// buffered by rustls, then sent as TLS records
|
||||
conn.writer().write_all(data).map_err(|e| HttpError::Tls(e.to_string()))?;
|
||||
while conn.wants_write() {
|
||||
Self::retry(limits, || conn.write_tls(sock))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read some bytes (0 at the end of the stream), waiting at most `stall` for them.
|
||||
fn read(&mut self, buf: &mut [u8], limits: &Limits) -> Result<usize, HttpError> {
|
||||
match self {
|
||||
Stream::Plain(sock) => Self::retry(limits, || sock.read(buf)),
|
||||
Stream::Tls(conn, sock) => {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if limits.cancel.load(Ordering::Relaxed) {
|
||||
return Err(HttpError::Cancelled);
|
||||
}
|
||||
match conn.reader().read(buf) {
|
||||
Ok(n) => return Ok(n),
|
||||
// closed without close_notify: the caller checks the length it got
|
||||
Err(e) if e.kind() == ErrorKind::UnexpectedEof => return Ok(0),
|
||||
Err(e) if e.kind() == ErrorKind::WouldBlock => {}
|
||||
Err(e) => return Err(HttpError::Tls(e.to_string())),
|
||||
}
|
||||
// no plaintext buffered: more TLS records from the socket
|
||||
match conn.read_tls(sock) {
|
||||
Ok(0) => return Ok(0),
|
||||
Ok(_) => {
|
||||
conn.process_new_packets().map_err(|e| HttpError::Tls(e.to_string()))?;
|
||||
while conn.wants_write() {
|
||||
Self::retry(limits, || conn.write_tls(sock))?;
|
||||
}
|
||||
}
|
||||
Err(e) if is_timeout(&e) => {
|
||||
if start.elapsed() >= limits.stall {
|
||||
return Err(HttpError::Stalled);
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(HttpError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The connection with a read buffer (headers and chunk sizes are read line by line).
|
||||
struct Conn {
|
||||
stream: Stream,
|
||||
buf: Vec<u8>,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl Conn {
|
||||
fn fill(&mut self, limits: &Limits) -> Result<usize, HttpError> {
|
||||
if self.pos >= self.buf.len() {
|
||||
self.buf.clear();
|
||||
self.pos = 0;
|
||||
}
|
||||
let mut tmp = [0u8; 64 * 1024];
|
||||
let n = self.stream.read(&mut tmp, limits)?;
|
||||
self.buf.extend_from_slice(tmp.get(..n).unwrap_or_default());
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// One line without its CRLF; at most `max` bytes.
|
||||
fn line(&mut self, max: usize, limits: &Limits) -> Result<String, HttpError> {
|
||||
loop {
|
||||
let avail = self.buf.get(self.pos..).unwrap_or_default();
|
||||
if let Some(i) = avail.iter().position(|b| *b == b'\n') {
|
||||
let line = avail.get(..i).unwrap_or_default();
|
||||
let line = line.strip_suffix(b"\r").unwrap_or(line);
|
||||
let s = String::from_utf8_lossy(line).into_owned();
|
||||
self.pos = self.pos.saturating_add(i + 1);
|
||||
return Ok(s);
|
||||
}
|
||||
if avail.len() > max {
|
||||
return Err(HttpError::Protocol("header line too long".into()));
|
||||
}
|
||||
// keep the partial line, read more after it
|
||||
if self.pos > 0 {
|
||||
self.buf.drain(..self.pos.min(self.buf.len()));
|
||||
self.pos = 0;
|
||||
}
|
||||
let mut tmp = [0u8; 16 * 1024];
|
||||
let n = self.stream.read(&mut tmp, limits)?;
|
||||
if n == 0 {
|
||||
return Err(HttpError::Protocol("connection closed in the middle of the response".into()));
|
||||
}
|
||||
self.buf.extend_from_slice(tmp.get(..n).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
|
||||
/// Up to `out.len()` body bytes (0 at the end of the stream).
|
||||
fn read(&mut self, out: &mut [u8], limits: &Limits) -> Result<usize, HttpError> {
|
||||
if self.pos >= self.buf.len() && self.fill(limits)? == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let avail = self.buf.get(self.pos..).unwrap_or_default();
|
||||
let n = avail.len().min(out.len());
|
||||
if let (Some(dst), Some(src)) = (out.get_mut(..n), avail.get(..n)) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
self.pos = self.pos.saturating_add(n);
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// A response's status, headers and body.
|
||||
pub struct Response {
|
||||
pub status: u16,
|
||||
headers: Vec<(String, String)>,
|
||||
conn: Conn,
|
||||
body: BodyMode,
|
||||
}
|
||||
|
||||
enum BodyMode {
|
||||
Length(u64),
|
||||
Chunked { left: u64, done: bool },
|
||||
Close,
|
||||
Done,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
/// The first header named `name` (case-insensitive).
|
||||
pub fn header(&self, name: &str) -> Option<&str> {
|
||||
self.headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
/// The body's length when the server announced it.
|
||||
pub fn content_length(&self) -> Option<u64> {
|
||||
match self.body {
|
||||
BodyMode::Length(n) => Some(n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read body bytes into `out` (0 at the end of the body).
|
||||
pub fn read(&mut self, out: &mut [u8], limits: &Limits) -> Result<usize, HttpError> {
|
||||
match &mut self.body {
|
||||
BodyMode::Done => Ok(0),
|
||||
BodyMode::Length(left) => {
|
||||
if *left == 0 {
|
||||
self.body = BodyMode::Done;
|
||||
return Ok(0);
|
||||
}
|
||||
let want = usize::try_from(*left).unwrap_or(usize::MAX).min(out.len());
|
||||
let n = self.conn.read(out.get_mut(..want).unwrap_or_default(), limits)?;
|
||||
if n == 0 {
|
||||
return Err(HttpError::Protocol("the connection closed before the end of the file".into()));
|
||||
}
|
||||
*left = left.saturating_sub(n as u64);
|
||||
Ok(n)
|
||||
}
|
||||
BodyMode::Close => {
|
||||
let n = self.conn.read(out, limits)?;
|
||||
if n == 0 {
|
||||
self.body = BodyMode::Done;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
BodyMode::Chunked { left, done } => {
|
||||
if *done {
|
||||
return Ok(0);
|
||||
}
|
||||
if *left == 0 {
|
||||
let line = self.conn.line(1024, limits)?;
|
||||
let hex = line.split(';').next().unwrap_or_default().trim();
|
||||
let size = u64::from_str_radix(hex, 16).map_err(|_| HttpError::Protocol(format!("bad chunk size `{hex}`")))?;
|
||||
if size == 0 {
|
||||
// trailers, up to the empty line
|
||||
let mut n = 0;
|
||||
while !self.conn.line(MAX_HEAD, limits)?.is_empty() {
|
||||
n += 1;
|
||||
if n > 100 {
|
||||
return Err(HttpError::Protocol("too many trailers".into()));
|
||||
}
|
||||
}
|
||||
*done = true;
|
||||
return Ok(0);
|
||||
}
|
||||
*left = size;
|
||||
}
|
||||
let want = usize::try_from(*left).unwrap_or(usize::MAX).min(out.len());
|
||||
let n = self.conn.read(out.get_mut(..want).unwrap_or_default(), limits)?;
|
||||
if n == 0 {
|
||||
return Err(HttpError::Protocol("the connection closed in the middle of a chunk".into()));
|
||||
}
|
||||
*left = left.saturating_sub(n as u64);
|
||||
if *left == 0 && !self.conn.line(16, limits)?.is_empty() {
|
||||
return Err(HttpError::Protocol("missing CRLF after a chunk".into()));
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send `GET url` with extra `headers` and read the status line and headers.
|
||||
pub fn get(url: &Url, headers: &[(&str, String)], limits: &Limits) -> Result<Response, HttpError> {
|
||||
let mut req = format!(
|
||||
"GET {} HTTP/1.1\r\nHost: {}\r\nUser-Agent: LightCraft/{}\r\nAccept: */*\r\nAccept-Encoding: identity\r\nConnection: close\r\n",
|
||||
url.path,
|
||||
url.host_header(),
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
for (k, v) in headers {
|
||||
if v.chars().any(|c| c == '\r' || c == '\n') || k.chars().any(|c| c == '\r' || c == '\n' || c == ':') {
|
||||
return Err(HttpError::BadUrl("header with a line break".into()));
|
||||
}
|
||||
req.push_str(&format!("{k}: {v}\r\n"));
|
||||
}
|
||||
req.push_str("\r\n");
|
||||
let mut stream = Stream::connect(url, limits)?;
|
||||
stream.write_all(req.as_bytes(), limits)?;
|
||||
let mut conn = Conn { stream, buf: Vec::new(), pos: 0 };
|
||||
let status_line = conn.line(MAX_HEAD, limits)?;
|
||||
let mut parts = status_line.split_whitespace();
|
||||
let version = parts.next().unwrap_or_default();
|
||||
if !version.starts_with("HTTP/1.") {
|
||||
return Err(HttpError::Protocol(format!("not an HTTP/1.x response: {}", status_line.chars().take(60).collect::<String>())));
|
||||
}
|
||||
let status = parts.next().and_then(|s| s.parse::<u16>().ok()).ok_or_else(|| HttpError::Protocol("bad status line".into()))?;
|
||||
let mut headers = Vec::new();
|
||||
let mut total = status_line.len();
|
||||
loop {
|
||||
let line = conn.line(MAX_HEAD, limits)?;
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
total = total.saturating_add(line.len());
|
||||
if total > MAX_HEAD || headers.len() > 200 {
|
||||
return Err(HttpError::Protocol("response headers too large".into()));
|
||||
}
|
||||
if let Some((k, v)) = line.split_once(':') {
|
||||
headers.push((k.trim().to_string(), v.trim().to_string()));
|
||||
}
|
||||
}
|
||||
let find = |name: &str| headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str());
|
||||
let body = if status == 204 || status == 304 || (100..200).contains(&status) {
|
||||
BodyMode::Done
|
||||
} else if find("transfer-encoding").is_some_and(|v| v.to_ascii_lowercase().contains("chunked")) {
|
||||
BodyMode::Chunked { left: 0, done: false }
|
||||
} else if let Some(n) = find("content-length") {
|
||||
BodyMode::Length(n.trim().parse::<u64>().map_err(|_| HttpError::Protocol(format!("bad Content-Length `{n}`")))?)
|
||||
} else {
|
||||
BodyMode::Close
|
||||
};
|
||||
Ok(Response { status, headers, conn, body })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_and_control_character_urls() {
|
||||
assert!(Url::parse(&format!("https://example.com/{}", "x".repeat(16 * 1024))).is_err());
|
||||
assert!(Url::parse("https://example.com/x?secret=abc\r\nInjected: 1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_urls() {
|
||||
let u = Url::parse("https://cdn.example.com/models/sam3/model.safetensors?sig=1#x").unwrap();
|
||||
assert_eq!((u.tls, u.host.as_str(), u.port, u.path.as_str()), (true, "cdn.example.com", 443, "/models/sam3/model.safetensors?sig=1"));
|
||||
assert_eq!(u.display(), "https://cdn.example.com/models/sam3/model.safetensors");
|
||||
let u = Url::parse("http://127.0.0.1:8080").unwrap();
|
||||
assert_eq!((u.tls, u.port, u.path.as_str()), (false, 8080, "/"));
|
||||
let u = Url::parse("http://[::1]:9/a").unwrap();
|
||||
assert_eq!((u.host.as_str(), u.port), ("::1", 9));
|
||||
for bad in ["ftp://x/y", "https://", "https://a b/", "https://u:p@h/", "http://h:0/", "http://h:99999/", "https://h/\r\nX: y", "file:///etc"]
|
||||
{
|
||||
assert!(Url::parse(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_redirects() {
|
||||
let u = Url::parse("https://a.example/x/y/file?q").unwrap();
|
||||
assert_eq!(u.join("https://b.example/z").unwrap().host, "b.example");
|
||||
assert_eq!(u.join("/root").unwrap().path, "/root");
|
||||
assert_eq!(u.join("other").unwrap().path, "/x/y/other");
|
||||
assert_eq!(u.join("//c.example/p").unwrap().host, "c.example");
|
||||
assert!(u.join("ftp://nope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tls_config_builds_with_the_pure_rust_provider() {
|
||||
assert!(tls_config().is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
//! Downloading a model's files, only when the user asked for it: from an ordered list of mirrors
|
||||
//! (the next one when a mirror fails), resuming partial files, with connect and stall timeouts,
|
||||
//! a size cap, progress and cancellation, and checked (exact size, SHA-256 where pinned) before
|
||||
//! each file is moved into the model folder. Runs on whatever thread calls [`download`] (the
|
||||
//! engine uses a background thread; the UI thread never waits for the network). Pure Rust: the
|
||||
//! HTTPS client is rustls with the RustCrypto provider (see [`http`]).
|
||||
//!
|
||||
//! Files are written to `<name>.part` next to their final place and renamed when verified, so a
|
||||
//! model folder never holds a half-written file under a real name, and an interrupted download
|
||||
//! resumes from where it stopped. A file that fails its check is deleted, never kept.
|
||||
//!
|
||||
//! What to download and where from belongs to the caller: it passes the [`FileSpec`]s and the
|
||||
//! default mirrors (see [`mirrors`]). `lightcraft-segment` does so for the SAM 3 weights, which
|
||||
//! are never part of LightCraft (SAM License, see docs/ai-masks.md).
|
||||
//!
|
||||
//! Native only: on wasm32 this crate is empty (the web build downloads no models).
|
||||
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod http;
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use http::{HttpError, Limits, Url};
|
||||
|
||||
/// One file of the model.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FileSpec<'a> {
|
||||
pub name: &'a str,
|
||||
/// Exact size in bytes, when pinned (then the download resumes and is checked against it).
|
||||
pub size: Option<u64>,
|
||||
/// SHA-256 (lower-case hex), when pinned.
|
||||
pub sha256: Option<&'a str>,
|
||||
/// Largest size accepted when `size` is not pinned.
|
||||
pub max: u64,
|
||||
}
|
||||
|
||||
/// Most mirrors used (a hostile list can't make a download loop forever).
|
||||
const MAX_MIRRORS: usize = 16;
|
||||
/// Redirects followed per request.
|
||||
const MAX_REDIRECTS: usize = 8;
|
||||
|
||||
/// The mirrors to try, in order: `env`'s (base URLs separated by commas, spaces or newlines), then
|
||||
/// the mirrors file's (one base URL per line, `#` comments), then `defaults`; duplicates and
|
||||
/// unusable URLs dropped, at most 16.
|
||||
pub fn mirrors(env: Option<&str>, file: Option<&Path>, defaults: &[&str]) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut add = |s: &str| {
|
||||
let s = s.trim().trim_end_matches('/');
|
||||
if !s.is_empty() && Url::parse(s).is_ok() && !out.iter().any(|m| m == s) && out.len() < MAX_MIRRORS {
|
||||
out.push(s.to_string());
|
||||
}
|
||||
};
|
||||
if let Some(env) = env {
|
||||
env.split([',', ' ', '\n', '\t']).for_each(&mut add);
|
||||
}
|
||||
// a small text file; anything larger isn't a mirror list
|
||||
if let Some(text) = file.and_then(|f| std::fs::metadata(f).ok().filter(|m| m.len() < 64 * 1024).and_then(|_| std::fs::read_to_string(f).ok())) {
|
||||
text.lines().map(|l| l.split('#').next().unwrap_or_default()).for_each(&mut add);
|
||||
}
|
||||
defaults.iter().for_each(|m| add(m));
|
||||
out
|
||||
}
|
||||
|
||||
/// Download settings.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Options {
|
||||
/// Longest wait to connect to a mirror.
|
||||
pub connect_timeout: Duration,
|
||||
/// Longest wait for the next bytes once connected.
|
||||
pub stall_timeout: Duration,
|
||||
/// Tries per mirror and file (each resumes where the last one stopped).
|
||||
pub attempts: u32,
|
||||
/// The environment variable holding a bearer token, sent to each configured mirror's own host (only over https,
|
||||
/// never to a host a redirect leads to), so list only mirrors that may see it. `None`: nothing is ever sent, so one model's token cannot leak to another's server.
|
||||
pub token_env: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Options { connect_timeout: Duration::from_secs(15), stall_timeout: Duration::from_secs(30), attempts: 3, token_env: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a download is.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Progress {
|
||||
pub file: String,
|
||||
/// Bytes of all files on disk so far, and the total expected (pinned sizes + announced ones).
|
||||
pub done: u64,
|
||||
pub total: u64,
|
||||
/// The mirror in use (without query).
|
||||
pub mirror: String,
|
||||
}
|
||||
|
||||
/// Why a download failed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DownloadError {
|
||||
/// No mirror is configured.
|
||||
NoMirrors,
|
||||
/// Invalid filename, size, hash or aggregate bound supplied by the caller.
|
||||
Invalid(String),
|
||||
Cancelled,
|
||||
/// A local file problem (folder not writable, disk full…).
|
||||
Disk(String),
|
||||
/// Every mirror failed for `file`; the reason for each.
|
||||
AllMirrorsFailed {
|
||||
file: String,
|
||||
errors: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DownloadError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DownloadError::Invalid(e) => write!(f, "invalid download: {e}"),
|
||||
DownloadError::NoMirrors => write!(f, "no download location is configured for this model in this build"),
|
||||
DownloadError::Cancelled => write!(f, "the download was cancelled"),
|
||||
DownloadError::Disk(e) => write!(f, "{e}"),
|
||||
DownloadError::AllMirrorsFailed { file, errors } => write!(f, "could not download {file}: {}", errors.join("; ")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One mirror's failure for one file; `Retry` resumes on the same mirror.
|
||||
enum Fail {
|
||||
Retry(String),
|
||||
NextMirror(String),
|
||||
Cancelled,
|
||||
Disk(String),
|
||||
}
|
||||
|
||||
/// Fetch a single file from its exact URL, preserving any query string. The local filename
|
||||
/// remains `file.name`; it never comes from redirects or response headers.
|
||||
pub fn download_url(
|
||||
file: &FileSpec<'_>,
|
||||
url: &str,
|
||||
dir: &Path,
|
||||
opts: &Options,
|
||||
cancel: &AtomicBool,
|
||||
progress: &mut dyn FnMut(&Progress),
|
||||
) -> Result<(), DownloadError> {
|
||||
download_source(std::slice::from_ref(file), &[url.to_string()], dir, opts, cancel, progress, true)
|
||||
}
|
||||
|
||||
/// Download `files` into `dir` from `mirrors` (in order; see [`mirrors`]). Verified files
|
||||
/// are kept. `progress` is called as bytes arrive (often: throttle in it).
|
||||
pub fn download(
|
||||
files: &[FileSpec<'_>],
|
||||
mirrors: &[String],
|
||||
dir: &Path,
|
||||
opts: &Options,
|
||||
cancel: &AtomicBool,
|
||||
progress: &mut dyn FnMut(&Progress),
|
||||
) -> Result<(), DownloadError> {
|
||||
download_source(files, mirrors, dir, opts, cancel, progress, false)
|
||||
}
|
||||
|
||||
fn download_source(
|
||||
files: &[FileSpec<'_>],
|
||||
mirrors: &[String],
|
||||
dir: &Path,
|
||||
opts: &Options,
|
||||
cancel: &AtomicBool,
|
||||
progress: &mut dyn FnMut(&Progress),
|
||||
exact_urls: bool,
|
||||
) -> Result<(), DownloadError> {
|
||||
let total = validate_files(files)?;
|
||||
if mirrors.len() > MAX_MIRRORS {
|
||||
return Err(DownloadError::Invalid("too many mirrors".into()));
|
||||
}
|
||||
std::fs::create_dir_all(dir).map_err(|e| DownloadError::Disk(format!("can't create {}: {e}", dir.display())))?;
|
||||
// what's left to fetch
|
||||
let mut todo = Vec::new();
|
||||
for f in files {
|
||||
if !verified(f, &dir.join(f.name))? {
|
||||
todo.push(f);
|
||||
}
|
||||
}
|
||||
if todo.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if mirrors.is_empty() {
|
||||
return Err(DownloadError::NoMirrors);
|
||||
}
|
||||
let mut before: u64 = files.iter().filter(|f| !todo.contains(f)).filter_map(|f| f.size).sum();
|
||||
for f in todo {
|
||||
let mut errors = Vec::new();
|
||||
let mut ok = false;
|
||||
'mirrors: for m in mirrors {
|
||||
let url = if exact_urls { m.clone() } else { format!("{m}/{}", f.name) };
|
||||
for _ in 0..opts.attempts.clamp(1, 16) {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(DownloadError::Cancelled);
|
||||
}
|
||||
let mut report = |n: u64, announced: u64| {
|
||||
let mirror = Url::parse(m).map(|u| u.display()).unwrap_or_default();
|
||||
progress(&Progress {
|
||||
file: f.name.to_string(),
|
||||
done: before.saturating_add(n),
|
||||
total: total.max(before.saturating_add(announced)),
|
||||
mirror,
|
||||
})
|
||||
};
|
||||
match fetch_file(f, &url, dir, opts, cancel, &mut report) {
|
||||
Ok(()) => {
|
||||
ok = true;
|
||||
break 'mirrors;
|
||||
}
|
||||
Err(Fail::Cancelled) => return Err(DownloadError::Cancelled),
|
||||
Err(Fail::Disk(e)) => return Err(DownloadError::Disk(e)),
|
||||
Err(Fail::Retry(e)) => {
|
||||
log::warn!("download of {}: {e} (retrying)", short(&url));
|
||||
errors.push(format!("{}: {e}", short(m)));
|
||||
}
|
||||
Err(Fail::NextMirror(e)) => {
|
||||
log::warn!("download of {}: {e}", short(&url));
|
||||
errors.push(format!("{}: {e}", short(m)));
|
||||
continue 'mirrors;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return Err(DownloadError::AllMirrorsFailed { file: f.name.to_string(), errors });
|
||||
}
|
||||
before = before.saturating_add(std::fs::metadata(dir.join(f.name)).map(|m| m.len()).unwrap_or(0));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate before touching disk, including names supplied by a user model catalog.
|
||||
fn validate_files(files: &[FileSpec<'_>]) -> Result<u64, DownloadError> {
|
||||
if files.len() > 256 {
|
||||
return Err(DownloadError::Invalid("too many files".into()));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut total = 0u64;
|
||||
for f in files {
|
||||
let stem = f.name.split('.').next().unwrap_or_default().to_ascii_uppercase();
|
||||
let reserved = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| stem
|
||||
.strip_prefix("COM")
|
||||
.or_else(|| stem.strip_prefix("LPT"))
|
||||
.is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
|
||||
if reserved
|
||||
|| f.name.ends_with(['.', ' '])
|
||||
|| f.name.ends_with(".part")
|
||||
|| f.name.is_empty()
|
||||
|| f.name.len() > 255
|
||||
|| f.name.starts_with('.')
|
||||
|| f.name.contains(['/', '\\', ':'])
|
||||
|| f.name.chars().any(char::is_control)
|
||||
|| !seen.insert(f.name)
|
||||
{
|
||||
return Err(DownloadError::Invalid("a filename must be a unique, ordinary filename".into()));
|
||||
}
|
||||
if f.max == 0
|
||||
|| f.size.is_some_and(|n| n == 0 || n > f.max)
|
||||
|| f.sha256.is_some_and(|h| h.len() != 64 || !h.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)))
|
||||
{
|
||||
return Err(DownloadError::Invalid("invalid file size or SHA-256".into()));
|
||||
}
|
||||
total = total.checked_add(f.size.unwrap_or(0)).ok_or_else(|| DownloadError::Invalid("total size overflow".into()))?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// The bearer token to send to `url`: only a non-empty one, only over https, and only to the host the download began
|
||||
/// at (`origin`), never to the host a redirect leads to.
|
||||
fn bearer<'a>(token: Option<&'a str>, url: &Url, origin: &str) -> Option<&'a str> {
|
||||
token.map(str::trim).filter(|t| !t.is_empty() && url.tls && url.host == origin)
|
||||
}
|
||||
|
||||
/// A mirror for messages (no query string: it may hold a token).
|
||||
fn short(m: &str) -> String {
|
||||
Url::parse(m).map(|u| u.display()).unwrap_or_else(|_| "mirror".into())
|
||||
}
|
||||
|
||||
/// Whether `path` is already the right file (exact size and hash when pinned).
|
||||
fn verified(f: &FileSpec<'_>, path: &Path) -> Result<bool, DownloadError> {
|
||||
let Ok(meta) = std::fs::metadata(path) else { return Ok(false) };
|
||||
if !meta.is_file() || meta.len() == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if f.size.is_some_and(|s| s != meta.len()) || meta.len() > f.max {
|
||||
return Ok(false);
|
||||
}
|
||||
match f.sha256 {
|
||||
Some(want) => Ok(sha256_file(path).map_err(DownloadError::Disk)? == want),
|
||||
None => Ok(true),
|
||||
}
|
||||
}
|
||||
|
||||
fn sha256_file(path: &Path) -> Result<String, String> {
|
||||
let mut file = File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut h = Sha256::new();
|
||||
let mut buf = vec![0u8; 1 << 20];
|
||||
loop {
|
||||
let n = file.read(&mut buf).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
h.update(buf.get(..n).unwrap_or_default());
|
||||
}
|
||||
Ok(h.finalize().iter().map(|b| format!("{b:02x}")).collect())
|
||||
}
|
||||
|
||||
fn part_path(dir: &Path, name: &str) -> PathBuf {
|
||||
dir.join(format!("{name}.part"))
|
||||
}
|
||||
|
||||
/// Fetch one file from one mirror into `<name>.part` (resuming it when its size is pinned),
|
||||
/// check it and move it into place.
|
||||
fn fetch_file(f: &FileSpec<'_>, url: &str, dir: &Path, opts: &Options, cancel: &AtomicBool, report: &mut dyn FnMut(u64, u64)) -> Result<(), Fail> {
|
||||
let part = part_path(dir, f.name);
|
||||
let limit = f.size.unwrap_or(f.max);
|
||||
let disk = |e: std::io::Error| Fail::Disk(format!("{}: {e}", part.display()));
|
||||
// resume only a pinned file (an unpinned one can't be checked after resuming)
|
||||
let mut have = match std::fs::metadata(&part) {
|
||||
Ok(m) if f.size.is_some() && m.len() <= limit => m.len(),
|
||||
Ok(_) => {
|
||||
std::fs::remove_file(&part).map_err(disk)?;
|
||||
0
|
||||
}
|
||||
Err(_) => 0,
|
||||
};
|
||||
let fetched = f.size != Some(have) || have == 0;
|
||||
if fetched {
|
||||
let limits = Limits { connect: opts.connect_timeout, stall: opts.stall_timeout, cancel };
|
||||
let mut url = Url::parse(url).map_err(|e| Fail::NextMirror(e.to_string()))?;
|
||||
let origin = url.host.clone();
|
||||
let mut redirects = 0;
|
||||
let mut resp = loop {
|
||||
let mut headers = vec![];
|
||||
if have > 0 {
|
||||
headers.push(("Range", format!("bytes={have}-")));
|
||||
}
|
||||
// a token for the configured host only (never sent on to a redirect's host)
|
||||
if let Some(t) = bearer(opts.token_env.and_then(|name| std::env::var(name).ok()).as_deref(), &url, &origin) {
|
||||
headers.push(("Authorization", format!("Bearer {t}")));
|
||||
}
|
||||
let r = http::get(&url, &headers, &limits).map_err(http_fail)?;
|
||||
match r.status {
|
||||
301 | 302 | 303 | 307 | 308 => {
|
||||
redirects += 1;
|
||||
if redirects > MAX_REDIRECTS {
|
||||
return Err(Fail::NextMirror("too many redirects".into()));
|
||||
}
|
||||
let loc = r.header("location").ok_or_else(|| Fail::NextMirror("redirect without a location".into()))?;
|
||||
url = url.join(loc).map_err(|e| Fail::NextMirror(e.to_string()))?;
|
||||
}
|
||||
_ => break r,
|
||||
}
|
||||
};
|
||||
let mut file = match resp.status {
|
||||
200 => {
|
||||
// the whole file (from the start, also when a range was asked for)
|
||||
if resp.content_length().is_some_and(|n| n > limit || f.size.is_some_and(|s| s != n)) {
|
||||
return Err(Fail::NextMirror(format!("the server's file has the wrong size ({:?} bytes)", resp.content_length())));
|
||||
}
|
||||
have = 0;
|
||||
File::create(&part).map_err(disk)?
|
||||
}
|
||||
206 if have > 0 => {
|
||||
let range = resp.header("content-range").unwrap_or_default();
|
||||
let (start, total) = parse_content_range(range).ok_or_else(|| Fail::NextMirror(format!("bad Content-Range `{range}`")))?;
|
||||
if start != have || total.is_some_and(|t| Some(t) != f.size) {
|
||||
return Err(Fail::NextMirror(format!("the server resumed at the wrong place ({range})")));
|
||||
}
|
||||
OpenOptions::new().append(true).open(&part).map_err(disk)?
|
||||
}
|
||||
416 => {
|
||||
// the partial file doesn't fit what the server has: start over
|
||||
std::fs::remove_file(&part).map_err(disk)?;
|
||||
return Err(Fail::Retry("the partial download didn't match; starting over".into()));
|
||||
}
|
||||
401 | 403 => return Err(Fail::NextMirror(format!("access denied (HTTP {})", resp.status))),
|
||||
404 | 410 => return Err(Fail::NextMirror(format!("not found (HTTP {})", resp.status))),
|
||||
s if s >= 500 => return Err(Fail::Retry(format!("server error (HTTP {s})"))),
|
||||
s => return Err(Fail::NextMirror(format!("HTTP {s}"))),
|
||||
};
|
||||
let announced = f.size.unwrap_or_else(|| resp.content_length().unwrap_or(0));
|
||||
report(have, announced);
|
||||
let mut buf = vec![0u8; 256 * 1024];
|
||||
loop {
|
||||
let n = match resp.read(&mut buf, &limits) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
let _ = file.flush();
|
||||
if f.size.is_none() {
|
||||
drop(file);
|
||||
let _ = std::fs::remove_file(&part);
|
||||
}
|
||||
return Err(http_fail(e));
|
||||
}
|
||||
};
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
if have.saturating_add(n as u64) > limit {
|
||||
drop(file);
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(Fail::NextMirror(format!("the server sent more than the expected {limit} bytes")));
|
||||
}
|
||||
file.write_all(buf.get(..n).unwrap_or_default()).map_err(disk)?;
|
||||
have = have.saturating_add(n as u64);
|
||||
report(have, announced);
|
||||
}
|
||||
file.sync_all().map_err(disk)?;
|
||||
}
|
||||
// checks: exact size, hash
|
||||
let len = std::fs::metadata(&part).map_err(disk)?.len();
|
||||
if f.size.is_some_and(|s| s != len) {
|
||||
return Err(Fail::Retry(format!("incomplete ({len} of {} bytes)", f.size.unwrap_or(0))));
|
||||
}
|
||||
if len == 0 {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(Fail::NextMirror("the server sent an empty file".into()));
|
||||
}
|
||||
if let Some(want) = f.sha256 {
|
||||
let got = sha256_file(&part).map_err(Fail::Disk)?;
|
||||
if got != want {
|
||||
std::fs::remove_file(&part).map_err(disk)?;
|
||||
let why = format!("the file is damaged or not the official one (SHA-256 {got})");
|
||||
// a damaged partial file from an earlier run isn't this mirror's fault
|
||||
return Err(if fetched { Fail::NextMirror(why) } else { Fail::Retry(why) });
|
||||
}
|
||||
}
|
||||
std::fs::rename(&part, dir.join(f.name)).map_err(disk)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn http_fail(e: HttpError) -> Fail {
|
||||
match e {
|
||||
HttpError::Cancelled => Fail::Cancelled,
|
||||
HttpError::Stalled | HttpError::Io(_) | HttpError::Protocol(_) => Fail::Retry(e.to_string()),
|
||||
HttpError::BadUrl(_) | HttpError::Connect(_) | HttpError::Tls(_) => Fail::NextMirror(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `bytes a-b/total` → (a, total) (`*` total → None).
|
||||
fn parse_content_range(v: &str) -> Option<(u64, Option<u64>)> {
|
||||
let v = v.trim().strip_prefix("bytes")?.trim();
|
||||
let (range, total) = v.split_once('/')?;
|
||||
let (a, b) = range.split_once('-')?;
|
||||
let (a, b) = (a.trim().parse::<u64>().ok()?, b.trim().parse::<u64>().ok()?);
|
||||
if b < a {
|
||||
return None;
|
||||
}
|
||||
let total = match total.trim() {
|
||||
"*" => None,
|
||||
t => Some(t.parse::<u64>().ok()?),
|
||||
};
|
||||
Some((a, total))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,388 @@
|
||||
//! The downloader against a local HTTP server (std `TcpListener`; never the internet): mirror
|
||||
//! fallback, redirects, resuming, chunked bodies, stalls, hash mismatches, size caps, cancel.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The served file: 300 kB of a deterministic pattern.
|
||||
fn payload() -> Vec<u8> {
|
||||
(0..300_000u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect()
|
||||
}
|
||||
|
||||
fn sha_hex(b: &[u8]) -> String {
|
||||
Sha256::digest(b).iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn leak(s: String) -> &'static str {
|
||||
Box::leak(s.into_boxed_str())
|
||||
}
|
||||
|
||||
fn pinned() -> FileSpec<'static> {
|
||||
let p = payload();
|
||||
FileSpec { name: "model.bin", size: Some(p.len() as u64), sha256: Some(leak(sha_hex(&p))), max: p.len() as u64 }
|
||||
}
|
||||
|
||||
struct Server {
|
||||
base: String,
|
||||
hits: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
/// Serve on 127.0.0.1; the first path segment picks the behaviour.
|
||||
fn server() -> Server {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base = format!("http://{}", l.local_addr().unwrap());
|
||||
let hits = Arc::new(AtomicUsize::new(0));
|
||||
let h = hits.clone();
|
||||
std::thread::spawn(move || {
|
||||
for s in l.incoming().flatten() {
|
||||
let h = h.clone();
|
||||
std::thread::spawn(move || handle(s, &h));
|
||||
}
|
||||
});
|
||||
Server { base, hits }
|
||||
}
|
||||
|
||||
fn handle(mut s: TcpStream, hits: &AtomicUsize) {
|
||||
let n = hits.fetch_add(1, Ordering::SeqCst);
|
||||
let mut r = BufReader::new(s.try_clone().unwrap());
|
||||
let mut first = String::new();
|
||||
if r.read_line(&mut first).is_err() {
|
||||
return;
|
||||
}
|
||||
let mut range: Option<u64> = None;
|
||||
loop {
|
||||
let mut l = String::new();
|
||||
if r.read_line(&mut l).unwrap_or(0) == 0 || l.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some(v) = l.to_ascii_lowercase().strip_prefix("range: bytes=") {
|
||||
range = v.trim().trim_end_matches('-').parse().ok();
|
||||
}
|
||||
}
|
||||
let path = first.split_whitespace().nth(1).unwrap_or("/").to_string();
|
||||
let mode = path.trim_start_matches('/').split('/').next().unwrap_or("").to_string();
|
||||
let body = payload();
|
||||
let send = |s: &mut TcpStream, status: &str, headers: &str, data: &[u8]| {
|
||||
let _ = s.write_all(format!("HTTP/1.1 {status}\r\n{headers}Connection: close\r\n\r\n").as_bytes());
|
||||
let _ = s.write_all(data);
|
||||
};
|
||||
match mode.as_str() {
|
||||
"missing" => send(&mut s, "404 Not Found", "Content-Length: 9\r\n", b"not found"),
|
||||
"error" => send(&mut s, "500 Oops", "Content-Length: 25\r\n", b"<html>server error</html>"),
|
||||
"redirect" => send(&mut s, "302 Found", &format!("Location: {}\r\nContent-Length: 0\r\n", path.replacen("/redirect", "/good", 1)), b""),
|
||||
"wrong" => {
|
||||
let mut b = body.clone();
|
||||
b[1000] ^= 0xff;
|
||||
send(&mut s, "200 OK", &format!("Content-Length: {}\r\n", b.len()), &b)
|
||||
}
|
||||
"toolong" => {
|
||||
// no length announced, more bytes than the file can have
|
||||
let mut b = body.clone();
|
||||
b.extend_from_slice(&[7u8; 5000]);
|
||||
send(&mut s, "200 OK", "", &b)
|
||||
}
|
||||
"chunked" => {
|
||||
let _ = s.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n");
|
||||
for c in body.chunks(70_001) {
|
||||
let _ = s.write_all(format!("{:x};ext=1\r\n", c.len()).as_bytes());
|
||||
let _ = s.write_all(c);
|
||||
let _ = s.write_all(b"\r\n");
|
||||
}
|
||||
let _ = s.write_all(b"0\r\nX-Trailer: 1\r\n\r\n");
|
||||
}
|
||||
"stall" => {
|
||||
// half the file, then nothing
|
||||
let _ = s.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()).as_bytes());
|
||||
let _ = s.write_all(&body[..body.len() / 2]);
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
}
|
||||
"slow" => {
|
||||
let _ = s.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()).as_bytes());
|
||||
for c in body.chunks(1000) {
|
||||
if s.write_all(c).is_err() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
"flaky" if n.is_multiple_of(2) && range.is_none() => {
|
||||
// the first try breaks off after a third
|
||||
let _ = s.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()).as_bytes());
|
||||
let _ = s.write_all(&body[..body.len() / 3]);
|
||||
}
|
||||
"range416" if range.is_some() => send(&mut s, "416 Range Not Satisfiable", "Content-Length: 0\r\n", b""),
|
||||
"norange" | "range416" => send(&mut s, "200 OK", &format!("Content-Length: {}\r\n", body.len()), &body),
|
||||
_ => match range {
|
||||
Some(from) if from as usize >= body.len() => send(&mut s, "416 Range Not Satisfiable", "Content-Length: 0\r\n", b""),
|
||||
Some(from) => send(
|
||||
&mut s,
|
||||
"206 Partial Content",
|
||||
&format!("Content-Range: bytes {from}-{}/{}\r\nContent-Length: {}\r\n", body.len() - 1, body.len(), body.len() - from as usize),
|
||||
&body[from as usize..],
|
||||
),
|
||||
None => send(&mut s, "200 OK", &format!("Content-Length: {}\r\n", body.len()), &body),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-sam3-fetch-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn quick() -> Options {
|
||||
Options { connect_timeout: Duration::from_secs(2), stall_timeout: Duration::from_millis(600), attempts: 2, token_env: None }
|
||||
}
|
||||
|
||||
fn run(files: &[FileSpec<'_>], mirrors: &[String], dir: &Path, cancel: &AtomicBool) -> (Result<(), DownloadError>, Vec<Progress>) {
|
||||
let mut seen = Vec::new();
|
||||
let r = download(files, mirrors, dir, &quick(), cancel, &mut |p| seen.push(p.clone()));
|
||||
(r, seen)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_the_next_mirror_and_verifies() {
|
||||
let srv = server();
|
||||
let dir = tmp("fallback");
|
||||
let mirrors = vec![
|
||||
format!("{}/missing", srv.base),
|
||||
format!("{}/error", srv.base),
|
||||
"http://127.0.0.1:1/unreachable".to_string(),
|
||||
format!("{}/redirect", srv.base),
|
||||
];
|
||||
let (r, seen) = run(&[pinned()], &mirrors, &dir, &AtomicBool::new(false));
|
||||
r.unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload());
|
||||
assert!(!part_path(&dir, "model.bin").exists());
|
||||
let last = seen.last().unwrap();
|
||||
assert_eq!((last.done, last.total), (300_000, 300_000));
|
||||
// an error page never ends up in the file: nothing left over from the failed mirrors
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
|
||||
// installed: nothing is fetched again
|
||||
let hits = srv.hits.load(Ordering::SeqCst);
|
||||
run(&[pinned()], &mirrors, &dir, &AtomicBool::new(false)).0.unwrap();
|
||||
assert_eq!(srv.hits.load(Ordering::SeqCst), hits);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hash_mismatch_is_deleted_and_the_next_mirror_used() {
|
||||
let srv = server();
|
||||
let dir = tmp("hash");
|
||||
let (r, _) = run(&[pinned()], &[format!("{}/wrong", srv.base)], &dir, &AtomicBool::new(false));
|
||||
let e = r.unwrap_err().to_string();
|
||||
assert!(e.contains("SHA-256"), "{e}");
|
||||
assert!(!dir.join("model.bin").exists() && !part_path(&dir, "model.bin").exists(), "a damaged file is never kept");
|
||||
run(&[pinned()], &[format!("{}/wrong", srv.base), format!("{}/good", srv.base)], &dir, &AtomicBool::new(false)).0.unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumes_partial_files_and_restarts_bad_ones() {
|
||||
let srv = server();
|
||||
let dir = tmp("resume");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// a third already there
|
||||
std::fs::write(part_path(&dir, "model.bin"), &payload()[..100_000]).unwrap();
|
||||
run(&[pinned()], &[format!("{}/good", srv.base)], &dir, &AtomicBool::new(false)).0.unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload());
|
||||
// a partial file with the wrong bytes: the hash fails, it's deleted, the next try starts over
|
||||
std::fs::remove_file(dir.join("model.bin")).unwrap();
|
||||
std::fs::write(part_path(&dir, "model.bin"), vec![0u8; 100_000]).unwrap();
|
||||
run(&[pinned()], &[format!("{}/good", srv.base), format!("{}/good", srv.base)], &dir, &AtomicBool::new(false)).0.unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload());
|
||||
// a server ignoring Range: the whole file again, not appended
|
||||
std::fs::remove_file(dir.join("model.bin")).unwrap();
|
||||
std::fs::write(part_path(&dir, "model.bin"), &payload()[..5]).unwrap();
|
||||
run(&[pinned()], &[format!("{}/norange", srv.base)], &dir, &AtomicBool::new(false)).0.unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload());
|
||||
// a connection breaking off is resumed on the same mirror
|
||||
std::fs::remove_file(dir.join("model.bin")).unwrap();
|
||||
run(&[pinned()], &[format!("{}/flaky", srv.base)], &dir, &AtomicBool::new(false)).0.unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_416_or_a_bad_complete_part_starts_over() {
|
||||
let srv = server();
|
||||
let dir = tmp("416");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// the server refuses the range: the part is dropped and the file fetched whole (no loop)
|
||||
std::fs::write(part_path(&dir, "model.bin"), &payload()[..100]).unwrap();
|
||||
run(&[pinned()], &[format!("{}/range416", srv.base)], &dir, &AtomicBool::new(false)).0.unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload());
|
||||
// as long as the file but wrong: hash fails → deleted → downloaded again from the same mirror
|
||||
std::fs::remove_file(dir.join("model.bin")).unwrap();
|
||||
std::fs::write(part_path(&dir, "model.bin"), vec![1u8; 300_000]).unwrap();
|
||||
run(&[pinned()], &[format!("{}/good", srv.base)], &dir, &AtomicBool::new(false)).0.unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunked_bodies_and_unpinned_files() {
|
||||
let srv = server();
|
||||
let dir = tmp("chunked");
|
||||
let f = FileSpec { name: "vocab.json", size: None, sha256: None, max: 1 << 20 };
|
||||
run(std::slice::from_ref(&f), &[format!("{}/chunked", srv.base)], &dir, &AtomicBool::new(false)).0.unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("vocab.json")).unwrap(), payload());
|
||||
// more than the cap: refused, nothing kept
|
||||
let small = FileSpec { max: 1000, ..f };
|
||||
let dir2 = tmp("cap");
|
||||
let e = run(&[small], &[format!("{}/good", srv.base)], &dir2, &AtomicBool::new(false)).0.unwrap_err().to_string();
|
||||
assert!(e.contains("wrong size") || e.contains("more than"), "{e}");
|
||||
assert!(!dir2.join("vocab.json").exists() && !part_path(&dir2, "vocab.json").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_dir_all(&dir2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_sending_too_much_is_cut_off() {
|
||||
let srv = server();
|
||||
let dir = tmp("toolong");
|
||||
let e = run(&[pinned()], &[format!("{}/toolong", srv.base)], &dir, &AtomicBool::new(false)).0.unwrap_err().to_string();
|
||||
assert!(e.contains("more than"), "{e}");
|
||||
assert!(!dir.join("model.bin").exists() && !part_path(&dir, "model.bin").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stalled_server_times_out_and_the_next_mirror_finishes() {
|
||||
let srv = server();
|
||||
let dir = tmp("stall");
|
||||
let t = Instant::now();
|
||||
let (r, _) = run(&[pinned()], &[format!("{}/stall", srv.base), format!("{}/good", srv.base)], &dir, &AtomicBool::new(false));
|
||||
r.unwrap();
|
||||
assert!(t.elapsed() < Duration::from_secs(4), "{:?}", t.elapsed());
|
||||
assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_stops_quickly_and_keeps_the_part_for_later() {
|
||||
let srv = server();
|
||||
let dir = tmp("cancel");
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let c = cancel.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
c.store(true, Ordering::SeqCst);
|
||||
});
|
||||
let t = Instant::now();
|
||||
let (r, _) = run(&[pinned()], &[format!("{}/slow", srv.base)], &dir, &cancel);
|
||||
assert_eq!(r, Err(DownloadError::Cancelled));
|
||||
assert!(t.elapsed() < Duration::from_secs(3), "{:?}", t.elapsed());
|
||||
assert!(!dir.join("model.bin").exists());
|
||||
let kept = std::fs::metadata(part_path(&dir, "model.bin")).map(|m| m.len()).unwrap_or(0);
|
||||
assert!(kept > 0 && kept < 300_000, "{kept}");
|
||||
// and the next run resumes it
|
||||
run(&[pinned()], &[format!("{}/good", srv.base)], &dir, &AtomicBool::new(false)).0.unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_mirrors_is_a_clear_error_and_mirror_lists_are_parsed() {
|
||||
let dir = tmp("nomirror");
|
||||
let e = run(&[pinned()], &[], &dir, &AtomicBool::new(false)).0.unwrap_err();
|
||||
assert_eq!(e, DownloadError::NoMirrors);
|
||||
assert!(e.to_string().contains("no download location"));
|
||||
let file = std::env::temp_dir().join(format!("lc-sam3-mirrors-{}.txt", std::process::id()));
|
||||
std::fs::write(&file, "# mine\nhttps://b.example/m/ # second\n\nnot a url\nhttps://a.example/x\n").unwrap();
|
||||
let defaults = ["https://c.example/d", "https://a.example/x"];
|
||||
let m = mirrors(Some("https://a.example/x/, ftp://no"), Some(&file), &defaults);
|
||||
// the environment's, then the file's, then the defaults; a repeated one is kept once
|
||||
assert_eq!(m, ["https://a.example/x", "https://b.example/m", "https://c.example/d"]);
|
||||
assert_eq!(mirrors(None, Some(Path::new("/nonexistent/mirrors.txt")), &defaults).len(), 2);
|
||||
assert!(mirrors(None, None, &[]).is_empty());
|
||||
let _ = std::fs::remove_file(&file);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_ranges() {
|
||||
assert_eq!(parse_content_range("bytes 100-299/300"), Some((100, Some(300))));
|
||||
assert_eq!(parse_content_range("bytes 0-0/*"), Some((0, None)));
|
||||
assert_eq!(parse_content_range("bytes 9-1/300"), None);
|
||||
assert_eq!(parse_content_range("items 1-2/3"), None);
|
||||
}
|
||||
|
||||
/// A bearer token goes only to https, only to the host the download began at, and only when there is one.
|
||||
#[test]
|
||||
fn a_token_is_sent_only_over_https_to_the_first_host() {
|
||||
let u = |s: &str| Url::parse(s).unwrap();
|
||||
let first = u("https://huggingface.co/x/model.bin");
|
||||
assert_eq!(bearer(Some(" hf_abc \n"), &first, "huggingface.co"), Some("hf_abc"));
|
||||
// the storage host a redirect leads to, plain http, no token, an empty one
|
||||
assert_eq!(bearer(Some("hf_abc"), &u("https://cdn-lfs.example/x"), "huggingface.co"), None);
|
||||
assert_eq!(bearer(Some("hf_abc"), &u("http://huggingface.co/x"), "huggingface.co"), None);
|
||||
assert_eq!(bearer(None, &first, "huggingface.co"), None);
|
||||
assert_eq!(bearer(Some(" "), &first, "huggingface.co"), None);
|
||||
// a caller that names no variable sends nothing, whatever the environment holds
|
||||
assert!(Options::default().token_env.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_url_keeps_query_and_owned_catalog_values() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}/download?file=weights&sig=secret", listener.local_addr().unwrap());
|
||||
let worker = std::thread::spawn(move || {
|
||||
let (mut socket, _) = listener.accept().unwrap();
|
||||
let mut reader = BufReader::new(socket.try_clone().unwrap());
|
||||
let mut request = String::new();
|
||||
reader.read_line(&mut request).unwrap();
|
||||
assert_eq!(request.trim(), "GET /download?file=weights&sig=secret HTTP/1.1");
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).unwrap() == 0 || line.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nabc").unwrap();
|
||||
});
|
||||
let name = String::from("catalog.onnx");
|
||||
let hash = sha_hex(b"abc");
|
||||
let spec = FileSpec { name: &name, size: Some(3), sha256: Some(&hash), max: 3 };
|
||||
let dir = tmp("exact");
|
||||
let mut progress = Vec::new();
|
||||
download_url(&spec, &url, &dir, &quick(), &AtomicBool::new(false), &mut |p| progress.push(p.clone())).unwrap();
|
||||
worker.join().unwrap();
|
||||
assert_eq!(std::fs::read(dir.join(name)).unwrap(), b"abc");
|
||||
assert!(progress.iter().all(|p| !p.mirror.contains("secret")));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_file_specs_are_rejected_before_touching_disk() {
|
||||
let dir = tmp("invalid");
|
||||
let base = FileSpec { name: "valid.onnx", size: Some(1), sha256: None, max: 1 };
|
||||
let cancel = AtomicBool::new(false);
|
||||
for name in ["", ".hidden", "../escape", "a/b", "a\\b", "C:escape", "bad\nname", "trailing.", "CON.onnx", "a.part"] {
|
||||
let spec = FileSpec { name, ..base.clone() };
|
||||
assert!(matches!(run(&[spec], &[], &dir, &cancel).0, Err(DownloadError::Invalid(_))), "{name}");
|
||||
assert!(!dir.exists());
|
||||
}
|
||||
for spec in [
|
||||
FileSpec { max: 0, ..base.clone() },
|
||||
FileSpec { size: Some(0), ..base.clone() },
|
||||
FileSpec { size: Some(2), ..base.clone() },
|
||||
FileSpec { sha256: Some("not a SHA"), ..base.clone() },
|
||||
] {
|
||||
assert!(matches!(run(&[spec], &[], &dir, &cancel).0, Err(DownloadError::Invalid(_))));
|
||||
assert!(!dir.exists());
|
||||
}
|
||||
let huge = FileSpec { size: Some(u64::MAX), max: u64::MAX, ..base.clone() };
|
||||
let other = FileSpec { name: "other.onnx", ..huge.clone() };
|
||||
for specs in [vec![huge, other], vec![base.clone(); 257], vec![base.clone(), base]] {
|
||||
assert!(matches!(run(&specs, &[], &dir, &cancel).0, Err(DownloadError::Invalid(_))));
|
||||
assert!(!dir.exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
//! Interactive crop editing: what dragging a crop handle does.
|
||||
//!
|
||||
//! The crop rectangle lives in the straightened (rotated) frame and must stay inside the rotated
|
||||
//! image. A drag is a *domain operation* on the crop, not a UI detail: the handle being dragged
|
||||
//! decides which edges are anchored, an optional aspect lock decides the shape, and the image
|
||||
//! bounds decide how far the drag may go. [`drag_crop`] answers all three in one place so the app,
|
||||
//! CLI, control channel and MCP agree.
|
||||
|
||||
use crate::{CropGeometry, Point, Rect};
|
||||
|
||||
/// Smallest crop side, as a fraction of the image's shorter side.
|
||||
pub const MIN_CROP_FRACTION: f64 = 0.02;
|
||||
|
||||
/// Most elongated locked crop shape (width:height or its inverse) the drag supports.
|
||||
pub const MAX_RATIO: f64 = 20.0;
|
||||
|
||||
/// A grab point on the crop frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CropHandle {
|
||||
TopLeft,
|
||||
TopRight,
|
||||
BottomRight,
|
||||
BottomLeft,
|
||||
Top,
|
||||
Right,
|
||||
Bottom,
|
||||
Left,
|
||||
/// Drag the whole frame.
|
||||
Move,
|
||||
}
|
||||
|
||||
impl CropHandle {
|
||||
/// The overlay's numbering: 0..3 corners (clockwise from top-left), 4..7 edges (top, right,
|
||||
/// bottom, left), 8 = move.
|
||||
pub fn from_index(i: u8) -> Option<CropHandle> {
|
||||
Some(match i {
|
||||
0 => CropHandle::TopLeft,
|
||||
1 => CropHandle::TopRight,
|
||||
2 => CropHandle::BottomRight,
|
||||
3 => CropHandle::BottomLeft,
|
||||
4 => CropHandle::Top,
|
||||
5 => CropHandle::Right,
|
||||
6 => CropHandle::Bottom,
|
||||
7 => CropHandle::Left,
|
||||
8 => CropHandle::Move,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a command-parameter name (`"topLeft"`, `"top"`, `"move"`, …).
|
||||
pub fn from_name(n: &str) -> Option<CropHandle> {
|
||||
Some(match n {
|
||||
"topLeft" => CropHandle::TopLeft,
|
||||
"topRight" => CropHandle::TopRight,
|
||||
"bottomRight" => CropHandle::BottomRight,
|
||||
"bottomLeft" => CropHandle::BottomLeft,
|
||||
"top" => CropHandle::Top,
|
||||
"right" => CropHandle::Right,
|
||||
"bottom" => CropHandle::Bottom,
|
||||
"left" => CropHandle::Left,
|
||||
"move" => CropHandle::Move,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Drag `handle` of `start` from `grab` to `pointer` (both normalized, in the straightened frame)
|
||||
/// on a `w × h` image. `aspect` is the locked width/height ratio in pixels, if any.
|
||||
///
|
||||
/// Guarantees (for any input, including hostile numbers):
|
||||
/// - the result lies inside the rotated image;
|
||||
/// - the edges opposite the handle stay where they were (a move keeps the size);
|
||||
/// - with `aspect`, the frame keeps that shape on every handle (when `start` had it);
|
||||
/// - the drag stops at the image edge instead of pushing the frame around, and slides along it.
|
||||
pub fn drag_crop(start: CropGeometry, handle: CropHandle, grab: Point, pointer: Point, w: f64, h: f64, aspect: Option<f64>) -> CropGeometry {
|
||||
let sane = |v: f64| v.is_finite();
|
||||
let r0 = start.rect;
|
||||
if ![w, h, grab.x, grab.y, pointer.x, pointer.y, start.angle, r0.x0, r0.y0, r0.x1, r0.y1].iter().copied().all(sane)
|
||||
|| w <= 0.0
|
||||
|| h <= 0.0
|
||||
|| r0.is_empty()
|
||||
|| aspect.is_some_and(|a| !(a.is_finite() && a > 0.0))
|
||||
{
|
||||
return start;
|
||||
}
|
||||
// Start from a valid frame, then work in pixels of the straightened frame.
|
||||
let mut base = start.constrained(w, h);
|
||||
if base.angle == 0.0 {
|
||||
// absorb the 1e-6 tolerance of `is_within_image` so the exact test below accepts the start
|
||||
let r = base.rect;
|
||||
base.rect = Rect::new(r.x0.clamp(0.0, 1.0), r.y0.clamp(0.0, 1.0), r.x1.clamp(0.0, 1.0), r.y1.clamp(0.0, 1.0));
|
||||
}
|
||||
let sr = base.rect_px(w, h);
|
||||
// Moving further than twice the image never matters; clamping keeps the searches well-conditioned.
|
||||
let (dx, dy) = (((pointer.x - grab.x) * w).clamp(-2.0 * w, 2.0 * w), ((pointer.y - grab.y) * h).clamp(-2.0 * h, 2.0 * h));
|
||||
// Ratios beyond 20:1 make even the minimum frame larger than the image; treat them as 20:1.
|
||||
let aspect = aspect.map(|a| a.clamp(1.0 / MAX_RATIO, MAX_RATIO));
|
||||
// Unrotated, the frame must be exactly inside the image (`is_within_image` tolerates 1e-6, which
|
||||
// would leak out as a sliver past the edge and, after any clamp, a shifted ratio).
|
||||
let feasible = |r: Rect| {
|
||||
if base.angle == 0.0 {
|
||||
r.x0 >= 0.0 && r.y0 >= 0.0 && r.x1 <= w && r.y1 <= h
|
||||
} else {
|
||||
CropGeometry { rect: r.scale(1.0 / w, 1.0 / h), angle: base.angle }.is_within_image(w, h)
|
||||
}
|
||||
};
|
||||
let min_side = MIN_CROP_FRACTION * w.min(h);
|
||||
|
||||
let out = match (handle, aspect) {
|
||||
(CropHandle::Move, _) => {
|
||||
let slide = |cur: Rect, ax: f64, ay: f64| {
|
||||
let t = bisect(0.0, 1.0, |t| feasible(cur.translate(crate::Vec2::new(ax * t, ay * t))));
|
||||
cur.translate(crate::Vec2::new(ax * t, ay * t))
|
||||
};
|
||||
let cur = slide(sr, dx, 0.0);
|
||||
slide(cur, 0.0, dy)
|
||||
}
|
||||
(hd, None) => {
|
||||
let (mx0, mx1, my0, my1) = sides(hd);
|
||||
let grow = |cur: Rect, tx: f64, ty: f64| {
|
||||
// `tx`/`ty`: how far the moving edges would go; interpolate towards it
|
||||
let target = Rect::new(
|
||||
if mx0 { (cur.x0 + tx).min(cur.x1 - min_side) } else { cur.x0 },
|
||||
if my0 { (cur.y0 + ty).min(cur.y1 - min_side) } else { cur.y0 },
|
||||
if mx1 { (cur.x1 + tx).max(cur.x0 + min_side) } else { cur.x1 },
|
||||
if my1 { (cur.y1 + ty).max(cur.y0 + min_side) } else { cur.y1 },
|
||||
);
|
||||
let at = |t: f64| lerp_rect(cur, target, t);
|
||||
at(bisect(0.0, 1.0, |t| feasible(at(t))))
|
||||
};
|
||||
let cur = grow(sr, dx, 0.0);
|
||||
grow(cur, 0.0, dy)
|
||||
}
|
||||
(hd, Some(a)) => {
|
||||
let (mx0, _, my0, _) = sides(hd);
|
||||
let (min_w, min_h) = (min_side.max(min_side * a), min_side.max(min_side / a));
|
||||
let c = sr.center();
|
||||
// Anchor: the opposite corner / edge midpoint. `build(n, o)` is the frame of width `n` that
|
||||
// keeps it, shifted by `o` along the anchored edge (edge handles only).
|
||||
let (target_w, build): (f64, Box<dyn Fn(f64, f64) -> Rect>) = match hd {
|
||||
CropHandle::Top | CropHandle::Bottom => {
|
||||
let (ay, sy, edge) = if my0 { (sr.y1, -1.0, sr.y0) } else { (sr.y0, 1.0, sr.y1) };
|
||||
let nh = (sy * (edge + dy - ay)).max(min_h);
|
||||
(nh * a, Box::new(move |nw, o| vband(c.x + o, ay, sy, nw, a)))
|
||||
}
|
||||
CropHandle::Left | CropHandle::Right => {
|
||||
let (ax, sx, edge) = if mx0 { (sr.x1, -1.0, sr.x0) } else { (sr.x0, 1.0, sr.x1) };
|
||||
let nw = (sx * (edge + dx - ax)).max(min_w);
|
||||
(nw, Box::new(move |nw, o| hband(ax, sx, c.y + o, nw, a)))
|
||||
}
|
||||
_ => {
|
||||
let (ax, sx, ex) = if mx0 { (sr.x1, -1.0, sr.x0) } else { (sr.x0, 1.0, sr.x1) };
|
||||
let (ay, sy, ey) = if my0 { (sr.y1, -1.0, sr.y0) } else { (sr.y0, 1.0, sr.y1) };
|
||||
let (wx, wy) = (sx * (ex + dx - ax), sy * (ey + dy - ay));
|
||||
let nw = ((wx + wy * a) / 2.0).max(min_w);
|
||||
(nw, Box::new(move |nw, _| corner_rect(ax, sx, ay, sy, nw, a)))
|
||||
}
|
||||
};
|
||||
let target_w = target_w.min(2.0 * w.max(h * a));
|
||||
let slides = matches!(hd, CropHandle::Top | CropHandle::Bottom | CropHandle::Left | CropHandle::Right);
|
||||
let extent = if matches!(hd, CropHandle::Top | CropHandle::Bottom) { w } else { h };
|
||||
// Frame of width `n`, centred on the anchored edge if it fits, else slid the least distance.
|
||||
let place = |n: f64| -> Option<Rect> {
|
||||
let at = |o: f64| build(n, o);
|
||||
if feasible(at(0.0)) {
|
||||
return Some(at(0.0));
|
||||
}
|
||||
if !slides {
|
||||
return None;
|
||||
}
|
||||
const STEPS: u32 = 128;
|
||||
let step = extent / STEPS as f64;
|
||||
for k in 1..=STEPS {
|
||||
for sign in [1.0, -1.0] {
|
||||
let hi = sign * k as f64 * step;
|
||||
if feasible(at(hi)) {
|
||||
// closest feasible offset: bisect between the last infeasible and this one
|
||||
let (mut bad, mut good) = (sign * (k - 1) as f64 * step, hi);
|
||||
for _ in 0..40 {
|
||||
let mid = (bad + good) / 2.0;
|
||||
if feasible(at(mid)) {
|
||||
good = mid;
|
||||
} else {
|
||||
bad = mid;
|
||||
}
|
||||
}
|
||||
return Some(at(good));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
if place(min_w).is_some() {
|
||||
let n = bisect_range(min_w, target_w.max(min_w), |n| place(n).is_some());
|
||||
place(n).unwrap_or(sr)
|
||||
} else {
|
||||
sr
|
||||
}
|
||||
}
|
||||
};
|
||||
let rect = out.scale(1.0 / w, 1.0 / h);
|
||||
if [rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) { CropGeometry { rect, angle: base.angle } } else { start }
|
||||
}
|
||||
|
||||
/// Which sides a handle moves: (left, right, top, bottom).
|
||||
fn sides(h: CropHandle) -> (bool, bool, bool, bool) {
|
||||
use CropHandle::*;
|
||||
(
|
||||
matches!(h, TopLeft | BottomLeft | Left),
|
||||
matches!(h, TopRight | BottomRight | Right),
|
||||
matches!(h, TopLeft | TopRight | Top),
|
||||
matches!(h, BottomLeft | BottomRight | Bottom),
|
||||
)
|
||||
}
|
||||
|
||||
fn lerp_rect(a: Rect, b: Rect, t: f64) -> Rect {
|
||||
let l = |p: f64, q: f64| p + (q - p) * t;
|
||||
Rect::new(l(a.x0, b.x0), l(a.y0, b.y0), l(a.x1, b.x1), l(a.y1, b.y1))
|
||||
}
|
||||
|
||||
/// Frame of width `nw` and ratio `a` extending from corner (`ax`, `ay`) in directions `sx`, `sy`.
|
||||
fn corner_rect(ax: f64, sx: f64, ay: f64, sy: f64, nw: f64, a: f64) -> Rect {
|
||||
let nh = nw / a;
|
||||
Rect::from_points(Point::new(ax, ay), Point::new(ax + sx * nw, ay + sy * nh))
|
||||
}
|
||||
|
||||
/// Frame whose top/bottom edge (at `ay`, growing in direction `sy`) is anchored, centred on `cx`.
|
||||
fn vband(cx: f64, ay: f64, sy: f64, nw: f64, a: f64) -> Rect {
|
||||
let nh = nw / a;
|
||||
Rect::from_points(Point::new(cx - nw / 2.0, ay), Point::new(cx + nw / 2.0, ay + sy * nh))
|
||||
}
|
||||
|
||||
/// Frame whose left/right edge (at `ax`, growing in direction `sx`) is anchored, centred on `cy`.
|
||||
fn hband(ax: f64, sx: f64, cy: f64, nw: f64, a: f64) -> Rect {
|
||||
let nh = nw / a;
|
||||
Rect::from_points(Point::new(ax, cy - nh / 2.0), Point::new(ax + sx * nw, cy + nh / 2.0))
|
||||
}
|
||||
|
||||
/// Largest `t` in `[lo, hi]` for which `ok` holds, given that it holds at `lo` and the set of good
|
||||
/// `t` is an interval (the feasible region is convex).
|
||||
fn bisect_range(lo: f64, hi: f64, ok: impl Fn(f64) -> bool) -> f64 {
|
||||
if ok(hi) {
|
||||
return hi;
|
||||
}
|
||||
let (mut good, mut bad) = (lo, hi);
|
||||
for _ in 0..48 {
|
||||
let mid = (good + bad) / 2.0;
|
||||
if ok(mid) {
|
||||
good = mid;
|
||||
} else {
|
||||
bad = mid;
|
||||
}
|
||||
}
|
||||
good
|
||||
}
|
||||
|
||||
fn bisect(lo: f64, hi: f64, ok: impl Fn(f64) -> bool) -> f64 {
|
||||
bisect_range(lo, hi, ok)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use CropHandle::*;
|
||||
|
||||
const W: f64 = 600.0;
|
||||
const H: f64 = 400.0;
|
||||
const EPS: f64 = 1e-6;
|
||||
|
||||
fn geo(x0: f64, y0: f64, x1: f64, y1: f64) -> CropGeometry {
|
||||
CropGeometry { rect: Rect::new(x0, y0, x1, y1), angle: 0.0 }
|
||||
}
|
||||
fn p(x: f64, y: f64) -> Point {
|
||||
Point::new(x, y)
|
||||
}
|
||||
fn drag(g: CropGeometry, hd: CropHandle, from: Point, to: Point, aspect: Option<f64>) -> CropGeometry {
|
||||
drag_crop(g, hd, from, to, W, H, aspect)
|
||||
}
|
||||
fn px_aspect(g: &CropGeometry) -> f64 {
|
||||
g.rect.width() * W / (g.rect.height() * H)
|
||||
}
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < EPS
|
||||
}
|
||||
|
||||
// Feature: dragging a crop handle
|
||||
// Scenario: a free corner drag inside the image
|
||||
#[test]
|
||||
fn given_free_crop_when_corner_dragged_inside_then_corner_follows_and_opposite_corner_stays() {
|
||||
let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(0.9, 0.7), None);
|
||||
assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2));
|
||||
assert!(near(g.rect.x1, 0.9) && near(g.rect.y1, 0.7));
|
||||
}
|
||||
|
||||
// Scenario: dragging past the right edge (the weird behaviour of issue 295)
|
||||
#[test]
|
||||
fn given_corner_dragged_past_image_edge_then_it_stops_at_the_edge_and_anchor_stays() {
|
||||
let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(1.3, 0.7), None);
|
||||
assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2), "anchor moved: {:?}", g.rect);
|
||||
assert!(near(g.rect.x1, 1.0), "stops at the edge: {:?}", g.rect);
|
||||
assert!(near(g.rect.y1, 0.7), "other axis still follows the pointer: {:?}", g.rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_locked_aspect_when_corner_dragged_past_edge_then_ratio_and_anchor_hold() {
|
||||
let a = 1.5;
|
||||
let g = drag(geo(0.2, 0.2, 0.8, 0.2 + 0.6 * W / H / a), BottomRight, p(0.8, 0.6), p(1.4, 0.9), Some(a));
|
||||
assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2), "{:?}", g.rect);
|
||||
assert!(near(px_aspect(&g), a), "{}", px_aspect(&g));
|
||||
assert!(g.rect.x1 <= 1.0 + EPS && g.rect.y1 <= 1.0 + EPS);
|
||||
assert!(g.rect.x1 > 0.95 || g.rect.y1 > 0.95, "grows until it touches an edge: {:?}", g.rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_locked_aspect_when_edge_dragged_then_ratio_holds_and_frame_grows_about_its_midline() {
|
||||
let a = 2.0;
|
||||
let start = geo(0.3, 0.3, 0.7, 0.3 + 0.4 * W / H / a);
|
||||
let g = drag(start, Bottom, p(0.5, start.rect.y1), p(0.5, start.rect.y1 + 0.1), Some(a));
|
||||
assert!(near(g.rect.y0, start.rect.y0), "opposite edge stays");
|
||||
assert!(near(g.rect.center().x, start.rect.center().x), "grows symmetrically sideways");
|
||||
assert!(near(px_aspect(&g), a));
|
||||
assert!(g.rect.height() > start.rect.height());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_free_crop_when_edge_dragged_then_only_that_edge_moves() {
|
||||
let g = drag(geo(0.2, 0.2, 0.8, 0.8), Left, p(0.2, 0.5), p(0.1, 0.9), None);
|
||||
assert!(near(g.rect.x0, 0.1) && near(g.rect.y0, 0.2) && near(g.rect.x1, 0.8) && near(g.rect.y1, 0.8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_move_past_edge_then_size_is_kept_and_frame_slides_along_the_edge() {
|
||||
let start = geo(0.5, 0.25, 0.9, 0.65);
|
||||
let g = drag(start, Move, p(0.7, 0.45), p(1.3, 0.55), None);
|
||||
assert!(near(g.rect.width(), 0.4) && near(g.rect.height(), 0.4));
|
||||
assert!(near(g.rect.x1, 1.0), "flush with the right edge: {:?}", g.rect);
|
||||
assert!(near(g.rect.y0, 0.35), "still follows the pointer vertically: {:?}", g.rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_edge_dragged_across_the_opposite_edge_then_crop_keeps_a_minimum_size() {
|
||||
let g = drag(geo(0.2, 0.2, 0.8, 0.8), Right, p(0.8, 0.5), p(-0.5, 0.5), None);
|
||||
assert!(g.rect.width() * W >= MIN_CROP_FRACTION * H - EPS && g.rect.x1 > g.rect.x0);
|
||||
assert!(near(g.rect.x0, 0.2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_straightened_image_when_dragged_outside_then_crop_stays_inside_the_rotated_image() {
|
||||
let start = crate::crop_fit_angle(W, H, 12.0, Some(1.5));
|
||||
let c = start.rect.center();
|
||||
for hd in [TopLeft, TopRight, BottomRight, BottomLeft, Top, Right, Bottom, Left, Move] {
|
||||
for aspect in [None, Some(1.5)] {
|
||||
let g = drag(start, hd, c, p(3.0, -3.0), aspect);
|
||||
assert!(g.is_within_image(W, H), "{hd:?} {aspect:?}: {:?}", g.rect);
|
||||
assert_eq!(g.angle, 12.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_hostile_numbers_then_the_start_crop_is_returned_not_a_panic() {
|
||||
let s = geo(0.2, 0.2, 0.8, 0.8);
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(bad, 0.5), None), s);
|
||||
}
|
||||
assert_eq!(drag_crop(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), 0.0, 400.0, None), s);
|
||||
assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), Some(f64::NAN)), s);
|
||||
assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), Some(-1.0)), s);
|
||||
}
|
||||
|
||||
// Scenario: a locked edge drag when the frame already touches the image edge (review finding)
|
||||
#[test]
|
||||
fn given_locked_frame_flush_with_the_top_when_right_edge_dragged_out_then_it_grows_sliding_down() {
|
||||
let a = 1.5;
|
||||
let start = geo(0.2, 0.0, 0.5, 0.3 * W / H * 0.0 + 0.3 * W / (a * H));
|
||||
let g = drag(start, Right, p(0.5, 0.2), p(0.9, 0.2), Some(a));
|
||||
assert!(near(g.rect.x0, 0.2), "anchor edge stays: {:?}", g.rect);
|
||||
assert!(g.rect.width() > start.rect.width() + 0.05, "it must grow: {:?}", g.rect);
|
||||
assert!(near(px_aspect(&g), a));
|
||||
assert!(g.is_within_image(W, H));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_full_fit_straightened_crop_when_locked_edge_dragged_then_it_still_respects_the_image() {
|
||||
let start = crate::crop_fit_angle(W, H, 20.0, Some(1.5));
|
||||
let g = drag(start, Right, p(0.9, 0.5), p(1.5, 0.5), Some(1.5));
|
||||
assert!(g.is_within_image(W, H) && near(px_aspect(&g), 1.5));
|
||||
}
|
||||
|
||||
// Scenario: extreme or hostile magnitudes
|
||||
#[test]
|
||||
fn given_an_extreme_locked_ratio_then_the_frame_can_still_be_moved() {
|
||||
let start = geo(0.1, 0.4, 0.9, 0.4 + 0.8 * W / (60.0 * H));
|
||||
let g = drag(start, Move, p(0.5, 0.4), p(0.5, 0.7), Some(60.0));
|
||||
assert!(g.rect.y0 > 0.5, "moved: {:?}", g.rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_a_huge_finite_pointer_then_a_locked_corner_still_grows_to_the_edge() {
|
||||
let a = 1.5;
|
||||
let start = geo(0.2, 0.2, 0.5, 0.2 + 0.3 * W / (a * H));
|
||||
let g = drag(start, BottomRight, p(0.5, 0.4), p(1e300, 1e300), Some(a));
|
||||
assert!(g.rect.x1 > 0.95 || g.rect.y1 > 0.95, "{:?}", g.rect);
|
||||
let g = drag(start, BottomRight, p(0.5, 0.4), p(1e300, 1e300), None);
|
||||
assert!(near(g.rect.x1, 1.0) && near(g.rect.y1, 1.0), "{:?}", g.rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_an_unrotated_image_then_results_never_leave_the_unit_square() {
|
||||
let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(5.0, 5.0), None);
|
||||
assert!(g.rect.x1 <= 1.0 && g.rect.y1 <= 1.0 && g.rect.x0 >= 0.0 && g.rect.y0 >= 0.0, "{:?}", g.rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_numbering_matches_the_overlay() {
|
||||
assert_eq!(CropHandle::from_index(0), Some(TopLeft));
|
||||
assert_eq!(CropHandle::from_index(6), Some(Bottom));
|
||||
assert_eq!(CropHandle::from_index(8), Some(Move));
|
||||
assert_eq!(CropHandle::from_index(9), None);
|
||||
assert_eq!(CropHandle::from_name("bottomLeft"), Some(BottomLeft));
|
||||
assert_eq!(CropHandle::from_name("nope"), None);
|
||||
}
|
||||
|
||||
mod props {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
fn any_handle() -> impl Strategy<Value = CropHandle> {
|
||||
(0u8..9).prop_map(|i| CropHandle::from_index(i).unwrap_or(Move))
|
||||
}
|
||||
|
||||
proptest! {
|
||||
/// Whatever the pointer does, the crop stays valid; the anchor and the ratio hold.
|
||||
#[test]
|
||||
fn drag_is_always_inside_anchored_and_shaped(
|
||||
angle in -45.0f64..45.0,
|
||||
ratio in prop::option::of(0.3f64..4.0),
|
||||
hd in any_handle(),
|
||||
tx in -4.0f64..5.0, ty in -4.0f64..5.0,
|
||||
shrink in 0.2f64..1.0,
|
||||
) {
|
||||
let base = crate::crop_fit_angle(W, H, angle, ratio);
|
||||
// a smaller start crop so there is room to move
|
||||
let c = base.rect.center();
|
||||
let start = CropGeometry { rect: Rect::from_center(c, base.rect.width() * shrink, base.rect.height() * shrink), angle };
|
||||
prop_assume!(start.is_within_image(W, H));
|
||||
let g = drag_crop(start, hd, c, p(tx, ty), W, H, ratio);
|
||||
prop_assert!(g.is_within_image(W, H), "{:?} -> {:?}", start.rect, g.rect);
|
||||
prop_assert!(g.rect.width() > 0.0 && g.rect.height() > 0.0);
|
||||
let min = MIN_CROP_FRACTION * W.min(H) - 1e-6;
|
||||
prop_assert!(g.rect.width() * W >= min.min(start.rect.width() * W) && g.rect.height() * H >= min.min(start.rect.height() * H), "min size {:?}", g.rect);
|
||||
if let Some(a) = ratio {
|
||||
prop_assert!((px_aspect(&g) - a).abs() < 1e-6 * a.max(1.0), "ratio {a}: {}", px_aspect(&g));
|
||||
}
|
||||
// anchors: edges opposite the handle do not move
|
||||
let (s, r) = (start.rect, g.rect);
|
||||
let keep_l = matches!(hd, TopRight | BottomRight | Right);
|
||||
let keep_r = matches!(hd, TopLeft | BottomLeft | Left);
|
||||
let keep_t = matches!(hd, BottomLeft | BottomRight | Bottom);
|
||||
let keep_b = matches!(hd, TopLeft | TopRight | Top);
|
||||
{
|
||||
if keep_l { prop_assert!((r.x0 - s.x0).abs() < 1e-6, "x0"); }
|
||||
if keep_r { prop_assert!((r.x1 - s.x1).abs() < 1e-6, "x1"); }
|
||||
if keep_t { prop_assert!((r.y0 - s.y0).abs() < 1e-6, "y0"); }
|
||||
if keep_b { prop_assert!((r.y1 - s.y1).abs() < 1e-6, "y1"); }
|
||||
}
|
||||
if hd == Move {
|
||||
prop_assert!((r.width() - s.width()).abs() < 1e-9 && (r.height() - s.height()).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_never_panics_on_arbitrary_numbers(
|
||||
x0 in -2.0f64..3.0, y0 in -2.0f64..3.0, x1 in -2.0f64..3.0, y1 in -2.0f64..3.0,
|
||||
angle in -90.0f64..90.0, tx in -1e9f64..1e9, ty in -1e9f64..1e9,
|
||||
ratio in prop::option::of(-1.0f64..10.0), hd in any_handle(),
|
||||
) {
|
||||
let g = drag_crop(CropGeometry { rect: Rect::new(x0, y0, x1, y1), angle }, hd, p(0.5, 0.5), p(tx, ty), W, H, ratio);
|
||||
prop_assert!(g.rect.x0.is_finite() && g.rect.y1.is_finite());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Affine, Point};
|
||||
use crate::{Affine, Point, Real};
|
||||
|
||||
/// Row-major 3×3 matrix mapping homogeneous points: p' = H·p.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -23,10 +23,15 @@ impl Homography {
|
||||
}
|
||||
|
||||
pub fn apply(&self, p: Point) -> Point {
|
||||
let (x, y) = self.apply_real(p.x, p.y);
|
||||
Point::new(x, y)
|
||||
}
|
||||
|
||||
/// [`Homography::apply`] for any [`Real`] (e.g. [`Interval`](crate::Interval) bounds over many points).
|
||||
pub fn apply_real<T: Real>(&self, x: T, y: T) -> (T, T) {
|
||||
let m = &self.0;
|
||||
let w = m[6] * p.x + m[7] * p.y + m[8];
|
||||
let w = if w.abs() < 1e-300 { 1e-300 } else { w };
|
||||
Point::new((m[0] * p.x + m[1] * p.y + m[2]) / w, (m[3] * p.x + m[4] * p.y + m[5]) / w)
|
||||
let w = (x * m[6] + y * m[7] + m[8]).clamp_tiny(1e-300);
|
||||
((x * m[0] + y * m[1] + m[2]) / w, (x * m[3] + y * m[4] + m[5]) / w)
|
||||
}
|
||||
|
||||
pub fn mul(&self, o: &Homography) -> Homography {
|
||||
|
||||
+10
-1
@@ -6,10 +6,14 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod crop;
|
||||
mod crop_drag;
|
||||
mod homography;
|
||||
mod real;
|
||||
|
||||
pub use crop::{CropGeometry, crop_fit_angle, max_inscribed_scale};
|
||||
pub use crop_drag::{CropHandle, MAX_RATIO, MIN_CROP_FRACTION, drag_crop};
|
||||
pub use homography::Homography;
|
||||
pub use real::{Interval, Real};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -213,8 +217,13 @@ impl Affine {
|
||||
Affine::translate(p.to_vec()) * Affine::rotate(a) * Affine::translate(-p.to_vec())
|
||||
}
|
||||
pub fn apply(&self, p: Point) -> Point {
|
||||
let (x, y) = self.apply_real(p.x, p.y);
|
||||
Point::new(x, y)
|
||||
}
|
||||
/// [`Affine::apply`] for any [`Real`] (e.g. [`Interval`] bounds over many points).
|
||||
pub fn apply_real<T: Real>(&self, x: T, y: T) -> (T, T) {
|
||||
let [a, b, c, d, e, f] = self.0;
|
||||
Point::new(a * p.x + c * p.y + e, b * p.x + d * p.y + f)
|
||||
(x * a + y * c + e, x * b + y * d + f)
|
||||
}
|
||||
pub fn apply_vec(&self, v: Vec2) -> Vec2 {
|
||||
let [a, b, c, d, ..] = self.0;
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Numbers the coordinate formulas are generic over: `f64` (the reference evaluation) and [`Interval`]
|
||||
//! (bounds on the `f64` results over a whole range of inputs, e.g. a block of output pixels).
|
||||
//!
|
||||
//! Formulas written once over [`Real`] serve both. For `f64` every operator is the plain IEEE operation, so a
|
||||
//! generic formula evaluated with `f64` is bit-identical to the same formula written with `f64` directly, as long
|
||||
//! as it keeps the operation order (`a * k` instead of `k * a` is fine: IEEE `+` and `×` are commutative).
|
||||
|
||||
use std::ops::{Add, Div, Mul, Sub};
|
||||
|
||||
/// A scalar the geometry formulas can be evaluated with. Mixed operations take a parameter (`f64`) on the right.
|
||||
pub trait Real:
|
||||
Copy
|
||||
+ Add<Output = Self>
|
||||
+ Sub<Output = Self>
|
||||
+ Mul<Output = Self>
|
||||
+ Div<Output = Self>
|
||||
+ Add<f64, Output = Self>
|
||||
+ Sub<f64, Output = Self>
|
||||
+ Mul<f64, Output = Self>
|
||||
+ Div<f64, Output = Self>
|
||||
{
|
||||
/// `if |v| < tiny { tiny } else { v }`: the guard of a denominator against zero ([`crate::Homography::apply`]).
|
||||
fn clamp_tiny(self, tiny: f64) -> Self;
|
||||
}
|
||||
|
||||
impl Real for f64 {
|
||||
fn clamp_tiny(self, tiny: f64) -> f64 {
|
||||
if self.abs() < tiny { tiny } else { self }
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed interval `[lo, hi]` with outward rounding, or *undecided* (both NaN).
|
||||
///
|
||||
/// Invariant: if the `f64` operands of an operation lie in the operand intervals, the `f64` result (rounded to
|
||||
/// nearest) lies in the result interval. Each bound is computed in `f64` and then moved one ulp outward
|
||||
/// (`next_down` / `next_up`); since rounding to nearest is monotonic and errs by less than one ulp, the moved
|
||||
/// bound is on the far side of every rounded result. By induction a formula evaluated with intervals bounds the
|
||||
/// *`f64` evaluation* of the same formula (not only the exact real value) for every input in the input intervals.
|
||||
///
|
||||
/// A result that can't be bounded becomes undecided and stays so: division by an interval that contains zero,
|
||||
/// [`Real::clamp_tiny`] when some value could be clamped, and any infinite or NaN bound. Undecided intervals fail
|
||||
/// every comparison of their bounds, so callers must test the bounds with conditions that are false for NaN.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Interval {
|
||||
lo: f64,
|
||||
hi: f64,
|
||||
}
|
||||
|
||||
impl Interval {
|
||||
const UNDECIDED: Interval = Interval { lo: f64::NAN, hi: f64::NAN };
|
||||
|
||||
/// `[lo, hi]` exactly (the bounds are `f64` values already); undecided unless finite with `lo <= hi`.
|
||||
pub fn new(lo: f64, hi: f64) -> Interval {
|
||||
if lo.is_finite() && hi.is_finite() && lo <= hi { Interval { lo, hi } } else { Interval::UNDECIDED }
|
||||
}
|
||||
|
||||
/// The lower bound (NaN if undecided).
|
||||
pub fn lo(self) -> f64 {
|
||||
self.lo
|
||||
}
|
||||
|
||||
/// The upper bound (NaN if undecided).
|
||||
pub fn hi(self) -> f64 {
|
||||
self.hi
|
||||
}
|
||||
|
||||
/// Whether the bounds are usable (not undecided).
|
||||
pub fn is_decided(self) -> bool {
|
||||
self.lo <= self.hi
|
||||
}
|
||||
|
||||
/// Bounds computed in round-to-nearest, moved one ulp outward.
|
||||
fn outward(lo: f64, hi: f64) -> Interval {
|
||||
Interval::new(lo.next_down(), hi.next_up())
|
||||
}
|
||||
|
||||
/// The hull of four products or quotients, moved outward. NaN in any of them makes it undecided.
|
||||
fn hull(v: [f64; 4]) -> Interval {
|
||||
if v.iter().any(|v| !v.is_finite()) {
|
||||
return Interval::UNDECIDED;
|
||||
}
|
||||
let lo = v.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let hi = v.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
Interval::outward(lo, hi)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f64> for Interval {
|
||||
fn from(v: f64) -> Interval {
|
||||
Interval::new(v, v)
|
||||
}
|
||||
}
|
||||
|
||||
impl Add for Interval {
|
||||
type Output = Interval;
|
||||
fn add(self, o: Interval) -> Interval {
|
||||
Interval::outward(self.lo + o.lo, self.hi + o.hi)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub for Interval {
|
||||
type Output = Interval;
|
||||
fn sub(self, o: Interval) -> Interval {
|
||||
Interval::outward(self.lo - o.hi, self.hi - o.lo)
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul for Interval {
|
||||
type Output = Interval;
|
||||
fn mul(self, o: Interval) -> Interval {
|
||||
Interval::hull([self.lo * o.lo, self.lo * o.hi, self.hi * o.lo, self.hi * o.hi])
|
||||
}
|
||||
}
|
||||
|
||||
impl Div for Interval {
|
||||
type Output = Interval;
|
||||
/// Undecided when the divisor may be zero (or is undecided).
|
||||
fn div(self, o: Interval) -> Interval {
|
||||
if !(o.lo > 0.0 || o.hi < 0.0) {
|
||||
return Interval::UNDECIDED;
|
||||
}
|
||||
Interval::hull([self.lo / o.lo, self.lo / o.hi, self.hi / o.lo, self.hi / o.hi])
|
||||
}
|
||||
}
|
||||
|
||||
impl Add<f64> for Interval {
|
||||
type Output = Interval;
|
||||
fn add(self, k: f64) -> Interval {
|
||||
Interval::outward(self.lo + k, self.hi + k)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub<f64> for Interval {
|
||||
type Output = Interval;
|
||||
fn sub(self, k: f64) -> Interval {
|
||||
Interval::outward(self.lo - k, self.hi - k)
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul<f64> for Interval {
|
||||
type Output = Interval;
|
||||
fn mul(self, k: f64) -> Interval {
|
||||
let (a, b) = (self.lo * k, self.hi * k);
|
||||
if k >= 0.0 { Interval::outward(a, b) } else { Interval::outward(b, a) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Div<f64> for Interval {
|
||||
type Output = Interval;
|
||||
/// Undecided when `k` is zero (or NaN).
|
||||
fn div(self, k: f64) -> Interval {
|
||||
let (a, b) = (self.lo / k, self.hi / k);
|
||||
if k > 0.0 {
|
||||
Interval::outward(a, b)
|
||||
} else if k < 0.0 {
|
||||
Interval::outward(b, a)
|
||||
} else {
|
||||
Interval::UNDECIDED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Real for Interval {
|
||||
/// Unchanged when no value in the interval would be clamped, else undecided.
|
||||
fn clamp_tiny(self, tiny: f64) -> Interval {
|
||||
if self.lo >= tiny || self.hi <= -tiny { self } else { Interval::UNDECIDED }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
fn contains(i: Interval, v: f64) -> bool {
|
||||
i.lo <= v && v <= i.hi
|
||||
}
|
||||
|
||||
/// A value inside `[lo, hi]` at fraction `t`.
|
||||
fn pick(i: Interval, t: f64) -> f64 {
|
||||
(i.lo + (i.hi - i.lo) * t).clamp(i.lo, i.hi)
|
||||
}
|
||||
|
||||
proptest! {
|
||||
/// Every `f64` result of `a ∘ b` with `a ∈ A`, `b ∈ B` lies in `A ∘ B` (or that is undecided).
|
||||
#[test]
|
||||
fn operations_enclose_the_f64_results(
|
||||
a0 in -1e6f64..1e6, aw in 0.0f64..1e3, b0 in -1e3f64..1e3, bw in 0.0f64..10.0,
|
||||
ta in 0.0f64..=1.0, tb in 0.0f64..=1.0, k in -1e3f64..1e3,
|
||||
) {
|
||||
let (ia, ib) = (Interval::new(a0, a0 + aw), Interval::new(b0, b0 + bw));
|
||||
let (a, b) = (pick(ia, ta), pick(ib, tb));
|
||||
prop_assert!(contains(ia + ib, a + b));
|
||||
prop_assert!(contains(ia - ib, a - b));
|
||||
prop_assert!(contains(ia * ib, a * b));
|
||||
let q = ia / ib;
|
||||
prop_assert!(!q.is_decided() || contains(q, a / b));
|
||||
prop_assert!(contains(ia + k, a + k));
|
||||
prop_assert!(contains(ia - k, a - k));
|
||||
prop_assert!(contains(ia * k, a * k));
|
||||
prop_assert!(k == 0.0 || contains(ia / k, a / k));
|
||||
let c = ib.clamp_tiny(1.0);
|
||||
prop_assert!(!c.is_decided() || contains(c, b.clamp_tiny(1.0)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbounded_results_are_undecided() {
|
||||
let i = Interval::new(-1.0, 2.0);
|
||||
assert!(!(i / Interval::new(-1e-9, 1.0)).is_decided());
|
||||
assert!(!(i / Interval::new(0.0, 1.0)).is_decided());
|
||||
assert!((i / Interval::new(1e-9, 1.0)).is_decided());
|
||||
assert!(!(i / 0.0).is_decided());
|
||||
assert!(!(Interval::new(f64::MAX, f64::MAX) * 2.0).is_decided());
|
||||
assert!(!Interval::new(2.0, 1.0).is_decided());
|
||||
assert!(!Interval::new(f64::NAN, 1.0).is_decided());
|
||||
// undecided propagates through everything
|
||||
let u = Interval::UNDECIDED;
|
||||
for r in [u + i, i - u, u * i, i / u, u + 1.0, u * 0.0, u.clamp_tiny(1e-300)] {
|
||||
assert!(!r.is_decided());
|
||||
assert!(r.lo.is_nan() && r.hi.is_nan(), "undecided bounds are NaN, which fails every comparison");
|
||||
}
|
||||
// the denominator guard: unchanged only when no value is clamped
|
||||
assert!(Interval::new(1e-300, 1.0).clamp_tiny(1e-300).is_decided());
|
||||
assert!(Interval::new(-1.0, -1e-300).clamp_tiny(1e-300).is_decided());
|
||||
assert!(!Interval::new(-1.0, 1.0).clamp_tiny(1e-300).is_decided());
|
||||
assert!(!Interval::new(5e-301, 1.0).clamp_tiny(1e-300).is_decided());
|
||||
}
|
||||
}
|
||||
@@ -155,8 +155,16 @@ pub(crate) fn with_init_marker<T>(backends: Backends, f: impl FnOnce() -> T) ->
|
||||
/// If the init marker `path` is present — the last process died while creating the GPU device —
|
||||
/// remove it and return what it recorded.
|
||||
pub fn take_init_marker(path: &std::path::Path) -> Option<String> {
|
||||
let text = std::fs::read_to_string(path).ok()?;
|
||||
let text = read_init_marker(path)?;
|
||||
let _ = std::fs::remove_file(path);
|
||||
Some(text)
|
||||
}
|
||||
|
||||
/// What the init marker `path` recorded, if it is present — without removing it. For sessions
|
||||
/// that must leave the disk as they found it (`--memory`, issues #164 and #169): GPU rendering
|
||||
/// still starts off, and the marker stays for the next ordinary launch to report and clear.
|
||||
pub fn read_init_marker(path: &std::path::Path) -> Option<String> {
|
||||
let text = std::fs::read_to_string(path).ok()?;
|
||||
Some(text.trim().to_string())
|
||||
}
|
||||
|
||||
@@ -225,10 +233,13 @@ mod tests {
|
||||
set_init_marker(None);
|
||||
assert!(seen, "the marker exists while the device is created");
|
||||
assert!(!m.exists(), "and is removed afterwards");
|
||||
// a marker left behind by a crashed process is reported once
|
||||
// a marker left behind by a crashed process is reported once; reading it leaves it in place
|
||||
std::fs::write(&m, "GPU device creation started (backends DX12)").unwrap();
|
||||
assert!(read_init_marker(&m).unwrap().contains("DX12"));
|
||||
assert!(m.exists(), "read_init_marker leaves the marker for the next launch");
|
||||
assert!(take_init_marker(&m).unwrap().contains("DX12"));
|
||||
assert_eq!(take_init_marker(&m), None);
|
||||
assert_eq!(read_init_marker(&m), None);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ const MODULES: &[Module] = &[
|
||||
},
|
||||
Module {
|
||||
src: include_str!("wgsl/geom.wgsl"),
|
||||
bindings: &[("src", false, "f32"), ("dst", true, "f32")],
|
||||
bindings: &[("src", false, "f32"), ("dst", true, "f32"), ("coverage", false, "u32")],
|
||||
entries: &["orient", "sample_affine", "sample_warp"],
|
||||
},
|
||||
];
|
||||
|
||||
+110
-4
@@ -410,6 +410,42 @@ fn warp_params(
|
||||
p
|
||||
}
|
||||
|
||||
/// Output rows per block of [`coverage_mask`]; a block is one mask word (32 px) wide. 16 rows measured
|
||||
/// ~20 % faster than 8 at 6000 × 4000 (fewer interval evaluations, few more edge pixels).
|
||||
const COVER_ROWS: usize = 16;
|
||||
|
||||
/// The reference framing decision ([`Warp::covers`](lightcraft_pipeline::optics::Warp::covers)) for every
|
||||
/// output pixel, one bit per pixel, rows padded to 32-bit words. Blocks of 32 × [`COVER_ROWS`] pixels whose
|
||||
/// interval bounds place them clearly inside or outside the image are filled at once
|
||||
/// ([`Warp::block_coverage`](lightcraft_pipeline::optics::Warp::block_coverage), the same formulas evaluated
|
||||
/// with outward-rounded intervals); only blocks near the image edge evaluate each pixel. Same bits as
|
||||
/// evaluating every pixel.
|
||||
fn coverage_mask(wp: &lightcraft_pipeline::optics::Warp, o2t: &lightcraft_geom::Affine, w: usize, h: usize) -> Vec<u32> {
|
||||
let words = w.div_ceil(32);
|
||||
let mut coverage = vec![0u32; words * h];
|
||||
if words == 0 {
|
||||
return coverage;
|
||||
}
|
||||
lightcraft_raster::par_rows(&mut coverage, words * COVER_ROWS, |band, rows| {
|
||||
let y0 = band * COVER_ROWS;
|
||||
let y1 = y0 + rows.len() / words;
|
||||
for word in 0..words {
|
||||
let (x0, x1) = (word * 32, (word * 32 + 32).min(w));
|
||||
let full = if x1 - x0 == 32 { u32::MAX } else { (1u32 << (x1 - x0)) - 1 };
|
||||
let block = wp.block_coverage(o2t, x0, x1, y0, y1);
|
||||
for (y, row) in (y0..y1).zip(rows.chunks_exact_mut(words)) {
|
||||
let Some(bits) = row.get_mut(word) else { continue };
|
||||
*bits = match block {
|
||||
Some(true) => full,
|
||||
Some(false) => 0,
|
||||
None => (x0..x1).filter(|&x| wp.covers(o2t, x, y)).fold(0, |b, x| b | 1 << (x - x0)),
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
coverage
|
||||
}
|
||||
|
||||
/// Resample the source into the output frame (`Frame::sample`). May return the source buffer
|
||||
/// itself when the frame is the identity.
|
||||
fn sample(cx: &mut Cx<'_>, src: &Arc<Rgb32f>, src_buf: Arc<Buf>, plan: &Plan<'_>) -> Arc<Buf> {
|
||||
@@ -422,7 +458,7 @@ fn sample(cx: &mut Cx<'_>, src: &Arc<Rgb32f>, src_buf: Arc<Buf>, plan: &Plan<'_>
|
||||
let out = cx.gpu.buffer(sp.ow * sp.oh * 3);
|
||||
let mut p = vec![src.width as u32, src.height as u32, sp.ow as u32, sp.oh as u32];
|
||||
p.extend(orient_map(fr.orient, src.width, src.height).map(|v| v as u32));
|
||||
cx.run("orient", &p, &[Some(&src_buf), Some(&out)], groups2(sp.ow, sp.oh, [16, 16]));
|
||||
cx.run("orient", &p, &[Some(&src_buf), Some(&out), None], groups2(sp.ow, sp.oh, [16, 16]));
|
||||
Arc::new(out)
|
||||
};
|
||||
let (base, (bw, bh)) = match sp.prefilter {
|
||||
@@ -433,7 +469,7 @@ fn sample(cx: &mut Cx<'_>, src: &Arc<Rgb32f>, src_buf: Arc<Buf>, plan: &Plan<'_>
|
||||
let out = cx.gpu.buffer(w * h * 3);
|
||||
let mut p = vec![bw as u32, bh as u32, w as u32, h as u32];
|
||||
p.extend(affine_bits(xf));
|
||||
cx.run("sample_affine", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16]));
|
||||
cx.run("sample_affine", &p, &[Some(&base), Some(&out), None], groups2(w, h, [16, 16]));
|
||||
Arc::new(out)
|
||||
};
|
||||
match (&sp.mode, fr.warp.as_ref()) {
|
||||
@@ -444,7 +480,10 @@ fn sample(cx: &mut Cx<'_>, src: &Arc<Rgb32f>, src_buf: Arc<Buf>, plan: &Plan<'_>
|
||||
(SampleMode::Warp(o2t), Some(wp)) => {
|
||||
let out = cx.gpu.buffer(w * h * 3);
|
||||
let p = warp_params(wp, (bw, bh), (w, h), o2t, sp.sx, sp.sy);
|
||||
cx.run("sample_warp", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16]));
|
||||
// f32 rounding can cross a source edge. Keep the reference f64
|
||||
// framing decision in a bit mask; color sampling stays on the GPU.
|
||||
let coverage = cx.gpu.upload(&coverage_mask(wp, o2t, w, h));
|
||||
cx.run("sample_warp", &p, &[Some(&base), Some(&out), Some(&coverage)], groups2(w, h, [16, 16]));
|
||||
Arc::new(out)
|
||||
}
|
||||
}
|
||||
@@ -866,7 +905,7 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
|
||||
Some(2)
|
||||
}
|
||||
MaskShape::ColorRange { samples, refine } => {
|
||||
let tol = 0.04 + 0.16 * (*refine as f32 / 100.0);
|
||||
let tol = lightcraft_pipeline::masks::color_range_tolerance(*refine);
|
||||
p.extend([tol.to_bits(), ev.exp2().to_bits(), samples.len() as u32]);
|
||||
aux.extend(samples.iter().flat_map(|s| s.map(|v| v as f32)));
|
||||
Some(3)
|
||||
@@ -950,6 +989,73 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn coverage_mask_matches_per_pixel_decision() {
|
||||
use lightcraft_develop::{EmbeddedLens, EmbeddedWarp};
|
||||
let lens = EmbeddedLens {
|
||||
warp: Some(EmbeddedWarp {
|
||||
planes: [[1.0, -0.03, 0.01, 0.0, 0.001, -0.002]; 3],
|
||||
center: lightcraft_geom::Point::new(0.52, 0.48),
|
||||
radius: 0.6,
|
||||
}),
|
||||
vignette: None,
|
||||
};
|
||||
let mut s = DevelopSettings::default();
|
||||
s.optics.lens_profile = true;
|
||||
s.optics.distortion = -40.0;
|
||||
s.geometry.horizontal = -15.0;
|
||||
s.geometry.rotate = 3.0;
|
||||
for orientation in [Orientation::Normal, Orientation::Rotate270] {
|
||||
s.orientation = orientation;
|
||||
let f = lightcraft_pipeline::geometry::Frame::with_lens(900, 600, &s, true, Some(&lens));
|
||||
let wp = f.warp.as_ref().expect("warp");
|
||||
// widths that do and don't fill the last word; heights that do and don't fill the last band
|
||||
for (w, h) in [f.fit(700, 700), (333, 221), (64, 16), (1, 1)] {
|
||||
let o2t = f.out_to_oriented(w, h);
|
||||
let mask = coverage_mask(wp, &o2t, w, h);
|
||||
let words = w.div_ceil(32);
|
||||
assert_eq!(mask.len(), words * h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let bit = mask[y * words + x / 32] >> (x % 32) & 1 == 1;
|
||||
assert_eq!(bit, wp.covers(&o2t, x, y), "{orientation:?} {w}x{h}: pixel {x},{y}");
|
||||
}
|
||||
let pad = mask[y * words + words - 1] >> (w % 32);
|
||||
assert!(w.is_multiple_of(32) || pad == 0, "padding bits set");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The perspective horizon in view: its denominator is exactly 0 on output row 75 (clamped to 1e-300) and
|
||||
/// changes sign there, so the blocks around it are undecided (clamp, and division by a range that may hold
|
||||
/// zero) and are decided per pixel, while blocks clear of it are filled at once.
|
||||
#[test]
|
||||
fn coverage_mask_with_the_horizon_in_view() {
|
||||
use lightcraft_geom::{Affine, Homography};
|
||||
let (w, h) = (900, 600);
|
||||
let mut wp = lightcraft_pipeline::optics::Warp::identity(w as f64, h as f64);
|
||||
// centred coordinates: v = (y − 300) / 450, denominator 2·v + m8, exactly 0 at y = 75.5
|
||||
let m8 = -((75.5 - 300.0) / 450.0 * 2.0);
|
||||
wp.persp_inv = Homography([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 2.0, m8]);
|
||||
wp.persp = wp.persp_inv.inverse().expect("invertible");
|
||||
let o2t = Affine::IDENTITY;
|
||||
assert_eq!(wp.block_coverage(&o2t, 0, 32, 64, 80), None, "the block holding the horizon");
|
||||
assert_eq!(wp.block_coverage(&o2t, 0, 32, 0, 16), Some(false), "beyond the horizon");
|
||||
assert_eq!(wp.block_coverage(&o2t, 448, 480, 288, 304), Some(true), "the centre");
|
||||
let mask = coverage_mask(&wp, &o2t, w, h);
|
||||
let words = w.div_ceil(32);
|
||||
let mut covered = 0;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let bit = mask[y * words + x / 32] >> (x % 32) & 1 == 1;
|
||||
assert_eq!(bit, wp.covers(&o2t, x, y), "pixel {x},{y}");
|
||||
covered += bit as usize;
|
||||
}
|
||||
}
|
||||
assert!(covered > 0 && covered < w * h, "{covered} px covered");
|
||||
}
|
||||
|
||||
/// Kernel timings on a 24 MP plane: `cargo test --release -p lightcraft-gpu -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
|
||||
@@ -177,7 +177,9 @@ fn sample_warp(@builtin(global_invocation_id) g: vec3<u32>) {
|
||||
let s = corrected_to_source(c, 1u);
|
||||
let sx = pf(10u);
|
||||
let sy = pf(11u);
|
||||
if (!(s.x >= -0.5 && s.y >= -0.5 && s.x <= pf(12u) + 0.5 && s.y <= pf(13u) + 0.5)) {
|
||||
// CPU reference coverage avoids an f32 rounding discontinuity at the edge.
|
||||
let word = g.y * ((w + 31u) / 32u) + g.x / 32u;
|
||||
if ((coverage[word] & (1u << (g.x % 32u))) == 0u) {
|
||||
put(g.y * w + g.x, vec3<f32>(BLANK_R, BLANK_G, BLANK_B));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -430,7 +430,6 @@ fn geometry_variants() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp};
|
||||
let src = scene(2, 900, 600);
|
||||
let raw = SourceInfo { raw: true, ..Default::default() };
|
||||
use Orientation::*;
|
||||
@@ -445,7 +444,19 @@ fn geometry_variants() {
|
||||
check(&format!("{o:?} crop={crop}"), &src, &raw, &s, &req);
|
||||
}
|
||||
}
|
||||
// embedded DNG lens corrections (per-plane warp + vignette) with manual CA
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_lens_perspective_edges_match() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp};
|
||||
let src = scene(2, 900, 600);
|
||||
let raw = SourceInfo { raw: true, ..Default::default() };
|
||||
// The unrotated case includes a position only 0.000035 px outside the edge.
|
||||
// f32 classification previously sampled the photo instead of blank canvas.
|
||||
// Embedded DNG lens corrections (per-plane warp + vignette) with manual CA.
|
||||
let lens = EmbeddedLens {
|
||||
warp: Some(EmbeddedWarp {
|
||||
planes: [[1.0, -0.03, 0.01, 0.0, 0.001, -0.002], [1.0, -0.028, 0.01, 0.0, 0.001, -0.002], [1.0, -0.026, 0.01, 0.0, 0.001, -0.002]],
|
||||
@@ -460,7 +471,7 @@ fn geometry_variants() {
|
||||
s.optics.ca_red = 30.0;
|
||||
s.geometry.horizontal = -15.0;
|
||||
check("embedded lens + perspective", &src, &info, &s, &RenderRequest::fit(700, 700));
|
||||
s.orientation = Rotate270;
|
||||
s.orientation = Orientation::Rotate270;
|
||||
check("embedded lens rotated", &src, &info, &s, &RenderRequest::fit(700, 700));
|
||||
}
|
||||
|
||||
@@ -628,3 +639,24 @@ fn output_spaces_match() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tint_directions_match_cpu_for_raw_and_rendered_sources() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let src = Arc::new(Rgb32f::filled(64, 64, [0.18; 3]));
|
||||
for info in [
|
||||
SourceInfo::default(),
|
||||
SourceInfo { raw: true, relative_wb: true, ..Default::default() },
|
||||
SourceInfo { raw: true, as_shot_temp: 4200.0, as_shot_tint: 15.0, ..Default::default() },
|
||||
] {
|
||||
for delta in [-50.0, 50.0] {
|
||||
let mut s = DevelopSettings::default();
|
||||
s.wb.mode = WbMode::Custom;
|
||||
s.wb.temp = info.as_shot_temp;
|
||||
s.wb.tint = info.as_shot_tint + delta;
|
||||
check("tint direction", &src, &info, &s, &RenderRequest::fit(64, 64));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,10 @@ use serde_json::{Value, json};
|
||||
use crate::backend::Backend;
|
||||
|
||||
/// File extensions recognised as photos when expanding folders.
|
||||
pub const PHOTO_EXTENSIONS: &[&str] =
|
||||
&["jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "pef", "psd", "jxl", "gif", "bmp", "avif"];
|
||||
pub const PHOTO_EXTENSIONS: &[&str] = &[
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl", "gif",
|
||||
"bmp", "avif",
|
||||
];
|
||||
|
||||
/// Headless backend: a [`Session`] with filesystem hooks.
|
||||
pub struct Headless {
|
||||
@@ -59,7 +61,7 @@ impl Headless {
|
||||
fn export(&mut self, p: &Value) -> Result<Value, String> {
|
||||
use lightcraft_engine::export::{Destination, ExportFormat, ExportOptions, Resize, export_batch};
|
||||
let p = &self.session.export_params(p)?;
|
||||
let mut opts = ExportOptions::from_json(p);
|
||||
let mut opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?;
|
||||
if !ExportOptions::has_size_param(p) {
|
||||
opts.resize = Some(Resize::long_edge(3000));
|
||||
}
|
||||
@@ -105,7 +107,7 @@ impl Backend for Headless {
|
||||
"engine.commands" => {
|
||||
let mut v: Vec<Value> = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
|
||||
v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null,
|
||||
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?, limitKb?, colorSpace?, bitDepth?, sharpen?, metadata?, watermark?, naming?}",
|
||||
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, inset? (margin, 0..0.4 of the short edge; 0.025), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default",
|
||||
"enabled": self.session.active().is_some()}));
|
||||
Ok(Value::Array(v))
|
||||
}
|
||||
|
||||
@@ -120,6 +120,25 @@ fn path_writes_never_replace_an_original() {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Issue #181: a misspelled or unreadable `app.export` param is an error through the headless
|
||||
/// backend (what `lightcraft-cli run` and the MCP `export` tool use), not a silent default.
|
||||
#[test]
|
||||
fn export_refuses_unknown_params_and_bad_values() {
|
||||
let mut b = Headless::demo();
|
||||
let out = "/nonexistent-lc-test/x.jpg";
|
||||
let e = b.call("app.export", json!({"path": out, "longEdgee": 400})).unwrap_err();
|
||||
assert!(e.contains("unknown parameter `longEdgee` (did you mean `longEdge`?)"), "{e}");
|
||||
let e = b.call("app.export", json!({"path": out, "longEdge": "banana"})).unwrap_err();
|
||||
assert!(e.contains("`longEdge` must be a number"), "{e}");
|
||||
let e = b.call("app.export", json!({"path": out, "watermark": {"text": "x", "size": 3}})).unwrap_err();
|
||||
assert!(e.contains("`watermark.size` must be a number 0.005..0.5"), "{e}");
|
||||
// `export` tool and preset expansion go through the same check
|
||||
let r = call_tool(&mut b, "export", &json!({"path": out, "quality": 101}));
|
||||
assert!(r.is_error, "{r:?}");
|
||||
let e = b.session.execute("export.savePreset", &json!({"name": "Typo", "params": {"qualty": 5}})).unwrap_err().to_string();
|
||||
assert!(e.contains("did you mean `quality`"), "{e}");
|
||||
}
|
||||
|
||||
/// The `import` helper moves: renamed into the folder template, the source removed.
|
||||
#[test]
|
||||
fn import_tool_moves_with_a_folder_template() {
|
||||
@@ -205,3 +224,24 @@ fn resources() {
|
||||
}
|
||||
assert_eq!(rpc(&mut s, 99, "resources/read", json!({"uri": "lightcraft://nope"}))["error"]["code"], -32002);
|
||||
}
|
||||
|
||||
/// `select_photos` with an id that is not in the library is a tool error, and the photo that was
|
||||
/// active stays active (#182).
|
||||
#[test]
|
||||
fn select_photos_rejects_unknown_ids_and_keeps_the_active_photo() {
|
||||
let mut b = Headless::demo();
|
||||
let first = b.session.visible_cloned()[0];
|
||||
let r = call_tool(&mut b, "select_photos", &json!({"ids": [first.0]}));
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
let r = call_tool(&mut b, "select_photos", &json!({"ids": [9999]}));
|
||||
assert!(r.is_error, "{r:?}");
|
||||
assert!(r.content[0]["text"].as_str().unwrap().contains("no such photo 9999"), "{r:?}");
|
||||
assert_eq!(b.session.active(), Some(first));
|
||||
let r = call_tool(&mut b, "get_develop", &json!({}));
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
let r = call_tool(&mut b, "get_develop", &json!({"id": 9999}));
|
||||
assert!(r.is_error, "{r:?}");
|
||||
let r = call_tool(&mut b, "set_develop", &json!({"id": 9999, "values": {"light.exposure": 1.0}}));
|
||||
assert!(r.is_error, "{r:?}");
|
||||
assert_eq!(b.session.active(), Some(first));
|
||||
}
|
||||
@@ -102,6 +102,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
||||
"mode": {"type": "string", "enum": ["add", "copy", "move"], "description": "add (default) = reference the files in place; copy = copy them into `destination` (default: the library's Originals/); move = as copy, then each original and its XMP sidecars are removed from the source once the copy is verified and catalogued (duplicates and failures keep their sources; `kept` lists sources left in place and why; undo leaves the moved files at the destination)"},
|
||||
"destination": {"type": "string", "description": "Copy or move: destination folder"},
|
||||
"organize": {"type": "string", "description": "Copy or move: folders inside the destination — date (YYYY/YYYY-MM-DD, default), month (YYYY/YYYY-MM), flat, or a folder template such as {date:%Y}/{date:%Y%m%d} (→ 2026/20260114; the template's / make the levels, tokens as for rename; relative, no ..). Dated by capture time, else the import time"},
|
||||
"onDeleted": {"type": "string", "enum": ["skip", "restore", "fresh"], "description": "A file that is in Recently Deleted: skip (default) = leave it there (the duplicate is reported with existingDeleted); restore = bring the photo back with its edits (`restored` lists it); fresh = delete the trashed record and import the file as a new photo"},
|
||||
"rename": {"type": "string", "description": "Copy or move: file-name template, e.g. {date:%Y%m%d_%H%M%S}_{seq:3} (run_command photo.renameTokens lists the tags); the extension is kept"}
|
||||
}),
|
||||
&["paths"],
|
||||
@@ -205,7 +206,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, 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}"}
|
||||
"watermark": {"description": "Text, or {text, vertical (boolean; defaults to false: upright columns right to left), size (text height as a fraction of the short edge, 0.005..0.5; default 0.035), opacity (0..1; default 0.7), anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset (margin as a fraction of the short edge, 0..0.4; default 0.025), color [r,g,b] (sRGB 0..255), shadow (boolean), image (path of a graphic drawn instead of the text), imageWidth (fraction of the photo's width, 0.01..1; default 0.2)}. Unknown keys and out-of-range sizes are errors"}
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
@@ -475,7 +476,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
|
||||
return ToolResult::error(format!("no photos found in {paths:?}"));
|
||||
}
|
||||
let mut p = json!({"paths": files});
|
||||
for k in ["album", "mode", "destination", "organize", "rename"] {
|
||||
for k in ["album", "mode", "destination", "organize", "rename", "onDeleted"] {
|
||||
if let Some(v) = args.get(k).filter(|v| !v.is_null()) {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
|
||||
@@ -111,9 +111,7 @@ pub fn load_frame(bytes: &[u8], max_edge: Option<usize>, orient: bool) -> Result
|
||||
if lightcraft_raw::probe(bytes).is_some() {
|
||||
let mut raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?;
|
||||
// geometric lens corrections stay with the develop settings ("profile corrections")
|
||||
raw.opcodes
|
||||
.list3
|
||||
.retain(|op| !matches!(op, lightcraft_raw::Opcode::WarpRectilinear { .. } | lightcraft_raw::Opcode::FixVignetteRadial { .. }));
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let small = max_edge.is_some_and(|m| m <= 1200);
|
||||
let method = if small { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd };
|
||||
let mut image = raw.develop(method).map_err(|e| MergeError::Decode(e.to_string()))?;
|
||||
|
||||
@@ -191,3 +191,28 @@ mod bw_tests {
|
||||
assert_eq!(auto_bw_mix(&grey, &SourceInfo::default(), &DevelopSettings::default()), [0.0; 8]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tint_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn auto_wb_and_picker_correct_green_with_positive_tint() {
|
||||
// The picker delegates a sampled patch to this same auto_wb implementation.
|
||||
for (rgb, sign) in [([0.18, 0.24, 0.18], 1.0), ([0.24, 0.18, 0.24], -1.0)] {
|
||||
let img = Rgb32f::filled(16, 16, rgb);
|
||||
let info = SourceInfo { raw: true, relative_wb: true, ..Default::default() };
|
||||
let (temp, tint) = auto_wb(&img, &info);
|
||||
assert!(tint * sign > 0.0, "{rgb:?}: temp {temp}, tint {tint}");
|
||||
let mut s = DevelopSettings::default();
|
||||
s.wb.mode = lightcraft_develop::WbMode::Custom;
|
||||
s.wb.temp = temp;
|
||||
s.wb.tint = tint;
|
||||
let mut corrected = img;
|
||||
crate::local::white_balance(&mut corrected, &info, &s);
|
||||
let p = corrected.get(0, 0);
|
||||
let spread = p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]);
|
||||
assert!(spread < 0.003, "{rgb:?} -> {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,13 +282,10 @@ fn sample_warped(base: &Rgb32f, sx: f64, sy: f64, wp: &Warp, o2t: Affine, w: usi
|
||||
let mut out = Rgb32f::new(w, h);
|
||||
par_rows(&mut out.data, w, |y, row| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let t = o2t.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
|
||||
let c = wp.to_corrected(t);
|
||||
let g = wp.corrected_to_source(c, 1);
|
||||
if !wp.inside(g) {
|
||||
let Some((c, g)) = wp.frame(&o2t, x, y) else {
|
||||
*px = BLANK;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut v = base.sample_bilinear((g.x * sx) as f32, (g.y * sy) as f32);
|
||||
if per_channel {
|
||||
for ch in [0usize, 2] {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Shapes are defined in normalized oriented-image coordinates; sizes are in "long-edge units".
|
||||
//! Components combine with Add (max), Subtract (a·(1−c)) and Intersect (a·c).
|
||||
|
||||
use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskOp, MaskShape};
|
||||
use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskOp, MaskShape, SegMask};
|
||||
use lightcraft_geom::Point;
|
||||
use lightcraft_raster::{Plane, Rgb32f};
|
||||
|
||||
@@ -127,7 +127,7 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R
|
||||
}
|
||||
}
|
||||
MaskShape::ColorRange { samples, refine } => {
|
||||
let tol = 0.04 + 0.16 * (*refine as f32 / 100.0);
|
||||
let tol = color_range_tolerance(*refine);
|
||||
let samples: Vec<[f32; 3]> = samples.iter().map(|s| [s[0] as f32, s[1] as f32, s[2] as f32]).collect();
|
||||
for (v, c) in out.data.iter_mut().zip(&img.data) {
|
||||
let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(c.map(|v| v * gain)));
|
||||
@@ -153,6 +153,19 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R
|
||||
}
|
||||
smooth_plane(&mut out, 0.01 * frame_px(frame, w));
|
||||
}
|
||||
MaskShape::Object { seg, detail, edge, .. } | MaskShape::Prompt { seg, detail, edge, .. } if seg.is_some() || !detail.is_empty() => {
|
||||
// Edge: below 0 a steeper transition (up to 8× the logits), above 0 feathered (up to
|
||||
// 2 % of the long edge, so previews and exports match)
|
||||
let e = (*edge / 100.0).clamp(-1.0, 1.0) as f32;
|
||||
let gain = if e < 0.0 { 1.0 - 7.0 * e } else { 1.0 };
|
||||
sample_seg(seg.as_ref(), detail, gain, frame, w, h, &mut out);
|
||||
if e > 0.0 {
|
||||
smooth_plane(&mut out, e * 0.02 * frame_px(frame, w));
|
||||
}
|
||||
}
|
||||
// not computed yet (no clicks, or a build without the model): nothing selected
|
||||
MaskShape::Object { hint, .. } if hint.is_empty() => {}
|
||||
MaskShape::Prompt { .. } => {}
|
||||
MaskShape::Subject | MaskShape::Object { .. } | MaskShape::People { .. } => {
|
||||
// Saliency heuristic: centre-weighted local contrast (replaced by the segmenter in M12).
|
||||
let m = frame.out_to_norm(w, h);
|
||||
@@ -176,6 +189,61 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R
|
||||
out
|
||||
}
|
||||
|
||||
/// A decoded [`SegMask`]: its logits and the normalized rectangle they cover.
|
||||
struct SegGrid {
|
||||
logits: Vec<f32>,
|
||||
side: usize,
|
||||
r: [f64; 4],
|
||||
}
|
||||
|
||||
impl SegGrid {
|
||||
fn new(seg: &SegMask) -> Option<SegGrid> {
|
||||
Some(SegGrid { logits: seg.logits()?, side: seg.side as usize, r: seg.bounds()? })
|
||||
}
|
||||
|
||||
fn contains(&self, n: Point) -> bool {
|
||||
n.x >= self.r[0] && n.x <= self.r[2] && n.y >= self.r[1] && n.y <= self.r[3]
|
||||
}
|
||||
|
||||
/// The bilinearly sampled logit at normalized image point `n` (cell centres at
|
||||
/// `(i + 0.5) / side` of the rectangle).
|
||||
fn logit(&self, n: Point) -> f32 {
|
||||
let side = self.side;
|
||||
let at = |x: usize, y: usize| self.logits.get(y.min(side - 1) * side + x.min(side - 1)).copied().unwrap_or(-16.0);
|
||||
let u = (n.x - self.r[0]) / (self.r[2] - self.r[0]);
|
||||
let v = (n.y - self.r[1]) / (self.r[3] - self.r[1]);
|
||||
let (fx, fy) = ((u * side as f64 - 0.5) as f32, (v * side as f64 - 0.5) as f32);
|
||||
let (fx, fy) = (fx.clamp(0.0, (side - 1) as f32), fy.clamp(0.0, (side - 1) as f32));
|
||||
let (x0, y0) = (fx.floor() as usize, fy.floor() as usize);
|
||||
let (tx, ty) = (fx - x0 as f32, fy - y0 as f32);
|
||||
let top = at(x0, y0) * (1.0 - tx) + at(x0 + 1, y0) * tx;
|
||||
let bot = at(x0, y0 + 1) * (1.0 - tx) + at(x0 + 1, y0 + 1) * tx;
|
||||
top * (1.0 - ty) + bot * ty
|
||||
}
|
||||
}
|
||||
|
||||
/// Stored model segmentations at each output pixel: the zoomed-in `detail` passes inside
|
||||
/// their rectangles (the highest of them where they overlap), the whole-photo `seg`
|
||||
/// elsewhere; the logit (times `gain`: > 1 is a harder edge) through a sigmoid. Damaged
|
||||
/// data selects nothing.
|
||||
fn sample_seg(seg: Option<&SegMask>, detail: &[SegMask], gain: f32, frame: &Frame, w: usize, h: usize, out: &mut Plane) {
|
||||
let coarse = seg.and_then(SegGrid::new);
|
||||
// (capped: a hostile document can hold many)
|
||||
let patches: Vec<SegGrid> = detail.iter().take(lightcraft_develop::segmask::MAX_DETAIL).filter_map(SegGrid::new).collect();
|
||||
if coarse.is_none() && patches.is_empty() {
|
||||
return;
|
||||
}
|
||||
let m = frame.out_to_norm(w, h);
|
||||
for_rows(&mut out.data, w, |y, row| {
|
||||
for (x, v) in row.iter_mut().enumerate() {
|
||||
let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
|
||||
let fine = patches.iter().filter(|p| p.contains(n)).map(|p| p.logit(n)).reduce(f32::max);
|
||||
let l = fine.or_else(|| coarse.as_ref().map(|c| c.logit(n))).unwrap_or(-16.0);
|
||||
*v = 1.0 / (1.0 + (-l * gain).exp());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn frame_px(frame: &Frame, w: usize) -> f32 {
|
||||
frame.px_per_long(w) as f32
|
||||
}
|
||||
@@ -189,6 +257,21 @@ pub(crate) fn tonemap_for_select(c: [f32; 3]) -> [f32; 3] {
|
||||
c.map(|v| v / (1.0 + v))
|
||||
}
|
||||
|
||||
/// Colour range: the OkLab distance (a, b at full weight, L at a quarter) at which a pixel leaves
|
||||
/// the selection for a Refine amount of 0..100. A pixel is fully selected up to half of it and
|
||||
/// fades out over the other half (CPU here, `mask.wgsl` kind 3; both take this value).
|
||||
///
|
||||
/// Issue #157: `0.04 + 0.16·refine` selected the whole photo. Through [`tonemap_for_select`] a
|
||||
/// slightly teal mid-grey (sRGB 120,150,155) and a neutral light grey (200,198,195) are only ~0.063
|
||||
/// apart — L 0.60 vs 0.72, a/b ≤ 0.02 — so at the default Refine 50 (full selection below 0.06)
|
||||
/// the grey patch was 99 % selected, and at Refine 85 / 100 still most of it. A JND is ~0.01 on
|
||||
/// this scale, so Refine 50 now holds a surface up to ~2 JND from its sample and drops everything
|
||||
/// beyond ~4.5 (full to 0.0225, gone at 0.045); Refine 85 is gone at 0.066, past that grey patch;
|
||||
/// Refine 100 reaches 0.075 and Refine 0 is the sample's own shade (full to 0.0075, gone at 0.015).
|
||||
pub fn color_range_tolerance(refine: f64) -> f32 {
|
||||
0.015 + 0.06 * (refine / 100.0).clamp(0.0, 1.0) as f32
|
||||
}
|
||||
|
||||
/// A brush stroke resolved to output pixels: dab centres (spacing r/4 along the path), radius,
|
||||
/// hard-core radius, flow and density (0..1), and whether it erases.
|
||||
pub struct BrushDabs {
|
||||
@@ -356,6 +439,93 @@ mod tests {
|
||||
assert!((a.get(50, 25) - 0.5).abs() < 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_segmentations_render_at_any_size_and_follow_the_crop() {
|
||||
// the left half selected, on a 16² logit grid
|
||||
let side = 16;
|
||||
let logits: Vec<f32> = (0..side * side).map(|i| if i % side < side / 2 { 12.0 } else { -12.0 }).collect();
|
||||
let seg = SegMask::from_logits(side, &logits);
|
||||
for shape in [
|
||||
MaskShape::Object { hint: vec![Point::new(0.25, 0.5)], exclude: vec![], seg: Some(seg.clone()), detail: vec![], edge: 0.0 },
|
||||
MaskShape::Prompt { text: "left".into(), seg: Some(seg.clone()), detail: vec![], edge: 0.0 },
|
||||
] {
|
||||
for (w, h) in [(40, 20), (400, 200)] {
|
||||
let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0);
|
||||
assert!(a.get(1, h / 2) > 0.99 && a.get(w - 2, h / 2) < 0.01, "{w}×{h}");
|
||||
if w >= 400 {
|
||||
let soft = (0..w).filter(|x| (0.05..0.95).contains(&a.get(*x, h / 2))).count();
|
||||
assert!((2..30).contains(&soft), "an anti-aliased edge, not a ramp: {soft} px");
|
||||
}
|
||||
}
|
||||
}
|
||||
// cropped to the right half: nothing left selected
|
||||
let mut d = DevelopSettings::default();
|
||||
d.crop.geometry.rect = lightcraft_geom::Rect::from_center(Point::new(0.75, 0.5), 0.5, 1.0);
|
||||
let f = Frame::new(40, 20, &d, true);
|
||||
let shape = MaskShape::Prompt { text: "left".into(), seg: Some(seg), detail: vec![], edge: 0.0 };
|
||||
let a = shape_alpha(&shape, &f, 20, 20, &Rgb32f::new(20, 20), &Plane::new(20, 20), 0.0);
|
||||
assert!(a.data.iter().all(|v| *v < 0.05));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_patches_replace_the_coarse_mask_inside_their_rectangle() {
|
||||
// coarse: nothing; a patch over the right half selects its own left half (x 0.5..0.75)
|
||||
let coarse = SegMask::from_logits(4, &[-12.0; 16]);
|
||||
let side = 16;
|
||||
let l: Vec<f32> = (0..side * side).map(|i| if i % side < side / 2 { 12.0 } else { -12.0 }).collect();
|
||||
let patch = SegMask::from_logits_in(side, &l, [0.5, 0.0, 1.0, 1.0]);
|
||||
let shape = MaskShape::Object { hint: vec![Point::new(0.6, 0.5)], exclude: vec![], seg: Some(coarse), detail: vec![patch], edge: 0.0 };
|
||||
let (w, h) = (200, 100);
|
||||
let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0);
|
||||
assert!(a.get(20, 50) < 0.01, "outside the patch: the coarse mask");
|
||||
assert!(a.get(120, 50) > 0.99, "inside the patch, its selected half");
|
||||
assert!(a.get(190, 50) < 0.01, "inside the patch, its unselected half");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hostile_number_of_detail_patches_is_capped() {
|
||||
// built in memory (a deserialized document is capped already): only the first
|
||||
// MAX_DETAIL are decoded and sampled, so this needs 4 × 4 MB, not 20 000 × 4 MB
|
||||
let side = 1024;
|
||||
let l: Vec<f32> = vec![12.0; side * side];
|
||||
let patch = SegMask::from_logits_in(side, &l, [0.0, 0.0, 0.5, 1.0]);
|
||||
let shape = MaskShape::Object { hint: vec![Point::new(0.2, 0.5)], exclude: vec![], seg: None, detail: vec![patch; 20_000], edge: 0.0 };
|
||||
let (w, h) = (64, 32);
|
||||
let t = std::time::Instant::now();
|
||||
let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0);
|
||||
assert!(a.get(5, 16) > 0.99 && a.get(60, 16) < 0.01);
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_edge_setting_hardens_or_feathers_the_transition() {
|
||||
let side = 16;
|
||||
let l: Vec<f32> = (0..side * side).map(|i| ((i % side) as f32 - 7.5) * -0.6).collect(); // a gentle ramp
|
||||
let seg = SegMask::from_logits(side, &l);
|
||||
let (w, h) = (400, 40);
|
||||
let soft_px = |edge: f64| {
|
||||
let shape = MaskShape::Prompt { text: "x".into(), seg: Some(seg.clone()), detail: vec![], edge };
|
||||
let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0);
|
||||
assert!(a.get(2, h / 2) > 0.9 && a.get(w - 3, h / 2) < 0.1, "still the same selection at {edge}");
|
||||
(0..w).filter(|x| (0.1..0.9).contains(&a.get(*x, h / 2))).count()
|
||||
};
|
||||
let (hard, plain, soft) = (soft_px(-100.0), soft_px(0.0), soft_px(100.0));
|
||||
assert!(hard < plain && plain < soft, "transition widths {hard} < {plain} < {soft}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_shapes_without_a_segmentation_select_nothing() {
|
||||
let f = frame(30, 30);
|
||||
let (img, l) = (Rgb32f::new(30, 30), Plane::new(30, 30));
|
||||
for shape in [
|
||||
MaskShape::Object { hint: vec![], exclude: vec![], seg: None, detail: vec![], edge: 0.0 },
|
||||
MaskShape::Prompt { text: "sky".into(), seg: None, detail: vec![], edge: 0.0 },
|
||||
MaskShape::Prompt { text: "sky".into(), seg: Some(SegMask { side: 4, data: "damaged!".into(), rect: None }), detail: vec![], edge: 0.0 },
|
||||
] {
|
||||
assert!(shape_alpha(&shape, &f, 30, 30, &img, &l, 0.0).data.iter().all(|v| *v == 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_inside_and_invert() {
|
||||
let f = frame(100, 100);
|
||||
@@ -443,4 +613,42 @@ mod tests {
|
||||
let (plain, sharp) = (step(&soft), step(&refined));
|
||||
assert!(sharp > plain * 1.5 && sharp > 0.1, "the edge in the mask follows the photo's: {plain} → {sharp}");
|
||||
}
|
||||
|
||||
/// Issue #157: a colour-range sample of one flat patch must not select a clearly different
|
||||
/// patch. The two patches are the ones from the report (sRGB 120,150,155 and 200,198,195),
|
||||
/// sampled the way `mask.sampleColor` does (3×3 mean of the selection-space OkLab).
|
||||
#[test]
|
||||
fn color_range_keeps_to_the_sampled_colour() {
|
||||
use lightcraft_color::{REC2020, SRGB, transfer::decode_srgb8};
|
||||
let (w, h) = (120usize, 40usize);
|
||||
let m = SRGB.to_space(&REC2020);
|
||||
let patch = |rgb: [u8; 3]| m.apply_f32(rgb.map(decode_srgb8));
|
||||
let (left, right) = (patch([120, 150, 155]), patch([200, 198, 195]));
|
||||
let img = Rgb32f::from_fn(w, h, |x, _| if x < 60 { left } else { right });
|
||||
let log_l = img.map(crate::local::log_lum);
|
||||
let f = Frame::new(w, h, &Default::default(), true);
|
||||
let mut sample = [0f64; 3];
|
||||
for y in 19..=21 {
|
||||
for x in 29..=31 {
|
||||
let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(img.data[y * w + x]));
|
||||
for k in 0..3 {
|
||||
sample[k] += lab[k] as f64 / 9.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let alpha = |refine| {
|
||||
let shape = MaskShape::ColorRange { samples: vec![sample], refine };
|
||||
let a = shape_alpha(&shape, &f, w, h, &img, &log_l, 0.0);
|
||||
(a.get(30, 20), a.get(90, 20))
|
||||
};
|
||||
for refine in [0.0, 50.0, 85.0] {
|
||||
let (l, r) = alpha(refine);
|
||||
assert!(l > 0.95, "refine {refine}: sampled patch {l}");
|
||||
assert!(r < 0.05, "refine {refine}: other patch {r}");
|
||||
}
|
||||
// Refine 100 reaches furthest (the grey is ~0.063 away, the fade ends at 0.075), but the
|
||||
// other patch stays mostly out
|
||||
let (l, r) = alpha(100.0);
|
||||
assert!(l > 0.95 && r < 0.3, "refine 100: {l} vs {r}");
|
||||
}
|
||||
}
|
||||
+315
-22
@@ -11,13 +11,14 @@
|
||||
//! dimensionless scales), so a 400 px preview and a full-size export are warped identically.
|
||||
//!
|
||||
//! Also here: automatic lateral chromatic aberration estimation ([`estimate_lateral_ca`]) and [`defringe`].
|
||||
//! Profile corrections use only lens data embedded in DNG files (`WarpRectilinear` / `FixVignetteRadial`).
|
||||
//! Profile corrections use only lens data embedded in the files: DNG `WarpRectilinear` / `FixVignetteRadial`, and the
|
||||
//! raw readers' equivalent of a camera's own correction (Panasonic / Leica RW2 distortion).
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use lightcraft_color::perceptual::{oklab_from_2020, oklab_to_2020};
|
||||
use lightcraft_develop::{DevelopSettings, EmbeddedLens, EmbeddedVignette, EmbeddedWarp};
|
||||
use lightcraft_geom::{Homography, Orientation, Point};
|
||||
use lightcraft_geom::{Affine, Homography, Interval, Orientation, Point, Real};
|
||||
use lightcraft_raster::{Plane, Rgb32f};
|
||||
|
||||
use crate::for_rows;
|
||||
@@ -122,12 +123,18 @@ impl Warp {
|
||||
|
||||
/// Transformed px → lens-corrected px (inverse perspective).
|
||||
pub fn to_corrected(&self, p: Point) -> Point {
|
||||
let (x, y) = self.to_corrected_real(p.x, p.y);
|
||||
Point::new(x, y)
|
||||
}
|
||||
|
||||
/// [`Warp::to_corrected`] for any [`Real`].
|
||||
fn to_corrected_real<T: Real>(&self, x: T, y: T) -> (T, T) {
|
||||
if self.persp == Homography::IDENTITY {
|
||||
return p;
|
||||
return (x, y);
|
||||
}
|
||||
let (c, l) = (self.centre(), self.half_long());
|
||||
let q = self.persp_inv.apply(Point::new((p.x - c.x) / l, (p.y - c.y) / l));
|
||||
Point::new(c.x + q.x * l, c.y + q.y * l)
|
||||
let (qx, qy) = self.persp_inv.apply_real((x - c.x) / l, (y - c.y) / l);
|
||||
(qx * l + c.x, qy * l + c.y)
|
||||
}
|
||||
|
||||
/// Lens-corrected px → transformed px (forward perspective).
|
||||
@@ -142,26 +149,32 @@ impl Warp {
|
||||
|
||||
/// Lens-corrected px → source px for colour plane `ch` (0 = R, 1 = G, 2 = B).
|
||||
pub fn corrected_to_source(&self, p: Point, ch: usize) -> Point {
|
||||
let (x, y) = self.corrected_to_source_real(p.x, p.y, ch);
|
||||
Point::new(x, y)
|
||||
}
|
||||
|
||||
/// [`Warp::corrected_to_source`] for any [`Real`].
|
||||
fn corrected_to_source_real<T: Real>(&self, x: T, y: T, ch: usize) -> (T, T) {
|
||||
let c = self.centre();
|
||||
let hd = (self.w.hypot(self.h) / 2.0).max(1e-9);
|
||||
let mut q = p;
|
||||
let (mut qx, mut qy) = (x, y);
|
||||
if self.k1 != 0.0 {
|
||||
let (dx, dy) = (q.x - c.x, q.y - c.y);
|
||||
let (dx, dy) = (qx - c.x, qy - c.y);
|
||||
let r2 = (dx * dx + dy * dy) / (hd * hd);
|
||||
let f = 1.0 + self.k1 * r2;
|
||||
q = Point::new(c.x + dx * f, c.y + dy * f);
|
||||
let f = r2 * self.k1 + 1.0;
|
||||
(qx, qy) = (dx * f + c.x, dy * f + c.y);
|
||||
}
|
||||
if self.lens_dist != 0.0
|
||||
&& let Some(wp) = self.lens.and_then(|l| l.warp)
|
||||
{
|
||||
let s = embedded_warp(&wp, q, ch, self.w, self.h);
|
||||
q = Point::new(q.x + (s.x - q.x) * self.lens_dist, q.y + (s.y - q.y) * self.lens_dist);
|
||||
let (sx, sy) = embedded_warp_real(&wp, qx, qy, ch, self.w, self.h);
|
||||
(qx, qy) = (qx + (sx - qx) * self.lens_dist, qy + (sy - qy) * self.lens_dist);
|
||||
}
|
||||
let k = self.ca[ch];
|
||||
if k != 0.0 {
|
||||
q = Point::new(c.x + (q.x - c.x) * (1.0 + k), c.y + (q.y - c.y) * (1.0 + k));
|
||||
(qx, qy) = ((qx - c.x) * (1.0 + k) + c.x, (qy - c.y) * (1.0 + k) + c.y);
|
||||
}
|
||||
q
|
||||
(qx, qy)
|
||||
}
|
||||
|
||||
/// Transformed px → source px for plane `ch`.
|
||||
@@ -185,24 +198,86 @@ impl Warp {
|
||||
g as f32
|
||||
}
|
||||
|
||||
/// Whether a source position lies on the image (with a half-pixel margin).
|
||||
pub fn inside(&self, s: Point) -> bool {
|
||||
s.x >= -0.5 && s.y >= -0.5 && s.x <= self.w + 0.5 && s.y <= self.h + 0.5
|
||||
/// Whether a source position lies on the image (within [`edges`](Self::edges)).
|
||||
fn inside(&self, s: Point) -> bool {
|
||||
let (lo, hx, hy) = self.edges();
|
||||
s.x >= lo && s.y >= lo && s.x <= hx && s.y <= hy
|
||||
}
|
||||
|
||||
/// The source area that frames output pixels, `lo..=hx × lo..=hy`: the image with a half-pixel margin.
|
||||
fn edges(&self) -> (f64, f64, f64) {
|
||||
(-0.5, self.w + 0.5, self.h + 0.5)
|
||||
}
|
||||
|
||||
/// The framing chain of output position `(x, y)` (`o2t`: output px → transformed px): its lens-corrected
|
||||
/// position and its green-plane source position.
|
||||
fn framing<T: Real>(&self, o2t: &Affine, x: T, y: T) -> ((T, T), (T, T)) {
|
||||
let (tx, ty) = o2t.apply_real(x, y);
|
||||
let (cx, cy) = self.to_corrected_real(tx, ty);
|
||||
((cx, cy), self.corrected_to_source_real(cx, cy, 1))
|
||||
}
|
||||
|
||||
/// The reference framing of output pixel `(x, y)`: its lens-corrected and green-plane source positions if
|
||||
/// the latter is [`inside`](Self::inside) the image, else `None` (the pixel is blank canvas).
|
||||
pub fn frame(&self, o2t: &Affine, x: usize, y: usize) -> Option<(Point, Point)> {
|
||||
let ((cx, cy), (gx, gy)) = self.framing(o2t, x as f64 + 0.5, y as f64 + 0.5);
|
||||
let g = Point::new(gx, gy);
|
||||
self.inside(g).then(|| (Point::new(cx, cy), g))
|
||||
}
|
||||
|
||||
/// Whether output pixel `(x, y)` shows the image ([`frame`](Self::frame) is `Some`).
|
||||
pub fn covers(&self, o2t: &Affine, x: usize, y: usize) -> bool {
|
||||
self.frame(o2t, x, y).is_some()
|
||||
}
|
||||
|
||||
/// [`covers`](Self::covers) for every pixel of the output block `x0..x1 × y0..y1` at once, when that is
|
||||
/// certain: `Some(true)` if all are covered, `Some(false)` if none is, `None` if the block may straddle the
|
||||
/// image edge (or can't be bounded) and each pixel must be decided alone.
|
||||
///
|
||||
/// Why the answer equals the per-pixel decision: the block's pixel centres `x + 0.5` are exact `f64` values in
|
||||
/// `[x0 + 0.5, x1 − 0.5]` (likewise y). [`framing`](Self::framing) evaluated with [`Interval`]s runs the very
|
||||
/// same operations as its `f64` evaluation in [`frame`](Self::frame), each rounded outward, so its result
|
||||
/// bounds the `f64` source position of every pixel of the block — the rounded values, not only the exact
|
||||
/// ones (see [`Interval`]). Divisions by a range that may contain zero, the homography's denominator clamp
|
||||
/// and overflow make the bounds undecided (NaN), and NaN fails every test below. So when the bounds lie
|
||||
/// within [`edges`](Self::edges) every pixel's position does, and when they lie beyond one edge every
|
||||
/// pixel's position does: the same `f64` comparisons [`inside`](Self::inside) makes, no margin needed.
|
||||
pub fn block_coverage(&self, o2t: &Affine, x0: usize, x1: usize, y0: usize, y1: usize) -> Option<bool> {
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return None;
|
||||
}
|
||||
let px = Interval::new(x0 as f64 + 0.5, (x1 - 1) as f64 + 0.5);
|
||||
let py = Interval::new(y0 as f64 + 0.5, (y1 - 1) as f64 + 0.5);
|
||||
let (_, (sx, sy)) = self.framing(o2t, px, py);
|
||||
let (lo, hx, hy) = self.edges();
|
||||
if sx.lo() >= lo && sy.lo() >= lo && sx.hi() <= hx && sy.hi() <= hy {
|
||||
Some(true)
|
||||
} else if sx.hi() < lo || sy.hi() < lo || sx.lo() > hx || sy.lo() > hy {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// DNG `WarpRectilinear` source position for corrected position `p` (px) and plane `ch`.
|
||||
pub fn embedded_warp(wp: &EmbeddedWarp, p: Point, ch: usize, w: f64, h: f64) -> Point {
|
||||
let (x, y) = embedded_warp_real(wp, p.x, p.y, ch, w, h);
|
||||
Point::new(x, y)
|
||||
}
|
||||
|
||||
/// [`embedded_warp`] for any [`Real`].
|
||||
fn embedded_warp_real<T: Real>(wp: &EmbeddedWarp, x: T, y: T, ch: usize, w: f64, h: f64) -> (T, T) {
|
||||
let (cx, cy) = (wp.center.x * w, wp.center.y * h);
|
||||
let m = (wp.radius * w.max(h)).max(1e-9);
|
||||
let [kr0, kr1, kr2, kr3, kt0, kt1] = wp.planes[ch.min(2)];
|
||||
let dx = (p.x - cx) / m;
|
||||
let dy = (p.y - cy) / m;
|
||||
let dx = (x - cx) / m;
|
||||
let dy = (y - cy) / m;
|
||||
let r2 = dx * dx + dy * dy;
|
||||
let f = kr0 + r2 * (kr1 + r2 * (kr2 + r2 * kr3));
|
||||
let sx = dx * f + kt0 * 2.0 * dx * dy + kt1 * (r2 + 2.0 * dx * dx);
|
||||
let sy = dy * f + kt1 * 2.0 * dx * dy + kt0 * (r2 + 2.0 * dy * dy);
|
||||
Point::new(cx + m * sx, cy + m * sy)
|
||||
let f = (((r2 * kr3 + kr2) * r2 + kr1) * r2) + kr0;
|
||||
let sx = dx * f + dx * (kt0 * 2.0) * dy + (r2 + dx * 2.0 * dx) * kt1;
|
||||
let sy = dy * f + dx * (kt1 * 2.0) * dy + (r2 + dy * 2.0 * dy) * kt0;
|
||||
(sx * m + cx, sy * m + cy)
|
||||
}
|
||||
|
||||
/// DNG `FixVignetteRadial` gain at source position `p` (px).
|
||||
@@ -514,6 +589,224 @@ fn min_filter(p: &Plane, r: usize, max: bool) -> Plane {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// splitmix64: deterministic pseudo-random numbers for the warp tests.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
/// Uniform in `lo..hi`.
|
||||
fn range(&mut self, lo: f64, hi: f64) -> f64 {
|
||||
lo + (hi - lo) * ((self.next() >> 11) as f64 / (1u64 << 53) as f64)
|
||||
}
|
||||
/// `range(lo, hi)` or, one time in four, exactly 0 (to take the "no correction" branches).
|
||||
fn maybe(&mut self, lo: f64, hi: f64) -> f64 {
|
||||
if self.next().is_multiple_of(4) { 0.0 } else { self.range(lo, hi) }
|
||||
}
|
||||
}
|
||||
|
||||
/// A pseudo-random warp: perspective, manual distortion, CA and an embedded lens warp, each sometimes off.
|
||||
/// Sizes are Pythagorean (exact `hypot`), so results don't depend on the platform's libm.
|
||||
fn random_warp(r: &mut Rng) -> Warp {
|
||||
const SIZES: [(f64, f64); 6] = [(4000.0, 3000.0), (3000.0, 4000.0), (1200.0, 900.0), (1200.0, 500.0), (56.0, 33.0), (6000.0, 4500.0)];
|
||||
let (w, h) = SIZES[(r.next() % SIZES.len() as u64) as usize];
|
||||
let mut wp = Warp::identity(w, h);
|
||||
if !r.next().is_multiple_of(4) {
|
||||
let m = [
|
||||
1.0 + r.range(-0.2, 0.2),
|
||||
r.range(-0.2, 0.2),
|
||||
r.range(-0.1, 0.1),
|
||||
r.range(-0.2, 0.2),
|
||||
1.0 + r.range(-0.2, 0.2),
|
||||
r.range(-0.1, 0.1),
|
||||
r.range(-0.4, 0.4),
|
||||
r.range(-0.4, 0.4),
|
||||
1.0 + r.range(-0.1, 0.1),
|
||||
];
|
||||
wp.persp_inv = Homography(m);
|
||||
wp.persp = wp.persp_inv.inverse().unwrap_or(Homography::IDENTITY);
|
||||
}
|
||||
wp.k1 = r.maybe(-0.2, 0.2);
|
||||
wp.ca = [r.maybe(-0.003, 0.003), r.maybe(-0.003, 0.003), r.maybe(-0.003, 0.003)];
|
||||
if !r.next().is_multiple_of(4) {
|
||||
let mut plane = || {
|
||||
[
|
||||
1.0 + r.range(-0.05, 0.05),
|
||||
r.range(-0.08, 0.08),
|
||||
r.range(-0.05, 0.05),
|
||||
r.range(-0.02, 0.02),
|
||||
r.range(-0.003, 0.003),
|
||||
r.range(-0.003, 0.003),
|
||||
]
|
||||
};
|
||||
let planes = [plane(), plane(), plane()];
|
||||
let warp = EmbeddedWarp { planes, center: Point::new(r.range(0.4, 0.6), r.range(0.4, 0.6)), radius: r.range(0.5, 0.8) };
|
||||
wp.lens = Some(EmbeddedLens { warp: Some(warp), vignette: None });
|
||||
wp.lens_dist = r.maybe(0.0, 2.0);
|
||||
}
|
||||
wp
|
||||
}
|
||||
|
||||
/// FNV-1a over the bits of the f64 warp results (`Affine::apply`, `Homography::apply`, `to_corrected`,
|
||||
/// `from_corrected`, `corrected_to_source`, `to_source`, `embedded_warp`) for pseudo-random warps and points.
|
||||
/// The expected value was captured before these formulas became generic over `Real`: the f64 path must keep
|
||||
/// the same operations in the same order, bit for bit. To recompute it after changing the inputs, run this
|
||||
/// test (printing `hash`) on a scratch copy of 419261c (thumb-split, pre-generic formulas) and paste it here.
|
||||
#[test]
|
||||
fn warp_f64_results_are_bit_identical_to_the_pre_generic_formulas() {
|
||||
let mut hash = 0xCBF2_9CE4_8422_2325u64;
|
||||
let mut eat = |p: Point| {
|
||||
for v in [p.x, p.y] {
|
||||
for b in v.to_bits().to_le_bytes() {
|
||||
hash = (hash ^ b as u64).wrapping_mul(0x0000_0100_0000_01B3);
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut r = Rng(42);
|
||||
for _ in 0..2000 {
|
||||
let wp = random_warp(&mut r);
|
||||
let a = lightcraft_geom::Affine([
|
||||
r.range(-2.0, 2.0),
|
||||
r.range(-2.0, 2.0),
|
||||
r.range(-2.0, 2.0),
|
||||
r.range(-2.0, 2.0),
|
||||
r.range(-500.0, 500.0),
|
||||
r.range(-500.0, 500.0),
|
||||
]);
|
||||
for _ in 0..40 {
|
||||
let p = Point::new(r.range(-0.2, 1.2) * wp.w, r.range(-0.2, 1.2) * wp.h);
|
||||
eat(a.apply(p));
|
||||
eat(wp.persp_inv.apply(Point::new(p.x / wp.w - 0.5, p.y / wp.h - 0.5)));
|
||||
eat(wp.to_corrected(p));
|
||||
eat(wp.from_corrected(p));
|
||||
for ch in 0..3 {
|
||||
eat(wp.corrected_to_source(p, ch));
|
||||
eat(wp.to_source(p, ch));
|
||||
}
|
||||
if let Some(ew) = wp.lens.and_then(|l| l.warp) {
|
||||
for ch in 0..4 {
|
||||
eat(embedded_warp(&ew, p, ch, wp.w, wp.h));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// `Homography::apply`'s near-zero denominator clamp, including subnormal and signed-zero denominators and
|
||||
// ±1e-300 exactly (not clamped: `<`, not `<=`)
|
||||
let hz = Homography([1.0, 0.5, 0.25, -0.5, 1.0, 0.125, 1.0, 0.0, 0.0]);
|
||||
for x in [0.0, -0.0, 1e-310, -1e-310, 1e-300, -1e-300, 1e-299, -1e-299, 0.5] {
|
||||
eat(hz.apply(Point::new(x, 0.75)));
|
||||
}
|
||||
assert_eq!(hash, 0x3FA5_7473_D795_9E32, "f64 warp results changed");
|
||||
}
|
||||
|
||||
/// The interval evaluation of `framing` bounds the f64 evaluation of every point of the box.
|
||||
#[test]
|
||||
fn interval_framing_encloses_every_f64_result() {
|
||||
let mut r = Rng(7);
|
||||
let (mut decided, mut total) = (0, 0);
|
||||
for _ in 0..500 {
|
||||
let wp = random_warp(&mut r);
|
||||
let a = lightcraft_geom::Affine([
|
||||
r.range(0.5, 2.0),
|
||||
r.range(-0.2, 0.2),
|
||||
r.range(-0.2, 0.2),
|
||||
r.range(0.5, 2.0),
|
||||
r.range(-300.0, 300.0),
|
||||
r.range(-300.0, 300.0),
|
||||
]);
|
||||
for _ in 0..20 {
|
||||
let (x0, y0) = (r.range(-0.2, 1.1) * wp.w, r.range(-0.2, 1.1) * wp.h);
|
||||
let (bw, bh) = (r.range(0.0, 40.0), r.range(0.0, 40.0));
|
||||
let (ix, iy) = (Interval::new(x0, x0 + bw), Interval::new(y0, y0 + bh));
|
||||
let (_, (sx, sy)) = wp.framing(&a, ix, iy);
|
||||
total += 1;
|
||||
if !(sx.is_decided() && sy.is_decided()) {
|
||||
continue;
|
||||
}
|
||||
decided += 1;
|
||||
for i in 0..=6 {
|
||||
for j in 0..=6 {
|
||||
let (x, y) = if i == 6 && j == 6 {
|
||||
(r.range(x0, x0 + bw), r.range(y0, y0 + bh))
|
||||
} else {
|
||||
(x0 + bw * i as f64 / 5.0, y0 + bh * j as f64 / 5.0)
|
||||
};
|
||||
let (x, y) = (x.clamp(ix.lo(), ix.hi()), y.clamp(iy.lo(), iy.hi()));
|
||||
let (_, (fx, fy)) = wp.framing(&a, x, y);
|
||||
assert!(
|
||||
sx.lo() <= fx && fx <= sx.hi() && sy.lo() <= fy && fy <= sy.hi(),
|
||||
"({x}, {y}) → ({fx}, {fy}) outside {sx:?} × {sy:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(decided * 10 > total * 9, "{decided}/{total} boxes bounded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_coverage_agrees_with_every_pixel() {
|
||||
let lens = EmbeddedLens {
|
||||
warp: Some(EmbeddedWarp {
|
||||
planes: [[1.0, -0.03, 0.01, 0.0, 0.001, -0.002], [1.0, -0.028, 0.01, 0.0, 0.001, -0.002], [1.0, -0.026, 0.01, 0.0, 0.001, -0.002]],
|
||||
center: Point::new(0.52, 0.48),
|
||||
radius: 0.6,
|
||||
}),
|
||||
vignette: None,
|
||||
};
|
||||
let mut cases: Vec<DevelopSettings> = Vec::new();
|
||||
let mut s = DevelopSettings::default();
|
||||
s.optics.lens_profile = true;
|
||||
s.optics.ca_red = 30.0;
|
||||
s.geometry.horizontal = -15.0;
|
||||
cases.push(s.clone());
|
||||
s.orientation = Orientation::Rotate270;
|
||||
cases.push(s.clone());
|
||||
let mut s = DevelopSettings::default();
|
||||
s.optics.distortion = -60.0;
|
||||
s.optics.ca_blue = 50.0;
|
||||
s.geometry.vertical = 25.0;
|
||||
s.geometry.rotate = 7.0;
|
||||
cases.push(s.clone());
|
||||
s.optics.distortion = 80.0;
|
||||
s.geometry.horizontal = 30.0;
|
||||
s.orientation = Orientation::Rotate90;
|
||||
cases.push(s);
|
||||
for (i, s) in cases.iter().enumerate() {
|
||||
let f = crate::geometry::Frame::with_lens(900, 600, s, true, Some(&lens));
|
||||
let wp = f.warp.as_ref().expect("warp");
|
||||
let (w, h) = f.fit(700, 700);
|
||||
let o2t = f.out_to_oriented(w, h);
|
||||
let (mut decided, mut tiles, mut blank) = (0, 0, 0);
|
||||
for (tw, th) in [(32, 8), (7, 5), (64, 64)] {
|
||||
for y0 in (0..h).step_by(th) {
|
||||
for x0 in (0..w).step_by(tw) {
|
||||
let (x1, y1) = ((x0 + tw).min(w), (y0 + th).min(h));
|
||||
tiles += 1;
|
||||
let Some(all) = wp.block_coverage(&o2t, x0, x1, y0, y1) else { continue };
|
||||
decided += 1;
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
assert_eq!(wp.covers(&o2t, x, y), all, "case {i}: pixel {x},{y} in block {x0}..{x1} × {y0}..{y1}");
|
||||
blank += !all as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// most blocks are decided wholesale, including blank ones away from the edge
|
||||
assert!(decided * 10 > tiles * 8, "case {i}: {decided}/{tiles} blocks decided");
|
||||
// strong distortion leaves blank margins wide enough for whole blocks
|
||||
assert!(blank > 0 || i < 2, "case {i}: no blank block decided");
|
||||
assert_eq!(wp.block_coverage(&o2t, 5, 5, 0, 1), None, "empty block");
|
||||
}
|
||||
}
|
||||
|
||||
/// A grid of dark lines on white with lateral CA: red magnified by `ar`, blue by `ab` (about the centre).
|
||||
fn ca_grid(w: usize, h: usize, ar: f64, ab: f64) -> Rgb32f {
|
||||
let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0);
|
||||
|
||||
@@ -330,3 +330,28 @@ fn soft_proof_maps_into_the_proof_gamut_and_flags_what_does_not_fit() {
|
||||
assert!(blue(&pro) > 0);
|
||||
assert!(red(&pro) < n, "ProPhoto holds more than sRGB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tint_negative_is_green_and_positive_is_magenta() {
|
||||
use lightcraft_develop::WbMode;
|
||||
let src = Rgb32f::filled(16, 16, [0.18; 3]);
|
||||
// Rendered images, uncalibrated RAW and calibrated RAW with a nonzero As Shot tint.
|
||||
for info in [
|
||||
SourceInfo::default(),
|
||||
SourceInfo { raw: true, relative_wb: true, ..Default::default() },
|
||||
SourceInfo { raw: true, as_shot_temp: 4200.0, as_shot_tint: 15.0, ..Default::default() },
|
||||
] {
|
||||
let mut s = DevelopSettings::default();
|
||||
let neutral = render(&src, &info, &s, &RenderRequest::fit(16, 16)).image.get(8, 8);
|
||||
assert!((neutral[0] as i16 - neutral[1] as i16).abs() <= 1);
|
||||
assert!((neutral[2] as i16 - neutral[1] as i16).abs() <= 1);
|
||||
s.wb.mode = WbMode::Custom;
|
||||
s.wb.temp = info.as_shot_temp;
|
||||
for delta in [-50.0, 50.0] {
|
||||
s.wb.tint = info.as_shot_tint + delta;
|
||||
let p = render(&src, &info, &s, &RenderRequest::fit(16, 16)).image.get(8, 8);
|
||||
let magenta = (p[0] as f64 + p[2] as f64) / 2.0 - p[1] as f64;
|
||||
assert!(magenta * delta > 100.0, "delta {delta} must follow the green/magenta track, got {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user