mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-22 15:14:24 -04:00
Compare commits
285
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec0cda9a6f | ||
|
|
79d916c149 | ||
|
|
e142e2ffa4 | ||
|
|
22ae8bc9a8 | ||
|
|
21903294bf | ||
|
|
69fcc25535 | ||
|
|
a1455fa7cd | ||
|
|
e9e44643a5 | ||
|
|
697d201e97 | ||
|
|
047e9bb962 | ||
|
|
5443225c3c | ||
|
|
4e581739bd | ||
|
|
523894b00b | ||
|
|
a44a2eeb1d | ||
|
|
c1c9f94094 | ||
|
|
73ac15ddaf | ||
|
|
06a5c2edb7 | ||
|
|
80a7b8bba8 | ||
|
|
25feff7182 | ||
|
|
83ac0bca70 | ||
|
|
4cf0b3472e | ||
|
|
47be297b69 | ||
|
|
3ac7034ef5 | ||
|
|
8424779044 | ||
|
|
aee07a0376 | ||
|
|
452f317d38 | ||
|
|
e68a0e753d | ||
|
|
598a6ecb9b | ||
|
|
142eca0970 | ||
|
|
6e893fdaa3 | ||
|
|
32791d4bb4 | ||
|
|
9dd811de5d | ||
|
|
6196ebb968 | ||
|
|
0a27b0d804 | ||
|
|
7c3aed08fa | ||
|
|
0899915bdc | ||
|
|
5aa5c6681f | ||
|
|
70e6b02569 | ||
|
|
346a5be628 | ||
|
|
30b6d3160c | ||
|
|
5e5f1259ad | ||
|
|
26c1c2b1b0 | ||
|
|
087a82e88a | ||
|
|
8ec24dc868 | ||
|
|
1a4d81fd5a | ||
|
|
e13e6eb6c4 | ||
|
|
7760d71655 | ||
|
|
fcd0832355 | ||
|
|
145095663b | ||
|
|
893fdffc75 | ||
|
|
7f9f97148d | ||
|
|
a4cbdb4937 | ||
|
|
af5b7bc3c0 | ||
|
|
b1b9463e93 | ||
|
|
fcbbda591c | ||
|
|
1c4b9c3c61 | ||
|
|
9bac4fbd88 | ||
|
|
265b35a975 | ||
|
|
df9d830189 | ||
|
|
cf2bfa4ece | ||
|
|
31dac2db32 | ||
|
|
3ac21d18a5 | ||
|
|
abb49bdd76 | ||
|
|
5fe74557ec | ||
|
|
666f78432b | ||
|
|
b9c9a97d7a | ||
|
|
fa135940e7 | ||
|
|
30fea8d34c | ||
|
|
f08522b8a9 | ||
|
|
2015f44c06 | ||
|
|
a2a41383c4 | ||
|
|
4c4f4ca215 | ||
|
|
54c6505191 | ||
|
|
e3d2654bd2 | ||
|
|
04e11e701a | ||
|
|
97ef4f7ecc | ||
|
|
3a52cbe464 | ||
|
|
4b8a5317fc | ||
|
|
e2ea657668 | ||
|
|
d4627e9c26 | ||
|
|
5f17b48c8d | ||
|
|
58c38ead2f | ||
|
|
0360f940e3 | ||
|
|
a3d00301cf | ||
|
|
de98d3a1ba | ||
|
|
266e668376 | ||
|
|
d999e382ba | ||
|
|
81f02a6947 | ||
|
|
15e9a24891 | ||
|
|
f140629bfd | ||
|
|
55b7b47925 | ||
|
|
6bb4517d77 | ||
|
|
1ed76d851d | ||
|
|
349c27917b | ||
|
|
00f55ef352 | ||
|
|
fb7ac08b9a | ||
|
|
16417e2eca | ||
|
|
5b5cdb646e | ||
|
|
9eec26804d | ||
|
|
7b2d7b25ea | ||
|
|
bb9fe91bf2 | ||
|
|
a6c05ea0d9 | ||
|
|
c495ac8e09 | ||
|
|
debfa644ec | ||
|
|
18cfda0ee9 | ||
|
|
bf6e10fb06 | ||
|
|
31a677fe3d | ||
|
|
a945533f63 | ||
|
|
714ca70b20 | ||
|
|
e6e5d2d8a6 | ||
|
|
2b3c41a85a | ||
|
|
d43dd1fc8f | ||
|
|
6f5d662df3 | ||
|
|
70265ed17a | ||
|
|
19008bdee9 | ||
|
|
1de6401aeb | ||
|
|
56c9b49634 | ||
|
|
2c60d2ab27 | ||
|
|
4b6db59ade | ||
|
|
cbdbd50f84 | ||
|
|
fe7edd7b26 | ||
|
|
1e4aa93d58 | ||
|
|
7614fb14f1 | ||
|
|
2983a40385 | ||
|
|
583e3ceefc | ||
|
|
5b66a4825e | ||
|
|
dee8358c38 | ||
|
|
8d41bf69f7 | ||
|
|
f4e7d07e9b | ||
|
|
4e77935e6e | ||
|
|
7134099101 | ||
|
|
ad22d059db | ||
|
|
29384a0404 | ||
|
|
70100a34c5 | ||
|
|
363dbbdd9f | ||
|
|
80e2d0e64a | ||
|
|
d3b6266d07 | ||
|
|
c9f383b368 | ||
|
|
389be0f6a9 | ||
|
|
f819c8439b | ||
|
|
e6a7501adc | ||
|
|
0a0cb19011 | ||
|
|
7b65a6a59f | ||
|
|
d43889145b | ||
|
|
6985cfa54c | ||
|
|
592cb335cb | ||
|
|
d6a9a2e8f9 | ||
|
|
11308a5c8a | ||
|
|
9bf08f6ce6 | ||
|
|
5c85bc3ee4 | ||
|
|
0c3c500ea4 | ||
|
|
23f00822eb | ||
|
|
ad43a976f3 | ||
|
|
9c812b88a9 | ||
|
|
07b2f55022 | ||
|
|
601ddce6ef | ||
|
|
eabcae0fdc | ||
|
|
6e97193b00 | ||
|
|
302faf547b | ||
|
|
b9159b0fce | ||
|
|
863adcac6f | ||
|
|
4f0b110bcb | ||
|
|
20ccedf56e | ||
|
|
43a47e5a3b | ||
|
|
cebb2be4d4 | ||
|
|
f48b2ad895 | ||
|
|
0d2c77c258 | ||
|
|
5589827178 | ||
|
|
2218d97e89 | ||
|
|
d515f9affe | ||
|
|
fef2f36197 | ||
|
|
01495684d9 | ||
|
|
9f2052f56b | ||
|
|
36d22a2745 | ||
|
|
555eb4c9fd | ||
|
|
f9b0c8c8a1 | ||
|
|
2b173f0bee | ||
|
|
e6587c3f82 | ||
|
|
d54673bb95 | ||
|
|
4d8f93585a | ||
|
|
a8bc882092 | ||
|
|
9c4fce176f | ||
|
|
16beb4db40 | ||
|
|
27c278ab33 | ||
|
|
f6d465c72c | ||
|
|
fafa70724b | ||
|
|
035d572ac8 | ||
|
|
414764ef77 | ||
|
|
e0c02d5e89 | ||
|
|
8e268431e0 | ||
|
|
005881ce94 | ||
|
|
de44200a8d | ||
|
|
62eb6627a6 | ||
|
|
edc369565e | ||
|
|
80e439f6dc | ||
|
|
86147e1eeb | ||
|
|
5be931e48a | ||
|
|
53f57b9404 | ||
|
|
7b934ca2b2 | ||
|
|
346681561a | ||
|
|
3accb56196 | ||
|
|
0a9675c6d9 | ||
|
|
312dd04830 | ||
|
|
0d30c99e87 | ||
|
|
9392039726 | ||
|
|
7db0a64d7e | ||
|
|
76689a563a | ||
|
|
50690745e8 | ||
|
|
62e82479d2 | ||
|
|
ed5afb7eb0 | ||
|
|
2d57a1466d | ||
|
|
5902a07354 | ||
|
|
109e4dabd3 | ||
|
|
fa26767b81 | ||
|
|
b67bb01952 | ||
|
|
f34c7a8a89 | ||
|
|
3885b61e6b | ||
|
|
2e0906b71a | ||
|
|
180fdb3b41 | ||
|
|
0802892132 | ||
|
|
e14db89462 | ||
|
|
c8f741fd0b | ||
|
|
54c6c43b07 | ||
|
|
90a248edad | ||
|
|
37a3ab4177 | ||
|
|
c5deea05e4 | ||
|
|
abf29ea392 | ||
|
|
08e3893731 | ||
|
|
9bec9db541 | ||
|
|
fa715c8c94 | ||
|
|
6938be39e4 | ||
|
|
dde403946b | ||
|
|
b5dc500c54 | ||
|
|
5bf0d45ba8 | ||
|
|
0f67225e83 | ||
|
|
c806debada | ||
|
|
d020c55062 | ||
|
|
25d466fd69 | ||
|
|
7ac6dd81fe | ||
|
|
79d726f856 | ||
|
|
b9b80aee8a | ||
|
|
a43cb68593 | ||
|
|
8a4d6f2ebf | ||
|
|
d770c52313 | ||
|
|
112e202fed | ||
|
|
de038ca3d4 | ||
|
|
c990dec0f0 | ||
|
|
71f7c2ce59 | ||
|
|
342afcb81e | ||
|
|
0da998f24d | ||
|
|
e8856c828c | ||
|
|
b17483a3ce | ||
|
|
e6dceb9f89 | ||
|
|
9a0f076105 | ||
|
|
5e9f7f1a65 | ||
|
|
9ddb8d1824 | ||
|
|
8291f08ab7 | ||
|
|
1d7699056e | ||
|
|
2179473536 | ||
|
|
132b0e3ac8 | ||
|
|
4212907656 | ||
|
|
888a54f52a | ||
|
|
aed214da7e | ||
|
|
31f4ad43fe | ||
|
|
ef216d2110 | ||
|
|
f906998174 | ||
|
|
1130bda456 | ||
|
|
48d69bfae5 | ||
|
|
e65b97e239 | ||
|
|
540a281ccd | ||
|
|
61ec5c596c | ||
|
|
f625c74c7a | ||
|
|
2b86ca0537 | ||
|
|
d598c304aa | ||
|
|
3ee61f28ed | ||
|
|
0242a75b5a | ||
|
|
80a8acaa30 | ||
|
|
6f12b28f28 | ||
|
|
6a66860ea9 | ||
|
|
8d91abe603 | ||
|
|
a2911a5086 | ||
|
|
c78b457c0d | ||
|
|
76f12dcabd | ||
|
|
44d5cf7590 | ||
|
|
b273f8e608 |
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -0,0 +1,30 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -1,16 +1,23 @@
|
||||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
# FreeBSD build and package check in a QEMU VM.
|
||||
name: FreeBSD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
- ".github/workflows/freebsd.yml"
|
||||
- "packaging/freebsd/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/workflows/freebsd.yml"
|
||||
- "packaging/freebsd/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -22,34 +29,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Optional font build input, so the Japanese font tests run (they skip without it).
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
require-craft-fonts: true
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
@@ -18,11 +18,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- name: nix flake check --no-build
|
||||
run: nix flake check --no-build --all-systems
|
||||
nix:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
@@ -1,7 +1,5 @@
|
||||
# Quick checks for the release pipeline (no Rust build): workflow syntax, packaging
|
||||
# shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata (seconds), plus a
|
||||
# Windows job that builds and installs throwaway per-user MSI probes for the install-folder
|
||||
# searches (a few minutes).
|
||||
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
|
||||
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
|
||||
name: Packaging lint
|
||||
|
||||
on:
|
||||
@@ -21,64 +19,10 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
|
||||
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
run: |
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/lightcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
|
||||
if grep -oE '<Icon Id="[^"]*"' packaging/windows/lightcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
# The interactive installer asks where to install and ends on a completion page (issues #18, #399).
|
||||
for want in '<UIRef Id="LightcraftUI" />' 'Value="InstallDirDlg"' '<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />' \
|
||||
'Key="Software\LightCraft" Name="InstallFolder"' 'Value="LaunchLightcraft"'; do
|
||||
grep -qF "$want" packaging/windows/lightcraft.wxs || { echo "::error::lightcraft.wxs lost: $want"; exit 1; }
|
||||
done
|
||||
# The completion page the Publish events point at must actually exist: a Publish alone
|
||||
# does not pull the dialog fragment into the MSI, and without the DialogRef the
|
||||
# installer finishes silently (issues #790, #892).
|
||||
grep -qF '<DialogRef Id="ExitDialog" />' packaging/windows/lightcraft.wxs \
|
||||
|| { echo "::error::ExitDialog is not referenced; installs and removes end without a completion dialog"; exit 1; }
|
||||
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 - <<'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
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
with:
|
||||
extra-checks-script: packaging/check-app-specific.sh
|
||||
|
||||
windows-install-folder:
|
||||
runs-on: windows-latest
|
||||
|
||||
+26
-479
@@ -1,13 +1,5 @@
|
||||
# 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.
|
||||
#
|
||||
# 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, Linux riscv64, 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.
|
||||
# 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.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -21,479 +13,34 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
LIGHTCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
timeout-minutes: 10
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release` here or on the other jobs that sign nothing: no release secrets.
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
. packaging/env.sh
|
||||
v="${INPUT_VERSION#v}"
|
||||
v="${v:-$VERSION}"
|
||||
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
|
||||
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{
|
||||
echo "version=$v"
|
||||
echo "date=$(date -u +%Y-%m-%d)"
|
||||
echo "prerelease=$pre"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
echo "### LightCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos:
|
||||
timeout-minutes: 90
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
|
||||
environment: release
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: packaging/macos/import-cert.sh
|
||||
- name: Build, sign, notarize
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
- name: Remove temporary keychain
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
|
||||
|
||||
windows:
|
||||
timeout-minutes: 90
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
|
||||
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
shell: bash
|
||||
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
|
||||
# package.ps1 adds the matching WixToolset.UI/Util.wixext (5.0.2) to the global extension cache.
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Build, sign, package
|
||||
shell: pwsh
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
timeout-minutes: 90
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
|
||||
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately
|
||||
path: craft-fonts
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
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 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"
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/lightcraft$|metainfo|applications'
|
||||
ldd target/release/lightcraft
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.lightcraft.yml')); assert m['id'] == 'ai.storyteller.lightcraft'"
|
||||
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
timeout-minutes: 90
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
|
||||
# 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
linux-riscv64:
|
||||
timeout-minutes: 90
|
||||
name: Linux riscv64
|
||||
needs: version
|
||||
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
|
||||
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
|
||||
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
|
||||
# CLI is smoke-tested under QEMU.
|
||||
runs-on: ubuntu-24.04
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-riscv64
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
. /etc/os-release
|
||||
sudo dpkg --add-architecture riscv64
|
||||
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
|
||||
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
fi
|
||||
if [ -f /etc/apt/sources.list ]; then
|
||||
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
|
||||
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
|
||||
fi
|
||||
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
|
||||
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
|
||||
done | sudo tee /etc/apt/sources.list.d/riscv64.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
|
||||
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
|
||||
libxi-dev:riscv64 libgl1-mesa-dev:riscv64
|
||||
- name: Build and package (tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: "1"
|
||||
CROSS_ARCH: riscv64
|
||||
CROSS_TARGET: riscv64gc-unknown-linux-gnu
|
||||
CROSS_COMPILE: riscv64-linux-gnu-
|
||||
EMULATOR: qemu-riscv64
|
||||
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
|
||||
run: packaging/linux/package.sh --formats tar
|
||||
- name: Check the tarball
|
||||
run: |
|
||||
tar -tzf dist/release/lightcraft-*-linux-riscv64.tar.gz \
|
||||
| grep -E '/bin/lightcraft$|/bin/lightcraft-cli$|/share/applications/ai\.storyteller\.lightcraft\.desktop$'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-riscv64
|
||||
path: dist/release/*.tar.gz
|
||||
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately
|
||||
path: craft-fonts
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
timeout-minutes: 90
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
WASM_BINDGEN_VERSION: 0.2.129
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install wasm-bindgen CLI
|
||||
run: |
|
||||
# Must match the wasm-bindgen version in Cargo.lock exactly (cargo xtask web checks it).
|
||||
if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION"; }; then
|
||||
cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
|
||||
fi
|
||||
wasm-bindgen --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
|
||||
- name: Build and zip
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
timeout-minutes: 10
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
PRERELEASE: ${{ needs.version.outputs.prerelease }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Checksums
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
sha256sum -- * | tee SHA256SUMS.txt
|
||||
- name: Create or update the draft release
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
title="LightCraft v$VERSION"
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
|
||||
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
|
||||
if [ "$draft" != "true" ]; then
|
||||
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updating draft $tag: replacing its assets"
|
||||
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
|
||||
gh release delete-asset "$tag" "$asset" --yes
|
||||
done
|
||||
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
echo "Creating draft $tag"
|
||||
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
|
||||
echo
|
||||
sed 's/^/ /' SHA256SUMS.txt
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
app-name: lightcraft
|
||||
app-title: LightCraft
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
@@ -1,22 +1,27 @@
|
||||
# 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: PdfCraft saw an intermittent access violation in egui UI tests there.)
|
||||
# Windows ARM64 build and smoke test.
|
||||
#
|
||||
# Release packages on arm64-windows are produced on x64 runners via cross-compilation
|
||||
# (target: aarch64-pc-windows-msvc) using MSVC's ARM64 cross tools, then installed and run
|
||||
# on GitHub Actions' native windows-11-arm runner.
|
||||
name: Windows ARM64
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "**/build.rs"
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
|
||||
- ".github/workflows/windows-arm64.yml"
|
||||
- "packaging/windows/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/workflows/windows-arm64.yml"
|
||||
- "packaging/windows/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -26,70 +31,10 @@ concurrency:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: package (cross-compiled on x64)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Build and package (unsigned)
|
||||
shell: pwsh
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: lightcraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
install:
|
||||
name: install and run on ARM64
|
||||
needs: package
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: lightcraft-windows-arm64
|
||||
path: dist
|
||||
- name: Install the MSI, check it, run it, uninstall it
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
|
||||
$dir = Join-Path $env:ProgramFiles 'LightCraft'
|
||||
foreach ($exe in 'lightcraft.exe', 'lightcraft-cli.exe') {
|
||||
$path = Join-Path $dir $exe
|
||||
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
|
||||
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
|
||||
Write-Output "ok $path (ARM64)"
|
||||
}
|
||||
# The installed CLI runs natively on ARM64.
|
||||
$cli = Join-Path $dir 'lightcraft-cli.exe'
|
||||
$out = & $cli --version 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "lightcraft-cli --version exited $LASTEXITCODE" }
|
||||
Write-Output "ok lightcraft-cli --version: $($out.Trim())"
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
|
||||
if (Test-Path (Join-Path $dir 'lightcraft.exe')) { throw 'uninstall left lightcraft.exe behind' }
|
||||
Write-Output 'ok uninstalled'
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: lightcraft
|
||||
app-dir: LightCraft
|
||||
Generated
+41
-33
@@ -1072,6 +1072,11 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -3073,7 +3078,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3098,7 +3103,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-catalog"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"lightcraft-develop",
|
||||
@@ -3115,7 +3120,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-cli"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"dhat",
|
||||
"lightcraft-codecs",
|
||||
@@ -3131,10 +3136,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-codecs"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"avif-serialize",
|
||||
"bytemuck",
|
||||
"dhat",
|
||||
"image",
|
||||
"image-webp",
|
||||
"jixel",
|
||||
@@ -3161,7 +3167,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-color"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"proptest",
|
||||
@@ -3170,7 +3176,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-denoise"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"faer-core",
|
||||
"lightcraft-denoise-core",
|
||||
@@ -3187,7 +3193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-denoise-core"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"lightcraft-raster",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -3202,7 +3208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-develop"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
@@ -3214,9 +3220,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-engine"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"craft-docpath",
|
||||
"harfrust",
|
||||
"jxl-oxide",
|
||||
"libc",
|
||||
@@ -3256,7 +3263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-faces"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"lightcraft-testkit",
|
||||
"matrixmultiply",
|
||||
@@ -3271,7 +3278,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-fetch"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rustls",
|
||||
@@ -3282,7 +3289,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-geocode"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
"serde",
|
||||
@@ -3290,7 +3297,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-geom"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3298,12 +3305,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-gpu"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"lightcraft-color",
|
||||
"lightcraft-denoise",
|
||||
"lightcraft-develop",
|
||||
"lightcraft-faces",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-pipeline",
|
||||
"lightcraft-raster",
|
||||
@@ -3317,7 +3325,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-heif"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
"rayon",
|
||||
@@ -3325,14 +3333,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-inpaint"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-lensdb"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"lightcraft-develop",
|
||||
"lightcraft-geom",
|
||||
@@ -3341,7 +3349,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-mcp"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-engine",
|
||||
@@ -3352,7 +3360,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-merge"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-color",
|
||||
@@ -3368,7 +3376,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-meta"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"lightcraft-tiff",
|
||||
@@ -3380,7 +3388,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-pipeline"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -3398,7 +3406,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-preview"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"lightcraft-raster",
|
||||
@@ -3410,7 +3418,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raster"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"lightcraft-color",
|
||||
@@ -3422,7 +3430,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raw"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"dhat",
|
||||
"jpeg-encoder",
|
||||
@@ -3446,7 +3454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-scenes"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -3457,7 +3465,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-segment"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
@@ -3471,7 +3479,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-slideshow"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"lightcraft-raster",
|
||||
"serde",
|
||||
@@ -3480,15 +3488,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-sysmem"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-testkit"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-tiff"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3497,7 +3505,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-ui-egui"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -3526,7 +3534,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-web"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"eframe",
|
||||
@@ -6996,7 +7004,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"flate2",
|
||||
|
||||
+2
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -11,6 +11,7 @@ repository = "https://github.com/storytold/lightcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
# internal
|
||||
lightcraft-denoise-core = { path = "crates/denoise-core" }
|
||||
lightcraft-denoise = { path = "crates/denoise", default-features = false }
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<b>Photo library and raw development; an open-source, clean-room reimplementation of Adobe Lightroom, rebuilt in pure Rust.</b><br>
|
||||
<b>Photo library and raw development; an open-source, non-destructive photo library and raw developer, built in pure Rust.</b><br>
|
||||
Native on macOS, Windows and Linux. In the browser via WebAssembly. Drivable end to end by AI agents over MCP.
|
||||
</p>
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
<img alt="MCP server included" src="https://img.shields.io/badge/MCP-ready-8a5800?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-8a5800?style=flat-square">
|
||||
<a href="ROADMAP.md"><img alt="Status: young and moving fast" src="https://img.shields.io/badge/status-young%20%26%20moving%20fast-f2a516?style=flat-square"></a>
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_lightcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/lightcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -129,7 +132,7 @@ Split-tone shadows, midtones and highlights independently with drag-anywhere col
|
||||
|
||||
## Before & after
|
||||
|
||||
Hold <kbd>\\</kbd> to peek at the original, press <kbd>Y</kbd> for side by side, or <kbd>Shift</kbd>+<kbd>Y</kbd> for a
|
||||
Press <kbd>\\</kbd> to toggle the Before view, <kbd>Y</kbd> for side by side, or <kbd>Shift</kbd>+<kbd>Y</kbd> for a
|
||||
split view. Every image below is a real screenshot of LightCraft, captured automatically by an agent through the
|
||||
[control channel](#built-for-agents).
|
||||
|
||||
@@ -150,9 +153,13 @@ split view. Every image below is a real screenshot of LightCraft, captured autom
|
||||
|
||||
Paint with a **Brush** (size, feather, flow, density, erase), drop **Linear** and **Radial Gradients** with draggable
|
||||
pins, or select by **Luminance Range**, **Color Range**, **Sky**, **Subject** and **Background**. Combine components
|
||||
with **Add / Subtract / Intersect**, invert any of them, and dial in 15 local adjustments per mask (Temp, Tint,
|
||||
Exposure, Contrast, Highlights, Shadows, Whites, Blacks, Texture, Clarity, Dehaze, Hue, Saturation, Sharpness, Noise)
|
||||
plus an overall Amount.
|
||||
with **Add / Subtract / Intersect**, invert any of them, and dial in 17 local adjustments per mask (Temp, Tint,
|
||||
Exposure, Contrast, Highlights, Shadows, Whites, Blacks, Texture, Clarity, Dehaze, Hue, Saturation, Sharpness, Noise,
|
||||
Moiré, Defringe), plus an overall Amount.
|
||||
|
||||
Desktop **Object** and **Describe** masks use SAM 3 for click-based and text-prompt selection, with a separately
|
||||
installed model; see [model setup and terms](docs/ai-masks.md). Subject, Sky and Background selections still use
|
||||
classical heuristics. Existing model-generated masks render and export without the model installed.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/masking.jpg" alt="Masking panel with a linear Sky mask and a radial Sun glow mask; the radial gradient is drawn as a red overlay around the sun on a lake scene" width="100%">
|
||||
@@ -164,7 +171,7 @@ plus an overall Amount.
|
||||
|
||||
## Presets, profiles & the Color Mixer
|
||||
|
||||
Eighteen hand-built presets ship in the box (*Golden Hour, Teal & Orange, Faded Matte, Selenium Tone, Crisp
|
||||
Forty-one hand-built presets ship in the box (*Golden Hour, Teal & Orange, Faded Matte, Selenium Tone, Crisp
|
||||
Landscape* and more), each with an **Amount** slider from 0 to 200 %. Save your own from any group of settings, mark
|
||||
favourites, and copy, paste or sync edits across a whole selection with exactly the groups you choose.
|
||||
|
||||
@@ -209,6 +216,10 @@ A library that stays out of your way: **All Photos**, **Recently Added**, **Pick
|
||||
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.
|
||||
|
||||
The **People** view groups named faces, including names read from XMP; face boxes can be edited in the loupe.
|
||||
On desktop, automatic detection and recognition use optional models installed through **Settings → Faces**; no weights are
|
||||
bundled. See [face models and setup](docs/faces.md).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/images/grid-demo.jpg" alt="Justified Photo Grid of 24 demo photos with ratings and flags, and a sidebar of nested albums under a Travel 2026 folder"><br><sub>Photo Grid with albums nested in a folder, ratings and flags. <i>Demo library.</i></sub></td>
|
||||
@@ -281,7 +292,13 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
print.cell.add width=5 height=7 print.cell.add width=2.5 height=3.5 print.layout.autoLayout print.exportPdf path=Package.pdf
|
||||
```
|
||||
|
||||
Printer colour management and page overlays (identity plate, watermark, page numbers) are not there yet.
|
||||
The Print Job panel supports printer/paper ICC profiles, rendering intent, black point compensation,
|
||||
print adjustment, sharpening and 8/16-bit PDF output. Direct printing through an OS print dialog and page
|
||||
overlays (identity plate, watermark, page numbers) are not there yet.
|
||||
- **Slideshows:** **View → Slideshow** plays the photos in view full screen, with fades, repeat and random order.
|
||||
**Settings → Slideshow** configures layouts, templates, backgrounds, overlays and title screens. Native exports
|
||||
support PDF, JPEG sequences and AV1 video in MP4 or WebM; music and a dedicated Slideshow module editor remain
|
||||
missing. See [docs/slideshow.md](docs/slideshow.md).
|
||||
- **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents operate the real UI, not a
|
||||
side door.
|
||||
- The screenshots in this README were produced end to end by the [`docs/showcase/`](docs/showcase/) scripts.
|
||||
@@ -293,7 +310,7 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
|
||||
- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2/CR3, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans,
|
||||
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; HEIC/HEIF photos too with `--features heif` (HEVC is a build-time choice,
|
||||
PSD composites, JPEG XL, GIF (first frame) and BMP open today; HEIC/HEIF photos too with `--features heif` (HEVC is a build-time choice,
|
||||
as in PhotoCraft).
|
||||
- **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.
|
||||
@@ -319,17 +336,21 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (details in the [roadmap](ROADMAP.md) and
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md)):
|
||||
|
||||
- **By feature count we're at ~81%** of Lightroom (core features 98%), tracked row by row in
|
||||
- **By feature count we're at ~82%** (core features 98%), tracked row by row in
|
||||
[docs/parity-checklist.md](docs/parity-checklist.md).
|
||||
- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~51% for a typical working photographer and ~65% for
|
||||
- **As a day-to-day tool we're nearer 61% for the whole product, ~51% for a typical working photographer and ~65% for
|
||||
casual use.** It's great for JPEG/DNG and most Sony / Panasonic /
|
||||
Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera.
|
||||
- **The biggest gaps:**
|
||||
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
|
||||
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
|
||||
- **AI masks and denoise:** subject and sky selection are classical heuristics;
|
||||
- **AI quality and model setup:** Subject and Sky selection are classical heuristics; Object/Describe use an
|
||||
optional SAM 3 model. AI Denoise supports Bayer RAW with separately installed weights, while X-Trans and
|
||||
linear-RGB denoise and Super Resolution remain missing; see [AI masks](docs/ai-masks.md) and [AI Denoise](docs/denoise.md);
|
||||
- **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
|
||||
- **on-screen HDR display, video and the Classic Print / Book / Map modules.**
|
||||
- **on-screen HDR display, video import/editing, and the Book / Map module views.** Print layouts and
|
||||
colour-managed PDF output work; direct printer output and page overlays remain missing. Slideshow playback
|
||||
and export work, with a dedicated module editor and music still missing.
|
||||
- **What's next:** see [the ranked gaps](docs/gaps.md) and [the roadmap](docs/roadmap.md).
|
||||
|
||||
| Area | Status |
|
||||
@@ -337,16 +358,16 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
|
||||
| Library: albums, folders, smart albums, stacks (incl. auto-stack), virtual copies, ratings, flags, labels, filter bar, search, sort, grids, filmstrip | ✅ |
|
||||
| Culling: Compare (synced zoom) and Survey views, auto-advance, instant previews | ✅ |
|
||||
| Light, Color, Effects (vignette styles), Tone Curve (+ refine saturation, targeted adjustment), Color Mixer (+ targeted), Point Color, Color Grading, Calibration, B&W | ✅ |
|
||||
| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) |
|
||||
| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect; Object and Describe selections | ✅ · Object/Describe require optional SAM 3; Subject/Sky remain heuristic ([details](docs/ai-masks.md)) |
|
||||
| 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 | ✅ DNG · 🚧 own Sony/Fujifilm profiles; measured calibration database missing |
|
||||
| Camera colour: DNG matrices, guarded camera-JPEG look fits and built-in profiles for selected cameras | 🚧 measured calibration and per-model coverage remain incomplete ([raw parity](docs/raw-parity.md)) |
|
||||
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
|
||||
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed, incl. lossy after split), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed, 12.8 bpp), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🚧 · other CR3 variants, compressed ORF / SRW and Nikon HE NEF decode ⬜ ([raw parity](docs/raw-parity.md)) |
|
||||
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise 🚧 (Bayer, user-installed weights), 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: HDR editing with a headroom limit, SDR rendition, Visualize HDR; export as ISO 21496-1 gain map JPEG, PQ AVIF or 32-bit float TIFF | ✅ · HDR display ⬜ |
|
||||
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise 🚧 (Bayer, user-installed weights; [setup](docs/denoise.md)), Super Resolution ⬜ |
|
||||
| Remove / Heal / Clone spots (auto source), non-generative Fill, editable spot pins, Visualize Spots, Red Eye and Pet Eye | ✅ · Fill uses PatchMatch; no generative fill |
|
||||
| Export: JPEG / PNG / TIFF / flat PSD / WebP / AVIF / JPEG XL / raw or rendered DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ |
|
||||
| HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; gain map JPEG, PQ/HLG AVIF, PQ PNG/JPEG XL and float TIFF export | ✅ · HDR display ⬜ |
|
||||
| Library persistence (crash-safe op log + snapshots, background compaction, failed saves reported), disk thumbnail cache | ✅ |
|
||||
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ |
|
||||
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
|
||||
@@ -354,7 +375,11 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
|
||||
| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files, Panasonic / Leica RW2 / RWL distortion data and Nikon Z 6II / Z 8 / Z f 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; video | 🚧 (see [gaps](docs/gaps.md)) |
|
||||
| People view, XMP face names and editable face boxes; automatic face detection and recognition | ✅ · detection/recognition require optional models ([setup](docs/faces.md)) |
|
||||
| GPX tracklog auto-tagging and offline reverse geocoding | ✅ · place names require the GeoNames dataset; Map view ⬜ |
|
||||
| Print layouts, picture packages, templates and colour-managed PDF export | ✅ layouts/PDF · 🚧 direct printer output and page overlays |
|
||||
| Slideshow playback, layouts, overlays, templates and PDF/JPEG/AV1 export | ✅ playback · 🚧 dedicated module editor and music ([details](docs/slideshow.md)) |
|
||||
| Video import/editing and Book module | ⬜ |
|
||||
| Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 |
|
||||
|
||||
<sub>✅ works today · 🚧 in progress · ⬜ not started</sub>
|
||||
@@ -373,7 +398,7 @@ cargo run --release -p lightcraft -- --memory # a throwaway in-memory
|
||||
cargo run --release -p lightcraft -- --control 7980 # with the automation channel
|
||||
cargo xtask web --serve # the same app in the browser: http://127.0.0.1:8080/
|
||||
cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5
|
||||
cargo xtask ci # fmt, clippy, tests, layering, wasm checks
|
||||
cargo xtask ci # fmt, clippy, HEIF, tests, parity, layers, assets, WASM
|
||||
```
|
||||
|
||||
CI defaults to line-table debug information and one build job per 1.5 GB of RAM
|
||||
@@ -426,7 +451,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
|
||||
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
|
||||
repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme).
|
||||
|
||||
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Čeština, ქართული) or **Settings → General →
|
||||
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Polski, Čeština, Italiano, ქართული) 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
|
||||
@@ -491,9 +516,9 @@ egui frontend is one swappable crate; nothing below it knows a UI exists. `mcp`
|
||||
|
||||
Humans and agents follow the same rules, so read [AGENTS.md](AGENTS.md) first. The short version:
|
||||
|
||||
- **Clean-room.** Never read Adobe binaries or GPL raw/photo code (darktable, RawTherapee, LibRaw, rawspeed, dcraw…);
|
||||
- **Clean-room.** Never read proprietary binaries or GPL raw/photo code (darktable, RawTherapee, LibRaw, rawspeed, dcraw…);
|
||||
work from public specs and black-box observation.
|
||||
- **No Adobe assets, ever:** no icons, screenshots, presets, profiles, LUTs or fonts from Adobe products. Every
|
||||
- **No proprietary assets, ever:** no icons, screenshots, presets, profiles, LUTs or fonts from commercial products. Every
|
||||
image, icon and font in the repo is original, public domain, Creative Commons, OFL or permissively licensed, and has
|
||||
an entry in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) added in the same commit. New fonts go to
|
||||
[storytold/craft-fonts](https://github.com/storytold/craft-fonts), not here.
|
||||
@@ -536,7 +561,7 @@ option to start it. For unattended installs: `msiexec /i lightcraft-<ver>-window
|
||||
|
||||
| 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) |
|
||||
| AppImage | `lightcraft-<ver>-linux-x86_64.AppImage` | `lightcraft-<ver>-linux-aarch64.AppImage` | Portable; updates 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` | |
|
||||
@@ -623,8 +648,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and LightCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. LightCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+5
-7
@@ -18,7 +18,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<b>照片图库与 raw 显影;Adobe Lightroom 的开源净室实现,用纯 Rust 重建。</b><br>
|
||||
<b>照片图库与 raw 显影;开源、非破坏性的照片图库与 raw 显影工具,用纯 Rust 构建。</b><br>
|
||||
在 macOS、Windows 和 Linux 上原生运行,也可通过 WebAssembly 在浏览器中使用,并能由 AI 智能体经 MCP 端到端驱动。
|
||||
</p>
|
||||
|
||||
@@ -299,9 +299,9 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
|
||||
LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(细节见[路线图](ROADMAP.md#where-we-stand)):
|
||||
|
||||
- **按功能数量算,我们达到了 Lightroom 的约 79%**(核心功能 98%),逐行记录在
|
||||
- **按功能数量算,我们达到了约 79%**(核心功能 98%),逐行记录在
|
||||
[docs/parity.md](docs/parity.md)。
|
||||
- **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
|
||||
- **作为日常使用的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
|
||||
较老佳能的 raw 都很出色。
|
||||
- **最大的缺口:**
|
||||
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
|
||||
@@ -453,9 +453,9 @@ egui 前端是一个可替换的 crate;它下面没有任何东西知道 UI
|
||||
|
||||
人和智能体遵循同样的规则,所以请先读 [AGENTS.md](AGENTS.md)。简短版本:
|
||||
|
||||
- **净室。** 绝不阅读 Adobe 的二进制文件或 GPL 的 raw/照片代码(darktable、RawTherapee、LibRaw、rawspeed、dcraw……);
|
||||
- **净室。** 绝不阅读专有软件的二进制文件或 GPL 的 raw/照片代码(darktable、RawTherapee、LibRaw、rawspeed、dcraw……);
|
||||
只依据公开规范和黑盒观察来工作。
|
||||
- **绝不使用 Adobe 资源:** 不使用 Adobe 产品的图标、截图、预设、配置文件、LUT 或字体。仓库中的每一个
|
||||
- **绝不使用专有资源:** 不使用商业产品的图标、截图、预设、配置文件、LUT 或字体。仓库中的每一个
|
||||
图像、图标和字体都是原创、公有领域、知识共享、OFL 或宽松许可的,并在同一提交中
|
||||
于 [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 添加条目。新字体提交到
|
||||
[storytold/craft-fonts](https://github.com/storytold/craft-fonts),而不是这里。
|
||||
@@ -579,8 +579,6 @@ ArtCraft 团队的商标,不在本许可范围内。它们只能在未修改
|
||||
本仓库和 LightCraft 的一部分,按 [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) 使用。
|
||||
分支和修改版本必须移除它们。
|
||||
|
||||
<sub>Adobe、Photoshop、Illustrator、Premiere Pro、Lightroom、Acrobat、After Effects 和 InDesign 是 Adobe Inc. 在美国和/或其他国家的商标或注册商标。LightCraft 是一个独立的开源项目,与 Adobe Inc. 无隶属关系,也未获其赞助或认可;这些名称仅用于说明它所兼容的工作流。</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>由 <a href="https://getartcraft.com/">ArtCraft</a> 团队和社区制作。</sub>
|
||||
|
||||
+3
-2
@@ -46,7 +46,7 @@ Hours are calibrated on this repo's PR history; see
|
||||
|---|---:|---:|---:|---|
|
||||
| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) |
|
||||
| ↳ RAW cameras, colour, lens profiles | ~60% | 83% of 1,446 Adobe models decode, 38% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
|
||||
| UI/UX fidelity | ~70% | ~85% | 34–58 h | [ui-parity](docs/ui-parity.md) |
|
||||
| UI/UX fidelity | ~70% | ~85% | 32–54 h | [ui-parity](docs/ui-parity.md) |
|
||||
| File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Localization | ~55% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) |
|
||||
@@ -105,7 +105,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
|
||||
1. Camera colour of our own: measured calibration for the popular models (50–90 h)
|
||||
2. CR3 for every Canon body (25–45 h)
|
||||
3. Render-fidelity suite against Lightroom, then tuning (30–60 h)
|
||||
4. Full-resolution zoom (8–15 h)
|
||||
4. ~~Full-resolution zoom~~ (done 2026-10-12)
|
||||
5. AI masks: Subject / Sky / People (40–70 h + model decision)
|
||||
6. Compressed ORF and HE NEF (28–55 h)
|
||||
7. Lens profile database of our own (40–80 h)
|
||||
@@ -192,6 +192,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
|
||||
| 2026-10-12 | minor | Mamiya MEF decoded and imported: ZD verified (241); headline numbers unchanged |
|
||||
| 2026-10-12 | minor | Epson ERF decoded: 3 more verified models (240); headline numbers unchanged |
|
||||
| 2026-10-12 | minor | Georgian (`ka`) interface language added (every catalog message) |
|
||||
| 2026-10-14 | minor | Full-resolution zoom (gap 4) closed: soft proofing gets zoom windows; headline numbers unchanged |
|
||||
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged |
|
||||
| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); headline numbers unchanged |
|
||||
| 2026-10-12 | minor | 22 more CR3 bodies verified, EOS R6 Mark III white balance: RAW decoding 77% → 83%, RAW ~56% → ~60%; mainstream 49% → 51%, essentials 64% → 65%; full unchanged at ~61% |
|
||||
|
||||
@@ -732,12 +732,13 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
for (k, v) in opts {
|
||||
p[k] = v;
|
||||
}
|
||||
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)
|
||||
.ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
|
||||
ExportFormat::parse(&ext).ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
|
||||
// given before validating, so `bitDepth` is checked against the format written (issue #1952)
|
||||
p["format"] = json!(ext);
|
||||
}
|
||||
let o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?;
|
||||
// Add to Apple Photos: refused before anything is written when it can't be done
|
||||
let after = s.after_export(&o)?;
|
||||
let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?;
|
||||
|
||||
@@ -22,6 +22,12 @@ fn err(e: JsValue) -> String {
|
||||
e.as_string().or_else(|| e.dyn_ref::<js_sys::Error>().map(|e| String::from(e.message()))).unwrap_or_else(|| format!("{e:?}"))
|
||||
}
|
||||
|
||||
/// Did an OPFS call fail because the entry doesn't exist (`NotFoundError`)? Any other failure
|
||||
/// (security, type mismatch, an unreadable or evicted store) is an error, never "absent" (#1507).
|
||||
fn not_found(e: &JsValue) -> bool {
|
||||
crate::files::is_not_found(Reflect::get(e, &"name".into()).ok().and_then(|n| n.as_string()).as_deref())
|
||||
}
|
||||
|
||||
/// `navigator.storage` in a window or a worker.
|
||||
fn storage_manager() -> Option<web_sys::StorageManager> {
|
||||
let g = js_sys::global();
|
||||
@@ -134,10 +140,7 @@ impl Backend {
|
||||
opts.set_create(create);
|
||||
match JsFuture::from(dir.get_directory_handle_with_options(p, &opts)).await {
|
||||
Ok(d) => dir = d.unchecked_into(),
|
||||
Err(e) if !create => {
|
||||
let _ = e;
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) if !create && not_found(&e) => return Ok(None),
|
||||
Err(e) => return Err(err(e)),
|
||||
}
|
||||
}
|
||||
@@ -150,7 +153,7 @@ impl Backend {
|
||||
opts.set_create(create);
|
||||
match JsFuture::from(dir.get_file_handle_with_options(&name, &opts)).await {
|
||||
Ok(h) => Ok(Some(h.unchecked_into())),
|
||||
Err(_) if !create => Ok(None),
|
||||
Err(e) if !create && not_found(&e) => Ok(None),
|
||||
Err(e) => Err(err(e)),
|
||||
}
|
||||
}
|
||||
@@ -250,7 +253,7 @@ impl Backend {
|
||||
let Some((dir, name)) = Self::parent(root, path, false).await? else { return Ok(()) };
|
||||
match JsFuture::from(dir.remove_entry(&name)).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) if Reflect::get(&e, &"name".into()).ok().and_then(|n| n.as_string()).as_deref() == Some("NotFoundError") => Ok(()),
|
||||
Err(e) if not_found(&e) => Ok(()),
|
||||
Err(e) => Err(err(e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,18 @@ pub fn valid_hash(h: &str) -> bool {
|
||||
!h.is_empty() && h.len() <= 128 && h.chars().all(|c| c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
/// Why a backup can't start yet, given [`lightcraft_engine::Session::unsaved`]: changes applied in
|
||||
/// the app but not yet written to browser storage (a save failed). The zip copies the stored
|
||||
/// library files, so it would report success while missing them; `None` when everything is saved.
|
||||
pub fn unsaved_refusal(unsaved: Option<(usize, &str)>) -> Option<String> {
|
||||
let (ops, why) = unsaved?;
|
||||
let changes = if ops == 1 { "1 change".to_string() } else { format!("{ops} changes") };
|
||||
Some(format!(
|
||||
"Backup not started: {changes} could not be saved to browser storage yet ({why}), so the backup would miss them. \
|
||||
Free up storage space; LightCraft retries the save with every change. Back up once nothing is left unsaved."
|
||||
))
|
||||
}
|
||||
|
||||
/// Zip entry name for an original.
|
||||
pub fn original_entry(hash: &str, file_name: &str) -> String {
|
||||
let name: String = file_name.chars().map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c }).collect();
|
||||
|
||||
@@ -198,6 +198,27 @@ impl Store for FilesStore {
|
||||
/// Names of the library files read at start-up.
|
||||
pub const LIBRARY_FILES: [&str; 6] = ["catalog.snap", "catalog.log", "presets.json", "view.json", "prefs.json", "ui.json"];
|
||||
|
||||
/// Is a browser storage error, by its `DOMException` name, "the entry doesn't exist"? Only
|
||||
/// `NotFoundError` is; anything else (security, type mismatch, an unreadable store) is a failure.
|
||||
pub fn is_not_found(dom_exception_name: Option<&str>) -> bool {
|
||||
dom_exception_name == Some("NotFoundError")
|
||||
}
|
||||
|
||||
/// Preload the library files read at start-up (`None`: the file doesn't exist yet). A file that
|
||||
/// exists but couldn't be read stops start-up instead (#1507): opening the library without it
|
||||
/// would show an incomplete catalog and then save over the stored copy.
|
||||
pub fn preload_library(files: &Files, reads: Vec<(&str, Result<Option<Vec<u8>>, String>)>) -> Result<(), String> {
|
||||
let mut failed = Vec::new();
|
||||
for (name, read) in reads {
|
||||
match read {
|
||||
Ok(Some(data)) => files.preload(name, data),
|
||||
Ok(None) => {}
|
||||
Err(e) => failed.push(format!("{name}: {e}")),
|
||||
}
|
||||
}
|
||||
if failed.is_empty() { Ok(()) } else { Err(format!("couldn't read the library from browser storage ({})", failed.join("; "))) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -282,6 +303,66 @@ mod tests {
|
||||
assert_eq!(s2.catalog.photo(id).unwrap().rating, 5, "the rating survived");
|
||||
}
|
||||
|
||||
/// A backup copies the stored library files, so while a save is failing it would miss the
|
||||
/// queued changes: it is refused with the reason until they are written (#1508).
|
||||
#[test]
|
||||
fn backup_is_refused_while_changes_are_unsaved() {
|
||||
let files = Files::default();
|
||||
let mut s = Session::new();
|
||||
let stores = LibraryStores { dir: "browser".into(), catalog: Box::new(files.store()), files: Box::new(files.store()), on_disk: false };
|
||||
s.open_library_in(stores, true).unwrap();
|
||||
assert_eq!(crate::backup::unsaved_refusal(s.unsaved()), None, "everything saved: the backup may start");
|
||||
files.set_error(Some("QuotaExceededError".into()));
|
||||
let _ = s.execute("photo.rate", &json!({"rating": 5}));
|
||||
let refusal = crate::backup::unsaved_refusal(s.unsaved()).expect("refused while the rating is unsaved");
|
||||
assert!(refusal.contains("could not be saved") && refusal.contains("QuotaExceeded"), "{refusal}");
|
||||
files.set_error(None);
|
||||
s.persist_if_dirty();
|
||||
s.persist().unwrap();
|
||||
assert_eq!(crate::backup::unsaved_refusal(s.unsaved()), None, "allowed again once the change is written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsaved_refusal_counts_changes() {
|
||||
use crate::backup::unsaved_refusal;
|
||||
assert!(unsaved_refusal(Some((1, "quota"))).unwrap().contains("1 change could not"));
|
||||
assert!(unsaved_refusal(Some((3, "quota"))).unwrap().contains("3 changes could not"));
|
||||
}
|
||||
|
||||
/// Start-up only treats a missing file as absent: a stored file that couldn't be read stops
|
||||
/// it, naming the file, instead of opening an incomplete library (#1507).
|
||||
#[test]
|
||||
fn unreadable_library_file_stops_start_up() {
|
||||
let files = Files::default();
|
||||
let reads = vec![
|
||||
("catalog.snap", Ok(Some(b"snap".to_vec()))),
|
||||
("catalog.log", Err("NotReadableError: the file could not be read".to_string())),
|
||||
("presets.json", Ok(None)),
|
||||
];
|
||||
let e = preload_library(&files, reads).unwrap_err();
|
||||
assert!(e.contains("catalog.log") && e.contains("NotReadableError"), "{e}");
|
||||
}
|
||||
|
||||
/// A new library (nothing stored yet) still starts.
|
||||
#[test]
|
||||
fn missing_library_files_are_a_new_library() {
|
||||
let files = Files::default();
|
||||
let reads = LIBRARY_FILES.iter().map(|n| (*n, Ok(None))).collect();
|
||||
preload_library(&files, reads).unwrap();
|
||||
assert!(files.get("catalog.snap").is_none());
|
||||
let reads = vec![("catalog.snap", Ok(Some(b"snap".to_vec()))), ("catalog.log", Ok(None))];
|
||||
preload_library(&files, reads).unwrap();
|
||||
assert_eq!(files.get("catalog.snap").as_deref(), Some(&b"snap"[..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_not_found_errors_mean_absent() {
|
||||
assert!(is_not_found(Some("NotFoundError")));
|
||||
for name in [Some("SecurityError"), Some("TypeMismatchError"), Some("NotReadableError"), Some("InvalidStateError"), None] {
|
||||
assert!(!is_not_found(name), "{name:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_survives_reload_through_flushes() {
|
||||
let mut disk = BTreeMap::new();
|
||||
|
||||
@@ -173,6 +173,9 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
|
||||
screen_profile: None,
|
||||
backup_library: Some(Box::new(move |session: &mut Session| {
|
||||
let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
|
||||
if let Some(why) = crate::backup::unsaved_refusal(session.unsaved()) {
|
||||
return Err(why);
|
||||
}
|
||||
// the photos' own names for the originals in the zip
|
||||
let names: std::collections::HashMap<String, String> = session
|
||||
.catalog
|
||||
@@ -280,6 +283,9 @@ struct Boot {
|
||||
index: ThumbIndex,
|
||||
/// The library folder in storage (`library`, or a restored backup's).
|
||||
lib_dir: String,
|
||||
/// Library files that exist but couldn't be read (#1507): the library isn't opened and
|
||||
/// nothing is saved, so the stored copy stays as it was.
|
||||
load_error: Option<String>,
|
||||
}
|
||||
|
||||
async fn boot(opts: Options) -> Boot {
|
||||
@@ -298,6 +304,7 @@ async fn boot(opts: Options) -> Boot {
|
||||
let files = Files::default();
|
||||
let mut index = ThumbIndex::default();
|
||||
let mut lib_dir = DEFAULT_LIBRARY_DIR.to_string();
|
||||
let mut load_error = None;
|
||||
if let Some(b) = &backend {
|
||||
if opts.reset {
|
||||
// everything this site keeps in the browser, photos included: never without asking
|
||||
@@ -315,21 +322,32 @@ async fn boot(opts: Options) -> Boot {
|
||||
}
|
||||
}
|
||||
// a restored backup lives in its own folder (see `backup`)
|
||||
if let Ok(Some(d)) = b.read(ACTIVE_LIBRARY).await {
|
||||
let d = String::from_utf8_lossy(&d).trim().to_string();
|
||||
if valid_library_dir(&d) {
|
||||
lib_dir = d;
|
||||
match b.read(ACTIVE_LIBRARY).await {
|
||||
Ok(Some(d)) => {
|
||||
let d = String::from_utf8_lossy(&d).trim().to_string();
|
||||
if valid_library_dir(&d) {
|
||||
lib_dir = d;
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
// not knowing which library is in use, any library opened could be the wrong one
|
||||
Err(e) => load_error = Some(format!("couldn't read which library is in use from browser storage ({e})")),
|
||||
}
|
||||
if load_error.is_none() {
|
||||
let mut reads = Vec::new();
|
||||
for name in LIBRARY_FILES {
|
||||
reads.push((name, b.read(&library_key(&lib_dir, name)).await));
|
||||
}
|
||||
if let Err(e) = crate::files::preload_library(&files, reads) {
|
||||
log::error!("{e}");
|
||||
load_error = Some(e);
|
||||
}
|
||||
}
|
||||
for name in LIBRARY_FILES {
|
||||
match b.read(&library_key(&lib_dir, name)).await {
|
||||
Ok(Some(data)) => files.preload(name, data),
|
||||
Ok(None) => {}
|
||||
Err(e) => log::error!("reading {name}: {e}"),
|
||||
}
|
||||
}
|
||||
if let Ok(Some(j)) = b.read(THUMB_INDEX).await {
|
||||
index = ThumbIndex::from_json(&j);
|
||||
let index_read = b.read(THUMB_INDEX).await;
|
||||
match &index_read {
|
||||
Ok(Some(j)) => index = ThumbIndex::from_json(j),
|
||||
Ok(None) => {}
|
||||
Err(e) => log::warn!("reading the thumbnail index: {e}"),
|
||||
}
|
||||
crate::backend::request_persistence(|granted| {
|
||||
if !granted {
|
||||
@@ -339,7 +357,8 @@ async fn boot(opts: Options) -> Boot {
|
||||
);
|
||||
}
|
||||
});
|
||||
// thumbnails stored without an index entry (index saved late, or lost): delete them
|
||||
// thumbnails stored without an index entry (index saved late, or lost): delete them; not
|
||||
// when the index couldn't be read, which would make every thumbnail look unindexed
|
||||
let b2 = b.clone();
|
||||
let known: std::collections::HashSet<String> = b
|
||||
.list("thumbs")
|
||||
@@ -349,7 +368,7 @@ async fn boot(opts: Options) -> Boot {
|
||||
.filter(|n| n.ends_with(".jpg"))
|
||||
.map(|n| n.trim_end_matches(".jpg").to_string())
|
||||
.collect();
|
||||
let orphans: Vec<String> = known.into_iter().filter(|k| !index.contains(k)).collect();
|
||||
let orphans: Vec<String> = if index_read.is_ok() { known.into_iter().filter(|k| !index.contains(k)).collect() } else { Vec::new() };
|
||||
if !orphans.is_empty() {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
for k in &orphans {
|
||||
@@ -366,7 +385,7 @@ async fn boot(opts: Options) -> Boot {
|
||||
index.len(),
|
||||
index.total() as f64 / 1e6
|
||||
);
|
||||
Boot { opts, backend, files, index, lib_dir }
|
||||
Boot { opts, backend, files, index, lib_dir, load_error }
|
||||
}
|
||||
|
||||
struct WebApp {
|
||||
@@ -389,7 +408,7 @@ struct WebApp {
|
||||
|
||||
impl WebApp {
|
||||
fn new(cc: &eframe::CreationContext<'_>, boot: Boot, chinese_font: Vec<u8>) -> Self {
|
||||
let Boot { opts, backend, files, index, lib_dir } = boot;
|
||||
let Boot { opts, backend, files, index, lib_dir, load_error } = boot;
|
||||
let originals = Originals::default();
|
||||
let t = perf_now();
|
||||
let mut session = Session::new();
|
||||
@@ -401,7 +420,13 @@ impl WebApp {
|
||||
on_disk: false,
|
||||
};
|
||||
let mut problem = None;
|
||||
match session.open_library_in(stores, true).cloned() {
|
||||
// a library that couldn't be read is not opened: it would show incomplete, then save over the stored copy
|
||||
let read_failed = load_error.is_some();
|
||||
let opened = match load_error {
|
||||
Some(e) => Err(format!("{e}. Nothing was changed in storage")),
|
||||
None => session.open_library_in(stores, true).cloned().map_err(|e| e.to_string()),
|
||||
};
|
||||
match opened {
|
||||
Ok(r) => log::info!(
|
||||
"lightcraft: library {} in {:.0} ms ({} photos; snapshot seq {}, {} ops replayed)",
|
||||
if r.created { "created" } else { "loaded" },
|
||||
@@ -432,7 +457,8 @@ impl WebApp {
|
||||
}
|
||||
// previews are large (≤ 2560 px, f32): keep few in a 32-bit address space
|
||||
session.media.preview_capacity = 3;
|
||||
let frozen = Rc::new(Cell::new(false));
|
||||
// nothing is saved while the stored library couldn't be read
|
||||
let frozen = Rc::new(Cell::new(read_failed));
|
||||
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone()))
|
||||
.with_chinese_font(chinese_font);
|
||||
let ui_written = files.get("ui.json").unwrap_or_default();
|
||||
@@ -667,11 +693,40 @@ impl eframe::App for WebApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// "Auto" follows the browser's languages (issue #2085): the first of `navigator.languages` (else
|
||||
/// `navigator.language`) that LightCraft ships becomes the UI's default. Every step is fallible
|
||||
/// (as in [`crate::safety::acquire_tab_lock`]): without a usable navigator Auto stays English,
|
||||
/// as before.
|
||||
fn install_browser_language() {
|
||||
let Ok(navigator) = js_sys::Reflect::get(&js_sys::global(), &"navigator".into()) else { return };
|
||||
let mut tags: Vec<String> = Vec::new();
|
||||
if let Ok(languages) = js_sys::Reflect::get(&navigator, &"languages".into())
|
||||
&& let Some(languages) = languages.dyn_ref::<js_sys::Array>()
|
||||
{
|
||||
for tag in languages.iter() {
|
||||
if let Some(tag) = tag.as_string() {
|
||||
tags.push(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
if tags.is_empty()
|
||||
&& let Ok(language) = js_sys::Reflect::get(&navigator, &"language".into())
|
||||
&& let Some(language) = language.as_string()
|
||||
{
|
||||
tags.push(language);
|
||||
}
|
||||
if let Some(language) = lightcraft_ui_egui::i18n::locale_from_tags(&tags) {
|
||||
lightcraft_ui_egui::i18n::set_default_language(language);
|
||||
log::info!("lightcraft: default language {language:?} from the browser's languages");
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the app with the separately fetched Simplified Chinese font (empty in font-free builds).
|
||||
#[wasm_bindgen]
|
||||
pub fn start(chinese_font: Vec<u8>) {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
safety::install_panic_hook();
|
||||
install_browser_language();
|
||||
log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now());
|
||||
if chinese_font.is_empty() {
|
||||
log::warn!("lightcraft: no Simplified Chinese font supplied; Chinese-only glyphs will show as boxes");
|
||||
|
||||
@@ -45,3 +45,19 @@ sync_github_name = true
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.nledford]
|
||||
real_name = "Nathaniel Ledford"
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.egegl]
|
||||
real_name = "Ege Gursel"
|
||||
sync_github_name = true
|
||||
|
||||
[people.mahmatias]
|
||||
display_name = "Márcio Matias de Andrade"
|
||||
sync_github_name = true
|
||||
@@ -49,7 +49,9 @@ pub fn shift_iso(iso: &str, secs: i64) -> Option<String> {
|
||||
if !(first..=last).contains(&t) {
|
||||
return None;
|
||||
}
|
||||
let tail = iso.trim().get(19..).unwrap_or("");
|
||||
let iso = iso.trim();
|
||||
// the seconds are optional (`10:00+02:00`): without them the tail starts right after the minutes
|
||||
let tail = iso.get(if iso.get(16..17) == Some(":") { 19 } else { 16 }..).unwrap_or("");
|
||||
Some(format!("{}{tail}", civil(t)))
|
||||
}
|
||||
|
||||
@@ -222,6 +224,24 @@ mod tests {
|
||||
assert_eq!(normalize_iso("2026-02-30"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_keeps_the_zone_of_a_time_without_seconds() {
|
||||
assert_eq!(shift_iso("2026-01-01T10:00+02:00", 3600).as_deref(), Some("2026-01-01T11:00:00+02:00"));
|
||||
assert_eq!(shift_iso("2026-01-01T10:00Z", 60).as_deref(), Some("2026-01-01T10:01:00Z"));
|
||||
assert_eq!(shift_iso("2026-01-01T10:00", 3600).as_deref(), Some("2026-01-01T11:00:00"));
|
||||
assert_eq!(shift_iso("2026-01-01T10:00:00.5-05:00", 3600).as_deref(), Some("2026-01-01T11:00:00.5-05:00"));
|
||||
assert_eq!(iso_seconds("2026-01-01T10:00+02:00"), iso_seconds("2026-01-01T10:00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn impossible_times_are_rejected_not_rolled_over() {
|
||||
for bad in ["2026-04-01T10:75:00", "2026-04-01 10:00:75", "2026-04-01T10:60", "2026-04-01T24:00:00", "2026-04-01T10:59:60"] {
|
||||
assert_eq!(normalize_iso(bad), None, "{bad}");
|
||||
assert_eq!(shift_iso(bad, 5), None, "{bad}");
|
||||
}
|
||||
assert_eq!(normalize_iso("2026-04-01T23:59:59").as_deref(), Some("2026-04-01T23:59:59"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_by_day_month_year_keep_stacks_together() {
|
||||
let mut c = Catalog::new();
|
||||
|
||||
@@ -271,7 +271,7 @@ fn resync(line: &[u8], last: u64) -> Option<(usize, (u64, Op))> {
|
||||
const START: &[u8] = b"{\"seq\":";
|
||||
(1..line.len().saturating_sub(START.len() - 1)).filter(|&i| line[i..].starts_with(START)).find_map(|i| {
|
||||
let rec = std::str::from_utf8(&line[i..]).ok().map(|l| l.trim_end_matches('\r')).and_then(decode_record)?;
|
||||
(rec.0 <= last + 1).then_some((i, rec))
|
||||
(rec.0 <= last.saturating_add(1)).then_some((i, rec))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -298,6 +298,9 @@ fn write_snapshot(store: &mut dyn Store, seq: u64, catalog: &Catalog) -> std::io
|
||||
Ok(SnapshotTiming { serialize_ms, write_sync_ms: (ms_since(t0) - serialize_ms).max(0.0), bytes, ..Default::default() })
|
||||
}
|
||||
|
||||
/// The highest `seq` a snapshot may hold; beyond it the counter is treated as corrupt, far from overflow.
|
||||
const MAX_SEQ: u64 = u64::MAX / 2;
|
||||
|
||||
/// Just the identification of `catalog.snap` (read before the catalog itself).
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SnapHeader {
|
||||
@@ -331,6 +334,10 @@ impl Journal {
|
||||
return Err(newer(format!("{SNAPSHOT} is catalog format v{}", h.version)));
|
||||
}
|
||||
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
|
||||
// a seq this high can't come from real use; the next record's seq would wrap
|
||||
if s.seq > MAX_SEQ {
|
||||
return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: seq {} is out of range", s.seq)));
|
||||
}
|
||||
let mut catalog = s.catalog;
|
||||
report.repaired = catalog.repair();
|
||||
for r in &report.repaired {
|
||||
@@ -392,7 +399,7 @@ impl Journal {
|
||||
let _ = op;
|
||||
report.stale += 1;
|
||||
}
|
||||
Some((seq, op)) if seq == j.seq + 1 => {
|
||||
Some((seq, op)) if j.seq.checked_add(1) == Some(seq) => {
|
||||
if catalog.apply(op).is_err() {
|
||||
report.failed += 1;
|
||||
} else {
|
||||
@@ -476,7 +483,7 @@ impl Journal {
|
||||
let mut buf = String::new();
|
||||
let mut seq = self.seq;
|
||||
for op in ops {
|
||||
seq += 1;
|
||||
seq = seq.checked_add(1).ok_or_else(|| CatalogError::Corrupt(format!("{LOG}: op counter overflow")))?;
|
||||
buf.push_str(&encode_record(seq, op));
|
||||
buf.push('\n');
|
||||
}
|
||||
@@ -527,7 +534,7 @@ impl Journal {
|
||||
log::warn!("catalog: background snapshot failed ({e}); writing one now");
|
||||
}
|
||||
let t0 = web_time::Instant::now();
|
||||
let seq = self.seq + unlogged;
|
||||
let seq = self.seq.checked_add(unlogged).ok_or_else(|| CatalogError::Corrupt(format!("{SNAPSHOT}: op counter overflow")))?;
|
||||
let mut timing = match write_snapshot(self.store.as_mut(), seq, catalog) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
|
||||
@@ -643,6 +643,7 @@ mod edited_tests {
|
||||
("NIKON CORPORATION NIKON D850", "NEF", false),
|
||||
("Canon Canon EOS 5D Mark III", "CR2", false),
|
||||
("Canon Canon EOS R6m2", "CR3", false),
|
||||
("Canon Canon EOS R6 Mark II", "CR3", false),
|
||||
("Canon Canon EOS R7", "CR3", true),
|
||||
("FUJIFILM X-T4", "RAF", false),
|
||||
("ILCE-7M3", "ARW", false),
|
||||
|
||||
@@ -157,13 +157,20 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
|
||||
Some((_, v)) => (x - v).abs() < 1e-9,
|
||||
None => false,
|
||||
};
|
||||
// Fields stored as `f32` compare at f32 precision: 2.8f32 widened to f64 is not 2.8.
|
||||
let cmp32 = |x: f32, s: &str| match num(s) {
|
||||
Some(('>', v)) => x > v as f32,
|
||||
Some(('<', v)) => x < v as f32,
|
||||
Some((_, v)) => x == v as f32,
|
||||
None => false,
|
||||
};
|
||||
return match field {
|
||||
"rating" | "stars" => cmp(p.rating as f64, val),
|
||||
"flag" => Flag::parse(val) == Some(p.flag),
|
||||
"label" | "color" => p.label.is_some_and(|l| format!("{l:?}").eq_ignore_ascii_case(val)),
|
||||
"iso" => p.meta.iso.is_some_and(|i| cmp(i as f64, val)),
|
||||
"f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp(a as f64, val)),
|
||||
"focal" => p.meta.focal_mm.is_some_and(|a| cmp(a as f64, val)),
|
||||
"f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp32(a, val)),
|
||||
"focal" => p.meta.focal_mm.is_some_and(|a| cmp32(a, val)),
|
||||
"camera" => p.meta.camera.to_lowercase().contains(val),
|
||||
"lens" => p.meta.lens.to_lowercase().contains(val),
|
||||
"keyword" | "kw" => p.meta.keywords.iter().any(|k| crate::keywords::is_under(k, val)),
|
||||
@@ -173,11 +180,18 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
|
||||
"date" => p.captured.as_deref().is_some_and(|c| c.starts_with(val)),
|
||||
"copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(),
|
||||
"name" | "file" => p.file_name.to_lowercase().contains(val),
|
||||
_ => false,
|
||||
// Not a known field: the colon is part of a plain word ("10:30", "Re:").
|
||||
_ => text_matches(p, &t),
|
||||
};
|
||||
}
|
||||
text_matches(p, &t)
|
||||
}
|
||||
|
||||
/// Whether the lower-cased word `t` is in the file name, title, caption, camera, lens, location,
|
||||
/// format or a keyword of `p`.
|
||||
fn text_matches(p: &Photo, t: &str) -> bool {
|
||||
let hay = [&p.file_name, &p.meta.title, &p.meta.caption, &p.meta.camera, &p.meta.lens, &p.meta.location, &p.format];
|
||||
hay.iter().any(|h| h.to_lowercase().contains(&t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&t))
|
||||
hay.iter().any(|h| h.to_lowercase().contains(t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(t))
|
||||
}
|
||||
|
||||
/// Whether `p` has a named face region called `name` (case-insensitive).
|
||||
|
||||
@@ -294,7 +294,11 @@ pub fn iso_seconds(s: &str) -> Option<i64> {
|
||||
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
|
||||
return None;
|
||||
}
|
||||
let (hh, mm, ss) = if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, num(17, 19).unwrap_or(0)) } else { (0, 0, 0) };
|
||||
let (hh, mm, ss) =
|
||||
if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, if s.get(16..17) == Some(":") { num(17, 19).unwrap_or(0) } else { 0 }) } else { (0, 0, 0) };
|
||||
if hh >= 24 || mm >= 60 || ss >= 60 {
|
||||
return None;
|
||||
}
|
||||
// days from civil (proleptic Gregorian)
|
||||
let y2 = if m <= 2 { y - 1 } else { y };
|
||||
let era = y2.div_euclid(400);
|
||||
|
||||
@@ -242,7 +242,7 @@ impl Store for MemStore {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests_staging {
|
||||
use super::*;
|
||||
|
||||
|
||||
@@ -123,6 +123,48 @@ fn filter_search_sort() {
|
||||
assert_eq!(c.keywords(), vec![("ocean".to_string(), 1), ("summer".to_string(), 1)]);
|
||||
}
|
||||
|
||||
/// A search word with a colon whose left part is not a known field ("10:30", "Re:") is plain
|
||||
/// text, not a fielded token that matches nothing.
|
||||
#[test]
|
||||
fn free_text_with_a_colon_searches_the_text_fields() {
|
||||
let mut c = Catalog::new();
|
||||
let a = photo(&mut c, "meeting.jpg", "2026-05-01T10:00:00");
|
||||
let _b = photo(&mut c, "other.jpg", "2026-04-01T10:00:00");
|
||||
let mut m = c.photo(a).unwrap().meta.clone();
|
||||
m.caption = "Meeting at 10:30 with Re: notes".into();
|
||||
m.iso = Some(1600);
|
||||
c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap();
|
||||
let q = |text: &str| c.query(&Filter { text: text.into(), ..Default::default() }, &Sort::default());
|
||||
assert_eq!(q("meeting"), vec![a]);
|
||||
assert_eq!(q("10:30"), vec![a], "a free-text token with a colon");
|
||||
assert_eq!(q("re:"), vec![a]);
|
||||
assert_eq!(q("RE: notes"), vec![a], "case-insensitive, one token per word");
|
||||
assert_eq!(q("iso:>800"), vec![a], "known fields stay fielded");
|
||||
assert_eq!(q("iso:>2000"), Vec::<PhotoId>::new());
|
||||
assert_eq!(q("11:45"), Vec::<PhotoId>::new(), "an unknown field matching no text finds nothing");
|
||||
}
|
||||
|
||||
/// `f:`/`aperture:` and `focal:` are stored as `f32`; a typed value that isn't exact in binary
|
||||
/// (2.8, 5.6) must still match, and `<`/`>` must not count the photo's own value.
|
||||
#[test]
|
||||
fn search_tokens_match_f32_aperture_and_focal() {
|
||||
let mut c = Catalog::new();
|
||||
let a = photo(&mut c, "a.jpg", "2026-05-01T10:00:00");
|
||||
let mut m = c.photo(a).unwrap().meta.clone();
|
||||
m.aperture = Some(2.8);
|
||||
m.focal_mm = Some(5.6);
|
||||
c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap();
|
||||
let q = |t: &str| c.query(&Filter { text: t.into(), ..Default::default() }, &Sort::default());
|
||||
assert_eq!(q("f:2.8"), vec![a], "f:2.8");
|
||||
assert_eq!(q("aperture:=2.8"), vec![a], "aperture:=2.8");
|
||||
assert_eq!(q("focal:5.6"), vec![a], "focal:5.6");
|
||||
assert!(q("f:2.9").is_empty(), "f:2.9");
|
||||
assert!(q("f:<2.8").is_empty(), "f:<2.8");
|
||||
assert!(q("f:>2.8").is_empty(), "f:>2.8");
|
||||
assert!(q("focal:<5.6").is_empty(), "focal:<5.6");
|
||||
assert_eq!(q("f:>2.7"), vec![a], "f:>2.7");
|
||||
}
|
||||
|
||||
/// A `captured` string that is not ASCII (the Lightroom importer copies `captureTime` into an
|
||||
/// `AddPhoto` op verbatim, and a JSON snapshot is valid UTF-8 whatever it holds) must not be
|
||||
/// sliced on a byte index in `date_groups`: grouping counts the well-formed dates and ignores
|
||||
|
||||
@@ -376,3 +376,18 @@ fn a_damaged_process_value_loads_as_v1_with_the_edits() {
|
||||
let d = &loaded.photo(id).unwrap().develop;
|
||||
assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.8));
|
||||
}
|
||||
|
||||
/// A snapshot whose `seq` is near `u64::MAX` is refused as corrupt: the next record's seq would
|
||||
/// wrap (and every later edit would be skipped as stale on reopen) or overflow-panic in debug.
|
||||
#[test]
|
||||
fn snapshot_with_seq_near_u64_max_is_corrupt() {
|
||||
for seq in [u64::MAX, u64::MAX - 1, u64::MAX / 2 + 1] {
|
||||
let m = MemStore::new();
|
||||
m.set(
|
||||
SNAPSHOT,
|
||||
format!("{{\"format\":\"lightcraft-catalog\",\"version\":1,\"seq\":{seq},\"catalog\":{}}}\n", Catalog::new().to_snapshot()).into_bytes(),
|
||||
);
|
||||
let e = Journal::open(Box::new(m)).err();
|
||||
assert!(matches!(e, Some(CatalogError::Corrupt(_))), "seq {seq}: {e:?}");
|
||||
}
|
||||
}
|
||||
@@ -55,3 +55,6 @@ jxl-oxide = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
# Lossless synthetic HEIC files for the `heif` feature's tests.
|
||||
lightcraft-heif = { workspace = true, features = ["testdata"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
|
||||
dhat = { workspace = true }
|
||||
@@ -73,6 +73,10 @@ ICC profiles are parsed with `moxcms` (v2/v4, `curv`/`para` TRCs, `chad`); sRGB,
|
||||
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.
|
||||
|
||||
Before colour conversion, positive-infinity float samples become 1.0; NaN and negative infinity
|
||||
become 0.0. This also applies to alpha. Finite samples keep their existing range: matrix/TRC paths
|
||||
preserve values above 1.0 and below 0.0, while CMS inputs and alpha remain bounded to 0..1.
|
||||
|
||||
## Dependencies and licences
|
||||
|
||||
All pure Rust, verified with `cargo info` / `cargo tree`:
|
||||
|
||||
@@ -44,11 +44,23 @@ impl Buf {
|
||||
match self {
|
||||
Buf::U8(v) => v[i] as f32 / 255.0,
|
||||
Buf::U16(v) => v[i] as f32 / 65535.0,
|
||||
Buf::F32(v) => v[i],
|
||||
Buf::F32(v) => normalize_float(v[i]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clip non-finite device samples before colour conversion, preserving finite extended range.
|
||||
#[inline]
|
||||
fn normalize_float(value: f32) -> f32 {
|
||||
if value == f32::INFINITY {
|
||||
1.0
|
||||
} else if value.is_finite() {
|
||||
value
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Interleaved device samples as produced by a format decoder.
|
||||
pub(crate) struct Raw {
|
||||
pub width: usize,
|
||||
@@ -87,9 +99,10 @@ pub(crate) fn check_size(format: Format, w: u64, h: u64, opts: &DecodeOptions) -
|
||||
}
|
||||
|
||||
pub(crate) fn finish(format: Format, raw: Raw, meta: Meta, source_dims: (u32, u32), opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let n = raw.width * raw.height;
|
||||
let ch = raw.model.channels() + raw.alpha as usize;
|
||||
if n == 0 || raw.buf.len() < n * ch {
|
||||
let buf_len =
|
||||
crate::checked_sample_buf_len(raw.width, raw.height, ch).ok_or_else(|| Error::Malformed(format, "sample buffer too short".into()))?;
|
||||
if (raw.buf.len() as u64) < buf_len {
|
||||
return Err(Error::Malformed(format, "sample buffer too short".into()));
|
||||
}
|
||||
let float = matches!(raw.buf, Buf::F32(_));
|
||||
@@ -129,7 +142,9 @@ pub(crate) fn finish(format: Format, raw: Raw, meta: Meta, source_dims: (u32, u3
|
||||
let (image, alpha) = match opts.max_size {
|
||||
Some((mw, mh)) if (image.width as u32 > mw || image.height as u32 > mh) && mw > 0 && mh > 0 => {
|
||||
let factor = (image.width as f64 / mw as f64).max(image.height as f64 / mh as f64);
|
||||
let f = if factor >= 2.0 { Filter::Box } else { Filter::Mitchell };
|
||||
// (Lanczos at 2:1 and more as well: a box average there halves the contrast of
|
||||
// detail near the output's resolution limit and aliases what is finer)
|
||||
let f = if factor >= 2.0 { Filter::Lanczos3 } else { Filter::Mitchell };
|
||||
let img = fit(&image, mw as usize, mh as usize, f);
|
||||
let alpha = alpha.map(|a| fit(&a, mw as usize, mh as usize, f));
|
||||
(img, alpha)
|
||||
@@ -218,10 +233,7 @@ fn linearize(raw: &Raw, ch: usize, trc: &[Trc; 3]) -> Rgb32f {
|
||||
par_rows(&mut img.data, w, |y, row| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let i = (y * w + x) * ch;
|
||||
let s = |c: usize| {
|
||||
let val = v[i + c];
|
||||
if val.is_finite() { val } else { 0.0 }
|
||||
};
|
||||
let s = |c: usize| normalize_float(v[i + c]);
|
||||
*o = if gray {
|
||||
let l = if lin { s(0) } else { trc[1].to_linear(s(0)) };
|
||||
[l, l, l]
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
|
||||
use crate::space::{NamedSpace, SourceSpace, SpaceOrigin, Trc};
|
||||
use crate::{DecodeOptions, Decoded, Error, Format, Result};
|
||||
use jxl_oxide::color::{ColourSpace, EnumColourEncoding, Primaries, RenderingIntent, TransferFunction, WhitePoint};
|
||||
use jxl_oxide::{AuxBoxData, JxlImage};
|
||||
use jxl_oxide::{AllocTracker, AuxBoxData, InitializeResult, JxlImage};
|
||||
|
||||
const F: Format = Format::Jxl;
|
||||
|
||||
@@ -16,10 +16,60 @@ fn err(e: impl std::fmt::Display) -> Error {
|
||||
Error::Malformed(F, e.to_string())
|
||||
}
|
||||
|
||||
/// Tracked allocation budget per allowed pixel for frames, extra channels and modular trees.
|
||||
/// This bounds allocations covered by `AllocTracker`, not every allocation made by the decoder (#1517).
|
||||
const TRACKED_BYTES_PER_PIXEL: u64 = 4 * 4 * 8;
|
||||
|
||||
/// The allocation budget never drops below this, so small images keep the decoder's fixed
|
||||
/// overhead (group buffers, entropy tables) whatever `max_pixels` is.
|
||||
const MIN_TRACKED_BYTES: u64 = 64 << 20;
|
||||
|
||||
/// Bytes handed to the decoder at a time (the size `JxlImageBuilder::read` reads in).
|
||||
const INPUT_CHUNK: usize = 4096;
|
||||
|
||||
/// Initializes from bounded input, checks dimensions before rendering, then loads remaining frames
|
||||
/// within a tracked allocation budget.
|
||||
fn open(bytes: &[u8], opts: &DecodeOptions) -> Result<JxlImage> {
|
||||
let budget = opts.max_pixels.saturating_mul(TRACKED_BYTES_PER_PIXEL).max(MIN_TRACKED_BYTES);
|
||||
let tracker = AllocTracker::with_limit(usize::try_from(budget).unwrap_or(usize::MAX));
|
||||
let mut uninit = JxlImage::builder().alloc_tracker(tracker).build_uninit();
|
||||
// Initialization can load already-buffered frame data, so small feeds limit work before
|
||||
// dimension checking.
|
||||
let mut rest = bytes;
|
||||
let mut window = INPUT_CHUNK;
|
||||
let mut img = loop {
|
||||
let consumed = uninit.feed_bytes(rest.get(..window).unwrap_or(rest)).map_err(err)?;
|
||||
rest = rest.get(consumed..).unwrap_or_default();
|
||||
match uninit.try_init().map_err(err)? {
|
||||
InitializeResult::Initialized(img) => break img,
|
||||
InitializeResult::NeedMoreData(more) => uninit = more,
|
||||
}
|
||||
if consumed == 0 {
|
||||
// the parser wants more than the window holds at once
|
||||
if window >= rest.len() {
|
||||
return Err(err("truncated JPEG XL header"));
|
||||
}
|
||||
window = window.saturating_mul(2);
|
||||
}
|
||||
};
|
||||
check_size(F, img.width() as u64, img.height() as u64, opts)?;
|
||||
// Match `JxlImageBuilder::read`: bounded feeds until the final frame, not merely the first
|
||||
// keyframe. `feed_bytes` copies codestream data into an untracked Vec, including any tail
|
||||
// after the last frame; feeding the remaining file at once bypasses the allocation budget.
|
||||
while !img.is_loading_done() && !rest.is_empty() {
|
||||
let consumed = img.feed_bytes(rest.get(..INPUT_CHUNK).unwrap_or(rest)).map_err(err)?;
|
||||
if consumed == 0 {
|
||||
break;
|
||||
}
|
||||
rest = rest.get(consumed..).unwrap_or_default();
|
||||
}
|
||||
img.finalize().map_err(err)?;
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let mut img = JxlImage::builder().read(std::io::Cursor::new(bytes)).map_err(err)?;
|
||||
let mut img = open(bytes, opts)?;
|
||||
let (w, h) = (img.width(), img.height());
|
||||
check_size(F, w as u64, h as u64, opts)?;
|
||||
let header = img.image_header();
|
||||
let bits = header.metadata.bit_depth.bits_per_sample() as u8;
|
||||
let float = matches!(header.metadata.bit_depth, jxl_oxide::image::BitDepth::FloatSample { .. });
|
||||
|
||||
@@ -338,3 +338,29 @@ pub fn decode_thumbnail_with(bytes: &[u8], opts: &ThumbnailOptions) -> Result<Th
|
||||
source_height: d.source_height,
|
||||
})
|
||||
}
|
||||
|
||||
/// Checked sample-buffer length `width × height × ch`, in u64: `None` on overflow or a zero
|
||||
/// dimension. Image size is capped by `DecodeOptions::max_pixels` alone; once the buffer is known to
|
||||
/// hold this many samples, every later `usize` product is bounded by an allocation that exists.
|
||||
pub(crate) fn checked_sample_buf_len(width: usize, height: usize, ch: usize) -> Option<u64> {
|
||||
(width as u64).checked_mul(height as u64).and_then(|n| n.checked_mul(ch as u64)).filter(|&n| n > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod checked_arithmetic_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn checked_sample_buf_len_wrap_inputs_rejected() {
|
||||
assert_eq!(checked_sample_buf_len(usize::MAX, usize::MAX, 4), None);
|
||||
assert_eq!(checked_sample_buf_len(0, 1, 3), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_sample_buf_len_normal_accepted() {
|
||||
assert_eq!(checked_sample_buf_len(100, 1, 3), Some(300));
|
||||
assert_eq!(checked_sample_buf_len(1, 1, 1), Some(1));
|
||||
// 300 Mpx RGBA is within the default pixel limit, so it must not be refused by sample count.
|
||||
assert_eq!(checked_sample_buf_len(20_000, 15_000, 4), Some(1_200_000_000));
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,11 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
fn limits(opts: &DecodeOptions) -> Limits {
|
||||
let mut limits = Limits::default();
|
||||
limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize;
|
||||
// A strip or tile whose declared byte count exceeds this is refused: the default 128 MiB refuses a
|
||||
// single-strip uncompressed 16-bit export of ~22 MP (#1983). Raised to the decoded image's cap
|
||||
// (32 bytes per allowed pixel), never lowered: a strip or tile can outgrow the pixels it shows
|
||||
// (JPEG headers, padding to the tile size).
|
||||
limits.intermediate_buffer_size = limits.intermediate_buffer_size.max(limits.decoding_buffer_size);
|
||||
limits.ifd_value_size = 32 << 20;
|
||||
limits
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Non-finite samples in procedural 32-bit float TIFFs (issue #1952, item 6).
|
||||
|
||||
use lightcraft_codecs::{icc, *};
|
||||
|
||||
fn tiff(samples: &[f32], channels: u8, profile: Option<&[u8]>) -> Vec<u8> {
|
||||
encode_tiff(
|
||||
&EncodeImage::new((samples.len() / channels as usize) as u32, 1, channels, Samples::F32(samples)),
|
||||
TiffCompression::None,
|
||||
&EncodeMeta { icc: profile, ..Default::default() },
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_tiff_nonfinite_rgb_clips_without_losing_finite_hdr() {
|
||||
let samples =
|
||||
[[f32::INFINITY; 3], [f32::NAN; 3], [f32::NEG_INFINITY; 3], [-0.25; 3], [0.25, 0.5, 0.75], [2.0, 4.0, 8.0], [f32::INFINITY, 0.5, f32::NAN]];
|
||||
let bytes = tiff(samples.as_flattened(), 3, None);
|
||||
let decoded = decode(&bytes, DecodeOptions::default()).unwrap();
|
||||
assert!(decoded.float);
|
||||
assert_eq!(decoded.bit_depth, 32);
|
||||
assert_eq!(decoded.image.data, [[1.0; 3], [0.0; 3], [0.0; 3], [-0.25; 3], [0.25, 0.5, 0.75], [2.0, 4.0, 8.0], [1.0, 0.5, 0.0]]);
|
||||
let display = decoded.to_srgb8();
|
||||
assert_eq!(display.data[0], [255; 4]);
|
||||
for pixel in &display.data[1..4] {
|
||||
assert_eq!(*pixel, [0, 0, 0, 255]);
|
||||
}
|
||||
assert_eq!(display.data[6], [255, 188, 0, 255]);
|
||||
assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite()));
|
||||
|
||||
// A box-filtered pair must average clipped white with its ordinary neighbor.
|
||||
let pair = tiff(&[f32::INFINITY, f32::INFINITY, f32::INFINITY, 0.25, 0.25, 0.25], 3, None);
|
||||
let small = decode(&pair, DecodeOptions::fit(1, 1)).unwrap();
|
||||
assert_eq!(small.image.data, [[0.625; 3]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_tiff_nonfinite_gray_and_icc_curves_match_clipped_samples() {
|
||||
let samples = [f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.25, 1.0, 2.0, -0.25];
|
||||
let clipped = [1.0, 0.0, 0.0, 0.25, 1.0, 2.0, -0.25];
|
||||
let profiles = [
|
||||
None,
|
||||
Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Linear)),
|
||||
Some(icc::write_named(NamedSpace::Srgb)),
|
||||
Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Gamma(2.0))),
|
||||
Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Table(vec![0, 16384, 65535]))),
|
||||
Some(moxcms::ColorProfile::new_gray_with_gamma(2.0).encode().unwrap()),
|
||||
Some(b"invalid ICC profile".to_vec()),
|
||||
];
|
||||
for channels in [1, 3] {
|
||||
let samples: Vec<_> = samples.iter().flat_map(|v| std::iter::repeat_n(*v, channels as usize)).collect();
|
||||
let clipped: Vec<_> = clipped.iter().flat_map(|v| std::iter::repeat_n(*v, channels as usize)).collect();
|
||||
for profile in &profiles {
|
||||
let profile = profile.as_deref();
|
||||
let decoded = decode(&tiff(&samples, channels, profile), DecodeOptions::default()).unwrap();
|
||||
let reference = decode(&tiff(&clipped, channels, profile), DecodeOptions::default()).unwrap();
|
||||
assert_eq!(decoded.grayscale, channels == 1);
|
||||
assert_eq!(decoded.image, reference.image, "channels={channels}, space={:?}", decoded.space);
|
||||
assert_eq!(decoded.to_srgb8(), reference.to_srgb8());
|
||||
assert!(decoded.image.data[5].iter().all(|v| *v > 1.0), "finite HDR survives the curve");
|
||||
assert!(decoded.image.data[6].iter().all(|v| *v < 0.0), "finite negative samples survive the curve");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_tiff_nonfinite_alpha_is_finite_and_bounded() {
|
||||
let samples = [
|
||||
[f32::INFINITY, f32::INFINITY, f32::INFINITY, f32::INFINITY],
|
||||
[f32::NAN, f32::NEG_INFINITY, 0.5, f32::NAN],
|
||||
[0.25, 0.5, 2.0, f32::NEG_INFINITY],
|
||||
[2.0, 3.0, 4.0, 0.5],
|
||||
];
|
||||
let decoded = decode(&tiff(samples.as_flattened(), 4, None), DecodeOptions::default()).unwrap();
|
||||
assert_eq!(decoded.alpha.as_ref().unwrap().data, [1.0, 0.0, 0.0, 0.5]);
|
||||
assert_eq!(decoded.image.data, [[1.0; 3], [0.0, 0.0, 0.5], [0.25, 0.5, 2.0], [2.0, 3.0, 4.0]]);
|
||||
assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite()));
|
||||
|
||||
// Associated alpha takes the same path before unpremultiplication.
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
{
|
||||
use tiff::encoder::{TiffEncoder, colortype};
|
||||
let mut encoder = TiffEncoder::new(&mut cursor).unwrap();
|
||||
let mut image = encoder.new_image::<colortype::RGBA32Float>(2, 1).unwrap();
|
||||
image.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[1u16][..]).unwrap();
|
||||
image.write_data(&[f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.5, 1.0, 2.0, 3.0, 0.5]).unwrap();
|
||||
}
|
||||
let decoded = decode(&cursor.into_inner(), DecodeOptions::default()).unwrap();
|
||||
assert_eq!(decoded.alpha.as_ref().unwrap().data, [0.5, 0.5]);
|
||||
assert_eq!(decoded.image.data, [[2.0, 0.0, 0.0], [2.0, 4.0, 6.0]]);
|
||||
}
|
||||
|
||||
fn lut_profile() -> Vec<u8> {
|
||||
use moxcms::{ColorProfile, LutMultidimensionalType, LutWarehouse, Matrix3d, ToneReprCurve, Vector3d, Xyzd};
|
||||
let mut profile = ColorProfile::new_srgb();
|
||||
profile.cicp = None;
|
||||
profile.red_colorant = Xyzd::default();
|
||||
profile.green_colorant = Xyzd::default();
|
||||
profile.blue_colorant = Xyzd::default();
|
||||
profile.red_trc = None;
|
||||
profile.green_trc = None;
|
||||
profile.blue_trc = None;
|
||||
// A procedural mAB profile without a CLUT: linear RGB to D50 XYZ, then identity curves.
|
||||
profile.lut_a_to_b_colorimetric = Some(LutWarehouse::Multidimensional(LutMultidimensionalType {
|
||||
num_input_channels: 3,
|
||||
num_output_channels: 3,
|
||||
grid_points: [0; 16],
|
||||
clut: None,
|
||||
a_curves: vec![],
|
||||
m_curves: vec![ToneReprCurve::Parametric(vec![1.0]); 3],
|
||||
matrix: Matrix3d { v: NamedSpace::Srgb.to_xyz_d50().0.map(|r| r.map(|v| v * 32768.0 / 65535.0)) },
|
||||
b_curves: vec![ToneReprCurve::Parametric(vec![1.0]); 3],
|
||||
bias: Vector3d::default(),
|
||||
}));
|
||||
profile.encode().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_tiff_nonfinite_cms_samples_match_clipped_samples() {
|
||||
let profile = lut_profile();
|
||||
let samples = [f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.25, 0.5, 0.75];
|
||||
let clipped = [1.0, 0.0, 0.0, 0.25, 0.5, 0.75];
|
||||
let decoded = decode(&tiff(&samples, 3, Some(&profile)), DecodeOptions::default()).unwrap();
|
||||
let reference = decode(&tiff(&clipped, 3, Some(&profile)), DecodeOptions::default()).unwrap();
|
||||
assert_eq!(decoded.space.origin, SpaceOrigin::IccCms);
|
||||
assert_eq!(decoded.image, reference.image);
|
||||
assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite()));
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#![cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
|
||||
|
||||
use lightcraft_codecs::exif::minimal_exif;
|
||||
use lightcraft_codecs::*;
|
||||
use lightcraft_raster::Rgba8;
|
||||
|
||||
fn codestream(bytes: &[u8]) -> Vec<u8> {
|
||||
if bytes.starts_with(b"\xff\x0a") {
|
||||
return bytes.to_vec();
|
||||
}
|
||||
let mut raw = Vec::new();
|
||||
let mut at = 12;
|
||||
while at + 8 <= bytes.len() {
|
||||
let len = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
|
||||
let end = if len == 0 { bytes.len() } else { at + len };
|
||||
match &bytes[at + 4..at + 8] {
|
||||
b"jxlc" => raw.extend_from_slice(&bytes[at + 8..end]),
|
||||
b"jxlp" => raw.extend_from_slice(&bytes[at + 12..end]),
|
||||
_ => {}
|
||||
}
|
||||
at = end;
|
||||
}
|
||||
assert!(raw.starts_with(b"\xff\x0a"));
|
||||
raw
|
||||
}
|
||||
|
||||
fn box_bytes(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
|
||||
out.extend_from_slice(&u32::try_from(data.len() + 8).unwrap().to_be_bytes());
|
||||
out.extend_from_slice(kind);
|
||||
out.extend_from_slice(data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_in_final_feed_matches_builder() {
|
||||
let pixels = Rgba8::from_fn(8, 8, |x, y| [(x * 20) as u8, (y * 20) as u8, 80, 255]);
|
||||
let encoded = encode_jxl(&EncodeImage::rgba8(&pixels), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
|
||||
let raw = codestream(&encoded);
|
||||
let exif = minimal_exif(1);
|
||||
let mut exif_box = vec![0; 4]; // TIFF header offset, relative to the end of this field
|
||||
exif_box.extend_from_slice(&exif);
|
||||
// Split the codestream across two feeds, with Exif and XMP following its last frame.
|
||||
let mut bytes = b"\0\0\0\x0cJXL \x0d\x0a\x87\x0a".to_vec();
|
||||
box_bytes(&mut bytes, b"ftyp", b"jxl \0\0\0\0jxl ");
|
||||
let split = raw.len() / 2;
|
||||
let mut part = 0u32.to_be_bytes().to_vec();
|
||||
part.extend_from_slice(&raw[..split]);
|
||||
box_bytes(&mut bytes, b"jxlp", &part);
|
||||
let padding = vec![0; 4096 - bytes.len() - 8];
|
||||
box_bytes(&mut bytes, b"free", &padding);
|
||||
let mut part = 0x8000_0001u32.to_be_bytes().to_vec();
|
||||
part.extend_from_slice(&raw[split..]);
|
||||
box_bytes(&mut bytes, b"jxlp", &part);
|
||||
box_bytes(&mut bytes, b"Exif", &exif_box);
|
||||
box_bytes(&mut bytes, b"xml ", b"<xmp/>");
|
||||
assert!(bytes.len() < 8192, "metadata must fit in the completing input chunk");
|
||||
let old = jxl_oxide::JxlImage::builder().read(bytes.as_slice()).unwrap();
|
||||
let old_exif = match old.aux_boxes().first_exif().unwrap() {
|
||||
jxl_oxide::AuxBoxData::Data(ex) => ex.payload().to_vec(),
|
||||
_ => panic!("builder must preserve Exif in its final feed"),
|
||||
};
|
||||
let old_xml = match old.aux_boxes().first_xml() {
|
||||
jxl_oxide::AuxBoxData::Data(xml) => xml.to_vec(),
|
||||
_ => panic!("builder must preserve XMP in its final feed"),
|
||||
};
|
||||
let decoded = decode(&bytes, DecodeOptions::default()).unwrap();
|
||||
assert_eq!(decoded.exif.as_deref(), Some(old_exif.as_slice()));
|
||||
assert_eq!(decoded.xmp.as_deref(), Some(std::str::from_utf8(&old_xml).unwrap()));
|
||||
assert_eq!(decoded.to_srgb8(), pixels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progressive_truncation_matches_builder() {
|
||||
let pixels = Rgba8::from_fn(96, 64, |x, y| {
|
||||
let mut seed = (y * 96 + x + 1) as u32;
|
||||
let mut rgb = [0; 4];
|
||||
for channel in &mut rgb[..3] {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
*channel = seed as u8;
|
||||
}
|
||||
rgb[3] = 255;
|
||||
rgb
|
||||
});
|
||||
for lossless in [false, true] {
|
||||
let encoded =
|
||||
encode_jxl(&EncodeImage::rgba8(&pixels), &JxlOptions { lossless, progressive: true, ..Default::default() }, &EncodeMeta::default())
|
||||
.unwrap();
|
||||
let raw = codestream(&encoded);
|
||||
assert!(raw.len() > 4096, "fixture must exercise post-header feeds");
|
||||
for cut in [0, 1, 2, 12, 4095, 4096, 4097, raw.len() / 2, raw.len() - 1, raw.len()] {
|
||||
let bytes = &raw[..cut];
|
||||
// `finalize` permits partial images: rendering determines whether frame 0 is usable.
|
||||
let old_ok = jxl_oxide::JxlImage::builder().read(bytes).is_ok_and(|image| image.render_frame(0).is_ok());
|
||||
assert_eq!(decode(bytes, DecodeOptions::default()).is_ok(), old_ok, "lossless={lossless}, cut={cut}/{}", raw.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#![cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
|
||||
|
||||
use lightcraft_codecs::*;
|
||||
use lightcraft_raster::Rgba8;
|
||||
|
||||
#[global_allocator]
|
||||
static ALLOC: dhat::Alloc = dhat::Alloc;
|
||||
|
||||
fn codestream(bytes: &[u8]) -> Vec<u8> {
|
||||
if bytes.starts_with(b"\xff\x0a") {
|
||||
return bytes.to_vec();
|
||||
}
|
||||
let mut raw = Vec::new();
|
||||
let mut at = 12;
|
||||
while at + 8 <= bytes.len() {
|
||||
let len = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
|
||||
let end = if len == 0 { bytes.len() } else { at + len };
|
||||
match &bytes[at + 4..at + 8] {
|
||||
b"jxlc" => raw.extend_from_slice(&bytes[at + 8..end]),
|
||||
b"jxlp" => raw.extend_from_slice(&bytes[at + 12..end]),
|
||||
_ => {}
|
||||
}
|
||||
at = end;
|
||||
}
|
||||
assert!(raw.starts_with(b"\xff\x0a"));
|
||||
raw
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_jxl_tail_does_not_allocate_a_tail_sized_buffer() {
|
||||
let image = Rgba8::from_fn(8, 8, |x, y| [(x * 20) as u8, (y * 20) as u8, 80, 255]);
|
||||
let encoded = encode_jxl(&EncodeImage::rgba8(&image), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
|
||||
let mut raw = codestream(&encoded);
|
||||
let opts = DecodeOptions { max_pixels: 64, ..Default::default() };
|
||||
decode(&raw, opts).unwrap();
|
||||
raw.resize(raw.len() + (1 << 20), 0);
|
||||
let _profiler = dhat::Profiler::builder().testing().build();
|
||||
let decoded = decode(&raw, opts).unwrap();
|
||||
assert_eq!((decoded.width, decoded.height), (8, 8));
|
||||
let peak = dhat::HeapStats::get().max_bytes;
|
||||
eprintln!("peak heap bytes: {peak}");
|
||||
assert!(peak < (128 << 10), "tiny image used {peak} heap bytes");
|
||||
}
|
||||
@@ -263,6 +263,90 @@ fn tiff_cmyk_and_orientation() {
|
||||
assert!(red[0] > 0.99 && red[1] < 0.01 && red[2] < 0.01, "{red:?}");
|
||||
}
|
||||
|
||||
/// An uncompressed 16-bit RGB export in a single strip, the shape of the file in #1983 (3923 × 5877,
|
||||
/// 138 MB). Its one strip is larger than the `tiff` crate's default 128 MiB intermediate buffer, so
|
||||
/// the thumbnail (and the full decode) failed with "decoder limits exceeded".
|
||||
#[test]
|
||||
fn tiff_single_strip_larger_than_128_mib() {
|
||||
use tiff::encoder::{TiffEncoder, colortype};
|
||||
let (w, h) = (3923u32, 5877u32);
|
||||
let mut cur = std::io::Cursor::new(Vec::new());
|
||||
{
|
||||
let mut enc = TiffEncoder::new(&mut cur).unwrap();
|
||||
let mut im = enc.new_image::<colortype::RGB16>(w, h).unwrap();
|
||||
im.rows_per_strip(h).unwrap();
|
||||
im.write_data(&vec![0x8000u16; (w * h * 3) as usize]).unwrap();
|
||||
}
|
||||
let bytes = cur.into_inner();
|
||||
assert!(bytes.len() > 128 << 20);
|
||||
let t = decode_thumbnail(&bytes, 256).unwrap();
|
||||
assert_eq!((t.source_width, t.source_height), (w, h));
|
||||
let px = t.image.get(10, 10);
|
||||
assert!(px[0].abs_diff(128) <= 2 && px[0] == px[1] && px[1] == px[2], "{px:?}");
|
||||
}
|
||||
|
||||
/// A little-endian TIFF with one IFD of single-value `(tag, type, value)` entries, followed by `data`.
|
||||
fn ifd_tiff(entries: &[(u16, u16, u32)], data: &[u8]) -> Vec<u8> {
|
||||
let mut b = b"II*\0".to_vec();
|
||||
b.extend(8u32.to_le_bytes());
|
||||
b.extend((entries.len() as u16).to_le_bytes());
|
||||
for &(tag, ty, value) in entries {
|
||||
b.extend(tag.to_le_bytes());
|
||||
b.extend(ty.to_le_bytes());
|
||||
b.extend(1u32.to_le_bytes());
|
||||
b.extend(value.to_le_bytes());
|
||||
}
|
||||
b.extend(0u32.to_le_bytes());
|
||||
b.extend_from_slice(data);
|
||||
b
|
||||
}
|
||||
|
||||
/// A strip or tile can be larger than the pixels it shows (a JPEG header, padding to the tile size), so
|
||||
/// a small `max_pixels` must not shrink the intermediate buffer below the `tiff` crate's 128 MiB
|
||||
/// default. Both files below were refused with "decoder limits exceeded" when the buffer was capped at
|
||||
/// 32 bytes per allowed pixel. Fixtures from AlexChambers' review of #1989.
|
||||
#[test]
|
||||
fn tiff_small_pixel_budget_reads_headers_and_padded_tiles() {
|
||||
let opts = |max_pixels| DecodeOptions { max_size: None, max_pixels };
|
||||
|
||||
// 1 × 1 gray, one JPEG-compressed strip (~400 bytes for one pixel), with a one-pixel budget.
|
||||
let mut jpeg = Vec::new();
|
||||
jpeg_encoder::Encoder::new(&mut jpeg, 90).encode(&[128], 1, 1, jpeg_encoder::ColorType::Luma).unwrap();
|
||||
let at = 8 + 2 + 10 * 12 + 4;
|
||||
let entries = [
|
||||
(256, 4, 1),
|
||||
(257, 4, 1),
|
||||
(258, 3, 8),
|
||||
(259, 3, 7),
|
||||
(262, 3, 1),
|
||||
(273, 4, at),
|
||||
(277, 3, 1),
|
||||
(278, 4, 1),
|
||||
(279, 4, jpeg.len() as u32),
|
||||
(284, 3, 1),
|
||||
];
|
||||
let d = decode(&ifd_tiff(&entries, &jpeg), opts(1)).unwrap();
|
||||
assert_eq!((d.image.width, d.image.height), (1, 1));
|
||||
|
||||
// 16 × 16 gray stored in one 256 × 256 tile (64 KiB), with a budget of exactly 256 pixels.
|
||||
let at = 8 + 2 + 11 * 12 + 4;
|
||||
let entries = [
|
||||
(256, 4, 16),
|
||||
(257, 4, 16),
|
||||
(258, 3, 8),
|
||||
(259, 3, 1),
|
||||
(262, 3, 1),
|
||||
(277, 3, 1),
|
||||
(284, 3, 1),
|
||||
(322, 4, 256),
|
||||
(323, 4, 256),
|
||||
(324, 4, at),
|
||||
(325, 4, 65536),
|
||||
];
|
||||
let d = decode(&ifd_tiff(&entries, &[128; 65536]), opts(256)).unwrap();
|
||||
assert_eq!((d.image.width, d.image.height), (16, 16));
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -734,6 +818,21 @@ fn jxl_lossless_exact_with_metadata() {
|
||||
assert!(max_diff(&d.image, &want.image) < 1e-5, "16-bit lossless differs by {}", max_diff(&d.image, &want.image));
|
||||
}
|
||||
|
||||
/// #1517: the pixel limit rejects an oversized image; selected truncations never panic.
|
||||
#[test]
|
||||
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
|
||||
fn jxl_size_limit_and_truncation() {
|
||||
let b = encode_jxl(&EncodeImage::rgba8(&gradient(97, 61)), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
|
||||
let r = decode(&b, DecodeOptions { max_pixels: 97 * 61 - 1, ..Default::default() });
|
||||
assert!(matches!(r, Err(Error::TooLarge(97, 61))), "{r:?}");
|
||||
let d = decode(&b, DecodeOptions { max_pixels: 97 * 61, ..Default::default() }).unwrap();
|
||||
assert_eq!((d.width, d.height), (97, 61));
|
||||
for cut in [0, 1, 2, 5, 12, 40, b.len() / 2, b.len() - 1] {
|
||||
let _ = decode(&b[..cut], DecodeOptions::default());
|
||||
}
|
||||
assert!(decode(&b[..12.min(b.len())], DecodeOptions::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_lossy_quality_and_effort() {
|
||||
|
||||
+11
-2
@@ -180,8 +180,8 @@ pub struct Rgb8(pub u8, pub u8, pub u8);
|
||||
|
||||
impl Rgb8 {
|
||||
pub fn from_hex(s: &str) -> Option<Rgb8> {
|
||||
let s = s.trim_start_matches('#');
|
||||
if s.len() != 6 {
|
||||
let s = s.strip_prefix('#').unwrap_or(s);
|
||||
if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let v = u32::from_str_radix(s, 16).ok()?;
|
||||
@@ -196,6 +196,15 @@ impl Rgb8 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rgb8_from_hex_rejects_signs_and_extra_hashes() {
|
||||
assert_eq!(Rgb8::from_hex("#0f0f0f"), Some(Rgb8(15, 15, 15)));
|
||||
assert_eq!(Rgb8::from_hex("ff8000"), Some(Rgb8(255, 128, 0)));
|
||||
for bad in ["+12345", "#+12345", "#+f+f+f", "-12345", "##123456", "#12345", ""] {
|
||||
assert_eq!(Rgb8::from_hex(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_matrix_matches_standard() {
|
||||
let m = SRGB.to_xyz();
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::manifest::Gain;
|
||||
use crate::tiles::{starts, weight};
|
||||
|
||||
/// Most pixels one picture may have (guards the allocations that follow from its size).
|
||||
pub const MAX_PIXELS: usize = 100_000_000;
|
||||
pub const MAX_PIXELS: usize = 110_000_000;
|
||||
/// A tile whose mean signal is below this (white = 1) is too dark to measure its scale from.
|
||||
const MIN_SIGNAL: f32 = 0.003;
|
||||
|
||||
@@ -642,4 +642,19 @@ mod tests {
|
||||
assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![0.0; 5]), &p, &Control::default()), Err(Error::Model(_))));
|
||||
assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![f32::NAN; 3 * 64 * 64]), &p, &Control::default()), Err(Error::Model(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_102_mp_inputs_and_rejects_inputs_above_the_limit() {
|
||||
let layout = Layout { dx: 0, dy: 0 };
|
||||
let model = Mock { tile: 32, scale: 1.0 };
|
||||
let p = params(32, 8, Gain::None);
|
||||
assert!(matches!(
|
||||
denoise_bayer(&[], 11_648, 8_736, layout, &model, &p, &Control::default()),
|
||||
Err(Error::Input(message)) if message == "the mosaic does not have width × height samples"
|
||||
));
|
||||
assert!(matches!(
|
||||
denoise_bayer(&[], MAX_PIXELS + 1, 1, layout, &model, &p, &Control::default()),
|
||||
Err(Error::Input(message)) if message == "the picture has no size, or is too large"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ impl DevelopSettings {
|
||||
/// Merge a partial JSON object into these settings (fields not mentioned are kept). A white balance that
|
||||
/// names no [`WbScale`] keeps the meaning it always had (`Legacy`), whatever scale these settings were on: a
|
||||
/// partial from before the scale existed, or one that never said, must not become Kelvin by landing on
|
||||
/// Kelvin settings.
|
||||
/// Kelvin settings. Out-of-range values are brought back into range ([`DevelopSettings::keep_in_range`]).
|
||||
pub fn merged(&self, partial: &Value) -> Result<DevelopSettings, serde_json::Error> {
|
||||
let mut v = self.to_json();
|
||||
presets::deep_merge(&mut v, partial);
|
||||
@@ -52,9 +52,34 @@ impl DevelopSettings {
|
||||
{
|
||||
out.wb.scale = WbScale::Legacy;
|
||||
}
|
||||
out.keep_in_range(self);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What crs XMP import does to the values it reads, for settings that arrive as JSON (presets, `develop.merge`,
|
||||
/// `render --settings`; issue #1946): every slider clamped through its control spec, point curves in 0..1, and a
|
||||
/// crop with no area left as it was in `before`. Unrotated, the crop is clipped to the photo first; a turned crop
|
||||
/// may reach past 0..1 and is left to the crop tools, which know the photo's size.
|
||||
fn keep_in_range(&mut self, before: &DevelopSettings) {
|
||||
for c in controls::CONTROLS {
|
||||
if let Some(v) = controls::get(self, c.id) {
|
||||
controls::set(self, c.id, v);
|
||||
}
|
||||
}
|
||||
let c = &mut self.curve;
|
||||
for p in [&mut c.master, &mut c.red, &mut c.green, &mut c.blue].into_iter().flatten() {
|
||||
*p = lightcraft_geom::Point::new(p.x.clamp(0.0, 1.0), p.y.clamp(0.0, 1.0));
|
||||
}
|
||||
let g = &mut self.crop.geometry;
|
||||
if g.angle == 0.0 {
|
||||
g.rect = g.rect.intersect(&lightcraft_geom::Rect::UNIT);
|
||||
}
|
||||
if g.rect.is_empty() {
|
||||
let old = before.crop.geometry.rect;
|
||||
g.rect = if old.is_empty() { lightcraft_geom::Rect::UNIT } else { old };
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable 64-bit hash of the settings (FNV-1a over canonical JSON), for preview cache keys.
|
||||
pub fn hash64(&self) -> u64 {
|
||||
let s = serde_json::to_string(self).unwrap_or_default();
|
||||
@@ -255,6 +280,30 @@ mod tests {
|
||||
assert_eq!(m.light.exposure, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_settings_are_kept_in_range() {
|
||||
// issue #1946: `develop.merge`, `render --settings` and presets stored whatever the JSON said
|
||||
let mut base = DevelopSettings::default();
|
||||
base.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.2, 0.8, 0.9);
|
||||
let m = base
|
||||
.merged(&json!({"light": {"exposure": 1e308}, "vignette": {"amount": -1e308}, "geometry": {"scale": 0}, "optics": {"distortion": 1e308}}))
|
||||
.unwrap();
|
||||
assert_eq!((m.light.exposure, m.vignette.amount, m.geometry.scale, m.optics.distortion), (5.0, -100.0, 50.0, 100.0));
|
||||
// point curves stay in 0..1, as an imported XMP curve does
|
||||
let m = base.merged(&json!({"curve": {"master": [{"x": -3, "y": 0}, {"x": 1, "y": 1e308}]}})).unwrap();
|
||||
assert_eq!(m.curve.master, vec![lightcraft_geom::Point::new(0.0, 0.0), lightcraft_geom::Point::new(1.0, 1.0)]);
|
||||
// a crop with no area is left out, as XMP import leaves out one whose edges are inverted
|
||||
for rect in
|
||||
[json!({"x0": 0.9, "x1": 0.1, "y0": 0, "y1": 1}), json!({"x0": 1e308, "x1": -1e308, "y0": 0, "y1": 1}), json!({"x0": 0.5, "x1": 0.5})]
|
||||
{
|
||||
let m = base.merged(&json!({"crop": {"geometry": {"rect": rect}}})).unwrap();
|
||||
assert_eq!(m.crop.geometry.rect, base.crop.geometry.rect, "{rect}");
|
||||
}
|
||||
// unrotated, the crop stays inside the photo
|
||||
let m = base.merged(&json!({"crop": {"geometry": {"rect": {"x0": -1e308, "y0": 0.25, "x1": 1e308, "y1": 0.75}}}})).unwrap();
|
||||
assert_eq!(m.crop.geometry.rect, lightcraft_geom::Rect::new(0.0, 0.25, 1.0, 0.75));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unedited_and_reset() {
|
||||
let mut s = DevelopSettings::default();
|
||||
|
||||
@@ -184,17 +184,11 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value {
|
||||
}
|
||||
|
||||
/// Apply a partial settings object with an amount (1.0 = 100 %). Out-of-range values are clamped
|
||||
/// through the control specs.
|
||||
/// through the control specs, as every merge does ([`DevelopSettings::merged`]).
|
||||
pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings {
|
||||
let patch = if (amount - 1.0).abs() < 1e-9 { Cow::Borrowed(partial) } else { Cow::Owned(scale_patch(&s.to_json(), partial, amount)) };
|
||||
// (a white balance without a scale keeps its old meaning: `DevelopSettings::merged`)
|
||||
let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone());
|
||||
for c in crate::controls::CONTROLS {
|
||||
if let Some(v) = crate::controls::get(&out, c.id) {
|
||||
crate::controls::set(&mut out, c.id, v);
|
||||
}
|
||||
}
|
||||
out
|
||||
s.merged(&patch).unwrap_or_else(|_| s.clone())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -9,6 +9,8 @@ description = "LightCraft engine: session, command registry, history, render job
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
# Paths stored in a Lightroom catalog are followed only to local files or the catalog's share.
|
||||
craft-docpath = { workspace = true }
|
||||
lightcraft-geom = { workspace = true }
|
||||
lightcraft-color = { workspace = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Recipe from craft-fonts' docs/integration.md; craft-fonts is never a Cargo dependency, and this
|
||||
//! script only reads a local directory (no network).
|
||||
//!
|
||||
//! wasm32 embeds BIZ UDPGothic Regular (~4.5 MB) and Noto Sans Thai (~214 KiB), to keep the
|
||||
//! wasm32 embeds BIZ UDPGothic Regular (~4.5 MB) and Noto Sans Thai (~214 KiB) and Noto Sans Georgian, to keep the
|
||||
//! `.wasm` within the web size budget (Cloudflare serves at most 25 MiB per file); the export
|
||||
//! watermark falls back to it there. Native builds embed every font.
|
||||
use std::fmt::Write as _;
|
||||
@@ -78,7 +78,7 @@ fn craft_fonts(dir: &Path, required: bool) -> Result<String, String> {
|
||||
has_japanese_ui |= (*family, *style) == ("BIZ UDPGothic", "Regular");
|
||||
has_chinese_ui |= (*family, *style) == ("Noto Sans CJK SC", "Regular") && scripts.split(',').any(|script| script.trim() == "Hans");
|
||||
has_thai_ui |= (*family, *style) == ("Noto Sans Thai", "Regular") && scripts.split(',').any(|script| script.trim() == "Thai");
|
||||
if wasm && !matches!((*family, *style), ("BIZ UDPGothic" | "Noto Sans Thai", "Regular")) {
|
||||
if wasm && !matches!((*family, *style), ("BIZ UDPGothic" | "Noto Sans Thai" | "Noto Sans Georgian", "Regular")) {
|
||||
continue;
|
||||
}
|
||||
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! What a Synchronize Folder scan costs on a folder whose photos are all in the library and
|
||||
//! unchanged: the walk, and the check of every photo and its sidecar. Reads the folder; writes
|
||||
//! nothing there, and probes no file (the library is made from the folder's own listing).
|
||||
//!
|
||||
//! `cargo run --release -p lightcraft-engine --example sync_scan_bench -- <folder>`
|
||||
use std::time::Instant;
|
||||
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
use lightcraft_engine::Session;
|
||||
use lightcraft_engine::sync::{SyncInput, SyncProgress, scan_with};
|
||||
|
||||
fn main() {
|
||||
let Some(folder) = std::env::args().nth(1) else {
|
||||
eprintln!("usage: sync_scan_bench <folder>");
|
||||
return;
|
||||
};
|
||||
let mut s = Session::new();
|
||||
let t = Instant::now();
|
||||
let files = lightcraft_engine::import::expand(std::slice::from_ref(&folder), None);
|
||||
println!("{} files listed in {:.1} s (the library is made of them)", files.len(), t.elapsed().as_secs_f64());
|
||||
for f in &files {
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let name = std::path::Path::new(f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let p = Photo::new(id, Source::File { path: f.clone() }, &name, "JPEG", 6, 4, "2026-01-01T00:00:00");
|
||||
if s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).is_err() {
|
||||
eprintln!("could not add {name}");
|
||||
}
|
||||
}
|
||||
for run in 0..2 {
|
||||
let input = match SyncInput::new(&mut s, &folder, true) {
|
||||
Ok(input) => input,
|
||||
Err(e) => {
|
||||
eprintln!("{e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let t = Instant::now();
|
||||
let changes = scan_with(input, &SyncProgress::default());
|
||||
println!(
|
||||
"run {run}: scan {:.1} s ({} new, {} missing, {} with sidecar news)",
|
||||
t.elapsed().as_secs_f64(),
|
||||
changes.new.len(),
|
||||
changes.missing.len(),
|
||||
changes.metadata.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,8 @@ use lightcraft_raw::{
|
||||
/// 7: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected.
|
||||
/// 8: the bundled NIKON Z 6_2 profile.
|
||||
/// 9: the bundled NIKON Z 8 and NIKON D5300 profiles.
|
||||
pub const LOOK_VERSION: u32 = 9;
|
||||
/// 10: DNG tone fit for profiles with a look table and for 1024 px (Lightroom) previews.
|
||||
pub const LOOK_VERSION: u32 = 10;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct CameraLook {
|
||||
@@ -107,14 +108,13 @@ fn identity_tone_curve(curve: Option<&lightcraft_raw::profile::ToneCurve>) -> bo
|
||||
}
|
||||
|
||||
/// A DNG with its own colour model whose tone is not (yet) defined by the file: a matrix, no
|
||||
/// non-identity `ProfileToneCurve`, no `LookTable` (a look table is the file's own rendering).
|
||||
/// non-identity `ProfileToneCurve` and no `ProfileGainTableMap`. A `LookTable` does not define the tone: an
|
||||
/// Adobe Standard profile carries one without a tone curve and is rendered with Lightroom's default curve on
|
||||
/// top (Lightroom Classic's lossy DNGs rendered flat and dull without a fit), and the fit applies the tables
|
||||
/// to its proxy, so it fits only the tone around them.
|
||||
pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool {
|
||||
let p = &raw.color.profile;
|
||||
raw.format == RawFormat::Dng
|
||||
&& !t.matrix_is_fallback
|
||||
&& identity_tone_curve(p.tone_curve.as_ref())
|
||||
&& p.look_table.is_none()
|
||||
&& p.gain_table_map.is_none()
|
||||
raw.format == RawFormat::Dng && !t.matrix_is_fallback && identity_tone_curve(p.tone_curve.as_ref()) && p.gain_table_map.is_none()
|
||||
}
|
||||
|
||||
/// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white
|
||||
@@ -424,9 +424,9 @@ const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000;
|
||||
|
||||
/// A DNG's tone and chroma curves fitted to its own camera JPEG ([`dng_tone_eligible`]); the file's colour model
|
||||
/// (matrix, profile tables, white balance) stays as it is: the proxy goes through the file's tables and the given
|
||||
/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must be near
|
||||
/// the raw's size, show the same picture ([`structure_agrees`]), and the curve must not clip more highlights than
|
||||
/// the plain render ([`clips_more`]).
|
||||
/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must not be a
|
||||
/// thumbnail ([`MIN_PREVIEW_FRACTION`]), must show the same picture ([`structure_agrees`]), and the curve must not
|
||||
/// clip more highlights than the plain render ([`clips_more`]).
|
||||
fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&lightcraft_develop::EmbeddedLens>) -> Option<CameraLook> {
|
||||
// Logged under LIGHTCRAFT_PROFILE so a corpus run can count the outcome per gate.
|
||||
let refuse = |why: &str| {
|
||||
@@ -500,8 +500,9 @@ fn correct_vignetting(sensor: &mut Rgb32f, lens: Option<&lightcraft_develop::Emb
|
||||
}
|
||||
|
||||
/// The embedded preview's long edge must be at least this share of the raw's (and 384 px): a DNG whose preview is a
|
||||
/// small thumbnail is not fitted.
|
||||
const MIN_PREVIEW_FRACTION: f64 = 0.9;
|
||||
/// small thumbnail is not fitted. The fit itself runs on a proxy of at most 384 px, so the 1024 px preview
|
||||
/// Lightroom writes into its DNGs (15% of a 33 MP raw's long edge) is plenty.
|
||||
const MIN_PREVIEW_FRACTION: f64 = 0.1;
|
||||
|
||||
/// Lowest preview luminance and channel value a tone-only fit trains on (the other fits use 0.015 and 0.004): a dark
|
||||
/// night scene has most of its pixels below 0.015, and a curve fitted without them overshoots its shadows.
|
||||
@@ -3959,7 +3960,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Which DNGs get a tone fitted to their camera JPEG: a colour matrix, a tone curve that is absent or the
|
||||
/// identity, no look table; and no other raw format.
|
||||
/// identity, with or without a look table; and no other raw format.
|
||||
#[test]
|
||||
fn dng_tone_gates_follow_the_files_own_tags() {
|
||||
use lightcraft_raw::profile::{HsvTable, ToneCurve};
|
||||
@@ -3981,6 +3982,15 @@ mod tests {
|
||||
assert!(curved.profile.tone_curve.is_some() && !eligible(RawFormat::Dng, curved), "the file's own tone curve is kept");
|
||||
let mut looked = color;
|
||||
looked.profile.look_table = HsvTable::from_tags(&[1, 2, 1], &[0.0, 1.0, 1.0, 0.0, 1.0, 1.0], 0);
|
||||
assert!(looked.profile.look_table.is_some() && !eligible(RawFormat::Dng, looked), "a look table is the file's own rendering");
|
||||
assert!(looked.profile.look_table.is_some() && eligible(RawFormat::Dng, looked), "a look table defines colour, not tone");
|
||||
}
|
||||
|
||||
/// The 1024 px preview Lightroom Classic writes into a 7008 px DNG is fitted; a 256 px thumbnail is not.
|
||||
#[test]
|
||||
fn dng_tone_fit_takes_lightroom_sized_previews() {
|
||||
let floor = |raw_long: f64| (MIN_PREVIEW_FRACTION * raw_long).max(384.0);
|
||||
assert!(1024.0 >= floor(7008.0));
|
||||
assert!(256.0 < floor(7008.0));
|
||||
assert!(1024.0 >= floor(9504.0), "a 61 MP raw too");
|
||||
}
|
||||
}
|
||||
@@ -313,30 +313,15 @@ pub(crate) fn reload_ops(
|
||||
}
|
||||
|
||||
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
|
||||
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
|
||||
/// dimensions and content hash, cached sources dropped. The app runs the same steps
|
||||
/// ([`crate::cmd::reload`]) with the files read on a worker thread. → {reloaded: [ids]}
|
||||
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = s.targets(p)?;
|
||||
let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids
|
||||
.iter()
|
||||
.filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) {
|
||||
Some(Source::File { path }) => Some((*id, path)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let probed = crate::import::probe_paths(s, &paths.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>());
|
||||
let mut ops = Vec::new();
|
||||
let mut reloaded = Vec::new();
|
||||
for ((id, _), info) in paths.into_iter().zip(probed) {
|
||||
let Ok(info) = info else { continue };
|
||||
reload_ops(&s.catalog, id, &info, &mut ops, &mut reloaded);
|
||||
}
|
||||
for id in &reloaded {
|
||||
s.media.forget(*id);
|
||||
}
|
||||
if !ops.is_empty() {
|
||||
s.commit("Reload Changed Files", Op::Batch { ops })?;
|
||||
}
|
||||
Ok(json!({"reloaded": reloaded.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
use crate::cmd::reload as r;
|
||||
let items = r::plan(s, p)?;
|
||||
let probe = r::file_probe(s);
|
||||
let results = r::probe(probe.as_ref(), &items, &std::sync::atomic::AtomicBool::new(false), &|_| {});
|
||||
let reloaded = r::apply(s, items, results)?;
|
||||
Ok(r::to_json(&reloaded))
|
||||
}
|
||||
|
||||
/// Duplicate: copy the file next to itself (`<name>-copy`, never overwriting) and add it with the
|
||||
@@ -401,7 +386,7 @@ pub fn edit_specs() -> Vec<CommandSpec> {
|
||||
"Reload from Disk",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings) → {reloaded}",
|
||||
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings); in the app the files are read in the background (activity row `reload`, cancellable) and the reply is {background: true, count} unless `wait: true` → {reloaded}",
|
||||
has_selection,
|
||||
reload
|
||||
),
|
||||
|
||||
@@ -16,7 +16,7 @@ const SAME: f32 = 0.93;
|
||||
|
||||
fn secs(iso: &str) -> Option<i64> {
|
||||
let d = lightcraft_meta::DateTime::parse_iso(iso)?;
|
||||
Some(((d.year as i64 * 372 + d.month as i64 * 31 + d.day as i64) * 24 + d.hour as i64) * 3600 + d.minute as i64 * 60 + d.second as i64)
|
||||
Some(d.unix_seconds())
|
||||
}
|
||||
|
||||
fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -77,13 +77,22 @@ fn aspect_tuple(c: &str, x: f64, y: f64) -> Result<(u32, u32)> {
|
||||
Ok((a as u32, b as u32))
|
||||
}
|
||||
|
||||
fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>) {
|
||||
/// How a new aspect ratio is turned (issue #2253).
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Turn {
|
||||
/// As the crop is now: a named ratio ("2x3", "original"…) lands landscape on a landscape crop
|
||||
/// and portrait on a portrait one (after X), as in Lightroom. A square crop follows the image.
|
||||
LikeCrop,
|
||||
/// As given: a custom `[w, h]` taller than wide is a portrait crop.
|
||||
AsGiven,
|
||||
}
|
||||
|
||||
fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>, turn: Turn) {
|
||||
let r = d.crop.geometry.rect_px(w, h);
|
||||
let landscape = if (r.width() - r.height()).abs() > 1e-6 * r.width().max(r.height()) { r.width() > r.height() } else { w >= h };
|
||||
let aspect = aspect.map(|(aw, ah)| if turn == Turn::LikeCrop && (aw >= ah) != landscape { (ah, aw) } else { (aw, ah) });
|
||||
d.crop.aspect = aspect;
|
||||
let a = aspect.map(|(aw, ah)| {
|
||||
// orientation of the crop follows the image (landscape image → landscape crop)
|
||||
let (aw, ah) = (aw as f64, ah as f64);
|
||||
if (w >= h) == (aw >= ah) { aw / ah } else { ah / aw }
|
||||
});
|
||||
let a = aspect.map(|(aw, ah)| aw as f64 / ah as f64);
|
||||
let angle = d.crop.geometry.angle;
|
||||
d.crop.geometry = crop_fit_angle(w, h, angle, a);
|
||||
}
|
||||
@@ -628,11 +637,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Crop Aspect",
|
||||
[],
|
||||
None,
|
||||
"{aspect: \"original\"|\"free\"|\"current\" (lock the crop's present shape)|\"toggle\" (lock ↔ free)|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h]}",
|
||||
"{aspect: \"original\"|\"free\"|\"current\" (lock the crop's present shape)|\"toggle\" (lock ↔ free)|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h], orientation?: \"crop\"|\"given\"} — a named ratio takes the crop's present orientation (landscape, or portrait after X); [w,h] is used as given (taller than wide → portrait); `orientation` overrides either",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let id = active(s, "crop.aspect")?;
|
||||
let (w, h) = crop_dims(s, id);
|
||||
// checked first, whatever the aspect: a bad value is an error on every path
|
||||
let turn = match p.get("orientation") {
|
||||
Some(Value::String(o)) if o == "crop" => Turn::LikeCrop,
|
||||
Some(Value::String(o)) if o == "given" => Turn::AsGiven,
|
||||
Some(_) => return Err(bad("crop.aspect", "orientation is \"crop\" or \"given\"")),
|
||||
None if matches!(p.get("aspect"), Some(Value::Array(_))) => Turn::AsGiven,
|
||||
None => Turn::LikeCrop,
|
||||
};
|
||||
let locked = s.develop_of(id).is_some_and(|d| d.crop.aspect.is_some());
|
||||
let lock_current = match p.get("aspect").and_then(Value::as_str) {
|
||||
Some("current") => true,
|
||||
@@ -667,7 +684,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if aspect.is_none() {
|
||||
d.crop.aspect = None;
|
||||
} else {
|
||||
set_aspect(d, w, h, aspect);
|
||||
set_aspect(d, w, h, aspect, turn);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
@@ -1103,11 +1120,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, p| {
|
||||
let c = "history.restore";
|
||||
let id = active(s, c)?;
|
||||
let i = f64_req(p, "index", c)?;
|
||||
if !(i.is_finite() && i >= 0.0) {
|
||||
return Err(bad(c, "no such step"));
|
||||
}
|
||||
go_to_history_step(s, c, id, i as usize)
|
||||
let i = index_req(p, "index", c)?;
|
||||
go_to_history_step(s, c, id, i)
|
||||
}
|
||||
),
|
||||
cmd!("history.previous", "Previous History Step", [], None, "{} — steps the History cursor back one step", has_history_before, |s, _| {
|
||||
|
||||
@@ -17,6 +17,12 @@ impl Session {
|
||||
/// `app.export` params with a named `preset` expanded: the preset's params, overridden by the
|
||||
/// call's own (`preset` itself removed). Unknown preset names are an error.
|
||||
pub fn export_params(&self, p: &Value) -> std::result::Result<Value, String> {
|
||||
self.export_params_or(p, crate::export::ExportFormat::default())
|
||||
}
|
||||
|
||||
/// [`Session::export_params`] for a caller that writes `fallback` to a `path` naming no format
|
||||
/// (see [`crate::export::ExportOptions::export_format`]).
|
||||
pub fn export_params_or(&self, p: &Value, fallback: crate::export::ExportFormat) -> std::result::Result<Value, String> {
|
||||
let Some(name) = p.get("preset").and_then(Value::as_str) else { return Ok(p.clone()) };
|
||||
let (preset, _) = self
|
||||
.all_export_presets()
|
||||
@@ -24,12 +30,17 @@ impl Session {
|
||||
.find(|(x, _)| x.name.eq_ignore_ascii_case(name.trim()))
|
||||
.ok_or_else(|| format!("unknown export preset `{name}` (see export.presets)"))?;
|
||||
// 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);
|
||||
let mut out = crate::export::ExportOptions::known_keys(&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());
|
||||
}
|
||||
}
|
||||
// the preset's depth is judged against the format written, known only now (`path`); a
|
||||
// depth the call passes itself is the caller's, and validated as such
|
||||
if !p.get("bitDepth").is_some_and(|d| !d.is_null()) {
|
||||
crate::export::ExportOptions::drop_unfit_depth(&mut out, fallback);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
@@ -50,13 +61,25 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut params = match p.get("params") {
|
||||
Some(v @ Value::Object(_)) => v.clone(),
|
||||
Some(_) => return Err(bad(ID, "`params` must be an object of app.export params")),
|
||||
None => s.last_export.clone().ok_or_else(|| bad(ID, "no `params` and no previous export to save"))?,
|
||||
// a stored setting, not the caller's typo: a depth it holds that its `format` lacks was
|
||||
// written before #1952 and is dropped, as Export with Previous does; without `format`
|
||||
// it is judged when the preset exports
|
||||
None => {
|
||||
let last = s.last_export.as_ref().ok_or_else(|| bad(ID, "no `params` and no previous export to save"))?;
|
||||
let mut last = crate::export::ExportOptions::known_keys(last);
|
||||
if last.get("format").is_some_and(Value::is_string) {
|
||||
crate::export::ExportOptions::drop_unfit_depth(&mut last, Default::default());
|
||||
}
|
||||
last
|
||||
}
|
||||
};
|
||||
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)?;
|
||||
// a preset that app.export would refuse is refused here, where the typo is on screen; without
|
||||
// `format`, its `bitDepth` is judged when it exports, against the format written then
|
||||
let format = params.get("format").and_then(Value::as_str).map(|_| crate::export::ExportOptions::export_format(¶ms, Default::default()));
|
||||
crate::export::ExportOptions::validate_as(ID, ¶ms, format)?;
|
||||
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,
|
||||
|
||||
@@ -17,7 +17,7 @@ use lightcraft_faces::yunet::{Detector, Face, Options};
|
||||
use lightcraft_meta::{Region, RegionKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_selection};
|
||||
use super::{CommandSpec, always, bad, cmd, has_selection};
|
||||
use crate::{AiModel, EngineError, Result, Session};
|
||||
|
||||
const C: &str = "faces.detect";
|
||||
@@ -54,10 +54,12 @@ impl NoDetector {
|
||||
}
|
||||
}
|
||||
|
||||
/// The YuNet loaded last, and from where.
|
||||
static LOADED: Mutex<Option<(PathBuf, Arc<Detector>)>> = Mutex::new(None);
|
||||
|
||||
/// The installed YuNet, loaded once and kept (a new one is loaded when the model folder changes).
|
||||
/// (also used by the background scan, which aligns faces by the detector's landmarks and finds faces in photos that have none)
|
||||
pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, NoDetector> {
|
||||
static LOADED: Mutex<Option<(PathBuf, Arc<Detector>)>> = Mutex::new(None);
|
||||
let home = s
|
||||
.face_models_dir
|
||||
.as_deref()
|
||||
@@ -82,10 +84,133 @@ pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, NoDete
|
||||
let d = Arc::new(
|
||||
Detector::new(&bytes).map_err(|e| NoDetector::Unusable(format!("{e}: remove the detector in Settings > Faces and download it again")))?,
|
||||
);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let net = d.clone();
|
||||
offer_gpu(s, "detection", move |gpu| net.accelerate(gpu, true));
|
||||
}
|
||||
*loaded = Some((path, d.clone()));
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Where a face model runs, for Settings ▸ Faces and `faces.gpu`.
|
||||
#[derive(Clone, Debug, Default, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GpuState {
|
||||
/// The background check is still running.
|
||||
pub checking: bool,
|
||||
/// The GPU it runs on; `None` when it runs on the CPU.
|
||||
pub device: Option<String>,
|
||||
/// Why it runs on the CPU.
|
||||
pub why: Option<String>,
|
||||
}
|
||||
|
||||
/// Each model's [`GpuState`], by what it does ("detection", "recognition").
|
||||
static GPU_STATE: Mutex<Vec<(&'static str, GpuState)>> = Mutex::new(Vec::new());
|
||||
|
||||
pub(crate) fn gpu_state(what: &str) -> Option<GpuState> {
|
||||
GPU_STATE.lock().unwrap_or_else(PoisonError::into_inner).iter().find(|(w, _)| *w == what).map(|(_, st)| st.clone())
|
||||
}
|
||||
|
||||
fn set_gpu_state(what: &'static str, st: GpuState) {
|
||||
let mut all = GPU_STATE.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
match all.iter_mut().find(|(w, _)| *w == what) {
|
||||
Some((_, old)) => *old = st,
|
||||
None => all.push((what, st)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The user chose the CPU for face models (Settings ▸ Faces).
|
||||
pub(crate) fn cpu_only(s: &Session) -> bool {
|
||||
s.face_models_dir.as_deref().is_some_and(|d| super::face_models::read_settings(d).cpu_only)
|
||||
}
|
||||
|
||||
/// Check in the background whether the GPU runs a face model faster than the CPU and gives its results; the model
|
||||
/// runs on the CPU meanwhile, and stays there when the GPU is off, missing, wrong or slower, or the user chose the CPU.
|
||||
/// The check shows in the activity stack while it runs.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn offer_gpu(
|
||||
s: &Session,
|
||||
what: &'static str,
|
||||
attach: impl FnOnce(Arc<dyn lightcraft_faces::net::Accelerator>) -> std::result::Result<(), String> + Send + 'static,
|
||||
) {
|
||||
if cpu_only(s) {
|
||||
set_gpu_state(what, GpuState { why: Some("the CPU is chosen in Settings".into()), ..GpuState::default() });
|
||||
return;
|
||||
}
|
||||
let task = s.activity.start("facesGpu", "Checking the GPU for faces", crate::activity::Cancel::No);
|
||||
task.detail(what);
|
||||
set_gpu_state(what, GpuState { checking: true, ..GpuState::default() });
|
||||
let spawned = std::thread::Builder::new().name(format!("faces-gpu-{what}")).spawn(move || {
|
||||
let _task = task;
|
||||
let gpu = match lightcraft_gpu::faces::accelerator() {
|
||||
Ok(gpu) => gpu,
|
||||
Err(why) => {
|
||||
log::info!("faces: {what} runs on the CPU ({why})");
|
||||
return set_gpu_state(what, GpuState { why: Some(why), ..GpuState::default() });
|
||||
}
|
||||
};
|
||||
let device = lightcraft_faces::net::Accelerator::device(&*gpu);
|
||||
match attach(gpu) {
|
||||
Ok(()) => {
|
||||
log::info!("faces: {what} runs on {device}");
|
||||
set_gpu_state(what, GpuState { device: Some(device), ..GpuState::default() });
|
||||
}
|
||||
Err(why) => {
|
||||
log::info!("faces: {what} stays on the CPU: {why}");
|
||||
set_gpu_state(what, GpuState { why: Some(why), ..GpuState::default() });
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("faces: could not check the GPU for {what}: {e}");
|
||||
set_gpu_state(what, GpuState { why: Some(format!("could not check the GPU: {e}")), ..GpuState::default() });
|
||||
}
|
||||
}
|
||||
|
||||
/// `faces.gpu`: where the face models run, and the switch that keeps them on the CPU.
|
||||
fn gpu(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.gpu";
|
||||
if let Some(cpu) = p.get("cpuOnly") {
|
||||
let cpu = cpu.as_bool().ok_or_else(|| bad(C, "`cpuOnly` is true or false"))?;
|
||||
let dir = s.face_models_dir.clone().ok_or_else(|| bad(C, "this build has nowhere to keep face models (the desktop app does)"))?;
|
||||
super::face_models::set_cpu_only(&dir, cpu)?;
|
||||
let detector = LOADED.lock().unwrap_or_else(PoisonError::into_inner).as_ref().map(|(_, d)| d.clone());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let embedder = s.faces.embedder.as_ref().map(|(_, e)| e.clone());
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let embedder: Option<Arc<lightcraft_faces::runtime::Embedder>> = None;
|
||||
if cpu {
|
||||
for (what, net) in [("detection", detector.as_ref().map(|d| d.net())), ("recognition", embedder.as_ref().map(|e| e.net()))] {
|
||||
if let Some(net) = net {
|
||||
net.detach();
|
||||
}
|
||||
set_gpu_state(what, GpuState { why: Some("the CPU is chosen in Settings".into()), ..GpuState::default() });
|
||||
}
|
||||
} else {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if let Some(d) = detector {
|
||||
offer_gpu(s, "detection", move |gpu| d.accelerate(gpu, true));
|
||||
}
|
||||
if let Some(e) = embedder {
|
||||
offer_gpu(s, "recognition", move |gpu| e.accelerate(gpu, true).map_err(|e| e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(gpu_json(s))
|
||||
}
|
||||
|
||||
/// What `faces.gpu` and `faces.models.list` say about the GPU.
|
||||
pub(crate) fn gpu_json(s: &Session) -> Value {
|
||||
json!({
|
||||
"cpuOnly": cpu_only(s),
|
||||
"detection": gpu_state("detection"),
|
||||
"recognition": gpu_state("recognition"),
|
||||
})
|
||||
}
|
||||
|
||||
fn face_json(f: &Face) -> Value {
|
||||
json!({
|
||||
"rect": {"x0": f.x0, "y0": f.y0, "x1": f.x1, "y1": f.y1},
|
||||
@@ -173,15 +298,26 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"faces.detect",
|
||||
"Detect Faces",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched",
|
||||
has_selection,
|
||||
detect
|
||||
)]
|
||||
vec![
|
||||
cmd!(
|
||||
"faces.detect",
|
||||
"Detect Faces",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched",
|
||||
has_selection,
|
||||
detect
|
||||
),
|
||||
cmd!(
|
||||
query "faces.gpu",
|
||||
"Face Models on the GPU",
|
||||
[],
|
||||
None,
|
||||
"{cpuOnly?: bool} → {cpuOnly, detection, recognition}: where the face models run, each as {checking, device, why} (null until it is loaded). The GPU is used only after a background check shows it gives the CPU's results faster; `cpuOnly: true` keeps every face model on the CPU (Settings ▸ Faces), `false` checks the GPU again",
|
||||
always,
|
||||
gpu
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -27,6 +27,9 @@ pub(super) struct FaceSettings {
|
||||
pub(super) enabled: bool,
|
||||
/// The installed recogniser in use.
|
||||
pub(super) embedder: Option<String>,
|
||||
/// Keep every face model on the CPU, even when the GPU would be faster.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub(super) cpu_only: bool,
|
||||
}
|
||||
|
||||
fn fail(what: &str, e: impl std::fmt::Display) -> EngineError {
|
||||
@@ -57,6 +60,13 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Keep the face models on the CPU (or let them use the GPU again).
|
||||
pub(super) fn set_cpu_only(dir: &Path, on: bool) -> Result<()> {
|
||||
let mut st = read_settings(dir);
|
||||
st.cpu_only = on;
|
||||
write_settings(dir, &st)
|
||||
}
|
||||
|
||||
fn write_settings(dir: &Path, st: &FaceSettings) -> Result<()> {
|
||||
std::fs::create_dir_all(dir).map_err(|e| fail("could not create the face models folder", e))?;
|
||||
write_atomic(&dir.join("settings.json"), &serde_json::to_vec_pretty(st).map_err(|e| fail("settings", e))?)
|
||||
@@ -195,6 +205,8 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
"dir": dir.as_ref().map(|d| d.display().to_string()),
|
||||
"enabled": settings.enabled,
|
||||
"embedder": settings.embedder,
|
||||
// where the models run: {cpuOnly, detection, recognition} as `faces.gpu` gives it
|
||||
"gpu": super::face_detect::gpu_json(s),
|
||||
// Native builds always have the pure-Rust runner; model files/background scanning remain native-only.
|
||||
"runtime": cfg!(not(target_arch = "wasm32")),
|
||||
"models": models,
|
||||
|
||||
@@ -321,6 +321,8 @@ mod imp {
|
||||
return Ok(e.clone());
|
||||
}
|
||||
let embedder = Arc::new(Embedder::load(&dir.join(&id).join("model.onnx"), &installed.manifest).map_err(|e| bad(cmd, e.to_string()))?);
|
||||
let net = embedder.clone();
|
||||
super::super::face_detect::offer_gpu(s, "recognition", move |gpu| net.accelerate(gpu, true).map_err(|e| e.to_string()));
|
||||
let dim = match installed.manifest.output {
|
||||
lightcraft_faces::OutputSpec::Embedding { dim } => dim as usize,
|
||||
_ => return Err(bad(cmd, "the chosen model is not a recognition model")),
|
||||
@@ -672,6 +674,9 @@ mod imp {
|
||||
if let Some(w) = &s.faces.worker {
|
||||
w.allow(target, pace);
|
||||
}
|
||||
// the GPU is held back the same way: a light pace leaves it mostly free for the window's own renders
|
||||
let (runs, share) = crate::faces_worker::gpu_pace(pace);
|
||||
lightcraft_gpu::faces::set_pace(runs, share);
|
||||
while target > 0 && s.faces.in_flight.len() < target + PREFETCH {
|
||||
let Some(id) = s.faces.queue.pop() else { break };
|
||||
let Some(job) = prepare(s, id, true) else { continue };
|
||||
|
||||
@@ -115,6 +115,12 @@ fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place,
|
||||
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "metadata.reverseGeocode";
|
||||
let overwrite = bool_or(p, "overwrite", false);
|
||||
// the place list holds towns of 15 000 people or more: a photo in a smaller one finds the nearest
|
||||
// larger town, which may be another municipality; past this many metres City is left empty (#2242)
|
||||
let max_distance = match p.get("maxDistance") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_f64().filter(|m| m.is_finite() && *m > 0.0).ok_or_else(|| bad(C, "`maxDistance` must be a positive number of metres"))?),
|
||||
};
|
||||
let ids = s.targets(p)?;
|
||||
let todo: Vec<(PhotoId, LatLon)> = ids
|
||||
.iter()
|
||||
@@ -140,14 +146,24 @@ fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
t.progress(n as u64, todo.len() as u64);
|
||||
}
|
||||
let Some(place) = names.reverse(*at) else { continue };
|
||||
let Some(mut place) = names.reverse(*at) else { continue };
|
||||
// too far from the town found: State and Country still hold, City (and below) doesn't
|
||||
let too_far = max_distance.is_some_and(|m| place.distance > m);
|
||||
let nearest_city = too_far.then(|| std::mem::take(&mut place.city));
|
||||
if too_far {
|
||||
place.sublocation.clear();
|
||||
}
|
||||
let Some(ph) = s.catalog.photo(*id) else { continue };
|
||||
let mut meta = ph.meta.clone();
|
||||
fill(&mut meta, &place, overwrite);
|
||||
if meta != ph.meta {
|
||||
ops.push(Op::SetMeta { id: *id, meta: Box::new(meta) });
|
||||
}
|
||||
found.push(json!({"id": id.0, "place": place}));
|
||||
let mut entry = json!({"id": id.0, "place": place});
|
||||
if let Some(city) = nearest_city {
|
||||
entry["nearestCity"] = json!(city);
|
||||
}
|
||||
found.push(entry);
|
||||
}
|
||||
drop(task);
|
||||
let changed = ops.len();
|
||||
@@ -271,7 +287,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill Location from GPS",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?, overwrite?: bool (also replace place fields that already have text; default false: only empty ones are filled), dryRun?, dataset?: folder} → {looked, found, changed, photos: [{id, place: {sublocation, city, state, country, iso, distance}}], attribution} — fills City / State / Country (and Sublocation when known) from GPS with the offline GeoNames place names (download them first); one undo step",
|
||||
"{ids?, overwrite?: bool (also replace place fields that already have text; default false: only empty ones are filled), dryRun?, dataset?: folder, maxDistance?: metres (past this distance from the town found, City is left empty and the town is given as nearestCity)} → {looked, found, changed, photos: [{id, place: {sublocation, city, state, country, iso, distance}, nearestCity?}], attribution} — fills City / State / Country (and Sublocation when known) from GPS with the offline GeoNames place names (download them first); one undo step. The place names list towns of 15 000 people or more (GeoNames cities15000): a photo taken in a smaller town gets the nearest larger one, up to 50 km away (`distance` says how far); `maxDistance` keeps such a town out of City",
|
||||
has_selection,
|
||||
reverse
|
||||
),
|
||||
@@ -343,6 +359,36 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #2242: the place names hold only towns of 15 000 people or more, so a photo in a
|
||||
/// village finds the nearest larger town. `maxDistance` leaves City empty past that distance
|
||||
/// (State and Country still fill) and says which town it was.
|
||||
#[test]
|
||||
fn max_distance_leaves_a_far_town_out_of_city() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids = s.visible_cloned();
|
||||
let dir = dataset("maxdist");
|
||||
// about 5 km north of Paris (like Portofino and Rapallo in the issue), and right in Paris
|
||||
set_gps(&mut s, ids[0], Some((48.90, 2.35)), "");
|
||||
set_gps(&mut s, ids[1], Some((48.855, 2.35)), "");
|
||||
let r = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0, ids[1].0], "dataset": dir, "maxDistance": 2000})).unwrap();
|
||||
let m0 = s.catalog.photo(ids[0]).unwrap().meta.clone();
|
||||
assert_eq!((m0.city.as_str(), m0.state.as_str(), m0.country.as_str()), ("", "Île-de-France", "France"));
|
||||
assert_eq!(s.catalog.photo(ids[1]).unwrap().meta.city, "Paris", "close enough");
|
||||
let far = r["photos"].as_array().unwrap().iter().find(|p| p["id"] == json!(ids[0].0)).unwrap();
|
||||
assert_eq!((far["place"]["city"].as_str(), far["nearestCity"].as_str()), (Some(""), Some("Paris")), "{far}");
|
||||
assert!(far["place"]["distance"].as_f64().unwrap() > 2000.0);
|
||||
// without it, the nearest town fills City as before
|
||||
s.undo_step().unwrap();
|
||||
s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": dir})).unwrap();
|
||||
assert_eq!(s.catalog.photo(ids[0]).unwrap().meta.city, "Paris");
|
||||
// hostile values are refused, not taken as "no limit"
|
||||
for bad in [json!(-1), json!(0), json!("far"), json!(true)] {
|
||||
let e = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": dir, "maxDistance": bad})).unwrap_err();
|
||||
assert!(e.to_string().contains("maxDistance"), "{e}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explains_missing_data_and_gps() {
|
||||
let mut s = Session::with_demo();
|
||||
|
||||
@@ -1488,7 +1488,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always, |_, p| {
|
||||
let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?;
|
||||
let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?;
|
||||
Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings}))
|
||||
Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !p.original_is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings,"skipped":data.skipped}))
|
||||
}),
|
||||
cmd!(
|
||||
"library.importLightroom",
|
||||
|
||||
@@ -77,6 +77,7 @@ fn relative_ratings_and_history_use_auto_write() {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// Metadata-only fixtures: no import, image decoding or model/service dependency.
|
||||
for id in 1..=4 {
|
||||
std::fs::write(dir.join(format!("{id}.jpg")), b"original").unwrap();
|
||||
s.catalog
|
||||
.apply(Op::SetFile {
|
||||
id: PhotoId(id),
|
||||
|
||||
@@ -9,8 +9,13 @@ use crate::Result;
|
||||
|
||||
fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec<lightcraft_catalog::PhotoId>, String, usize)> {
|
||||
let template = str_param(p, "template").ok_or_else(|| bad(c, "missing `template`"))?.to_string();
|
||||
let start = p.get("start").and_then(Value::as_u64).unwrap_or(1) as usize;
|
||||
Ok((s.targets(p)?, template, start))
|
||||
let start = p.get("start").and_then(Value::as_u64).unwrap_or(1);
|
||||
let ids = s.targets(p)?;
|
||||
// every number of the sequence `start..start + n` must fit
|
||||
let last = start.checked_add((ids.len() as u64).saturating_sub(1));
|
||||
let start = last.and_then(|l| usize::try_from(l).ok()).and_then(|_| usize::try_from(start).ok());
|
||||
let start = start.ok_or_else(|| bad(c, "start out of range"))?;
|
||||
Ok((ids, template, start))
|
||||
}
|
||||
|
||||
/// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name).
|
||||
|
||||
@@ -287,12 +287,7 @@ fn effect_index(s: &Session, name: &str, c: &str) -> Result<usize> {
|
||||
/// The selected mask's adjustments on the active photo.
|
||||
fn selected_mask(s: &Session, p: &Value, c: &str) -> Result<Mask> {
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let mid = p
|
||||
.get("id")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
.or(s.active_mask)
|
||||
.ok_or_else(|| bad(c, "no mask selected (give `id`)"))?;
|
||||
let mid = super::masks::mask_id(p, s.active_mask, c)?;
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
d.masks.iter().find(|m| m.id == mid).cloned().ok_or_else(|| bad(c, format!("no mask {mid}")))
|
||||
}
|
||||
@@ -341,10 +336,12 @@ fn apply_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// the masks: `ids`, `all`, else `id` or the selected one
|
||||
let ids: Vec<u32> = if p.get("all").and_then(Value::as_bool).unwrap_or(false) {
|
||||
d.masks.iter().map(|m| m.id).collect()
|
||||
} else if let Some(a) = p.get("ids").and_then(Value::as_array) {
|
||||
a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).collect()
|
||||
} else {
|
||||
vec![selected_mask(s, p, C)?.id]
|
||||
match p.get("ids") {
|
||||
None | Some(Value::Null) => vec![selected_mask(s, p, C)?.id],
|
||||
Some(Value::Array(a)) => a.iter().map(|v| super::masks::parse_mask_id(v, C)).collect::<Result<_>>()?,
|
||||
Some(_) => return Err(bad(C, "`ids` must be a list of mask IDs")),
|
||||
}
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no masks to apply the effect to"));
|
||||
|
||||
@@ -199,8 +199,28 @@ fn connected_strokes(from: Point, last: &BrushStroke, to: BrushStroke) -> Vec<Br
|
||||
out
|
||||
}
|
||||
|
||||
fn mask_id(p: &Value, active: Option<u32>, c: &str) -> Result<u32> {
|
||||
p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)"))
|
||||
pub(super) fn parse_mask_id(v: &Value, c: &str) -> Result<u32> {
|
||||
v.as_u64().and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(c, "`id` must be a non-negative integer that fits in u32"))
|
||||
}
|
||||
|
||||
/// Omitted or null targets keep the command's default; malformed explicit targets never do.
|
||||
fn optional_mask_id(p: &Value, c: &str) -> Result<Option<u32>> {
|
||||
p.get("id").filter(|v| !v.is_null()).map(|v| parse_mask_id(v, c)).transpose()
|
||||
}
|
||||
|
||||
pub(super) fn mask_id(p: &Value, active: Option<u32>, c: &str) -> Result<u32> {
|
||||
optional_mask_id(p, c)?.or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)"))
|
||||
}
|
||||
|
||||
fn optional_index(p: &Value, key: &str, c: &str) -> Result<Option<usize>> {
|
||||
p.get(key)
|
||||
.filter(|v| !v.is_null())
|
||||
.map(|v| {
|
||||
v.as_u64()
|
||||
.and_then(|v| usize::try_from(v).ok())
|
||||
.ok_or_else(|| bad(c, format!("`{key}` must be a non-negative integer that fits in usize")))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn find(masks: &mut [Mask], id: u32, c: &str) -> Result<usize> {
|
||||
@@ -307,7 +327,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let connect = bool_or(p, "connect", false);
|
||||
let mut connected = false;
|
||||
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
|
||||
let want = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask);
|
||||
let want = optional_mask_id(p, "mask.brushStroke")?.or(s.active_mask);
|
||||
let adjust = super::mask_effects::new_mask_adjust(s, "brush");
|
||||
let mut out = masks_edit(s, "mask.brushStroke", "Brush", |masks, active| {
|
||||
let i = match want.and_then(|m| masks.iter().position(|x| x.id == m)) {
|
||||
@@ -363,7 +383,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("mask.update", "Update Mask Shape", [], None, "{id?, component?: index (0), shape: MaskShape JSON}", has_active, |s, p| {
|
||||
let shape: MaskShape =
|
||||
serde_json::from_value(p.get("shape").cloned().unwrap_or_default()).map_err(|e| bad("mask.update", e.to_string()))?;
|
||||
let comp = p.get("component").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let comp = optional_index(p, "component", "mask.update")?.unwrap_or(0);
|
||||
let mid = mask_id(p, s.active_mask, "mask.update")?;
|
||||
masks_edit(s, "mask.update", "Edit Mask", |masks, _| {
|
||||
let i = find(masks, mid, "mask.update")?;
|
||||
@@ -381,6 +401,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "mask.sampleColor";
|
||||
let mid = mask_id(p, s.active_mask, c)?;
|
||||
let comp = optional_index(p, "component", c)?;
|
||||
let (x, y) = (super::f64_req(p, "x", c)?, super::f64_req(p, "y", c)?);
|
||||
let add = p.get("add").and_then(Value::as_bool).unwrap_or(false);
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
@@ -390,8 +412,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let req = lightcraft_pipeline::RenderRequest::fit(384, 384);
|
||||
let lab = lightcraft_pipeline::color_range_sample(&src, &info, &d, &req, lightcraft_geom::Point::new(x, y))
|
||||
.ok_or_else(|| bad(c, "the point is outside the photo"))?;
|
||||
let mid = mask_id(p, s.active_mask, c)?;
|
||||
let comp = p.get("component").and_then(Value::as_u64).map(|v| v as usize);
|
||||
let mut out = Vec::new();
|
||||
masks_edit(s, c, "Color Range", |masks, _| {
|
||||
let i = find(masks, mid, c)?;
|
||||
@@ -482,8 +502,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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,
|
||||
let k = match optional_index(p, "component", c)? {
|
||||
Some(k) => k,
|
||||
None => m
|
||||
.components
|
||||
.iter()
|
||||
@@ -604,7 +624,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
cmd!("mask.select", "Select Mask", [], None, "{id|null}", has_active, |s, p| {
|
||||
s.active_mask = p.get("id").and_then(Value::as_u64).map(|v| v as u32);
|
||||
s.active_mask = optional_mask_id(p, "mask.select")?;
|
||||
ok()
|
||||
}),
|
||||
cmd!("mask.delete", "Delete Mask", [], None, "{id?}", has_active, |s, p| {
|
||||
@@ -684,7 +704,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, p| {
|
||||
let c = "mask.component";
|
||||
let mid = mask_id(p, s.active_mask, c)?;
|
||||
let k = p.get("component").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing component"))? as usize;
|
||||
let k = optional_index(p, "component", c)?.ok_or_else(|| bad(c, "missing component"))?;
|
||||
let action = str_param(p, "action").ok_or_else(|| bad(c, "missing action"))?.to_string();
|
||||
let op: Option<MaskOp> = match p.get("op") {
|
||||
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(c, e.to_string()))?),
|
||||
@@ -879,8 +899,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("spot.select", "Select Spot", [], None, "{index|null}", has_active, |s, p| {
|
||||
let n = spots_len(s);
|
||||
s.active_spot = match p.get("index").and_then(Value::as_u64) {
|
||||
Some(i) if (i as usize) < n => Some(i as usize),
|
||||
s.active_spot = match optional_index(p, "index", "spot.select")? {
|
||||
Some(i) if i < n => Some(i),
|
||||
Some(_) => return Err(bad("spot.select", "no such spot")),
|
||||
None => None,
|
||||
};
|
||||
@@ -1040,7 +1060,7 @@ fn spots_len(s: &Session) -> usize {
|
||||
|
||||
/// `index` from the params, else the selected spot.
|
||||
fn spot_index(s: &Session, p: &Value, c: &str) -> Result<usize> {
|
||||
let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_spot).ok_or_else(|| bad(c, "no spot selected (give `index`)"))?;
|
||||
let i = optional_index(p, "index", c)?.or(s.active_spot).ok_or_else(|| bad(c, "no spot selected (give `index`)"))?;
|
||||
if i >= spots_len(s) {
|
||||
return Err(bad(c, "no such spot"));
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ pub mod previews;
|
||||
mod print;
|
||||
mod query;
|
||||
pub mod recheck;
|
||||
pub mod reload;
|
||||
mod slideshow;
|
||||
mod sync;
|
||||
mod xmp;
|
||||
@@ -198,12 +199,18 @@ pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result<f64> {
|
||||
p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`")))
|
||||
}
|
||||
/// A non-negative integer parameter: `index: -1` must be an error, not element 0 (`-1.0 as usize`
|
||||
/// saturates to 0), just as `history.restore` already refuses a negative step index.
|
||||
/// A non-negative whole number that fits `usize`. Keep integer JSON exact; accept integral
|
||||
/// floating-point values for compatibility, without truncating or saturating their conversion.
|
||||
pub(crate) fn index_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
let invalid = || bad(cmd, format!("`{key}` must be a non-negative integer"));
|
||||
if let Some(i) = p.get(key).and_then(Value::as_u64) {
|
||||
return usize::try_from(i).map_err(|_| invalid());
|
||||
}
|
||||
let i = f64_req(p, key, cmd)?;
|
||||
if !(i.is_finite() && i >= 0.0) {
|
||||
return Err(bad(cmd, format!("`{key}` must be a non-negative integer")));
|
||||
// The exclusive bound is exactly representable, unlike usize::MAX on 64-bit targets.
|
||||
let upper_bound = 2.0_f64.powi(usize::BITS as i32);
|
||||
if !(i.is_finite() && i >= 0.0 && i.fract() == 0.0 && i < upper_bound) {
|
||||
return Err(invalid());
|
||||
}
|
||||
Ok(i as usize)
|
||||
}
|
||||
@@ -223,3 +230,29 @@ pub(crate) fn point(p: &Value, key: &str) -> Option<lightcraft_geom::Point> {
|
||||
let a = p.get(key)?.as_array()?;
|
||||
Some(lightcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::index_req;
|
||||
|
||||
#[test]
|
||||
fn command_indices_preserve_integer_precision_and_check_usize_bounds() {
|
||||
for i in [0, 1, usize::MAX] {
|
||||
assert_eq!(index_req(&json!({"index": i}), "index", "test").unwrap(), i);
|
||||
}
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
{
|
||||
let i = 9_007_199_254_740_993usize;
|
||||
assert_eq!(index_req(&json!({"index": i}), "index", "test").unwrap(), i, "integer JSON must not round through f64");
|
||||
}
|
||||
let upper_bound = 2.0_f64.powi(usize::BITS as i32);
|
||||
assert!(index_req(&json!({"index": upper_bound}), "index", "test").is_err(), "2^usize::BITS must not saturate");
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
{
|
||||
assert!(index_req(&json!({"index": u64::from(u32::MAX) + 1}), "index", "test").is_err());
|
||||
assert_eq!(index_req(&json!({"index": f64::from(u32::MAX)}), "index", "test").unwrap(), usize::MAX);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,7 +324,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{ids?} → {settings: {layout, image, page, guides, job}, papers, pageWidth, pageHeight, printable, pageCount, cellsPerPage, gridCells?, overlaps}; lengths are inches",
|
||||
always, get),
|
||||
cmd!(query "print.set", "Set Print Settings", [], None,
|
||||
"{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b]}, guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get",
|
||||
"{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b], pageNumbers, pageInfo, cropMarks} (Page Options: single image and contact sheet only), guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get",
|
||||
always, set),
|
||||
cmd!(query "print.reset", "Reset Print Settings", [], None, "{} → print.get", always, reset),
|
||||
cmd!(query "print.cell.add", "Add Print Cell", [], None,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Reload from Disk (`photo.reload`), split so the app can read the files on a worker thread: a
|
||||
//! raw re-decode takes a second or more per file, and a selection of them froze the window while
|
||||
//! the command ran. [`plan`] lists the photos to re-read (no file-system calls), [`probe`] reads
|
||||
//! their files without the session (cancellable, with progress), and [`apply`] commits what came
|
||||
//! of it as one undo step. The command itself (agents, CLI, MCP) does all three at once.
|
||||
//!
|
||||
//! Reloads can overlap (the user picks Reload from Disk again while a first one still reads), so
|
||||
//! each [`plan`] takes a ticket and [`apply`] drops a photo's result when a reload planned later
|
||||
//! has already been applied to it: an older read never replaces a newer one.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId, Source};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::media::{FileProbe, ProbeInfo};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// A photo to re-read: its id and the file it had when it was listed (a photo relinked meanwhile
|
||||
/// is skipped by [`apply`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Item {
|
||||
pub id: PhotoId,
|
||||
pub path: String,
|
||||
/// The [`plan`] it came from, in the order reloads were started (see [`Tickets`]).
|
||||
pub ticket: u64,
|
||||
}
|
||||
|
||||
/// Which reload each photo last took its file information from, so a reload started earlier but
|
||||
/// finishing later doesn't put back what its file was before (session memory, not saved).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Tickets {
|
||||
last: u64,
|
||||
applied: std::collections::BTreeMap<PhotoId, u64>,
|
||||
}
|
||||
|
||||
/// The photos `p` targets (`ids`, else the selection) that have a file, under a new ticket. No
|
||||
/// file-system calls.
|
||||
pub fn plan(s: &mut Session, p: &Value) -> Result<Vec<Item>> {
|
||||
let ids = s.targets(p)?;
|
||||
s.reload_tickets.last = s.reload_tickets.last.saturating_add(1);
|
||||
let ticket = s.reload_tickets.last;
|
||||
Ok(ids
|
||||
.into_iter()
|
||||
.filter_map(|id| match s.catalog.photo(id).map(|ph| &ph.source) {
|
||||
Some(Source::File { path }) => Some(Item { id, path: path.clone(), ticket }),
|
||||
_ => None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The session's file probe, to [`probe`] with on a worker thread (`None` in a session without file
|
||||
/// access, where a reload reads nothing but the file name, as the command does).
|
||||
pub fn file_probe(s: &Session) -> Option<FileProbe> {
|
||||
s.media.file_probe.clone()
|
||||
}
|
||||
|
||||
/// Read each listed photo's file as Reload does, several at once. Doesn't touch the session.
|
||||
/// `progress(done)` is called while it runs (from this thread); once `cancel` is set no further
|
||||
/// file is started, and the files not reached come back as errors (left unchanged by [`apply`]).
|
||||
pub fn probe(
|
||||
probe: Option<&FileProbe>,
|
||||
items: &[Item],
|
||||
cancel: &AtomicBool,
|
||||
progress: &dyn Fn(usize),
|
||||
) -> Vec<std::result::Result<ProbeInfo, String>> {
|
||||
let paths: Vec<String> = items.iter().map(|i| i.path.clone()).collect();
|
||||
let done = AtomicUsize::new(0);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let r = std::thread::scope(|sc| {
|
||||
let h = sc.spawn(|| crate::import::probe_all(probe, &paths, &done, cancel));
|
||||
while !h.is_finished() {
|
||||
progress(done.load(Ordering::Relaxed));
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
// a probe that panicked (already logged): nothing is known about any file
|
||||
h.join().unwrap_or_else(|_| paths.iter().map(|p| Err(format!("{p}: could not be read"))).collect())
|
||||
});
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let r = crate::import::probe_all(probe, &paths, &done, cancel);
|
||||
progress(done.load(Ordering::Relaxed));
|
||||
r
|
||||
}
|
||||
|
||||
/// Commit [`probe`]'s results as one undo step ("Reload Changed Files"): each photo and its virtual
|
||||
/// copies get what their file is now. A file that couldn't be read changes nothing; a photo removed
|
||||
/// or relinked since it was listed, or already given a newer reload's result, is skipped. Returns
|
||||
/// the photos that changed.
|
||||
pub fn apply(s: &mut Session, items: Vec<Item>, results: Vec<std::result::Result<ProbeInfo, String>>) -> Result<Vec<PhotoId>> {
|
||||
let mut ops = Vec::new();
|
||||
let mut reloaded = Vec::new();
|
||||
for (item, info) in items.into_iter().zip(results) {
|
||||
let Ok(info) = info else { continue };
|
||||
let current = s.catalog.photo(item.id).is_some_and(|ph| matches!(&ph.source, Source::File { path } if *path == item.path));
|
||||
let superseded = s.reload_tickets.applied.get(&item.id).is_some_and(|t| *t > item.ticket);
|
||||
if !current || superseded {
|
||||
continue;
|
||||
}
|
||||
s.reload_tickets.applied.insert(item.id, item.ticket);
|
||||
crate::cmd::convert::reload_ops(&s.catalog, item.id, &info, &mut ops, &mut reloaded);
|
||||
}
|
||||
for id in &reloaded {
|
||||
s.media.forget(*id);
|
||||
}
|
||||
if !ops.is_empty() {
|
||||
s.commit("Reload Changed Files", Op::Batch { ops })?;
|
||||
}
|
||||
Ok(reloaded)
|
||||
}
|
||||
|
||||
/// `{reloaded: [ids]}`.
|
||||
pub fn to_json(reloaded: &[PhotoId]) -> Value {
|
||||
json!({"reloaded": reloaded.iter().map(|i| i.0).collect::<Vec<_>>()})
|
||||
}
|
||||
@@ -99,6 +99,21 @@ fn adjust(c: &XmpValue) -> Value {
|
||||
Value::Object(a)
|
||||
}
|
||||
|
||||
/// The correction's local sliders we have no counterpart for (e.g. `LocalGrain`), when set:
|
||||
/// reported, never dropped silently.
|
||||
fn unmapped_local(c: &XmpValue) -> Vec<String> {
|
||||
let XmpValue::Struct(m) = c else { return Vec::new() };
|
||||
m.keys()
|
||||
.filter_map(|k| {
|
||||
let field = k.strip_prefix("crs:").filter(|f| f.starts_with("Local"))?;
|
||||
// 100 = "curve saturation unchanged", written with every correction
|
||||
let neutral = if field == "LocalCurveRefineSaturation" { 100.0 } else { 0.0 };
|
||||
let set = num(c, k).is_some_and(|v| v != neutral);
|
||||
(set && !LOCAL.iter().any(|(crs, ..)| *crs == field)).then(|| field.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Perceptual lightness 0..1 (how range masks store luminance) → our range scale: log
|
||||
/// luminance on −8..+4 EV mapped to 0..1.
|
||||
fn lightness_to_range(l: f64) -> f64 {
|
||||
@@ -214,8 +229,12 @@ fn component(m: &XmpValue, container: &str, aspect: f64) -> Result<Value, String
|
||||
Some("2") => {
|
||||
let q: Vec<f64> = text(r, "crs:LumRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
|
||||
let [a, b, c, d] = q[..] else { return Err("luminance range".into()) };
|
||||
// full strength at white (1, the top of their scale) takes in everything brighter: ours
|
||||
// goes on above scene white, so that end stays open instead of cutting off at 0 EV
|
||||
let open_top = c >= 1.0;
|
||||
let (a, b, c, d) = (lightness_to_range(a), lightness_to_range(b), lightness_to_range(c), lightness_to_range(d));
|
||||
json!({"kind": "luminanceRange", "lo": b, "hi": c, "lo_feather": (b - a).max(0.0), "hi_feather": (d - c).max(0.0)})
|
||||
let (hi, hi_feather) = if open_top { (1.0, 0.0) } else { (c, (d - c).max(0.0)) };
|
||||
json!({"kind": "luminanceRange", "lo": b, "hi": hi, "lo_feather": (b - a).max(0.0), "hi_feather": hi_feather})
|
||||
}
|
||||
Some("3") => {
|
||||
let q: Vec<f64> = text(r, "crs:DepthRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
|
||||
@@ -252,6 +271,7 @@ pub fn masks(values: &Values, aspect: f64) -> (Vec<Value>, Vec<String>) {
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
skipped.extend(unmapped_local(c));
|
||||
// brush dabs of one correction are one brush component
|
||||
let mut merged: Vec<Value> = Vec::new();
|
||||
for p in parts {
|
||||
@@ -506,6 +526,28 @@ mod tests {
|
||||
assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_to_white_stays_open_and_unmapped_local_sliders_are_reported() {
|
||||
// written for this test: a highlights range (full from 0.85 up to white) carrying a local slider we lack
|
||||
let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/">
|
||||
<crs:MaskGroupBasedCorrections><rdf:Seq>
|
||||
<rdf:li><rdf:Description crs:What="Correction" crs:LocalClarity2012="-0.3" crs:LocalGrain="0.4" crs:LocalCurveRefineSaturation="100">
|
||||
<crs:CorrectionMasks><rdf:Seq>
|
||||
<rdf:li crs:What="Mask/RangeMask"><crs:CorrectionRangeMask crs:Type="2" crs:LumRange="0.6 0.85 1 1"/></rdf:li>
|
||||
</rdf:Seq></crs:CorrectionMasks>
|
||||
</rdf:Description></rdf:li>
|
||||
</rdf:Seq></crs:MaskGroupBasedCorrections>
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
|
||||
let d = lightcraft_meta::parse_xmp(x).unwrap();
|
||||
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
|
||||
let s = DevelopSettings::default().merged(&partial).expect("valid settings");
|
||||
let MaskShape::LuminanceRange { hi, hi_feather, .. } = &s.masks[0].components[0].shape else { panic!() };
|
||||
assert_eq!((*hi, *hi_feather), (1.0, 0.0), "brighter than scene white is still in the range");
|
||||
assert_eq!(s.masks[0].adjust.clarity, -30.0);
|
||||
assert_eq!(unmapped, ["Mask: LocalGrain"], "a refine saturation of 100 is neutral, not reported");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lrtemplate_corrections() {
|
||||
let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Paths stored in a file LightCraft reads but the user did not choose path by path, such as the
|
||||
//! originals a Lightroom catalog lists.
|
||||
//!
|
||||
//! Such a path reaches the file system only through [`resolve`]: a relative path is taken from the
|
||||
//! document's folder, and only local files, or files on the network share the document itself is
|
||||
//! on, are looked at (see `craft_docpath`). A path that is not followed is handled like a missing
|
||||
//! file.
|
||||
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use craft_docpath::Refused;
|
||||
|
||||
/// Why a stored path is not followed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NotFollowed {
|
||||
/// The path is on another computer (other than the document's share), names a device, is a
|
||||
/// URL, or is not a usable path.
|
||||
Refused(Refused),
|
||||
/// The path is not an absolute path on this computer, such as a Windows drive path read on
|
||||
/// macOS or Linux.
|
||||
NotHere,
|
||||
}
|
||||
|
||||
impl fmt::Display for NotFollowed {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Refused(r) => r.fmt(f),
|
||||
Self::NotHere => f.write_str("the path is not a path on this computer"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NotFollowed {}
|
||||
|
||||
/// The file to look at for `stored`, a path or `file:` URL stored in the document at `document`,
|
||||
/// or why it is not followed. A relative `document` is taken from the working directory, where it
|
||||
/// was opened from.
|
||||
pub fn resolve(stored: &str, document: Option<&Path>) -> Result<PathBuf, NotFollowed> {
|
||||
let document = document.map(|d| if d.is_relative() { std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()) } else { d.to_path_buf() });
|
||||
let path = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?;
|
||||
// A path that is still relative would be taken from the working directory, which the
|
||||
// document does not choose.
|
||||
if path.is_relative() {
|
||||
return Err(NotFollowed::NotHere);
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn relative_paths_are_taken_from_the_document_folder_and_other_computers_are_refused() {
|
||||
let doc = std::env::temp_dir().join("lrcat").join("catalog.lrcat");
|
||||
assert_eq!(resolve("Photos/a.jpg", Some(&doc)), Ok(std::env::temp_dir().join("lrcat").join("Photos").join("a.jpg")));
|
||||
assert_eq!(resolve("a.jpg", None), Err(NotFollowed::NotHere));
|
||||
assert_eq!(resolve(r"\\192.0.2.1\share\x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network)));
|
||||
assert_eq!(resolve("file://192.0.2.1/s/x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network)));
|
||||
assert_eq!(resolve(r"\\.\COM1", Some(&doc)), Err(NotFollowed::Refused(Refused::Device)));
|
||||
}
|
||||
}
|
||||
+192
-7
@@ -816,8 +816,17 @@ impl ExportOptions {
|
||||
/// 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.
|
||||
/// for good after an update. A `bitDepth` the stored format doesn't have (written at the
|
||||
/// format's own depth before issue #1952) is dropped too.
|
||||
pub fn known_keys_only(p: &serde_json::Value) -> serde_json::Value {
|
||||
let mut p = Self::known_keys(p);
|
||||
Self::drop_unfit_depth(&mut p, ExportFormat::default());
|
||||
p
|
||||
}
|
||||
|
||||
/// [`ExportOptions::known_keys_only`] keeping `bitDepth`, for a preset whose format may only be
|
||||
/// known once the call's own params (`path`) are merged in.
|
||||
pub(crate) fn known_keys(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()));
|
||||
@@ -828,13 +837,44 @@ impl ExportOptions {
|
||||
p
|
||||
}
|
||||
|
||||
/// Drop a stored `bitDepth` the format `p` exports ([`ExportOptions::export_format`] with
|
||||
/// `fallback`) doesn't have: it was written at the format's own depth before issue #1952.
|
||||
pub(crate) fn drop_unfit_depth(p: &mut serde_json::Value, fallback: ExportFormat) {
|
||||
let format = Self::export_format(p, fallback);
|
||||
if let Some(o) = p.as_object_mut()
|
||||
&& let Some(depth) = o.get("bitDepth").and_then(whole)
|
||||
&& !Self::bit_depths(format).iter().any(|d| i64::from(d.0) == depth)
|
||||
{
|
||||
o.remove("bitDepth");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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))
|
||||
Self::from_params_or(p, ExportFormat::default())
|
||||
}
|
||||
|
||||
/// [`ExportOptions::from_params`] for a caller that writes `fallback` to a `path` naming no
|
||||
/// format; the format returned is [`ExportOptions::export_format`], the one written.
|
||||
pub fn from_params_or(p: &serde_json::Value, fallback: ExportFormat) -> crate::Result<Self> {
|
||||
let format = Self::export_format(p, fallback);
|
||||
Self::validate_as("app.export", p, Some(format))?;
|
||||
Ok(Self { format, ..Self::from_json(p) })
|
||||
}
|
||||
|
||||
/// The format `app.export` writes: `format` (`null`: the default), else the one the extension of
|
||||
/// `path` names, else `fallback` for a path that names none (the desktop app writes JPEG there,
|
||||
/// the headless backend PNG), else the default.
|
||||
pub fn export_format(p: &serde_json::Value, fallback: ExportFormat) -> ExportFormat {
|
||||
use serde_json::Value;
|
||||
match (p.get("format"), p.get("path").and_then(Value::as_str)) {
|
||||
(Some(f), _) => f.as_str().and_then(ExportFormat::parse).unwrap_or_default(),
|
||||
(None, Some(path)) => std::path::Path::new(path).extension().and_then(|e| e.to_str()).and_then(ExportFormat::parse).unwrap_or(fallback),
|
||||
(None, None) => ExportFormat::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check `app.export` params for `cmd` (the command named in the error): every key must be an
|
||||
@@ -842,6 +882,12 @@ impl ExportOptions {
|
||||
/// 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<()> {
|
||||
Self::validate_as(cmd, p, Some(Self::export_format(p, ExportFormat::default())))
|
||||
}
|
||||
|
||||
/// [`ExportOptions::validate`] with `bitDepth` checked against `format`, the one exported
|
||||
/// (`None`: not known yet, as for a preset without `format`).
|
||||
pub(crate) fn validate_as(cmd: &str, p: &serde_json::Value, format: Option<ExportFormat>) -> 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())) };
|
||||
@@ -949,6 +995,18 @@ impl ExportOptions {
|
||||
_ => return Err(bad(unknown_key("parameter", k, &known))),
|
||||
}
|
||||
}
|
||||
// issue #1952: a depth the format doesn't have would be written at the format's own depth
|
||||
if let (Some(format), Some(depth)) = (format, obj.get("bitDepth").and_then(whole)) {
|
||||
let mut depths: Vec<u8> = Self::bit_depths(format).iter().map(|d| d.0).collect();
|
||||
if !depths.iter().any(|d| i64::from(*d) == depth) {
|
||||
depths.sort_unstable();
|
||||
let mut list: Vec<String> = depths.iter().map(u8::to_string).collect();
|
||||
let last = list.pop().unwrap_or_default();
|
||||
let list = if list.is_empty() { last } else { format!("{} or {last}", list.join(", ")) };
|
||||
let name = serde_json::to_value(format).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default();
|
||||
return Err(bad(format!("`bitDepth` must be {list} for {name}")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1310,7 +1368,15 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
|
||||
let exif = meta.map(lightcraft_meta::write_exif);
|
||||
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
|
||||
let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) };
|
||||
let e = EncodeImage::rgba8(&img);
|
||||
// Opaque photo exports need no alpha channel (#1670). Keep real transparency for
|
||||
// callers of this encoder, and leave unrelated formats on their existing path.
|
||||
let rgb: Option<Vec<u8>> = (matches!(o.format, ExportFormat::Png | ExportFormat::Tiff | ExportFormat::Webp)
|
||||
&& img.data.iter().all(|p| p.last() == Some(&255)))
|
||||
.then(|| img.data.iter().flat_map(|p| p.iter().take(3).copied()).collect());
|
||||
let e = match &rgb {
|
||||
Some(data) => EncodeImage::new(img.width as u32, img.height as u32, 3, Samples::U8(data)),
|
||||
None => EncodeImage::rgba8(&img),
|
||||
};
|
||||
let r = match o.format {
|
||||
ExportFormat::Jpeg => {
|
||||
// 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality).
|
||||
@@ -2013,6 +2079,9 @@ fn run_batch_with(
|
||||
ahead: lanes,
|
||||
};
|
||||
std::thread::scope(|sc| {
|
||||
// stops the lanes however this thread leaves the scope: a panic in `progress` or the placer
|
||||
// would otherwise leave them waiting and the scope's join would never return
|
||||
let _stop = StopOnDrop(&queue);
|
||||
for k in 1..lanes {
|
||||
let q = &queue;
|
||||
// a lane that can't start leaves its photos to the others (and to this thread)
|
||||
@@ -2030,13 +2099,20 @@ fn run_batch_with(
|
||||
}
|
||||
Ok::<_, String>(())
|
||||
};
|
||||
let r = placed();
|
||||
queue.stop();
|
||||
r
|
||||
placed()
|
||||
})?;
|
||||
Ok(std::mem::take(&mut placer.out))
|
||||
}
|
||||
|
||||
/// Stops a [`Queue`] when dropped, also while unwinding from a panic.
|
||||
struct StopOnDrop<'a>(&'a Queue);
|
||||
|
||||
impl Drop for StopOnDrop<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one photo's export holding its working memory from [`crate::memory::export_gate`]; a panic
|
||||
/// becomes that photo's error, not the batch's end.
|
||||
fn run_gated(item: PreparedExport) -> Result<Exported, String> {
|
||||
@@ -2281,6 +2357,43 @@ mod tests {
|
||||
img
|
||||
}
|
||||
|
||||
/// Issue #1670: removing a redundant alpha channel preserves samples and metadata.
|
||||
#[test]
|
||||
fn opaque_eight_bit_exports_are_rgb() {
|
||||
let img = test_image();
|
||||
let metadata = Metadata { copyright: Some("Synthetic export test".into()), ..Default::default() };
|
||||
for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] {
|
||||
for compression in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] {
|
||||
let options = ExportOptions { format, tiff_compression: compression, ..Default::default() };
|
||||
let bytes = encode_with_metadata(&img, &options, Some(&metadata)).unwrap();
|
||||
let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap();
|
||||
assert!(!decoded.has_alpha, "{format:?}/{compression:?} must be RGB");
|
||||
assert_eq!((decoded.width, decoded.height, decoded.bit_depth), (96, 64, 8));
|
||||
assert_eq!(decoded.to_srgb8().data, img.data, "{format:?}/{compression:?} pixels");
|
||||
assert_eq!(decoded.icc.as_deref(), Some(icc::write_named(NamedSpace::Srgb).as_slice()));
|
||||
assert!(decoded.xmp.as_deref().unwrap_or_default().contains("Synthetic export test"));
|
||||
if format == ExportFormat::Png {
|
||||
assert_eq!(bytes[25], 2, "PNG IHDR declares RGB");
|
||||
}
|
||||
if format != ExportFormat::Tiff {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eight_bit_exports_preserve_real_alpha() {
|
||||
let mut img = test_image();
|
||||
img.data[0][3] = 128;
|
||||
for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] {
|
||||
let bytes = encode_image(&img, &ExportOptions { format, ..Default::default() }).unwrap();
|
||||
let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap();
|
||||
assert!(decoded.has_alpha, "{format:?} must retain real alpha");
|
||||
assert_eq!(decoded.to_srgb8().data, img.data, "{format:?} samples");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_have_magic() {
|
||||
let img = test_image();
|
||||
@@ -3167,6 +3280,7 @@ mod tests {
|
||||
resize: Some(Resize::default()),
|
||||
limit_kb: Some(500),
|
||||
watermark: Some(Watermark { text: "©".into(), ..Default::default() }),
|
||||
format: ExportFormat::Png,
|
||||
bit_depth: Some(16),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -3203,6 +3317,51 @@ mod tests {
|
||||
ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap();
|
||||
}
|
||||
|
||||
/// Issue #1952: a `bitDepth` the format doesn't have is refused (the Export dialog never offers
|
||||
/// it), not written at the format's own depth; the error names the depths the format takes.
|
||||
#[test]
|
||||
fn bit_depth_must_suit_the_format() {
|
||||
use serde_json::json;
|
||||
let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(err(json!({"format": "jpeg", "bitDepth": 16})).contains("`bitDepth` must be 8 for jpeg"));
|
||||
assert!(err(json!({"format": "png", "bitDepth": 10})).contains("`bitDepth` must be 8 or 16 for png"));
|
||||
assert!(err(json!({"format": "tiff", "bitDepth": 10})).contains("`bitDepth` must be 8, 16 or 32 for tiff"));
|
||||
// no `format`: the one `path` names, else the default (JPEG)
|
||||
assert!(err(json!({"path": "/out/a.png", "bitDepth": 32})).contains("for png"));
|
||||
assert!(err(json!({"bitDepth": 16})).contains("for jpeg"));
|
||||
assert!(ExportOptions::from_params(&json!({"path": "/out/a.tif", "bitDepth": 32})).is_ok());
|
||||
// a path naming no format, or a `null` format, exports the default too
|
||||
assert!(err(json!({"path": "/out/output", "bitDepth": 10})).contains("for jpeg"));
|
||||
assert!(err(json!({"path": "/out/output.xyz", "bitDepth": 10})).contains("for jpeg"));
|
||||
assert!(err(json!({"format": null, "path": "/out/a.png", "bitDepth": 16})).contains("for jpeg"));
|
||||
// or the caller's own format for such a path, which is the one returned
|
||||
let png = |p: serde_json::Value| ExportOptions::from_params_or(&p, ExportFormat::Png);
|
||||
assert!(png(json!({"path": "/out/output", "bitDepth": 10})).unwrap_err().to_string().contains("for png"));
|
||||
assert_eq!(png(json!({"path": "/out/output.xyz", "bitDepth": 16})).unwrap().format, ExportFormat::Png);
|
||||
assert_eq!(png(json!({"path": "/out/a.tif", "bitDepth": 32})).unwrap().format, ExportFormat::Tiff);
|
||||
// stored settings (a preset, Export with Previous) lose such a depth instead of failing
|
||||
let stored = ExportOptions::known_keys_only(&json!({"format": "jpeg", "bitDepth": 16}));
|
||||
assert_eq!(stored.get("bitDepth"), None);
|
||||
assert_eq!(ExportOptions::known_keys_only(&json!({"format": "tiff", "bitDepth": 32}))["bitDepth"], 32);
|
||||
for f in [
|
||||
ExportFormat::Jpeg,
|
||||
ExportFormat::Png,
|
||||
ExportFormat::Tiff,
|
||||
ExportFormat::Psd,
|
||||
ExportFormat::Webp,
|
||||
ExportFormat::Avif,
|
||||
ExportFormat::Jxl,
|
||||
ExportFormat::Original,
|
||||
ExportFormat::Dng,
|
||||
ExportFormat::RenderedDng,
|
||||
] {
|
||||
for &(b, _) in ExportOptions::bit_depths(f) {
|
||||
let o = ExportOptions { format: f, bit_depth: Some(b), ..Default::default() };
|
||||
assert_eq!(ExportOptions::from_params(&o.to_json()).ok(), Some(o), "{f:?} {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #956: `destinationMode` picks a specific folder (the default) or each photo's own
|
||||
/// folder; it is checked like the other params and kept in presets and Export with Previous.
|
||||
#[test]
|
||||
@@ -3429,6 +3588,32 @@ mod tests {
|
||||
assert_eq!((w.len(), sn.len()), (2, 3), "photos past the cancel are never written");
|
||||
}
|
||||
|
||||
// A panic in `progress` while lanes run must end the batch (the lanes stop waiting), not hang it
|
||||
#[test]
|
||||
fn a_panic_in_progress_does_not_hang_the_lanes() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let ids: Vec<_> = s.visible().iter().copied().take(5).collect();
|
||||
let o = ExportOptions::from_json(&json!({"format": "png", "width": 40}));
|
||||
let items = prepare_batch(&mut s, &ids, &o).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let to = Destination { dir: "out".into(), exact: None };
|
||||
let mut write = |_: &str, _: &[u8]| Ok(());
|
||||
let mut calls = 0;
|
||||
let mut progress = |_: usize, _: &str| {
|
||||
calls += 1;
|
||||
assert!(calls < 2, "progress panics on its second call");
|
||||
true
|
||||
};
|
||||
let r = crate::guard::catch("test batch", || {
|
||||
run_batch_with(items, &mut Placer::new(&o, &to, &mut write, &|_| false, true), &mut progress, 2)
|
||||
});
|
||||
tx.send(r.and_then(|r| r)).ok();
|
||||
});
|
||||
let r = rx.recv_timeout(std::time::Duration::from_secs(60));
|
||||
assert!(matches!(r, Ok(Err(_))), "the batch returns an error instead of hanging: {r:?}");
|
||||
}
|
||||
|
||||
// A photo that fails is reported in its place while the others are exported; with
|
||||
// stop_on_error the batch ends there and nothing after it is written
|
||||
#[test]
|
||||
|
||||
@@ -181,9 +181,16 @@ impl Drop for Downloads {
|
||||
for job in self.jobs.values() {
|
||||
job.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
while self.running() && started.elapsed() < Duration::from_secs(1) {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
// Give a running download a moment to notice. Not on the web: there are no download
|
||||
// threads, and `Instant::now` / `thread::sleep` panic on wasm32 ("time not implemented"),
|
||||
// which killed the page when a `Session` was replaced after its library failed to open
|
||||
// (issue #1507).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let started = std::time::Instant::now();
|
||||
while self.running() && started.elapsed() < Duration::from_secs(1) {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,6 +324,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dropping a `Downloads` with nothing running (every session has one) returns at once: the
|
||||
/// wait for running threads is the only part that takes time, and the web has no threads.
|
||||
#[test]
|
||||
fn dropping_idle_downloads_returns_at_once() {
|
||||
let t = std::time::Instant::now();
|
||||
drop(Downloads::default());
|
||||
let mut d = Downloads::default();
|
||||
d.set_outcome("m", State::Failed("x".into()));
|
||||
drop(d);
|
||||
assert!(t.elapsed() < Duration::from_millis(500), "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_running_download_shows_in_activity_and_cancels() {
|
||||
use crate::activity::{Activity, Unit};
|
||||
|
||||
@@ -167,6 +167,9 @@ impl Index {
|
||||
self.entries.insert((photo, key), (emb.into(), view));
|
||||
}
|
||||
}
|
||||
// a half-written last record (a crash while appending): written fresh at the next save, so later records are
|
||||
// not appended after it and shifted
|
||||
self.rewrite = !rest.is_empty();
|
||||
}
|
||||
|
||||
fn header(&self) -> Vec<u8> {
|
||||
@@ -201,12 +204,11 @@ impl Index {
|
||||
return Ok(());
|
||||
}
|
||||
if self.file_matches && !self.rewrite {
|
||||
let mut f = std::fs::OpenOptions::new().append(true).open(&path)?;
|
||||
let mut buf = Vec::new();
|
||||
for (k, e) in &self.unsaved {
|
||||
buf.extend(self.record(k, e));
|
||||
}
|
||||
f.write_all(&buf)?;
|
||||
append(&path, &buf)?;
|
||||
} else {
|
||||
let mut buf = self.header();
|
||||
for (k, e) in &self.entries {
|
||||
@@ -229,6 +231,22 @@ impl Drop for Index {
|
||||
}
|
||||
}
|
||||
|
||||
/// Append `buf` to the file at `path`. When the write fails (a full disk), the file is cut back to its old length so
|
||||
/// the part that got in does not shift what is appended later.
|
||||
fn append(path: &Path, buf: &[u8]) -> std::io::Result<()> {
|
||||
let mut f = std::fs::OpenOptions::new().append(true).open(path)?;
|
||||
let old_len = f.metadata()?.len();
|
||||
let written = f.write_all(buf);
|
||||
if written.is_err() {
|
||||
drop(f);
|
||||
// an append-only handle may not truncate (Windows): cut through a writable one
|
||||
if let Ok(w) = std::fs::OpenOptions::new().write(true).open(path) {
|
||||
let _ = w.set_len(old_len);
|
||||
}
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
const SCAN_MAGIC: &[u8; 4] = b"LCFS";
|
||||
const SCAN_VERSION: u8 = 1;
|
||||
/// The detector a search was made with: a record made by another one is ignored (a better detector may find more).
|
||||
@@ -325,8 +343,11 @@ impl Scanned {
|
||||
let head = self.header();
|
||||
let Some(rest) = bytes.strip_prefix(head.as_slice()) else { return };
|
||||
self.file_matches = true;
|
||||
// a half-written last id (a crash while appending) is ignored
|
||||
self.set.extend(rest.as_chunks::<8>().0.iter().map(|c| u64::from_le_bytes(*c)));
|
||||
// a half-written last id (a crash while appending) is ignored, and the record is written fresh at the next save
|
||||
// so later ids are not appended after it and shifted
|
||||
let (ids, torn) = rest.as_chunks::<8>();
|
||||
self.set.extend(ids.iter().map(|c| u64::from_le_bytes(*c)));
|
||||
self.rewrite = !torn.is_empty();
|
||||
}
|
||||
|
||||
/// Write what was added since the last save: appended when the file is this detector's, else the whole record
|
||||
@@ -341,9 +362,8 @@ impl Scanned {
|
||||
return Ok(());
|
||||
}
|
||||
if self.file_matches && !self.rewrite {
|
||||
let mut f = std::fs::OpenOptions::new().append(true).open(&path)?;
|
||||
let buf: Vec<u8> = self.unsaved.iter().flat_map(|id| id.to_le_bytes()).collect();
|
||||
f.write_all(&buf)?;
|
||||
append(&path, &buf)?;
|
||||
} else {
|
||||
let mut buf = self.header();
|
||||
buf.extend(self.set.iter().flat_map(|id| id.to_le_bytes()));
|
||||
@@ -487,6 +507,51 @@ mod tests {
|
||||
assert!(!t.contains(1) && t.contains(2) && t.contains(3));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A torn last record (a crash or a full disk while appending) does not shift the records appended after it
|
||||
/// (issue #2129).
|
||||
#[test]
|
||||
fn records_appended_after_a_torn_tail_stay_aligned() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-faces-torn-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (emb, scan) = (dir.join("face-embeddings.bin"), dir.join("face-scanned.bin"));
|
||||
let junk = |p: &Path| {
|
||||
let mut f = std::fs::OpenOptions::new().append(true).open(p).unwrap();
|
||||
f.write_all(&[0x3f; 5]).unwrap();
|
||||
};
|
||||
let mut a = Index::default();
|
||||
a.reset("m@abc", 3, Some(emb.clone()));
|
||||
a.insert(7, &rect(0.1), vec![1.0, 0.0, 0.5]);
|
||||
a.save().unwrap();
|
||||
drop(a);
|
||||
junk(&emb);
|
||||
let mut b = Index::default();
|
||||
b.reset("m@abc", 3, Some(emb.clone()));
|
||||
b.insert(9, &rect(0.5), vec![0.5, 0.25, 0.125]);
|
||||
b.save().unwrap();
|
||||
drop(b);
|
||||
let mut c = Index::default();
|
||||
c.reset("m@abc", 3, Some(emb));
|
||||
assert_eq!(c.len(), 2);
|
||||
assert_eq!(c.get(7, &rect(0.1)).map(|e| e.to_vec()), Some(vec![1.0, 0.0, 0.5]));
|
||||
assert_eq!(c.get(9, &rect(0.5)).map(|e| e.to_vec()), Some(vec![0.5, 0.25, 0.125]));
|
||||
let mut s = Scanned::default();
|
||||
s.reset(Some(scan.clone()));
|
||||
s.insert(42);
|
||||
s.save().unwrap();
|
||||
drop(s);
|
||||
junk(&scan);
|
||||
let mut t = Scanned::default();
|
||||
t.reset(Some(scan.clone()));
|
||||
t.insert(43);
|
||||
t.save().unwrap();
|
||||
drop(t);
|
||||
let mut u = Scanned::default();
|
||||
u.reset(Some(scan));
|
||||
assert!(u.contains(42) && u.contains(43) && u.len() == 2);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the recognition commands keep between calls: the loaded model and the embeddings made with it.
|
||||
|
||||
@@ -127,6 +127,17 @@ fn pool_threads_for(cores: usize, pace: Pace, cap: Option<usize>) -> usize {
|
||||
cap.map_or(n, |c| n.min(c.max(1)))
|
||||
}
|
||||
|
||||
/// How much of the GPU face models may take at `pace`: face model runs at once, and the share of the time it may be
|
||||
/// busy. Paused or light: one run at a time, busy a quarter of the time (the user is working, and the window renders
|
||||
/// on the same GPU); normal: two at once, busy most of the time; full: as fast as it goes.
|
||||
pub(crate) fn gpu_pace(pace: Pace) -> (usize, f32) {
|
||||
match pace {
|
||||
Pace::Pause | Pace::Light => (1, 0.25),
|
||||
Pace::Normal => (2, 0.75),
|
||||
Pace::Full => (4, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// One photo's faces to embed (and, if `detect`, to find first).
|
||||
pub(crate) struct Prepared {
|
||||
/// `<model id>@<sha256>` the embeddings are for.
|
||||
@@ -201,7 +212,8 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done {
|
||||
let img = Rgb { data: &rgb, width: w, height: h };
|
||||
let (iw, ih) = embedder.input_size();
|
||||
let scaled = |f: &Face| f.landmarks.map(|(x, y)| (x * w as f32, y * h as f32));
|
||||
let mut results: Vec<(Rect, Option<Vec<f32>>, Rect)> = todo
|
||||
// every face's crop first, then all of them through the model at once (one GPU dispatch for a group photo)
|
||||
let mut faces: Vec<(Rect, Option<Vec<u8>>, Rect)> = todo
|
||||
.into_iter()
|
||||
.map(|rect| {
|
||||
let best = found.iter().map(|f| (overlap(&rect, f), f)).filter(|(o, _)| *o >= MATCH_IOU).max_by(|a, b| a.0.total_cmp(&b.0));
|
||||
@@ -218,20 +230,18 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done {
|
||||
ih,
|
||||
),
|
||||
};
|
||||
let v = crop.and_then(|c| embedder.embed(&c).ok());
|
||||
// shown by the detector's box where it found this face: every face then has the same tightness
|
||||
let view = best.and_then(|(_, f)| face_rect(f)).unwrap_or(rect);
|
||||
(rect, v, view)
|
||||
(rect, crop, view)
|
||||
})
|
||||
.collect();
|
||||
// a photo with no face regions: what the detector is sure of becomes its faces, embedded in the same pass
|
||||
let new_faces = if detect {
|
||||
detected.map(|faces| {
|
||||
detected.map(|found| {
|
||||
let mut rects = Vec::new();
|
||||
for f in faces.iter().filter(|f| f.score >= REGION_SCORE) {
|
||||
for f in found.iter().filter(|f| f.score >= REGION_SCORE) {
|
||||
let Some(rect) = face_rect(f) else { continue };
|
||||
let v = align_to_template(&img, &scaled(f), iw, ih).and_then(|c| embedder.embed(&c).ok());
|
||||
results.push((rect, v, rect));
|
||||
faces.push((rect, align_to_template(&img, &scaled(f), iw, ih), rect));
|
||||
rects.push(rect);
|
||||
}
|
||||
rects
|
||||
@@ -239,6 +249,10 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let crops: Vec<&[u8]> = faces.iter().filter_map(|(_, c, _)| c.as_deref()).collect();
|
||||
let mut embedded = embedder.embed_batch(&crops).into_iter();
|
||||
let results: Vec<(Rect, Option<Vec<f32>>, Rect)> =
|
||||
faces.into_iter().map(|(rect, crop, view)| (rect, crop.and_then(|_| embedded.next().and_then(Result::ok)), view)).collect();
|
||||
(results, new_faces)
|
||||
}));
|
||||
let (results, found) = run.unwrap_or_else(|_| (fallback(rects), None));
|
||||
|
||||
@@ -707,8 +707,9 @@ pub(crate) fn decode_raw_preview(
|
||||
covering: Option<usize>,
|
||||
) -> Option<lightcraft_codecs::Decoded> {
|
||||
let jpeg = stand_in_image(bytes, covering)?;
|
||||
// the colour space is a property of the JPEG; a TIFF stand-in is read by the TIFF reader
|
||||
let is_jpeg = matches!(jpeg, std::borrow::Cow::Owned(_));
|
||||
// the colour space is a property of the JPEG; a TIFF stand-in (the file itself, or a Hasselblad RGB preview
|
||||
// wrapped as a TIFF) and a JPEG XL preview are read by their own decoders
|
||||
let is_jpeg = jpeg.starts_with(&[0xff, 0xd8]);
|
||||
match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) {
|
||||
Some(space) => {
|
||||
let space = match space {
|
||||
|
||||
@@ -105,7 +105,7 @@ mod tests {
|
||||
assert!(japanese(CRAFT_FONTS).next().is_some(), "craft-fonts carries Japanese faces");
|
||||
// Every script face that ships is a UI fallback, and has the glyphs of its script (#736).
|
||||
use ab_glyph::Font;
|
||||
for (script, text) in [("Thai", "ภาพถ่ายสวัสดี๑๒๓"), ("Arab", "صورة")] {
|
||||
for (script, text) in [("Thai", "ภาพถ่ายสวัสดี๑๒๓"), ("Arab", "صورة"), ("Geor", "ფოტო")] {
|
||||
let Some(face) = ui_fallback(CRAFT_FONTS, "Latn", "Regular").into_iter().find(|f| f.covers(script)) else { continue };
|
||||
let font = ab_glyph::FontRef::try_from_slice(face.bytes).expect("parses");
|
||||
assert!(text.chars().all(|c| font.glyph_id(c).0 != 0), "{} draws {script}", face.family);
|
||||
|
||||
@@ -382,6 +382,17 @@ pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec<String> {
|
||||
|
||||
/// [`expand`] with the walk bounded by `limits` → (files, whether a walk stopped at a bound).
|
||||
pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk::Limits) -> (Vec<String>, bool) {
|
||||
expand_noting(paths, skip, limits, None)
|
||||
}
|
||||
|
||||
/// [`expand_within`], handing the listing of every folder that is walked to `note`
|
||||
/// ([`crate::walk::files_in_noting`]).
|
||||
pub(crate) fn expand_noting(
|
||||
paths: &[String],
|
||||
skip: Option<&Path>,
|
||||
limits: crate::walk::Limits,
|
||||
mut note: Option<crate::walk::NoteFolder>,
|
||||
) -> (Vec<String>, bool) {
|
||||
let mut out = Vec::new();
|
||||
let mut truncated = false;
|
||||
for p in paths {
|
||||
@@ -390,7 +401,10 @@ pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk:
|
||||
continue;
|
||||
}
|
||||
if p.is_dir() {
|
||||
let w = crate::walk::files_in(p, skip, limits, is_supported);
|
||||
let w = match note.as_mut() {
|
||||
Some(note) => crate::walk::files_in_noting(p, skip, limits, is_supported, &mut **note),
|
||||
None => crate::walk::files_in(p, skip, limits, is_supported),
|
||||
};
|
||||
if w.truncated {
|
||||
log::warn!(
|
||||
"import: stopped looking in {} after {} entries (limits: {} entries, {} folder levels)",
|
||||
@@ -480,7 +494,7 @@ pub(crate) fn probe_with(probe: &crate::media::FileProbe, paths: &[String]) -> V
|
||||
|
||||
/// Probe `paths` (several at once), counting each in `done`. Stops before the next file once
|
||||
/// `cancel` is set: the files not reached come back as errors.
|
||||
fn probe_all(
|
||||
pub(crate) fn probe_all(
|
||||
probe: Option<&crate::media::FileProbe>,
|
||||
paths: &[String],
|
||||
done: &std::sync::atomic::AtomicUsize,
|
||||
@@ -638,6 +652,11 @@ impl ScanInput {
|
||||
(input, paths.to_vec())
|
||||
}
|
||||
|
||||
/// The folder a scan never enters (the library's own).
|
||||
pub(crate) fn skip(&self) -> Option<&Path> {
|
||||
self.skip.as_deref()
|
||||
}
|
||||
|
||||
/// The library as the scan sees it (shared, for [`crate::sync`], which looks at the same one).
|
||||
pub(crate) fn library(&self) -> std::sync::Arc<lightcraft_catalog::Catalog> {
|
||||
std::sync::Arc::clone(&self.library)
|
||||
@@ -656,9 +675,14 @@ pub fn scan(s: &mut Session, paths: &[String]) -> Vec<ImportCandidate> {
|
||||
|
||||
/// [`scan`] without the session, so it can run on a worker thread. Stops early (returning what it
|
||||
/// has) when `progress.cancel` is set.
|
||||
pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
pub fn scan_with(input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput {
|
||||
let files = expand(paths, input.skip.as_deref());
|
||||
scan_files(input, files, progress)
|
||||
}
|
||||
|
||||
/// [`scan_with`] for files that were found already (`paths` expanded, [`expand`]).
|
||||
pub(crate) fn scan_files(mut input: ScanInput, files: Vec<String>, progress: &ScanProgress) -> ScanOutput {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let by_path = library_files(&input.library, &files);
|
||||
let todo: Vec<String> = files.iter().filter(|f| !by_path.contains_key(Path::new(f))).cloned().collect();
|
||||
progress.total.store(todo.len(), Relaxed);
|
||||
@@ -941,9 +965,11 @@ impl ImportJob {
|
||||
}
|
||||
|
||||
/// What the library held when the import was set up, by path and by content: made once, with
|
||||
/// the first files (on the thread that prepares them), from the library as it was then.
|
||||
fn look_up_library(&mut self) {
|
||||
let Some((library, fresh)) = self.library.take() else { return };
|
||||
/// the first files (on the thread that prepares them), from the library as it was then. The
|
||||
/// snapshot comes back: the files the import is adding are ranked against it for the ownership
|
||||
/// of the shared stem sidecars they read ([`crate::sidecar::ImportStems`]).
|
||||
fn look_up_library(&mut self) -> Option<lightcraft_catalog::Catalog> {
|
||||
let (library, fresh) = self.library.take()?;
|
||||
for p in library.photos() {
|
||||
// a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate
|
||||
if p.deleted && fresh {
|
||||
@@ -956,12 +982,14 @@ impl ImportJob {
|
||||
self.by_hash.insert(h.clone(), Some(p.id));
|
||||
}
|
||||
}
|
||||
Some(library)
|
||||
}
|
||||
|
||||
/// [`ImportJob::prepare`] for files [`ImportJob::expand`] returned.
|
||||
pub fn prepare_files(&mut self, files: Vec<String>, cancel: &std::sync::atomic::AtomicBool) -> Prepared {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
self.look_up_library();
|
||||
// the library as it was, kept for the ownership of the shared stem sidecars below
|
||||
let library = self.look_up_library();
|
||||
let opts = self.opts.clone();
|
||||
let mode = self.mode;
|
||||
let mut out = Prepared { scanned: files.len(), items: Vec::new() };
|
||||
@@ -1001,6 +1029,13 @@ impl ImportJob {
|
||||
let probed: Vec<Result<ProbeInfo, String>> =
|
||||
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
|
||||
crate::memory::release();
|
||||
// Who owns the shared stem sidecar (`IMG_0001.xmp`) among the library's files and the ones
|
||||
// arriving with this import: a JPEG or a TIFF arriving beside its raw reads its own sidecar
|
||||
// and never the raw's packet (issue #1905).
|
||||
let stems = crate::sidecar::ImportStems::for_import(
|
||||
library.as_ref(),
|
||||
todo.iter().zip(&probed).filter_map(|(path, info)| info.as_ref().ok().map(|i| (path.as_str(), i.kind))),
|
||||
);
|
||||
// Files are planned one by one, in order (duplicates, sidecars, names, folders); their
|
||||
// copies or moves — the slow part, reading and writing every byte and verifying it — are
|
||||
// collected and run on several threads (issue #367), then their outcomes take their places.
|
||||
@@ -1039,7 +1074,7 @@ impl ImportJob {
|
||||
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
|
||||
// and names the copy when the file itself has none
|
||||
let raw = info.kind == MediaKind::Raw;
|
||||
let packet = crate::sidecar::find_sidecar(&path, self.naming)
|
||||
let packet = crate::sidecar::find_sidecar_for_import(&stems, self.naming, &path)
|
||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||
.or_else(|| info.xmp.clone().filter(|_| raw));
|
||||
let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera);
|
||||
@@ -1542,6 +1577,7 @@ impl Session {
|
||||
display: None,
|
||||
slide: None,
|
||||
preview_cut: None,
|
||||
display_fit: None,
|
||||
};
|
||||
let embedded = match (&self.media.preview_loader, c.kind) {
|
||||
(Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)),
|
||||
|
||||
+71
-11
@@ -29,6 +29,7 @@ pub mod demo;
|
||||
pub mod denoise;
|
||||
pub mod devices;
|
||||
pub mod display;
|
||||
pub mod doc_path;
|
||||
mod errors;
|
||||
pub mod export;
|
||||
pub mod face_download;
|
||||
@@ -56,6 +57,7 @@ pub mod media;
|
||||
pub mod memory;
|
||||
pub mod merge;
|
||||
mod model_download;
|
||||
pub mod open_path;
|
||||
pub mod originals;
|
||||
mod pdf;
|
||||
pub mod preset_import;
|
||||
@@ -184,6 +186,10 @@ pub struct Interaction {
|
||||
pub label: String,
|
||||
pub photo: PhotoId,
|
||||
pub original: Arc<DevelopSettings>,
|
||||
/// Whether an edit during the interaction changed the crop itself. If none did, the crop the
|
||||
/// photo shows was fitted by Constrain Crop, and the final commit starts again from the original
|
||||
/// crop, so Auto Sync carries the edit and not the active photo's fitted crop.
|
||||
pub crop_edited: bool,
|
||||
}
|
||||
|
||||
/// The most photos an edit may touch for the view to follow it photo by photo; a larger edit is worked out whole.
|
||||
@@ -238,6 +244,8 @@ pub struct Session {
|
||||
/// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on
|
||||
/// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends.
|
||||
pub(crate) skip_auto_write: bool,
|
||||
/// The order of Reload from Disk runs, so an older one finishing late is dropped ([`cmd::reload`]).
|
||||
pub(crate) reload_tickets: cmd::reload::Tickets,
|
||||
/// Photos whose automatic XMP sidecar write failed (their folder offline, read-only or full), written again by
|
||||
/// [`Session::retry_unsaved_sidecars`] once their file is reachable.
|
||||
pub(crate) unsaved_sidecars: std::collections::BTreeSet<PhotoId>,
|
||||
@@ -298,6 +306,10 @@ pub struct Session {
|
||||
pub xmp: sidecar::XmpPrefs,
|
||||
/// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json.
|
||||
pub last_export: Option<serde_json::Value>,
|
||||
/// The options of the last Photo Merge started, per command (`merge.hdr`, `merge.panorama`,
|
||||
/// `merge.hdrPanorama`): the merge dialog opens with them and "… with Last Settings" uses
|
||||
/// them. Persisted in prefs.json.
|
||||
pub last_merge: std::collections::BTreeMap<String, serde_json::Value>,
|
||||
/// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json.
|
||||
pub export_presets: Vec<export::ExportPreset>,
|
||||
/// The Print module's current layout, image settings, page, guides and job (`print.*`). Lives
|
||||
@@ -428,6 +440,7 @@ impl Session {
|
||||
pending_sidecar_repairs: Vec::new(),
|
||||
interaction: None,
|
||||
skip_auto_write: false,
|
||||
reload_tickets: Default::default(),
|
||||
unsaved_sidecars: Default::default(),
|
||||
denoise: Default::default(),
|
||||
face_models_dir: None,
|
||||
@@ -459,6 +472,7 @@ impl Session {
|
||||
library_identity: Arc::new(()),
|
||||
xmp: sidecar::XmpPrefs::default(),
|
||||
last_export: None,
|
||||
last_merge: Default::default(),
|
||||
export_presets: Vec::new(),
|
||||
print: Default::default(),
|
||||
print_templates: Vec::new(),
|
||||
@@ -874,22 +888,58 @@ impl Session {
|
||||
/// undo step; otherwise it is committed with a history entry.
|
||||
pub fn set_develop(&mut self, id: PhotoId, settings: DevelopSettings, label: &str) -> Result<()> {
|
||||
let now = (self.clock)();
|
||||
let settings = Arc::new(settings);
|
||||
if let Some(i) = &self.interaction
|
||||
// Auto Sync carries the edit as asked; each photo then fits its own crop (review of #1990)
|
||||
let requested = settings;
|
||||
let settings = Arc::new(self.fit_constrained_crop(id, requested.clone()));
|
||||
let crop_edited = self.develop_of(id).is_some_and(|d| d.crop.geometry != requested.crop.geometry);
|
||||
if let Some(i) = &mut self.interaction
|
||||
&& i.photo == id
|
||||
{
|
||||
i.crop_edited |= crop_edited;
|
||||
return self.apply_silent(Op::SetDevelop { id, settings, label: label.into(), edited: Some(now) });
|
||||
}
|
||||
let mut ops = vec![self.develop_op(id, (*settings).clone(), label).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?];
|
||||
ops.extend(self.auto_sync_ops(id, &settings, label));
|
||||
ops.extend(self.auto_sync_ops(id, &requested, label));
|
||||
let op = if ops.len() == 1 { ops.remove(0) } else { Op::Batch { ops } };
|
||||
self.commit(label, op)
|
||||
}
|
||||
|
||||
/// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`) over to the
|
||||
/// other selected photos: only the settings that changed; never spot removal or red eye (they
|
||||
/// belong to one photo's pixels), nor the rendering process (Sync doesn't carry it either), nor
|
||||
/// history / snapshot restores.
|
||||
/// "Constrain Crop" keeps the stored crop inside the warped photo, as Lightroom does (issue
|
||||
/// #1927): turning it on, or an edit that changes the warp (Geometry, Upright, lens corrections,
|
||||
/// their sections' eyes) while it is on, shrinks the crop about its centre until no blank corner
|
||||
/// is left, so the Crop tool shows what the render keeps. An edit of the crop itself is left as
|
||||
/// drawn (the render still constrains it).
|
||||
fn fit_constrained_crop(&self, id: PhotoId, mut d: DevelopSettings) -> DevelopSettings {
|
||||
if !d.geometry.constrain_crop {
|
||||
return d;
|
||||
}
|
||||
let Some(photo) = self.catalog.photo(id) else { return d };
|
||||
let before = &photo.develop;
|
||||
let turned_on = !before.geometry.constrain_crop;
|
||||
let warp_changed = before.geometry != d.geometry
|
||||
|| before.optics != d.optics
|
||||
|| before.orientation != d.orientation
|
||||
|| ["geometry", "optics"].iter().any(|s| before.section_enabled(s) != d.section_enabled(s));
|
||||
let crop_edited = before.crop.geometry != d.crop.geometry;
|
||||
if !(turned_on || (warp_changed && !crop_edited)) {
|
||||
return d;
|
||||
}
|
||||
let frame = lightcraft_pipeline::geometry::Frame::with_lens(
|
||||
photo.width.max(1) as usize,
|
||||
photo.height.max(1) as usize,
|
||||
&d,
|
||||
true,
|
||||
photo.embedded_lens.as_ref(),
|
||||
);
|
||||
d.crop.geometry.rect = frame.crop.rect;
|
||||
d
|
||||
}
|
||||
|
||||
/// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`, as asked,
|
||||
/// before its crop is fitted) over to the other selected photos: only the settings that changed;
|
||||
/// never spot removal or red eye (they belong to one photo's pixels), nor the rendering process
|
||||
/// (Sync doesn't carry it either), nor history / snapshot restores. Constrain Crop then fits each
|
||||
/// photo's own crop: the active photo's fitted crop is never copied over another's.
|
||||
fn auto_sync_ops(&self, id: PhotoId, new: &DevelopSettings, label: &str) -> Vec<Op> {
|
||||
if !self.auto_sync
|
||||
|| self.active() != Some(id)
|
||||
@@ -915,7 +965,7 @@ impl Session {
|
||||
.filter(|x| **x != id)
|
||||
.filter_map(|x| self.develop_of(*x).map(|d| (*x, d)))
|
||||
.filter_map(|(x, d)| {
|
||||
let synced = lightcraft_develop::apply_partial(&d, &delta, 1.0);
|
||||
let synced = self.fit_constrained_crop(x, lightcraft_develop::apply_partial(&d, &delta, 1.0));
|
||||
(synced != *d).then(|| self.develop_op(x, synced, label)).flatten()
|
||||
})
|
||||
.collect()
|
||||
@@ -937,7 +987,7 @@ impl Session {
|
||||
}
|
||||
let id = self.active().ok_or_else(|| EngineError::Other("no active photo".into()))?;
|
||||
let original = self.develop_of(id).unwrap_or_default();
|
||||
self.interaction = Some(Interaction { label: label.into(), photo: id, original });
|
||||
self.interaction = Some(Interaction { label: label.into(), photo: id, original, crop_edited: false });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -948,9 +998,15 @@ impl Session {
|
||||
if *cur == *i.original {
|
||||
return Ok(());
|
||||
}
|
||||
// Restore the original silently, then commit the final value as one step.
|
||||
// Restore the original silently, then commit the final value as one step. A crop nobody
|
||||
// edited goes back to the original's: Constrain Crop fits it again for the final warp, and
|
||||
// Auto Sync carries the edit, not the active photo's fitted crop (review of #1990).
|
||||
self.apply_silent(Op::SetDevelop { id: i.photo, settings: i.original.clone(), label: i.label.clone(), edited: None })?;
|
||||
self.set_develop(i.photo, (*cur).clone(), &i.label)
|
||||
let mut last = (*cur).clone();
|
||||
if !i.crop_edited {
|
||||
last.crop.geometry = i.original.crop.geometry;
|
||||
}
|
||||
self.set_develop(i.photo, last, &i.label)
|
||||
}
|
||||
|
||||
pub fn cancel_interaction(&mut self) -> Result<()> {
|
||||
@@ -1242,8 +1298,12 @@ mod tests_albums_local;
|
||||
#[cfg(test)]
|
||||
mod tests_color;
|
||||
#[cfg(test)]
|
||||
mod tests_constrain_crop;
|
||||
#[cfg(test)]
|
||||
mod tests_crop_angle;
|
||||
#[cfg(test)]
|
||||
mod tests_crop_aspect;
|
||||
#[cfg(test)]
|
||||
mod tests_denoise;
|
||||
#[cfg(test)]
|
||||
mod tests_export;
|
||||
|
||||
@@ -84,6 +84,9 @@ pub struct Library {
|
||||
/// Why the last append of queued ops failed; `None` once a save succeeds. While set, changes
|
||||
/// exist only in memory ([`Session::unsaved`]).
|
||||
pub unsaved_error: Option<String>,
|
||||
/// Why the last write of presets.json failed; `None` once it is written. While set, the preset
|
||||
/// changes exist only in memory and count as unsaved ([`Session::unsaved`], issue #2266).
|
||||
presets_error: Option<String>,
|
||||
/// When the frame loop may retry a failed append ([`Session::persist_if_dirty`] backs off).
|
||||
retry_at: Option<web_time::Instant>,
|
||||
/// What forgetting untouched Local records did when the library opened.
|
||||
@@ -207,6 +210,8 @@ struct PrefsFile {
|
||||
/// Parameters of the last export (for Export with Previous).
|
||||
#[serde(alias = "last_export")]
|
||||
last_export: Option<serde_json::Value>,
|
||||
/// Options of the last Photo Merge, per command.
|
||||
last_merge: std::collections::BTreeMap<String, serde_json::Value>,
|
||||
/// The user's export presets.
|
||||
export_presets: Vec<crate::export::ExportPreset>,
|
||||
/// Print templates.
|
||||
@@ -492,6 +497,7 @@ impl Session {
|
||||
let prefs = settings.read::<PrefsFile>(files.as_mut(), "prefs.json").unwrap_or_default();
|
||||
self.xmp = prefs.xmp;
|
||||
self.last_export = prefs.last_export;
|
||||
self.last_merge = prefs.last_merge;
|
||||
self.export_presets = prefs.export_presets;
|
||||
self.print_templates = prefs.print_templates;
|
||||
self.metadata_presets = prefs.metadata_presets;
|
||||
@@ -574,6 +580,7 @@ impl Session {
|
||||
report,
|
||||
last_error: None,
|
||||
unsaved_error: None,
|
||||
presets_error: None,
|
||||
retry_at: None,
|
||||
forgot_local: None,
|
||||
presets_saved,
|
||||
@@ -648,10 +655,17 @@ impl Session {
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
if state.hash == lib.presets_saved.hash {
|
||||
lib.presets_saved = state;
|
||||
lib.presets_error = None;
|
||||
} else if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) {
|
||||
// the state stays stale: the next save tries again
|
||||
// the state stays stale: retried by the next command and the frame loop (backing off);
|
||||
// meanwhile the presets count as unsaved, so the UI warns and a backup refuses
|
||||
log::error!("library: presets: {e}");
|
||||
lib.presets_error = Some(format!("presets: {e}"));
|
||||
lib.retry_at = Some(web_time::Instant::now() + RETRY_BACKOFF);
|
||||
} else {
|
||||
if lib.presets_error.take().is_some() {
|
||||
log::info!("library: presets written to disk");
|
||||
}
|
||||
lib.presets_saved = state;
|
||||
}
|
||||
Ok(())
|
||||
@@ -661,7 +675,8 @@ impl Session {
|
||||
/// changed outside [`Session::execute`]).
|
||||
pub fn persist_if_dirty(&mut self) {
|
||||
let backing_off = self.library.as_ref().and_then(|l| l.retry_at).is_some_and(|t| web_time::Instant::now() < t);
|
||||
if self.library.is_some() && !self.pending_log.is_empty() && !backing_off {
|
||||
let presets_unsaved = self.library.as_ref().is_some_and(|l| l.presets_error.is_some());
|
||||
if self.library.is_some() && (!self.pending_log.is_empty() || presets_unsaved) && !backing_off {
|
||||
let _ = self.persist();
|
||||
} else if let Some(lib) = self.library.as_mut() {
|
||||
poll_compaction(lib);
|
||||
@@ -669,11 +684,14 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Changes applied in memory but not yet written to disk because saving failed: the number of
|
||||
/// queued ops and why (`None` when everything is saved, or no library is open). Retried by
|
||||
/// every command and by [`Session::persist_if_dirty`].
|
||||
/// queued ops (plus one for presets.json) and why (`None` when everything is saved, or no library
|
||||
/// is open). Retried by every command and by [`Session::persist_if_dirty`].
|
||||
pub fn unsaved(&self) -> Option<(usize, &str)> {
|
||||
let lib = self.library.as_ref()?;
|
||||
lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty()).map(|e| (self.pending_log.len(), e))
|
||||
let ops = lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty());
|
||||
let presets = lib.presets_error.as_deref();
|
||||
let n = ops.map_or(0, |_| self.pending_log.len()) + usize::from(presets.is_some());
|
||||
ops.or(presets).map(|why| (n, why))
|
||||
}
|
||||
|
||||
/// Flush everything and write a snapshot (on quit). Ends an open interaction first.
|
||||
@@ -746,6 +764,7 @@ impl Session {
|
||||
let v = serde_json::to_vec_pretty(&PrefsFile {
|
||||
xmp: self.xmp,
|
||||
last_export: self.last_export.clone(),
|
||||
last_merge: self.last_merge.clone(),
|
||||
export_presets: self.export_presets.clone(),
|
||||
print_templates: self.print_templates.clone(),
|
||||
metadata_presets: self.metadata_presets.clone(),
|
||||
|
||||
@@ -524,6 +524,7 @@ mod tests {
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,6 +539,7 @@ mod tests {
|
||||
keywords: vec!["Trips|Goa".into()],
|
||||
history: vec![BTreeMap::from([("text".into(), serde_json::json!("step")), ("curve".into(), serde_json::json!([0, 128, 255]))])],
|
||||
snapshots: vec![BTreeMap::from([("name".into(), serde_json::json!("before"))])],
|
||||
original: crate::lightroom_catalog::Original::Local,
|
||||
};
|
||||
CatalogImport {
|
||||
source: "source.lrcat".into(),
|
||||
@@ -546,6 +548,7 @@ mod tests {
|
||||
members: vec![BTreeMap::from([("image".into(), serde_json::json!(7))])],
|
||||
collection_content: vec![BTreeMap::from([("rule".into(), serde_json::json!("rating > 3"))])],
|
||||
warnings: vec!["one warning".into()],
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,6 +624,7 @@ mod tests {
|
||||
keywords: Vec::new(),
|
||||
history: vec![BTreeMap::from([("text".into(), serde_json::json!(format!("history-{index}-{}", "x".repeat(8 * 1024))))])],
|
||||
snapshots: Vec::new(),
|
||||
original: crate::lightroom_catalog::Original::Local,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(store_best_effort(&path, &input).unwrap(), None);
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
//! Lightroom, Python and a C SQLite runtime are not needed. Original paths stay in place.
|
||||
//! Raw settings are archived before mutation; mapped edits are approximations, not Adobe renders.
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
use crate::lightroom_sqlite::{Database, LiveTable, Value as SqlValue};
|
||||
use crate::lightroom_sqlite::{Database, FileSource, LiveTable, Value as SqlValue};
|
||||
use lightcraft_catalog::{Album, Flag, Op, Photo, PhotoId, Source};
|
||||
use lightcraft_geom::{Affine, Orientation, Point, Rect};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -146,6 +146,31 @@ pub struct CatalogPhoto {
|
||||
pub keywords: Vec<String>,
|
||||
pub history: Vec<Row>,
|
||||
pub snapshots: Vec<Row>,
|
||||
/// Whether `path` may be looked at (derived when the catalog is read; not archived).
|
||||
#[serde(skip)]
|
||||
pub original: Original,
|
||||
}
|
||||
|
||||
/// Whether the original a catalog lists may be looked at. Its path is resolved with the catalog
|
||||
/// as the document ([`crate::doc_path::resolve`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Original {
|
||||
/// `path` is a local file path, or one on the network share the catalog is on.
|
||||
Local,
|
||||
/// `path` is not a path on this computer (a catalog made on another system): the photo is
|
||||
/// kept as an offline record for later relinking, and the path is not looked at.
|
||||
#[default]
|
||||
NotHere,
|
||||
/// `path` is refused (why): it is on another computer or a device, or its folder or file name
|
||||
/// is an absolute path. The photo is reported missing and not added to the library.
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
impl CatalogPhoto {
|
||||
/// Whether the original is a file here; only a [`Original::Local`] path is looked at.
|
||||
pub fn original_is_file(&self) -> bool {
|
||||
self.original == Original::Local && Path::new(&self.path).is_file()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -156,6 +181,34 @@ pub struct CatalogImport {
|
||||
pub members: Vec<Row>,
|
||||
pub collection_content: Vec<Row>,
|
||||
pub warnings: Vec<String>,
|
||||
/// Catalog images left out because their records are incomplete; reported as data (not text)
|
||||
/// so each front end can say so in its own language.
|
||||
#[serde(default)]
|
||||
pub skipped: Vec<SkippedImage>,
|
||||
}
|
||||
|
||||
/// A catalog image that was not imported, and why.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SkippedImage {
|
||||
/// The image's `Adobe_images.id_local`.
|
||||
pub image: i64,
|
||||
pub reason: SkipReason,
|
||||
}
|
||||
|
||||
/// Why a catalog image was not imported. Lightroom leaves such records behind and ignores them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SkipReason {
|
||||
/// Its `AgLibraryFile` record is gone.
|
||||
MissingFile,
|
||||
/// Its file's `AgLibraryFolder` record is gone.
|
||||
MissingFolder,
|
||||
/// Its folder's `AgLibraryRootFolder` record is gone.
|
||||
MissingRoot,
|
||||
/// Its file name or root folder path is empty.
|
||||
IncompletePath,
|
||||
/// A virtual copy whose master was skipped.
|
||||
MasterSkipped,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
@@ -343,25 +396,36 @@ fn committed_wal_len(main: &[u8], wal: &[u8]) -> Result<usize, String> {
|
||||
|
||||
/// Where a root folder is when its `absolutePath` isn't a folder here (the catalog came from
|
||||
/// another computer): `relativePathFromCatalog`, which Lightroom records for roots on the
|
||||
/// catalog's volume, resolved against the catalog's folder, if that is a folder.
|
||||
/// catalog's volume, resolved against the catalog's folder, if that is a folder. Both paths are
|
||||
/// looked at only when [`crate::doc_path::resolve`] follows them.
|
||||
fn relocated_root(absolute: &str, relative: &str, catalog: &Path) -> Option<PathBuf> {
|
||||
if relative.is_empty() || !Path::new(relative).is_relative() || Path::new(absolute).is_dir() {
|
||||
if relative.is_empty() || !Path::new(relative).is_relative() || crate::doc_path::resolve(absolute, Some(catalog)).is_ok_and(|p| p.is_dir()) {
|
||||
return None;
|
||||
}
|
||||
let dir = std::path::absolute(catalog).ok()?.parent()?.join(relative);
|
||||
let mut out = PathBuf::new();
|
||||
for part in dir.components() {
|
||||
match part {
|
||||
Component::ParentDir => {
|
||||
if !out.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Component::CurDir => {}
|
||||
part => out.push(part),
|
||||
}
|
||||
crate::doc_path::resolve(relative, Some(catalog)).ok().filter(|dir| dir.is_dir())
|
||||
}
|
||||
|
||||
/// Whether a folder path or file name from the catalog is absolute: `Path::join` would put it in
|
||||
/// place of the root folder.
|
||||
fn is_absolute_part(part: &str) -> bool {
|
||||
let bytes = part.as_bytes();
|
||||
part.starts_with(['/', '\\'])
|
||||
|| (bytes.first().is_some_and(u8::is_ascii_alphabetic) && bytes.get(1) == Some(&b':'))
|
||||
|| Path::new(part).is_absolute()
|
||||
}
|
||||
|
||||
/// The path of an original under `root` and whether it may be looked at, resolved with the
|
||||
/// catalog at `catalog` as the document.
|
||||
fn original_path(root: &str, folder: &str, name: &str, catalog: &Path) -> (String, Original) {
|
||||
let stored = Path::new(root).join(folder).join(name).to_string_lossy().to_string();
|
||||
if is_absolute_part(folder) || is_absolute_part(name) {
|
||||
return (stored, Original::Refused("its folder or file name is an absolute path".into()));
|
||||
}
|
||||
match crate::doc_path::resolve(&stored, Some(catalog)) {
|
||||
Ok(found) => (found.to_string_lossy().to_string(), Original::Local),
|
||||
Err(crate::doc_path::NotFollowed::NotHere) => (stored, Original::NotHere),
|
||||
Err(crate::doc_path::NotFollowed::Refused(why)) => (stored, Original::Refused(why.to_string())),
|
||||
}
|
||||
out.is_dir().then_some(out)
|
||||
}
|
||||
|
||||
/// Inspect `.lrcat` plus committed WAL pages without modifying either source file.
|
||||
@@ -372,6 +436,9 @@ pub fn read(path: &Path) -> Result<CatalogImport, String> {
|
||||
read_with_progress(path, &cancel, &total, &done)
|
||||
}
|
||||
|
||||
/// Read a Lightroom Classic catalog for import, counting the tables read in `total` / `done` and
|
||||
/// stopping when `cancel` is set. Images whose catalog records are incomplete are left out and
|
||||
/// listed in [`CatalogImport::skipped`] instead of failing the read.
|
||||
pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, done: &AtomicUsize) -> Result<CatalogImport, String> {
|
||||
// 13 catalog tables plus one validation stage; ImportJob adds one unit per original path.
|
||||
total.store(14, Ordering::Relaxed);
|
||||
@@ -380,15 +447,15 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
let before = std::fs::metadata(path).map_err(|e| e.to_string())?;
|
||||
let wal_path = PathBuf::from(format!("{}-wal", path.display()));
|
||||
let wal_before = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok()));
|
||||
let main = bytes(path)?;
|
||||
let main = FileSource::open(path)?;
|
||||
let wal = match std::fs::metadata(&wal_path) {
|
||||
Ok(_) => bytes(&wal_path)?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
let wal_len = committed_wal_len(&main, &wal)?;
|
||||
let wal_len = committed_wal_len(main.header(), &wal)?;
|
||||
ensure_read_active(cancel)?;
|
||||
let db = Database::open_with_wal(main, &wal[..wal_len]).map_err(|e| format!("invalid SQLite catalog: {e:?}"))?;
|
||||
let db = Database::open_file_with_wal(main, &wal[..wal_len]).map_err(|e| format!("invalid SQLite catalog: {e:?}"))?;
|
||||
let after = std::fs::metadata(path).map_err(|e| e.to_string())?;
|
||||
let wal_after = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok()));
|
||||
if before.len() != after.len() || before.modified().ok() != after.modified().ok() || wal_before != wal_after {
|
||||
@@ -457,6 +524,7 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
out
|
||||
};
|
||||
let mut root_paths = HashMap::new();
|
||||
let mut refused_roots = HashSet::new();
|
||||
let mut root_ids: Vec<_> = roots.keys().copied().collect();
|
||||
root_ids.sort_unstable();
|
||||
for (id, root) in root_ids.iter().filter_map(|id| Some((*id, roots.get(id)?))) {
|
||||
@@ -469,6 +537,11 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
}
|
||||
None => absolute.to_string(),
|
||||
};
|
||||
if let Err(crate::doc_path::NotFollowed::Refused(why)) = crate::doc_path::resolve(&resolved, Some(path)) {
|
||||
warnings
|
||||
.push(format!("root folder {}: {resolved} is not opened: {why}; its photos are listed as missing and not added", text(root, "name")));
|
||||
refused_roots.insert(id);
|
||||
}
|
||||
root_paths.insert(id, resolved);
|
||||
}
|
||||
let mut history = group(optional_table("Adobe_libraryImageDevelopHistoryStep", &mut warnings)?);
|
||||
@@ -480,20 +553,43 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
members: optional_table("AgLibraryCollectionImage", &mut warnings)?,
|
||||
collection_content: optional_table("AgLibraryCollectionContent", &mut warnings)?,
|
||||
warnings,
|
||||
skipped: Vec::new(),
|
||||
};
|
||||
// images whose file, folder or root record is gone, or whose path is empty (Lightroom leaves
|
||||
// these behind and ignores them); they and their virtual copies are skipped and reported
|
||||
// instead of failing the import
|
||||
let mut orphans = HashSet::new();
|
||||
for image in table("Adobe_images", true)? {
|
||||
ensure_read_active(cancel)?;
|
||||
let id = number(&image, "id_local");
|
||||
let file = files.get(&number(&image, "rootFile")).ok_or_else(|| format!("image {id}: missing file record"))?;
|
||||
let folder = folders.get(&number(file, "folder")).ok_or_else(|| format!("image {id}: missing folder record"))?;
|
||||
let root = roots.get(&number(folder, "rootFolder")).ok_or_else(|| format!("image {id}: missing root record"))?;
|
||||
let root_path = root_paths.get(&number(root, "id_local")).map_or("", String::as_str);
|
||||
let record = files.get(&number(&image, "rootFile")).ok_or(SkipReason::MissingFile).and_then(|file| {
|
||||
let folder = folders.get(&number(file, "folder")).ok_or(SkipReason::MissingFolder)?;
|
||||
let root = roots.get(&number(folder, "rootFolder")).ok_or(SkipReason::MissingRoot)?;
|
||||
Ok((file, folder, root))
|
||||
});
|
||||
let (file, folder, root) = match record {
|
||||
Ok(found) => found,
|
||||
Err(reason) => {
|
||||
out.skipped.push(SkippedImage { image: id, reason });
|
||||
orphans.insert(id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let root_id = number(root, "id_local");
|
||||
let root_path = root_paths.get(&root_id).map_or("", String::as_str);
|
||||
let folder_path = text(folder, "pathFromRoot");
|
||||
let name = text(file, "idx_filename");
|
||||
if root_path.is_empty() || name.is_empty() {
|
||||
return Err(format!("image {id}: incomplete original path"));
|
||||
out.skipped.push(SkippedImage { image: id, reason: SkipReason::IncompletePath });
|
||||
orphans.insert(id);
|
||||
continue;
|
||||
}
|
||||
let (photo_path, original) = original_path(root_path, folder_path, name, path);
|
||||
if let Original::Refused(why) = &original
|
||||
&& !refused_roots.contains(&root_id)
|
||||
{
|
||||
out.warnings.push(format!("image {id}: {photo_path} is not opened: {why}; listed as missing and not added"));
|
||||
}
|
||||
let path = Path::new(root_path).join(folder_path).join(name).to_string_lossy().to_string();
|
||||
let packet = match xmp(metadata.get(&id).and_then(|r| r.get("xmp"))) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
@@ -504,19 +600,89 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
out.photos.push(CatalogPhoto {
|
||||
source_id: id,
|
||||
uuid: text(&image, "id_global").into(),
|
||||
path,
|
||||
path: photo_path,
|
||||
image,
|
||||
settings: develops.get(&id).cloned().unwrap_or_default(),
|
||||
xmp: packet,
|
||||
keywords: keywords.remove(&id).unwrap_or_default(),
|
||||
history: history.remove(&id).unwrap_or_default(),
|
||||
snapshots: snapshots.remove(&id).unwrap_or_default(),
|
||||
original,
|
||||
});
|
||||
}
|
||||
drop_copies_of(&mut out, &orphans);
|
||||
refuse_non_media(&mut out);
|
||||
ensure_read_active(cancel)?;
|
||||
reader.db.ensure_unchanged()?;
|
||||
let wal_after = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok()));
|
||||
if wal_before != wal_after {
|
||||
return Err("catalog changed during reading; close Lightroom and retry".into());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Drop the virtual copies whose master was skipped, adding them to the skipped images: a copy has
|
||||
/// no original of its own.
|
||||
fn drop_copies_of(data: &mut CatalogImport, masters: &HashSet<i64>) {
|
||||
if masters.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut skipped = Vec::new();
|
||||
data.photos.retain(|p| {
|
||||
let master = number(&p.image, "masterImage");
|
||||
// 0 marks a master, never a copy
|
||||
if master == 0 || !masters.contains(&master) {
|
||||
return true;
|
||||
}
|
||||
skipped.push(SkippedImage { image: p.source_id, reason: SkipReason::MasterSkipped });
|
||||
false
|
||||
});
|
||||
data.skipped.extend(skipped);
|
||||
}
|
||||
|
||||
/// Drop the catalog's images whose original is not a file type LightCraft reads, saying so in the
|
||||
/// import's warnings.
|
||||
///
|
||||
/// The catalog decides these paths, and a record it leaves behind is one click from the system
|
||||
/// opener (Photo ▸ Edit In ▸ Open Original in Other Editor). A record for `payload.exe`,
|
||||
/// `start.lnk` or `Evil.app` would make a `.lrcat` someone was sent a way to have a program run
|
||||
/// (issue #2135), so such an image never becomes a photo: not imported, not relinkable, not
|
||||
/// openable. A virtual copy of a refused original goes with it — it has no original of its own.
|
||||
///
|
||||
/// Called where the catalog is read and again where its photos become records, so neither route
|
||||
/// can keep one; the second pass finds nothing left to say.
|
||||
pub(crate) fn refuse_non_media(data: &mut CatalogImport) {
|
||||
// masters first: a virtual copy may come before its master in the table
|
||||
let refused: HashSet<i64> = data
|
||||
.photos
|
||||
.iter()
|
||||
.filter(|p| number(&p.image, "masterImage") == 0 && !crate::open_path::is_media(Path::new(&p.path)))
|
||||
.map(|p| p.source_id)
|
||||
.collect();
|
||||
if refused.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
data.photos.retain(|p| {
|
||||
let master = number(&p.image, "masterImage");
|
||||
// `masterImage` is 0 for a master, so a refused id of 0 (a malformed catalog) must not
|
||||
// take every master of the catalog with it
|
||||
if master != 0 {
|
||||
if !refused.contains(&master) {
|
||||
return true;
|
||||
}
|
||||
warnings.push(format!("image {}: not imported; its original was not imported", p.source_id));
|
||||
return false;
|
||||
}
|
||||
if !refused.contains(&p.source_id) {
|
||||
return true;
|
||||
}
|
||||
warnings.push(format!("image {}: not imported; {} isn't one of the file types LightCraft reads", p.source_id, p.path));
|
||||
false
|
||||
});
|
||||
data.warnings.extend(warnings);
|
||||
}
|
||||
|
||||
fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> {
|
||||
if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) }
|
||||
}
|
||||
@@ -665,11 +831,11 @@ fn apply(s: &mut crate::Session, data: CatalogImport, update_existing: bool) ->
|
||||
let paths: Vec<_> = data
|
||||
.photos
|
||||
.iter()
|
||||
.filter(|p| number(&p.image, "masterImage") == 0 && !existing.contains_key(&key(&p.path)) && Path::new(&p.path).is_file())
|
||||
.filter(|p| number(&p.image, "masterImage") == 0 && !existing.contains_key(&key(&p.path)) && p.original_is_file())
|
||||
.map(|p| p.path.clone())
|
||||
.collect();
|
||||
let missing_sources: HashSet<String> =
|
||||
data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !Path::new(&p.path).is_file()).map(|p| key(&p.path)).collect();
|
||||
data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !p.original_is_file()).map(|p| key(&p.path)).collect();
|
||||
let files = crate::import::import(s, &paths, crate::import::ImportMode::Add)?;
|
||||
let applied =
|
||||
apply_prepared(s, data, ApplyPrepared { update_existing, files, index: &mut index, archive_path, missing_sources: &missing_sources, undo0 })?;
|
||||
@@ -692,8 +858,12 @@ pub(crate) struct ApplyPrepared<'a> {
|
||||
pub(crate) undo0: usize,
|
||||
}
|
||||
|
||||
pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result<AppliedImport> {
|
||||
/// Turn the read catalog into library records in one undo step and return the import report
|
||||
/// (added, missing, failed, warnings and skipped images).
|
||||
pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result<AppliedImport> {
|
||||
let ApplyPrepared { update_existing, files, index, archive_path, missing_sources, undo0 } = context;
|
||||
// nothing but a file type LightCraft reads becomes a record, whichever route got here (#2135)
|
||||
refuse_non_media(&mut data);
|
||||
validate(&data).map_err(error)?;
|
||||
let mut warnings = data.warnings.clone();
|
||||
let duplicates: HashMap<_, _> = files.duplicates.iter().filter_map(|d| d.existing.map(|id| (key(&d.path), PhotoId(id)))).collect();
|
||||
@@ -713,12 +883,18 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
|
||||
let mut photos: Vec<_> = data.photos.iter().collect();
|
||||
photos.sort_by_key(|p| number(&p.image, "masterImage") != 0);
|
||||
let copy_masters: HashSet<_> = data.photos.iter().map(|p| number(&p.image, "masterImage")).filter(|id| *id != 0).collect();
|
||||
let refused: HashSet<_> = data.photos.iter().filter(|p| matches!(p.original, Original::Refused(_))).map(|p| p.source_id).collect();
|
||||
let mut planned: HashMap<PhotoId, Photo> = HashMap::new();
|
||||
for src in photos {
|
||||
let master = number(&src.image, "masterImage");
|
||||
if master == 0 && missing_sources.contains(&key(&src.path)) {
|
||||
missing.push(src.path.clone());
|
||||
}
|
||||
// A refused path must not become a library record the app would look at later; its
|
||||
// virtual copies go with it.
|
||||
if refused.contains(&src.source_id) || refused.contains(&master) {
|
||||
continue;
|
||||
}
|
||||
let indexed = index
|
||||
.photos
|
||||
.get(&identity(&data, src))
|
||||
@@ -917,7 +1093,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
|
||||
}
|
||||
s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog");
|
||||
Ok(AppliedImport {
|
||||
report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"unmapped":unmapped,"archive":archive_path,"mapping":ids}),
|
||||
report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"skipped":data.skipped,"unmapped":unmapped,"archive":archive_path,"mapping":ids}),
|
||||
index: index.clone(),
|
||||
})
|
||||
}
|
||||
@@ -940,6 +1116,7 @@ mod tests {
|
||||
keywords: vec!["Travel|Goa".into()],
|
||||
history: vec![row(json!({"text":"s = { Exposure2012 = 0.5 }"}))],
|
||||
snapshots: Vec::new(),
|
||||
original: Original::Local,
|
||||
};
|
||||
let mut copy = photo.clone();
|
||||
copy.source_id = 2;
|
||||
@@ -960,8 +1137,51 @@ mod tests {
|
||||
],
|
||||
collection_content: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A crafted `.lrcat` that names a program as a photo's original gets no record for it, so
|
||||
/// Photo ▸ Edit In ▸ Open Original has nothing to hand the system opener (#2135). Its virtual
|
||||
/// copies go with it; the catalog's real photos still import.
|
||||
#[test]
|
||||
fn a_catalog_original_that_is_not_a_photo_file_is_not_imported() {
|
||||
let mut data = sample();
|
||||
let mut exe = data.photos[1].clone();
|
||||
exe.source_id = 3;
|
||||
exe.uuid = "payload".into();
|
||||
exe.path = "/Users/sky/Downloads/payload.exe".into();
|
||||
exe.image = row(json!({"id_local":3,"fileFormat":"ARW","fileWidth":20,"fileHeight":10}));
|
||||
let mut exe_copy = exe.clone();
|
||||
exe_copy.source_id = 4;
|
||||
exe_copy.uuid = "payload-copy".into();
|
||||
exe_copy.path = "/Users/sky/Downloads/payload.exe".into();
|
||||
exe_copy.image = row(json!({"id_local":4,"masterImage":3,"copyName":"Mono"}));
|
||||
// the copy comes before its master, as a catalog's table order may have it
|
||||
data.photos.insert(0, exe_copy);
|
||||
data.photos.push(exe);
|
||||
for bad in [r"C:\Users\sky\start.lnk", "/Applications/Evil.app", "/Users/sky/go.command", "/Users/sky/run.bat"] {
|
||||
let mut one = data.clone();
|
||||
one.photos.retain(|p| p.source_id < 3);
|
||||
let mut planted = one.photos[1].clone();
|
||||
planted.source_id = 5;
|
||||
planted.uuid = "planted".into();
|
||||
planted.path = bad.into();
|
||||
planted.image = row(json!({"id_local":5,"fileFormat":"ARW"}));
|
||||
one.photos.push(planted);
|
||||
let mut s = crate::Session::new();
|
||||
let r = apply(&mut s, one, false).unwrap();
|
||||
assert_eq!(r["photos"], 2, "{bad} is not catalogued: {r}");
|
||||
assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path == bad)), "{bad} became a library record");
|
||||
}
|
||||
let mut s = crate::Session::new();
|
||||
let r = apply(&mut s, data, false).unwrap();
|
||||
assert_eq!(r["photos"], 2, "only the real master and its copy: {r}");
|
||||
assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path.ends_with(".exe"))), "the program was catalogued");
|
||||
let warnings = r["warnings"].to_string();
|
||||
assert!(warnings.contains("payload.exe") && warnings.contains("file types LightCraft reads"), "{warnings}");
|
||||
assert!(warnings.contains("image 4: not imported"), "the virtual copy of the refused original goes with it: {warnings}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_local_master_and_copy_join_library_with_one_undo_step() {
|
||||
let mut s = crate::Session::new();
|
||||
@@ -1377,6 +1597,16 @@ mod tests {
|
||||
}
|
||||
/// A one-row-per-table SQLite file: `(name, columns, row)` each.
|
||||
fn native_catalog(tables: &[(&str, &str, Vec<SqlValue>)]) -> Vec<u8> {
|
||||
native_catalog_with_last_table_page(tables, None)
|
||||
}
|
||||
/// [`native_catalog`] whose last table claims `last_page` as its root page.
|
||||
fn native_catalog_with_last_table_page(tables: &[(&str, &str, Vec<SqlValue>)], last_page: Option<i64>) -> Vec<u8> {
|
||||
let tables: Vec<_> = tables.iter().map(|(name, columns, row)| (*name, *columns, vec![row.clone()])).collect();
|
||||
native_catalog_rows(&tables, last_page)
|
||||
}
|
||||
/// A SQLite file with one leaf page per table: `(name, columns, rows)` each. `last_page` overrides the last
|
||||
/// table's root page.
|
||||
fn native_catalog_rows(tables: &[(&str, &str, Vec<Vec<SqlValue>>)], last_page: Option<i64>) -> Vec<u8> {
|
||||
use SqlValue::{Integer as I, Text as T};
|
||||
let mut bytes = vec![0u8; 5 * 2048];
|
||||
bytes[..16].copy_from_slice(b"SQLite format 3\0");
|
||||
@@ -1390,15 +1620,15 @@ mod tests {
|
||||
bytes[44..48].copy_from_slice(&4u32.to_be_bytes());
|
||||
bytes[56..60].copy_from_slice(&1u32.to_be_bytes());
|
||||
let mut schema = Vec::new();
|
||||
for (i, (name, columns, row)) in tables.iter().enumerate() {
|
||||
for (i, (name, columns, rows)) in tables.iter().enumerate() {
|
||||
schema.push(vec![
|
||||
T("table".into()),
|
||||
T((*name).into()),
|
||||
T((*name).into()),
|
||||
I(i as i64 + 2),
|
||||
I(if i + 1 == tables.len() { last_page.unwrap_or(i as i64 + 2) } else { i as i64 + 2 }),
|
||||
T(format!("CREATE TABLE {name} ({columns})")),
|
||||
]);
|
||||
leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, std::slice::from_ref(row));
|
||||
leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, rows);
|
||||
}
|
||||
leaf(&mut bytes[..2048], 100, &schema);
|
||||
bytes
|
||||
@@ -1432,11 +1662,104 @@ mod tests {
|
||||
assert_eq!(catalog.photos[0].uuid, "fixture-image");
|
||||
assert_eq!(number(&catalog.photos[0].image, "rating"), 5);
|
||||
assert!(catalog.photos[0].path.ends_with("test.ARW"));
|
||||
// Put the required image table beyond the old 1 GiB cutoff without allocating it.
|
||||
// A main-file-sized allocation would defeat the purpose of the page-backed reader.
|
||||
// The large fixture relies on Unix sparse-file behavior; small parity runs everywhere.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let far_offset = 1u64 << 30;
|
||||
let bytes = native_catalog_with_last_table_page(&photo_tables(("/catalog/", "")), Some((far_offset / 2048 + 1) as i64));
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
let mut sparse = std::fs::OpenOptions::new().write(true).truncate(true).open(&path).unwrap();
|
||||
sparse.write_all(&bytes).unwrap();
|
||||
sparse.set_len(far_offset + 2048).unwrap();
|
||||
sparse.seek(SeekFrom::Start(far_offset)).unwrap();
|
||||
sparse.write_all(&bytes[4 * 2048..5 * 2048]).unwrap();
|
||||
drop(sparse);
|
||||
let large = read(&path).unwrap();
|
||||
assert_eq!(serde_json::to_value(&large.photos).unwrap(), serde_json::to_value(&catalog.photos).unwrap());
|
||||
let mut session = crate::Session::new();
|
||||
session.execute("library.importLightroom", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(session.catalog.len(), 1);
|
||||
assert_eq!(session.catalog.photos().next().unwrap().rating, 5);
|
||||
session.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(session.catalog.is_empty());
|
||||
}
|
||||
std::fs::write(&path, b"bad catalog").unwrap();
|
||||
assert!(read(&path).is_err());
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
/// Reads `tables` as a catalog file and returns what the reader made of it.
|
||||
fn read_tables(tables: &[(&str, &str, Vec<Vec<SqlValue>>)], name: &str) -> CatalogImport {
|
||||
let path = std::env::temp_dir().join(format!("lightcraft-{name}-{}.lrcat", std::process::id()));
|
||||
std::fs::write(&path, native_catalog_rows(tables, None)).unwrap();
|
||||
let catalog = read(&path).unwrap();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
catalog
|
||||
}
|
||||
|
||||
/// `tables` with each row as a one-row table, for [`native_catalog_rows`].
|
||||
fn one_row(tables: Vec<(&'static str, &'static str, Vec<SqlValue>)>) -> Vec<(&'static str, &'static str, Vec<Vec<SqlValue>>)> {
|
||||
tables.into_iter().map(|(name, columns, row)| (name, columns, vec![row])).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_with_incomplete_records_are_skipped_not_fatal() {
|
||||
use SqlValue::{Integer as I, Text as T};
|
||||
// (table, column, value): the record that breaks, and the reason the import gives
|
||||
let cases = [
|
||||
(3, 2, I(2), SkipReason::MissingFile), // Adobe_images.rootFile → no such file
|
||||
(2, 1, I(2), SkipReason::MissingFolder), // AgLibraryFile.folder → no such folder
|
||||
(1, 1, I(2), SkipReason::MissingRoot), // AgLibraryFolder.rootFolder → no such root
|
||||
(2, 2, T(String::new()), SkipReason::IncompletePath), // AgLibraryFile.idx_filename empty
|
||||
];
|
||||
for (table, column, value, reason) in cases {
|
||||
let mut tables = one_row(photo_tables(("/catalog/", "")));
|
||||
tables[table].2[0][column] = value;
|
||||
let catalog = read_tables(&tables, &format!("skipped-{reason:?}"));
|
||||
assert!(catalog.photos.is_empty(), "{reason:?}");
|
||||
assert_eq!(catalog.skipped, [SkippedImage { image: 1, reason }]);
|
||||
assert!(catalog.warnings.is_empty(), "{reason:?}: {:?}", catalog.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_virtual_copy_of_a_skipped_master_is_skipped_too() {
|
||||
use SqlValue::{Integer as I, Null as N, Text as T};
|
||||
let mut tables = one_row(photo_tables(("/catalog/", "")));
|
||||
tables[3] = (
|
||||
"Adobe_images",
|
||||
"id_local INTEGER PRIMARY KEY, id_global, rootFile, masterImage, fileFormat, fileWidth, fileHeight, rating, pick",
|
||||
vec![
|
||||
// the master's file record is gone, so it is skipped …
|
||||
vec![N, T("master".into()), I(2), N, T("ARW".into()), I(20), I(10), I(5), I(1)],
|
||||
// … and its virtual copy goes with it, although its own file record is there
|
||||
vec![N, T("copy".into()), I(1), I(1), T("ARW".into()), I(20), I(10), I(3), I(0)],
|
||||
],
|
||||
);
|
||||
let catalog = read_tables(&tables, "skipped-copy");
|
||||
assert!(catalog.photos.is_empty());
|
||||
assert_eq!(
|
||||
catalog.skipped,
|
||||
[SkippedImage { image: 1, reason: SkipReason::MissingFile }, SkippedImage { image: 2, reason: SkipReason::MasterSkipped }]
|
||||
);
|
||||
validate(&catalog).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_images_are_in_the_import_report() {
|
||||
let mut data = sample();
|
||||
data.photos.retain(|p| p.source_id != 1);
|
||||
drop_copies_of(&mut data, &HashSet::from([1]));
|
||||
data.skipped.insert(0, SkippedImage { image: 1, reason: SkipReason::MissingFile });
|
||||
let mut s = crate::Session::new();
|
||||
let report = apply(&mut s, data, false).unwrap();
|
||||
assert_eq!(report["photos"], 0);
|
||||
assert_eq!(report["skipped"], json!([{"image": 1, "reason": "missingFile"}, {"image": 2, "reason": "masterSkipped"}]));
|
||||
assert_eq!(report["warnings"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preparation_skips_missing_original_probes_but_keeps_catalog_records() {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-offline-probes-{}", std::process::id()));
|
||||
@@ -1508,11 +1831,57 @@ mod tests {
|
||||
// a relative path that leads nowhere keeps the recorded one
|
||||
std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../Elsewhere/")))).unwrap();
|
||||
let catalog = read(&path).unwrap();
|
||||
assert!(catalog.photos[0].path.starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path);
|
||||
assert!(Path::new(&catalog.photos[0].path).starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path);
|
||||
assert!(catalog.warnings.is_empty(), "{:?}", catalog.warnings);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn originals_on_other_computers_are_never_looked_at_or_added() {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-remote-roots-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("Photos")).unwrap();
|
||||
std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap();
|
||||
let source = dir.join("source.lrcat");
|
||||
let mut roots = vec![r"\\192.0.2.1\share\".to_string(), "file://192.0.2.1/s/".to_string()];
|
||||
if cfg!(unix) {
|
||||
// `//<local folder>/` is a local folder to Linux, but a network path to the resolver.
|
||||
roots.push(format!("/{}/", dir.display()));
|
||||
}
|
||||
for root in &roots {
|
||||
std::fs::write(&source, native_catalog(&photo_tables((root, "")))).unwrap();
|
||||
let catalog = read(&source).unwrap();
|
||||
assert!(matches!(catalog.photos[0].original, Original::Refused(_)), "{root}: {:?}", catalog.photos[0]);
|
||||
assert!(catalog.warnings.iter().any(|w| w.contains("not opened")), "{root}: {:?}", catalog.warnings);
|
||||
let mut s = crate::Session::new();
|
||||
let probes = Arc::new(AtomicUsize::new(0));
|
||||
let calls = probes.clone();
|
||||
s.media.file_probe = Some(Arc::new(move |_| {
|
||||
calls.fetch_add(1, Ordering::Relaxed);
|
||||
Err("a refused original must not be probed".into())
|
||||
}));
|
||||
let report = import(&mut s, &source, false).unwrap();
|
||||
assert_eq!(probes.load(Ordering::Relaxed), 0, "{root}");
|
||||
assert_eq!(report["missing"].as_array().map(Vec::len), Some(1), "{root}: {report}");
|
||||
assert!(s.catalog.is_empty(), "{root}");
|
||||
}
|
||||
// An absolute folder path would replace the root.
|
||||
let mut tables = photo_tables((dir.to_str().unwrap(), ""));
|
||||
tables[1].2[2] = SqlValue::Text(format!("{}/Photos/", dir.display()));
|
||||
std::fs::write(&source, native_catalog(&tables)).unwrap();
|
||||
assert!(matches!(read(&source).unwrap().photos[0].original, Original::Refused(_)));
|
||||
// A root beside the catalog is followed.
|
||||
std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap();
|
||||
let catalog = read(&source).unwrap();
|
||||
assert_eq!(catalog.photos[0].original, Original::Local);
|
||||
assert!(catalog.photos[0].original_is_file());
|
||||
// A root that moved is found through its path relative to the catalog.
|
||||
std::fs::write(&source, native_catalog(&photo_tables((r"\\192.0.2.1\share\", "./")))).unwrap();
|
||||
let catalog = read(&source).unwrap();
|
||||
assert!(catalog.photos[0].original_is_file(), "{:?}", catalog.photos[0]);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_optional_table_warns_and_is_skipped() {
|
||||
let mut main = vec![0u8; 2 * 512];
|
||||
|
||||
@@ -329,7 +329,7 @@ fn original_paths(data: &CatalogImport, cancel: &AtomicBool) -> crate::Result<(V
|
||||
if !seen.insert(key.clone()) {
|
||||
continue;
|
||||
}
|
||||
if Path::new(&photo.path).is_file() {
|
||||
if photo.original_is_file() {
|
||||
paths.push(photo.path.clone());
|
||||
} else {
|
||||
missing.insert(key);
|
||||
@@ -355,6 +355,7 @@ mod tests {
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,11 +394,13 @@ mod tests {
|
||||
keywords: Vec::new(),
|
||||
history: Vec::new(),
|
||||
snapshots: Vec::new(),
|
||||
original: lightroom_catalog::Original::Local,
|
||||
}],
|
||||
collections: Vec::new(),
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
},
|
||||
normal: Prepared::default(),
|
||||
index: ImportIndex::default(),
|
||||
@@ -531,6 +534,7 @@ mod tests {
|
||||
serde_json::json!(format!("history-{source_id}-{}", "x".repeat(8 * 1024))),
|
||||
)])],
|
||||
snapshots: Vec::new(),
|
||||
original: lightroom_catalog::Original::Local,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -6,7 +6,79 @@
|
||||
//! than a SQL engine. It never writes its input and has no SQLite runtime
|
||||
//! dependency.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::{File, Metadata};
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
const MAX_DATABASE_BYTES: u64 = 16 << 30;
|
||||
|
||||
/// A bounded, read-only page source. Only referenced pages are loaded, never the whole file.
|
||||
pub struct FileSource {
|
||||
file: Mutex<File>,
|
||||
path: PathBuf,
|
||||
before: Metadata,
|
||||
header: Vec<u8>,
|
||||
}
|
||||
|
||||
impl FileSource {
|
||||
pub fn open(path: &Path) -> Result<Self, String> {
|
||||
if !std::fs::metadata(path).map_err(|e| e.to_string())?.is_file() {
|
||||
return Err("SQLite catalog must be a regular file".into());
|
||||
}
|
||||
let mut file = File::open(path).map_err(|e| e.to_string())?;
|
||||
let before = file.metadata().map_err(|e| e.to_string())?;
|
||||
if !before.is_file() {
|
||||
return Err("SQLite catalog must be a regular file".into());
|
||||
}
|
||||
if before.len() > MAX_DATABASE_BYTES {
|
||||
return Err("catalog exceeds 16 GiB import limit".into());
|
||||
}
|
||||
let mut header = vec![0; 100];
|
||||
file.read_exact(&mut header).map_err(|e| format!("cannot read SQLite header: {e}"))?;
|
||||
let source = Self { file: Mutex::new(file), path: path.into(), before, header };
|
||||
source.ensure_unchanged()?;
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
pub fn header(&self) -> &[u8] {
|
||||
&self.header
|
||||
}
|
||||
|
||||
pub fn ensure_unchanged(&self) -> Result<(), String> {
|
||||
let file = self.file.lock().map_err(|_| "SQLite page source lock poisoned")?;
|
||||
let handle = file.metadata().map_err(|e| e.to_string())?;
|
||||
let path = std::fs::metadata(&self.path).map_err(|_| "catalog changed during reading; close Lightroom and retry")?;
|
||||
if !same_metadata(&self.before, &handle) || !same_metadata(&self.before, &path) {
|
||||
return Err("catalog changed during reading; close Lightroom and retry".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn page(&self, page_id: u32, size: usize) -> Result<Vec<u8>, String> {
|
||||
let start = u64::from(page_id).checked_sub(1).and_then(|n| n.checked_mul(size as u64)).ok_or("page offset overflow")?;
|
||||
if start.checked_add(size as u64).is_none_or(|end| end > self.before.len()) {
|
||||
return Err(format!("page {page_id} is truncated"));
|
||||
}
|
||||
let mut out = vec![0; size];
|
||||
let mut file = self.file.lock().map_err(|_| "SQLite page source lock poisoned")?;
|
||||
file.seek(SeekFrom::Start(start)).and_then(|_| file.read_exact(&mut out)).map_err(|e| format!("cannot read SQLite page {page_id}: {e}"))?;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn same_metadata(a: &Metadata, b: &Metadata) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if a.dev() != b.dev() || a.ino() != b.ino() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
a.len() == b.len() && a.modified().ok() == b.modified().ok() && a.created().ok() == b.created().ok()
|
||||
}
|
||||
|
||||
const MAX_PAGE_ID: u32 = 16_777_216;
|
||||
const MAX_COLUMNS: usize = 16_384;
|
||||
@@ -44,6 +116,7 @@ pub struct LiveTable {
|
||||
/// Read-only SQLite database image, with a committed WAL overlay if supplied.
|
||||
pub struct Database {
|
||||
main: Vec<u8>,
|
||||
file: Option<FileSource>,
|
||||
overlay: HashMap<u32, Vec<u8>>,
|
||||
page_size: usize,
|
||||
reserved: usize,
|
||||
@@ -84,17 +157,34 @@ impl Database {
|
||||
/// The WAL argument must include its 32-byte header and only complete
|
||||
/// frames through a commit. Frames are overlaid by page number, with the
|
||||
/// final commit's database-size field defining the visible page count.
|
||||
#[cfg(test)]
|
||||
pub fn open_with_wal(main: Vec<u8>, committed_wal: &[u8]) -> Result<Self, String> {
|
||||
Self::open(main, None, committed_wal)
|
||||
}
|
||||
|
||||
pub fn open_file_with_wal(source: FileSource, committed_wal: &[u8]) -> Result<Self, String> {
|
||||
Self::open(source.header().to_vec(), Some(source), committed_wal)
|
||||
}
|
||||
|
||||
pub fn ensure_unchanged(&self) -> Result<(), String> {
|
||||
if let Some(file) = &self.file {
|
||||
file.ensure_unchanged()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open(main: Vec<u8>, file: Option<FileSource>, committed_wal: &[u8]) -> Result<Self, String> {
|
||||
let main_header = main.get(..100).ok_or("truncated SQLite header")?;
|
||||
if main_header.get(..16) != Some(b"SQLite format 3\0") {
|
||||
return Err("invalid SQLite header signature".into());
|
||||
}
|
||||
let page_size = sqlite_page_size(be_u16(main_header, 16)?)?;
|
||||
validate_header_fields(main_header, page_size)?;
|
||||
if main.len() < page_size || !main.len().is_multiple_of(page_size) {
|
||||
let main_len = file.as_ref().map_or(main.len() as u64, |source| source.before.len());
|
||||
if main_len < page_size as u64 || !main_len.is_multiple_of(page_size as u64) {
|
||||
return Err("SQLite image is not a whole number of pages".into());
|
||||
}
|
||||
let main_pages = u32::try_from(main.len() / page_size).map_err(|_| "SQLite image is too large")?;
|
||||
let main_pages = u32::try_from(main_len / page_size as u64).map_err(|_| "SQLite image is too large")?;
|
||||
if main_pages == 0 || main_pages > MAX_PAGE_ID {
|
||||
return Err("invalid SQLite image page count".into());
|
||||
}
|
||||
@@ -140,7 +230,7 @@ impl Database {
|
||||
_ => return Err("unsupported SQLite text encoding".into()),
|
||||
};
|
||||
|
||||
let mut db = Self { main, overlay, page_size, reserved, encoding, page_count, tables: Vec::new(), rowid_aliases: HashMap::new() };
|
||||
let mut db = Self { main, file, overlay, page_size, reserved, encoding, page_count, tables: Vec::new(), rowid_aliases: HashMap::new() };
|
||||
let schema = db.parse_schema()?;
|
||||
db.tables = schema.tables;
|
||||
db.rowid_aliases = schema.aliases;
|
||||
@@ -166,17 +256,20 @@ impl Database {
|
||||
Ok(state.out)
|
||||
}
|
||||
|
||||
fn page(&self, page_id: u32) -> Result<&[u8], String> {
|
||||
fn page(&self, page_id: u32) -> Result<Cow<'_, [u8]>, String> {
|
||||
if page_id == 0 || page_id > self.page_count {
|
||||
return Err(format!("page {page_id} is outside database"));
|
||||
}
|
||||
if let Some(page) = self.overlay.get(&page_id) {
|
||||
return Ok(page);
|
||||
return Ok(Cow::Borrowed(page));
|
||||
}
|
||||
if let Some(file) = &self.file {
|
||||
return file.page(page_id, self.page_size).map(Cow::Owned);
|
||||
}
|
||||
let id = usize::try_from(page_id).map_err(|_| "page number does not fit host")?;
|
||||
let start = id.checked_sub(1).and_then(|n| n.checked_mul(self.page_size)).ok_or("page offset overflow")?;
|
||||
let end = start.checked_add(self.page_size).ok_or("page offset overflow")?;
|
||||
self.main.get(start..end).ok_or_else(|| format!("page {page_id} is truncated"))
|
||||
self.main.get(start..end).map(Cow::Borrowed).ok_or_else(|| format!("page {page_id} is truncated"))
|
||||
}
|
||||
|
||||
fn visit_table(&self, page_id: u32, ncols: usize, alias: Option<usize>, depth: usize, state: &mut WalkState) -> Result<(), String> {
|
||||
@@ -219,7 +312,7 @@ impl Database {
|
||||
if state.payload_total > MAX_TABLE_PAYLOAD {
|
||||
return Err("SQLite table payload exceeds bounded import size".into());
|
||||
}
|
||||
let payload = self.payload(page, at, payload_len)?;
|
||||
let payload = self.payload(&page, at, payload_len)?;
|
||||
let mut values = decode_record(&payload, ncols, self.encoding)?;
|
||||
if let Some(index) = alias {
|
||||
if index >= values.len() {
|
||||
@@ -325,7 +418,7 @@ impl Database {
|
||||
out.extend_from_slice(overflow.get(4..end).ok_or("truncated SQLite overflow page")?);
|
||||
remaining -= take;
|
||||
if remaining > 0 {
|
||||
next = be_u32_at(overflow, 0)?;
|
||||
next = be_u32_at(&overflow, 0)?;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
@@ -873,6 +966,72 @@ fn be_u32_at(bytes: &[u8], at: usize) -> Result<u32, String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
impl Scratch {
|
||||
fn new(bytes: &[u8]) -> Self {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!("lightcraft-page-source-{}-{n}.lrcat", std::process::id()));
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn file_backed(mut db: Database) -> (Scratch, Database) {
|
||||
let scratch = Scratch::new(&db.main);
|
||||
db.file = Some(FileSource::open(&scratch.0).unwrap());
|
||||
db.main.truncate(100);
|
||||
(scratch, db)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_source_bounds_and_mutation_detection() {
|
||||
let scratch = Scratch::new(&[0; 512]);
|
||||
let source = FileSource::open(&scratch.0).unwrap();
|
||||
assert_eq!(source.page(1, 512).unwrap().len(), 512);
|
||||
assert!(source.page(2, 512).unwrap_err().contains("truncated"));
|
||||
File::options().write(true).open(&scratch.0).unwrap().set_len(100).unwrap();
|
||||
assert!(source.page(1, 512).is_err());
|
||||
assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading"));
|
||||
drop(source);
|
||||
// Unix set_len creates a hole. Do not reserve a 16 GiB normal file on Windows.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
File::options().write(true).open(&scratch.0).unwrap().set_len(MAX_DATABASE_BYTES + 1).unwrap();
|
||||
assert_eq!(FileSource::open(&scratch.0).err().unwrap(), "catalog exceeds 16 GiB import limit");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn file_source_detects_same_sized_path_replacement() {
|
||||
let original = Scratch::new(&[0; 512]);
|
||||
let replacement = Scratch::new(&[0; 512]);
|
||||
let source = FileSource::open(&original.0).unwrap();
|
||||
std::fs::rename(&replacement.0, &original.0).unwrap();
|
||||
assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_source_detects_same_length_content_changes() {
|
||||
let scratch = Scratch::new(&[0; 512]);
|
||||
let source = FileSource::open(&scratch.0).unwrap();
|
||||
std::fs::write(&scratch.0, [1; 512]).unwrap();
|
||||
// Make the timestamp change deterministic even on filesystems with coarse timestamps.
|
||||
File::options()
|
||||
.write(true)
|
||||
.open(&scratch.0)
|
||||
.unwrap()
|
||||
.set_modified(source.before.modified().unwrap() + std::time::Duration::from_secs(2))
|
||||
.unwrap();
|
||||
assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading"));
|
||||
}
|
||||
|
||||
fn varint(mut value: u64) -> Vec<u8> {
|
||||
let mut out = vec![u8::try_from(value & 0x7f).unwrap_or(0)];
|
||||
value >>= 7;
|
||||
@@ -1026,6 +1185,40 @@ mod tests {
|
||||
assert!(tables.iter().any(|table| table.name == "t"));
|
||||
let rows = db.read_table(2, 2).unwrap();
|
||||
assert_eq!(rows.first().and_then(|row| row.values.first()), Some(&Value::Integer(1)));
|
||||
let scratch = Scratch::new(&db.main);
|
||||
let file_db = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &[]).unwrap();
|
||||
assert_eq!(file_db.live_tables().unwrap(), tables);
|
||||
assert_eq!(file_db.read_table(2, 2).unwrap(), rows);
|
||||
assert_eq!(file_db.main.len(), 100);
|
||||
file_db.ensure_unchanged().unwrap();
|
||||
// The same committed WAL page overrides the lazily read main page.
|
||||
let mut wal = vec![0; 32 + 24 + 512];
|
||||
wal[..4].copy_from_slice(&0x377f0682u32.to_be_bytes());
|
||||
wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes());
|
||||
wal[8..12].copy_from_slice(&512u32.to_be_bytes());
|
||||
wal[32..36].copy_from_slice(&2u32.to_be_bytes());
|
||||
wal[36..40].copy_from_slice(&2u32.to_be_bytes());
|
||||
leaf(&mut wal[56..], 0, 2, &[3, 0, 15, b'y']);
|
||||
let memory_wal = Database::open_with_wal(db.main.clone(), &wal).unwrap();
|
||||
let file_wal = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &wal).unwrap();
|
||||
assert_eq!(memory_wal.read_table(2, 2).unwrap(), file_wal.read_table(2, 2).unwrap());
|
||||
assert_eq!(file_wal.read_table(2, 2).unwrap()[0].values[0], Value::Integer(2));
|
||||
let mut malformed = db.main.clone();
|
||||
malformed.push(0);
|
||||
let partial = Scratch::new(&malformed);
|
||||
assert_eq!(
|
||||
Database::open_file_with_wal(FileSource::open(&partial.0).unwrap(), &[]).err().unwrap(),
|
||||
"SQLite image is not a whole number of pages"
|
||||
);
|
||||
let mut malformed = db.main.clone();
|
||||
malformed[24..28].copy_from_slice(&1u32.to_be_bytes());
|
||||
malformed[92..96].copy_from_slice(&1u32.to_be_bytes());
|
||||
malformed[28..32].copy_from_slice(&3u32.to_be_bytes());
|
||||
let oversized = Scratch::new(&malformed);
|
||||
assert_eq!(
|
||||
Database::open_file_with_wal(FileSource::open(&oversized.0).unwrap(), &[]).err().unwrap(),
|
||||
"SQLite header page count exceeds image"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1042,6 +1235,7 @@ mod tests {
|
||||
leaf(&mut main[1024..1536], 0, 1, &[2, 0]);
|
||||
leaf(&mut main[1536..], 0, 2, &[2, 0]);
|
||||
let db = Database {
|
||||
file: None,
|
||||
main,
|
||||
overlay: HashMap::new(),
|
||||
page_size: 512,
|
||||
@@ -1073,6 +1267,7 @@ mod tests {
|
||||
let mut aliases = HashMap::new();
|
||||
aliases.insert(1, 0);
|
||||
let db = Database {
|
||||
file: None,
|
||||
main,
|
||||
overlay: HashMap::new(),
|
||||
page_size: 512,
|
||||
@@ -1093,6 +1288,7 @@ mod tests {
|
||||
main[103..105].copy_from_slice(&1u16.to_be_bytes());
|
||||
main[108..110].copy_from_slice(&490u16.to_be_bytes());
|
||||
let db = Database {
|
||||
file: None,
|
||||
main,
|
||||
overlay: HashMap::new(),
|
||||
page_size: 512,
|
||||
@@ -1103,6 +1299,8 @@ mod tests {
|
||||
rowid_aliases: HashMap::new(),
|
||||
};
|
||||
assert!(db.read_table(1, 1).is_err());
|
||||
let (_scratch, db) = file_backed(db);
|
||||
assert!(db.read_table(1, 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1115,6 +1313,7 @@ mod tests {
|
||||
|
||||
let mut main = vec![0u8; 2 * 512];
|
||||
let db = Database {
|
||||
file: None,
|
||||
main: std::mem::take(&mut main),
|
||||
overlay: HashMap::new(),
|
||||
page_size: 512,
|
||||
@@ -1141,6 +1340,10 @@ mod tests {
|
||||
assert_eq!(bytes.len(), 600);
|
||||
assert_eq!(&bytes[..92], &[b'x'; 92]);
|
||||
assert_eq!(&bytes[92..], &[b'y'; 508]);
|
||||
let (_scratch, mut db) = file_backed(db);
|
||||
db.overlay.clear();
|
||||
let bytes = db.payload(&cell_page, 0, 600).unwrap();
|
||||
assert_eq!(&bytes[92..], &[b'y'; 508]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1164,6 +1367,7 @@ mod tests {
|
||||
let mut page = vec![0u8; 512];
|
||||
page[16..18].copy_from_slice(&512u16.to_be_bytes());
|
||||
let db = Database {
|
||||
file: None,
|
||||
main: vec![0; 512],
|
||||
overlay: HashMap::new(),
|
||||
page_size: 512,
|
||||
|
||||
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 64;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 65;
|
||||
|
||||
/// 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];
|
||||
@@ -277,7 +277,9 @@ impl SourceRef {
|
||||
if from.image.width.max(from.image.height) <= *max_edge {
|
||||
return Ok((**from).clone());
|
||||
}
|
||||
let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Box);
|
||||
// Lanczos, not a box average: a box at 2-3:1 halves the contrast of detail near
|
||||
// the output's resolution limit and aliases what is finer
|
||||
let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Lanczos3);
|
||||
return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() });
|
||||
}
|
||||
SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?,
|
||||
@@ -761,6 +763,24 @@ pub struct RenderJob {
|
||||
/// A face tile that can be cut from the raw's embedded preview, tried before `source`
|
||||
/// ([`crate::Session::face_job`]; `None` for every other job).
|
||||
pub preview_cut: Option<crate::face_tiles::PreviewCut>,
|
||||
/// Shrink a full-quality result to fit this size (physical pixels) with a Lanczos filter once
|
||||
/// it is rendered. The loupe renders a little larger than it is drawn (its edge is rounded up
|
||||
/// to a 64 px step) and the window would otherwise shrink the texture with 2-tap bilinear sampling, which blurs fine detail at
|
||||
/// small ratios and aliases it at large ones. The caches keep the full render.
|
||||
pub display_fit: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
/// `img` shrunk to fit `max_w × max_h` with a Lanczos filter (see [`RenderJob::display_fit`]).
|
||||
pub fn fit_for_display(img: &Rgba8, max_w: usize, max_h: usize) -> Rgba8 {
|
||||
use lightcraft_raster::resample::{Filter, fit};
|
||||
let f = lightcraft_raster::Image {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
data: img.data.iter().map(|p| [f32::from(p[0]), f32::from(p[1]), f32::from(p[2]), f32::from(p[3])]).collect(),
|
||||
};
|
||||
let small = fit(&f, max_w.max(1), max_h.max(1), Filter::Lanczos3);
|
||||
let c = |v: f32| v.round().clamp(0.0, 255.0) as u8;
|
||||
Rgba8 { width: small.width, height: small.height, data: small.data.iter().map(|p| [c(p[0]), c(p[1]), c(p[2]), c(p[3])]).collect() }
|
||||
}
|
||||
|
||||
pub struct RenderResult {
|
||||
@@ -784,6 +804,14 @@ pub struct RenderResult {
|
||||
|
||||
impl RenderJob {
|
||||
/// Render at draft quality (interactive drags). Gets its own result key.
|
||||
/// Shrink the full-quality result to fit `w × h` pixels with a Lanczos filter (see
|
||||
/// [`Self::display_fit`]). Gets its own result key, so a change of drawn size renders again.
|
||||
pub fn with_display_fit(mut self, w: usize, h: usize) -> Self {
|
||||
self.display_fit = Some((w, h));
|
||||
self.key ^= ((w as u64) << 32 | h as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn draft(mut self) -> Self {
|
||||
if self.request.quality != Quality::Draft {
|
||||
self.request.quality = Quality::Draft;
|
||||
@@ -994,6 +1022,15 @@ impl RenderJob {
|
||||
{
|
||||
cache.put_deferred_at(self.cache_generation, *key, Arc::new(srgb.unwrap_or_else(|| rendered.image.clone())));
|
||||
}
|
||||
// the display fit, after the caches have the full render
|
||||
if let Some((fw, fh)) = self.display_fit
|
||||
&& self.request.quality == Quality::Full
|
||||
{
|
||||
let (w, h) = (rendered.image.width, rendered.image.height);
|
||||
if w > fw.max(1) || h > fh.max(1) {
|
||||
rendered.image = fit_for_display(&rendered.image, fw, fh);
|
||||
}
|
||||
}
|
||||
RenderResult {
|
||||
request_id: self.request_id,
|
||||
source_key: self.source_key,
|
||||
@@ -1266,6 +1303,7 @@ impl crate::Session {
|
||||
display: None,
|
||||
slide: None,
|
||||
preview_cut: None,
|
||||
display_fit: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1319,6 +1357,7 @@ impl crate::Session {
|
||||
display: None,
|
||||
slide: None,
|
||||
preview_cut: None,
|
||||
display_fit: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1373,6 +1412,7 @@ impl crate::Session {
|
||||
display: None,
|
||||
slide: None,
|
||||
preview_cut,
|
||||
display_fit: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1798,6 +1838,28 @@ mod thumbnail_hash_tests {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A loupe job with a display fit returns its image shrunk to the drawn size, aspect kept;
|
||||
/// a draft, and a render no larger than the drawn size, are left as rendered.
|
||||
#[test]
|
||||
fn a_display_fit_shrinks_the_loupe_render_to_the_drawn_size() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
let plain = s.loupe_job(id, 1024, 1024, true).unwrap().run().rendered.unwrap().image;
|
||||
let long = plain.width.max(plain.height);
|
||||
assert!(long >= 600, "the demo renders at least 600 px at 1024: {long}");
|
||||
let fitted = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400).run().rendered.unwrap().image;
|
||||
assert_eq!(fitted.width.max(fitted.height), 400, "{}x{}", fitted.width, fitted.height);
|
||||
let aspect = |i: &Rgba8| i.width as f32 / i.height as f32;
|
||||
assert!((aspect(&fitted) - aspect(&plain)).abs() < 0.02, "aspect kept");
|
||||
let draft = s.loupe_job(id, 1024, 1024, true).unwrap().draft().with_display_fit(400, 400).run().rendered.unwrap().image;
|
||||
assert_eq!(draft.width.max(draft.height), long, "a draft is not shrunk");
|
||||
let same = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(plain.width, plain.height).run().rendered.unwrap().image;
|
||||
assert_eq!((same.width, same.height), (plain.width, plain.height), "no larger than drawn: left as rendered");
|
||||
let a = s.loupe_job(id, 1024, 1024, true).unwrap();
|
||||
let b = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400);
|
||||
assert_ne!(a.key, b.key, "the fit is part of the result key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_know_the_original_size_whichever_level_they_come_from() {
|
||||
// issue #960: noise reduction works in the original's pixels, so a grid cell drawn from the
|
||||
@@ -2189,6 +2251,40 @@ mod tests {
|
||||
assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b));
|
||||
}
|
||||
|
||||
// LR-VIEW-ZOOM: what the loupe shows at 1:1 is what an export at full size writes. A window of
|
||||
// the zoomed frame (the photo's own pixels) is pixel-for-pixel the export, at the centre and
|
||||
// at a corner, with tone, colour and detail edits on a camera-sized photo
|
||||
#[test]
|
||||
fn one_to_one_window_pixels_are_export_pixels() {
|
||||
use lightcraft_pipeline::{OutputDepth, OutputSpace, PixelWindow};
|
||||
let mut s = crate::Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
let p = s.catalog.photo(id).unwrap().clone();
|
||||
let (w, h) = (p.width as usize, p.height as usize);
|
||||
assert!(w.max(h) > 2560, "larger than the preview cap: {w}×{h}");
|
||||
let mut d = (*s.develop_of(id).unwrap()).clone();
|
||||
d.light.exposure = 0.7;
|
||||
d.light.contrast = 25.0;
|
||||
d.color.saturation = 20.0;
|
||||
s.set_develop(id, d, "Edits").unwrap();
|
||||
let export = s.render_export(id, w, h, OutputSpace::Srgb, OutputDepth::U8).unwrap().image;
|
||||
assert_eq!((export.width, export.height), (w, h), "a full-size export");
|
||||
for win in [PixelWindow { x: w / 2 - 256, y: h / 2 - 256, w: 512, h: 384 }, PixelWindow { x: 0, y: h - 300, w: 400, h: 300 }] {
|
||||
let tile = s.region_job(id, w, h, win, true).unwrap().run().rendered.unwrap().image;
|
||||
assert_eq!((tile.width, tile.height), (win.w, win.h));
|
||||
let mut worst = 0u8;
|
||||
for y in 0..win.h {
|
||||
for x in 0..win.w {
|
||||
let (a, b) = (tile.data[y * win.w + x], export.data[(win.y + y) * w + win.x + x]);
|
||||
for k in 0..3 {
|
||||
worst = worst.max(a[k].abs_diff(b[k]));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(worst <= 1, "window {win:?}: the 1:1 view differs from the export by up to {worst} levels");
|
||||
}
|
||||
}
|
||||
|
||||
/// The thumbnail of a photo whose preview-level source is decoded is cut from it: the file (here
|
||||
/// one nothing can decode) is not read again.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Whether a path the library holds may be handed to the operating system.
|
||||
//!
|
||||
//! A photo's `Source::File` path is not always something a user typed: it can come from an
|
||||
//! imported Lightroom catalog, an `.lrcat` someone was sent, a relink or a synchronised folder.
|
||||
//! Photo ▸ Edit In ▸ *Open Original in Other Editor* and the system default opener run whatever
|
||||
//! the path names, so a record pointing at `payload.exe`, `thing.lnk`, `run.bat`, `page.hta`
|
||||
//! (Windows) or `Evil.app`, `go.command` (macOS) would be executed on one click (issue #2135).
|
||||
//!
|
||||
//! [`openable`] is the one gate every such hand-over goes through: the path must be a regular
|
||||
//! file whose extension is one LightCraft itself reads ([`crate::import::EXTENSIONS`], which
|
||||
//! already covers the Edit In copy formats `tif`/`tiff`/`psd`), and what a link resolves to must
|
||||
//! be one too, so a `photo.jpg` pointing at `/bin/sh` is refused as well. What comes back is the
|
||||
//! absolute path to hand over; what comes back on refusal is the sentence to show the user.
|
||||
//!
|
||||
//! Nothing here opens, stats-for-content or reads the file: it only decides whether the path may
|
||||
//! leave LightCraft. Catalog import applies the same test ([`crate::lightroom_catalog`]), so a
|
||||
//! refused original never becomes a library record in the first place.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// The absolute path to hand to an opener or external editor, or the reason it is refused.
|
||||
///
|
||||
/// `label` names the thing in the error (a file name, a photo's name); an empty `label` uses
|
||||
/// the path's own file name.
|
||||
pub fn openable(path: &str, label: &str) -> Result<String, String> {
|
||||
let p = Path::new(path);
|
||||
let name = match label.trim() {
|
||||
"" => p.file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned()),
|
||||
l => l.to_string(),
|
||||
};
|
||||
if !is_media(p) {
|
||||
return Err(format!("{name} isn't one of the file types LightCraft reads, so it isn't handed to another application"));
|
||||
}
|
||||
match std::fs::metadata(p) {
|
||||
Ok(m) if m.is_file() => {}
|
||||
Ok(_) => return Err(format!("{name} isn't a file, so it isn't handed to another application")),
|
||||
Err(e) => return Err(format!("the original is missing: {path} ({e})")),
|
||||
}
|
||||
// a link may name a photo and point at a program; what runs is what it points at
|
||||
if let Ok(target) = p.canonicalize()
|
||||
&& !is_media(&target)
|
||||
{
|
||||
return Err(format!("{name} points at {}, which isn't a file type LightCraft reads", target.display()));
|
||||
}
|
||||
let absolute = std::path::absolute(p).map_err(|e| format!("{name}: {e}"))?;
|
||||
Ok(absolute.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Whether `path`'s extension is one LightCraft reads (the Edit In copy formats included).
|
||||
pub fn is_media(path: &Path) -> bool {
|
||||
crate::import::is_supported(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(dir: &Path, name: &str) -> String {
|
||||
let p = dir.join(name);
|
||||
std::fs::write(&p, b"x").unwrap();
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_file_types_lightcraft_reads_are_handed_over() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-openable-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for good in ["DSC_1.NEF", "a.jpg", "copy-Edit.tif", "copy-Edit.psd"] {
|
||||
let path = write(&dir, good);
|
||||
assert_eq!(openable(&path, "").unwrap(), path, "{good} is a photo LightCraft reads");
|
||||
}
|
||||
for bad in ["payload.exe", "shortcut.lnk", "run.bat", "page.hta", "go.command", "no-extension"] {
|
||||
let path = write(&dir, bad);
|
||||
let e = openable(&path, "").unwrap_err();
|
||||
assert!(e.contains(bad) && e.contains("file types LightCraft reads"), "{bad}: {e}");
|
||||
}
|
||||
// a bundle (macOS `.app`) and any other folder are not files
|
||||
let bundle = dir.join("Evil.app");
|
||||
std::fs::create_dir_all(&bundle).unwrap();
|
||||
let e = openable(&bundle.to_string_lossy(), "").unwrap_err();
|
||||
assert!(e.contains("isn't one of the file types") || e.contains("isn't a file"), "{e}");
|
||||
let e = openable(&dir.join("gone.jpg").to_string_lossy(), "").unwrap_err();
|
||||
assert!(e.contains("missing"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A relative path is made absolute: the opener runs with its own working directory.
|
||||
#[test]
|
||||
fn the_path_handed_over_is_absolute() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-openable-abs-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = write(&dir, "a.jpg");
|
||||
let dotted = Path::new(&path).parent().unwrap().join(".").join("a.jpg");
|
||||
let out = openable(&dotted.to_string_lossy(), "").unwrap();
|
||||
assert!(Path::new(&out).is_absolute(), "{out}");
|
||||
assert!(out.ends_with("a.jpg"), "{out}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A link named like a photo that points at a program is refused: what the opener runs is
|
||||
/// the target, not the name.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_link_to_a_program_is_refused() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-openable-link-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let target = write(&dir, "payload.command");
|
||||
let link = dir.join("holiday.jpg");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
let e = openable(&link.to_string_lossy(), "").unwrap_err();
|
||||
assert!(e.contains("points at") && e.contains("payload.command"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Composes print pages: places each cell's photo (fit or Zoom to Fill, optionally Rotate to Fit),
|
||||
//! draws Photo Border and Inner Stroke, fills the page background and returns [`PageRaster`]s.
|
||||
//! draws Photo Border and Inner Stroke, fills the page background, adds the Page Options (crop
|
||||
//! marks, page number, page info) and returns [`PageRaster`]s.
|
||||
//!
|
||||
//! Work is split like an export: [`prepare`] reads the session (which photos go in which cell, one
|
||||
//! render job per cell) and [`Prepared::run`] does the heavy part without it, so it can run on a
|
||||
@@ -28,6 +29,13 @@ pub const MAX_BORDER_IN: f64 = 2.0;
|
||||
pub const MAX_STROKE_PT: f64 = 36.0;
|
||||
/// Renders kept for cells that print the same photo at the same size.
|
||||
const RENDER_CACHE: usize = 4;
|
||||
/// A crop mark's length and its gap from the photo's corner, inches.
|
||||
pub const CROP_MARK_IN: f64 = 0.25;
|
||||
pub const CROP_MARK_GAP_IN: f64 = 0.0625;
|
||||
/// A crop mark's weight, points.
|
||||
pub const CROP_MARK_PT: f64 = 0.5;
|
||||
/// Size of the page number and page info text, points.
|
||||
pub const PAGE_TEXT_PT: f64 = 9.0;
|
||||
|
||||
/// Lightroom's Image Settings panel.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -85,11 +93,18 @@ impl ImageSettings {
|
||||
pub struct PageStyle {
|
||||
/// Page background colour, sRGB.
|
||||
pub background: [u8; 3],
|
||||
/// Page Options (Single Image and Contact Sheet, as in Lightroom; the packages ignore them):
|
||||
/// the page number at the bottom right of each page.
|
||||
pub page_numbers: bool,
|
||||
/// The print settings (sharpening, profile, resolution) at the bottom left of each page.
|
||||
pub page_info: bool,
|
||||
/// Marks at the corners of each photo to cut along.
|
||||
pub crop_marks: bool,
|
||||
}
|
||||
|
||||
impl Default for PageStyle {
|
||||
fn default() -> Self {
|
||||
Self { background: [255; 3] }
|
||||
Self { background: [255; 3], page_numbers: false, page_info: false, crop_marks: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +115,41 @@ pub fn rec2020_from_srgb8(c: [u8; 3]) -> [u16; 3] {
|
||||
lin.map(|v| (trc.encode(v) * 65535.0 + 0.5) as u16)
|
||||
}
|
||||
|
||||
/// Whether a layout style prints the Page Options (page numbers, page info, crop marks):
|
||||
/// Lightroom offers them for Single Image and Contact Sheet; its packages have Cut Guides instead.
|
||||
pub fn has_page_options(style: Style) -> bool {
|
||||
!style.is_package()
|
||||
}
|
||||
|
||||
/// The colour of crop marks and page text on `background`: black, or white on a dark page.
|
||||
pub fn mark_ink(background: [u8; 3]) -> [u8; 3] {
|
||||
let [r, g, b] = background.map(f64::from);
|
||||
if 0.2126 * r + 0.7152 * g + 0.0722 * b < 128.0 { [255; 3] } else { [0; 3] }
|
||||
}
|
||||
|
||||
/// The Page Info line: print sharpening, profile and resolution (Lightroom names the printer
|
||||
/// where the resolution is; this print goes to a PDF).
|
||||
pub fn page_info_text(settings: &PrintSettings) -> String {
|
||||
use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen};
|
||||
let c = &settings.job.color;
|
||||
let media = match c.media {
|
||||
PrintMedia::Matte => "Matte",
|
||||
PrintMedia::Glossy => "Glossy",
|
||||
};
|
||||
let sharpen = match c.sharpen {
|
||||
PrintSharpen::Off => "Off".to_string(),
|
||||
PrintSharpen::Low => format!("Low ({media})"),
|
||||
PrintSharpen::Standard => format!("Standard ({media})"),
|
||||
PrintSharpen::High => format!("High ({media})"),
|
||||
};
|
||||
let profile = match &c.profile {
|
||||
PrintProfile::Managed { space: ManagedSpace::Srgb } => "Managed by Printer (sRGB)".to_string(),
|
||||
PrintProfile::Managed { space: ManagedSpace::AdobeRgb } => "Managed by Printer (Adobe RGB)".to_string(),
|
||||
PrintProfile::Icc { path } => path.file_stem().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()),
|
||||
};
|
||||
format!("Print Sharpening: {sharpen} · Profile: {profile} · {:.0} ppi", settings.job.effective_ppi())
|
||||
}
|
||||
|
||||
// ---- placement ------------------------------------------------------------------------------
|
||||
|
||||
/// How a photo of `iw` x `ih` goes into an `aw` x `ah` area.
|
||||
@@ -355,8 +405,8 @@ impl CellStyle {
|
||||
}
|
||||
|
||||
/// Draws `photo` into `cell` of `page`: border frame, the photo fitted or filled (and turned when
|
||||
/// asked), then the inner stroke on top of it.
|
||||
pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<(), String> {
|
||||
/// asked), then the inner stroke on top of it. Returns where the photo landed.
|
||||
pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<PxRect, String> {
|
||||
// The border may not eat the cell: at least one pixel of photo area stays.
|
||||
let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2);
|
||||
let (ax, ay) = (cell.x + border, cell.y + border);
|
||||
@@ -382,7 +432,105 @@ pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &Cell
|
||||
page.fill_rect(dx, dy + s, s, h - 2 * s, style.stroke);
|
||||
page.fill_rect(dx + w - s, dy + s, s, h - 2 * s, style.stroke);
|
||||
}
|
||||
Ok(())
|
||||
Ok(PxRect { x: dx, y: dy, w, h })
|
||||
}
|
||||
|
||||
/// What the Page Options draw on every page, in page pixels.
|
||||
struct Marks {
|
||||
/// Crop mark length, gap from the corner and weight.
|
||||
crop: Option<(usize, usize, usize)>,
|
||||
numbers: bool,
|
||||
info: Option<String>,
|
||||
ink: [u16; 3],
|
||||
text_px: f32,
|
||||
/// The printable area: the text sits in the margin below it, aligned with its sides.
|
||||
area: Option<PxRect>,
|
||||
}
|
||||
|
||||
impl Marks {
|
||||
fn of(settings: &PrintSettings, ppi: f64, pw: usize, ph: usize) -> Self {
|
||||
let on = has_page_options(settings.layout.style);
|
||||
let page = &settings.page;
|
||||
let px = |inches: f64| (inches * ppi).round() as usize;
|
||||
Marks {
|
||||
crop: (on && page.crop_marks).then(|| (px(CROP_MARK_IN), px(CROP_MARK_GAP_IN), px(CROP_MARK_PT / 72.0).max(1))),
|
||||
numbers: on && page.page_numbers,
|
||||
info: (on && page.page_info).then(|| page_info_text(settings)),
|
||||
ink: rec2020_from_srgb8(mark_ink(page.background)),
|
||||
text_px: (PAGE_TEXT_PT / 72.0 * ppi) as f32,
|
||||
area: settings.layout.printable().ok().and_then(|a| px_rect(&a, ppi, pw, ph)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the marks of page `number` (one-based) around `photos`, the photos on it.
|
||||
fn draw(&self, page: &mut Image16, photos: &[PxRect], number: usize) {
|
||||
if let Some((len, gap, weight)) = self.crop {
|
||||
for r in photos {
|
||||
crop_marks(page, r, len, gap, weight, photos, self.ink);
|
||||
}
|
||||
}
|
||||
let area = self.area.unwrap_or(PxRect { x: 0, y: 0, w: page.w, h: page.h });
|
||||
let (area_bottom, page_h) = ((area.y + area.h) as f32, page.h as f32);
|
||||
// centred in the bottom margin, and on the paper when the margin is thinner than the text
|
||||
let top = |h: f32| (area_bottom + (page_h - area_bottom - h) / 2.0).clamp(0.0, (page_h - h).max(0.0));
|
||||
if self.numbers
|
||||
&& let Some(t) = crate::text::TextLayout::new(&number.to_string(), self.text_px, false, crate::fonts::CRAFT_FONTS)
|
||||
{
|
||||
let x = ((area.x + area.w) as f32 - t.width).max(0.0);
|
||||
draw_text(page, &t, x, top(t.height), self.ink);
|
||||
}
|
||||
if let Some(info) = &self.info
|
||||
&& let Some(t) = crate::text::TextLayout::new(info, self.text_px, false, crate::fonts::CRAFT_FONTS)
|
||||
{
|
||||
draw_text(page, &t, area.x as f32, top(t.height), self.ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Crop marks for the photo at `r`: at each corner, a line along each of its edges that starts
|
||||
/// `gap` past the corner and runs `len` outward, `weight` thick. Marks never cover a photo.
|
||||
fn crop_marks(page: &mut Image16, r: &PxRect, len: usize, gap: usize, weight: usize, photos: &[PxRect], ink: [u16; 3]) {
|
||||
let (x0, y0, x1, y1) = (r.x as i64, r.y as i64, (r.x + r.w) as i64, (r.y + r.h) as i64);
|
||||
let (l, g, t) = (len as i64, gap as i64, weight.min(r.w).min(r.h) as i64);
|
||||
let segments = [
|
||||
(x0 - g - l, y0, l, t),
|
||||
(x1 + g, y0, l, t),
|
||||
(x0 - g - l, y1 - t, l, t),
|
||||
(x1 + g, y1 - t, l, t),
|
||||
(x0, y0 - g - l, t, l),
|
||||
(x0, y1 + g, t, l),
|
||||
(x1 - t, y0 - g - l, t, l),
|
||||
(x1 - t, y1 + g, t, l),
|
||||
];
|
||||
let on_photo = |x: usize, y: usize| photos.iter().any(|p| x >= p.x && x < p.x + p.w && y >= p.y && y < p.y + p.h);
|
||||
for (sx, sy, sw, sh) in segments {
|
||||
for y in sy.max(0)..(sy + sh).min(page.h as i64) {
|
||||
for x in sx.max(0)..(sx + sw).min(page.w as i64) {
|
||||
let (x, y) = (x as usize, y as usize);
|
||||
if !on_photo(x, y) {
|
||||
page.fill_rect(x, y, 1, 1, ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blends text in `ink` into `page` with its top-left corner at (`x`, `y`).
|
||||
fn draw_text(page: &mut Image16, text: &crate::text::TextLayout, x: f32, y: f32, ink: [u16; 3]) {
|
||||
let (w, h) = (page.w, page.h);
|
||||
text.draw_in(x, y, w as f32, h as f32, |gx, gy, cov| {
|
||||
let (Ok(gx), Ok(gy)) = (usize::try_from(gx), usize::try_from(gy)) else { return };
|
||||
if gx >= w || gy >= h {
|
||||
return;
|
||||
}
|
||||
let i = (gy * w + gx) * 3;
|
||||
if let Some(px) = page.rgb.get_mut(i..i + 3) {
|
||||
let k = cov.clamp(0.0, 1.0);
|
||||
for (p, c) in px.iter_mut().zip(ink) {
|
||||
*p = (f32::from(*p) + (f32::from(c) - f32::from(*p)) * k).round().clamp(0.0, 65535.0) as u16;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- the job ---------------------------------------------------------------------------------
|
||||
@@ -402,6 +550,7 @@ pub struct Prepared {
|
||||
ppi: f32,
|
||||
background: [u16; 3],
|
||||
style: CellStyle,
|
||||
marks: Marks,
|
||||
/// Print sharpening, applied to each photo once it's rendered at its print size.
|
||||
color: crate::print_color::PrintColorSettings,
|
||||
}
|
||||
@@ -430,6 +579,7 @@ impl Prepared {
|
||||
let mut out = Vec::with_capacity(self.pages.len());
|
||||
for (index, &(w, h)) in self.pages.iter().enumerate() {
|
||||
let mut page = Image16::filled(w, h, self.background)?;
|
||||
let mut placed = Vec::new();
|
||||
while let Some(item) = items.next_if(|i| i.page == index) {
|
||||
if !progress(done, &item.name) {
|
||||
return Err("Print cancelled".into());
|
||||
@@ -448,8 +598,9 @@ impl Prepared {
|
||||
}
|
||||
};
|
||||
let photo = &cache.get(at).ok_or("render cache lost an entry")?.1;
|
||||
draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?;
|
||||
placed.push(draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?);
|
||||
}
|
||||
self.marks.draw(&mut page, &placed, index + 1);
|
||||
out.push(PageRaster { width: w as u32, height: h as u32, ppi: self.ppi, rgb: page.rgb });
|
||||
}
|
||||
if !progress(done, "") {
|
||||
@@ -508,6 +659,7 @@ pub fn prepare(session: &mut Session, settings: &PrintSettings, photos: &[PhotoI
|
||||
ppi: ppi as f32,
|
||||
background: rec2020_from_srgb8(settings.page.background),
|
||||
style,
|
||||
marks: Marks::of(&settings, ppi, pw, ph),
|
||||
color: settings.job.color.clone(),
|
||||
})
|
||||
}
|
||||
@@ -825,6 +977,94 @@ mod tests {
|
||||
assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels");
|
||||
}
|
||||
|
||||
/// Pages for two photos, one per 4x6 page at 100 ppi, each filling its 0.5 in margins (so the
|
||||
/// margins are bare background unless something draws there).
|
||||
fn page_options_pages(s: &mut Session, style: Style, page: PageStyle) -> Vec<PageRaster> {
|
||||
let ids = first_ids(s, 2);
|
||||
let mut set = settings(style);
|
||||
if style.is_package() {
|
||||
set.layout.add_cell(3.0, 5.0).unwrap();
|
||||
set.layout.new_page().unwrap();
|
||||
let id = set.layout.add_cell(3.0, 5.0).unwrap();
|
||||
set.layout.set_cell_page(id, 1).unwrap();
|
||||
}
|
||||
set.image.zoom_to_fill = true;
|
||||
set.page = page;
|
||||
prepare(s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap()
|
||||
}
|
||||
|
||||
fn rgb(p: &PageRaster, x: usize, y: usize) -> [u16; 3] {
|
||||
let i = (y * p.width as usize + x) * 3;
|
||||
[p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]]
|
||||
}
|
||||
|
||||
/// The pixels of `x0..x1` x `y0..y1` that are not the white background.
|
||||
fn inked(p: &PageRaster, x0: usize, x1: usize, y0: usize, y1: usize) -> usize {
|
||||
(y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| rgb(p, x, y) != WHITE).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_marks_sit_outside_each_photo_corner() {
|
||||
let mut s = Session::with_demo();
|
||||
let plain = page_options_pages(&mut s, Style::SingleImage, PageStyle::default());
|
||||
assert_eq!(inked(&plain[0], 0, 400, 0, 50), 0, "no marks unless asked");
|
||||
let pages = page_options_pages(&mut s, Style::SingleImage, PageStyle { crop_marks: true, ..Default::default() });
|
||||
// The photo fills 50..350 x 50..550; a mark is a line 0.25 in long, 1/16 in from the corner,
|
||||
// along each edge of the photo.
|
||||
for p in &pages {
|
||||
for (x, y) in [(30, 50), (50, 30), (370, 50), (349, 30), (30, 549), (50, 570), (370, 549), (349, 570)] {
|
||||
assert_eq!(rgb(p, x, y), [0; 3], "a mark at ({x}, {y})");
|
||||
}
|
||||
for (x, y) in [(47, 50), (50, 47), (30, 60), (60, 30), (10, 50), (50, 10)] {
|
||||
assert_eq!(rgb(p, x, y), WHITE, "the gap, or past the mark, at ({x}, {y})");
|
||||
}
|
||||
}
|
||||
// a dark page gets white marks
|
||||
let dark = page_options_pages(&mut s, Style::SingleImage, PageStyle { background: [20; 3], crop_marks: true, ..Default::default() });
|
||||
assert_eq!(rgb(&dark[0], 30, 50), WHITE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_numbers_and_info_print_in_the_bottom_margin() {
|
||||
let mut s = Session::with_demo();
|
||||
let numbered = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_numbers: true, ..Default::default() });
|
||||
// bottom right: the number, different on each page; nothing at the bottom left
|
||||
let (right, left) = ((200, 400), (0, 200));
|
||||
for p in &numbered {
|
||||
assert!(inked(p, right.0, right.1, 550, 600) > 10, "a page number");
|
||||
assert_eq!(inked(p, left.0, left.1, 550, 600), 0);
|
||||
assert_eq!(inked(p, 0, 400, 0, 50), 0, "nothing at the top");
|
||||
}
|
||||
let region = |p: &PageRaster| (550..600).flat_map(|y| (right.0..right.1).map(move |x| (x, y))).map(|(x, y)| rgb(p, x, y)).collect::<Vec<_>>();
|
||||
assert_ne!(region(&numbered[0]), region(&numbered[1]), "page 1 and page 2 are numbered differently");
|
||||
let info = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_info: true, ..Default::default() });
|
||||
for p in &info {
|
||||
assert!(inked(p, left.0, left.1, 550, 600) > 10, "the print settings line");
|
||||
}
|
||||
// the packages have no Page Options in Lightroom: nothing is drawn
|
||||
let all = PageStyle { page_numbers: true, page_info: true, crop_marks: true, ..Default::default() };
|
||||
let package = page_options_pages(&mut s, Style::PicturePackage, all);
|
||||
assert_eq!(package.len(), 2);
|
||||
for p in &package {
|
||||
assert_eq!(inked(p, 0, 400, 550, 600) + inked(p, 0, 400, 0, 50), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_info_names_the_sharpening_profile_and_resolution() {
|
||||
use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen};
|
||||
let mut set = PrintSettings::default();
|
||||
let line = page_info_text(&set);
|
||||
assert_eq!(line, "Print Sharpening: Standard (Glossy) · Profile: Managed by Printer (sRGB) · 240 ppi");
|
||||
set.job.color.sharpen = PrintSharpen::Off;
|
||||
set.job.color.media = PrintMedia::Matte;
|
||||
set.job.color.profile = PrintProfile::Icc { path: "/profiles/Lustre Paper.icc".into() };
|
||||
set.job.ppi = 300.0;
|
||||
assert_eq!(page_info_text(&set), "Print Sharpening: Off · Profile: Lustre Paper · 300 ppi");
|
||||
set.job.color.profile = PrintProfile::Managed { space: ManagedSpace::AdobeRgb };
|
||||
assert!(page_info_text(&set).contains("Managed by Printer (Adobe RGB)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_and_hostile_settings_are_refused() {
|
||||
let mut s = Session::with_demo();
|
||||
|
||||
@@ -916,7 +916,8 @@ fn plan_rename_core(
|
||||
) -> Vec<RenamePlan> {
|
||||
let mut taken: HashSet<String> = HashSet::new(); // lower-case target paths/names claimed in this batch
|
||||
let mut out = Vec::new();
|
||||
for (seq, p) in (start..).zip(photos) {
|
||||
for (i, p) in photos.iter().enumerate() {
|
||||
let seq = start.saturating_add(i);
|
||||
let want = expand(template, p, seq);
|
||||
let (stem, ext) = split_ext(&want);
|
||||
let (stem, ext) = (stem.to_string(), ext.to_string());
|
||||
|
||||
@@ -75,6 +75,7 @@ pub(crate) struct Input {
|
||||
}
|
||||
|
||||
/// What a detail pass re-runs.
|
||||
#[derive(Clone)]
|
||||
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
|
||||
pub(crate) enum Prompt {
|
||||
Clicks(Vec<Click>),
|
||||
@@ -440,7 +441,11 @@ impl Session {
|
||||
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,
|
||||
Ok(o) => {
|
||||
// (shown by the next poll in an app; logged anyway)
|
||||
self.segmenter.messages.extend(o.notice);
|
||||
o.result
|
||||
}
|
||||
Err(_) => Err("the AI model took too long; try again".into()),
|
||||
}
|
||||
}
|
||||
@@ -579,7 +584,7 @@ impl Session {
|
||||
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 outcome = worker::Outcome { tag, result: Ok(patch), superseded: false, notice: None };
|
||||
let mut polled = Polled::default();
|
||||
self.apply_outcome(outcome, &mut polled);
|
||||
}
|
||||
@@ -618,6 +623,7 @@ impl Session {
|
||||
if o.superseded {
|
||||
return;
|
||||
}
|
||||
polled.messages.extend(o.notice);
|
||||
match o.tag {
|
||||
Tag::Wait => {}
|
||||
Tag::Prepare => {
|
||||
@@ -711,6 +717,18 @@ impl Session {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(feature = "sam")]
|
||||
#[test]
|
||||
fn a_worker_notice_reaches_the_user_with_the_result() {
|
||||
// the job after which the model runs on the CPU (the GPU failed) says so
|
||||
let mut s = Session::with_demo();
|
||||
let notice = Some(worker::GPU_FAILED.to_string());
|
||||
s.segmenter.results.0.send(worker::Outcome { tag: Tag::Prepare, result: Ok(None), superseded: false, notice }).unwrap();
|
||||
let polled = s.segment_poll();
|
||||
assert_eq!(polled.messages, vec![worker::GPU_FAILED.to_string()]);
|
||||
assert!(s.segment_poll().messages.is_empty(), "once");
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
//! 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).
|
||||
//!
|
||||
//! Where the model can run on the GPU (Metal on macOS), a job that fails there (a panic or an
|
||||
//! error from candle, e.g. a kernel the GPU's compiler can't build, issue #1974) frees the
|
||||
//! model and runs once more on the CPU. When that works, the model stays on the CPU for the rest
|
||||
//! of the session and the job's outcome says so; when it fails too, the GPU wasn't the cause and
|
||||
//! the next job uses it again. `LIGHTCRAFT_GPU=0` or `LIGHTCRAFT_GPU_BACKEND=off` keep the model on
|
||||
//! the CPU from the start.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
@@ -29,6 +36,7 @@ use crate::media::RenderJob;
|
||||
pub const IDLE_UNLOAD: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
/// What to compute.
|
||||
#[derive(Clone)]
|
||||
pub enum Kind {
|
||||
/// Load the model and encode the photo (so the first click is fast).
|
||||
Prepare,
|
||||
@@ -42,6 +50,12 @@ pub enum Kind {
|
||||
/// Panics (tests of the worker's recovery).
|
||||
#[cfg(test)]
|
||||
Panic,
|
||||
/// Fails like the model on a GPU that can't run it (a panic or a candle error), and
|
||||
/// succeeds on the CPU (tests of the CPU fallback).
|
||||
#[cfg(test)]
|
||||
FailOnGpu {
|
||||
panic: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// One request.
|
||||
@@ -65,6 +79,70 @@ pub struct Outcome {
|
||||
pub result: Result<Option<SegMask>, String>,
|
||||
/// A newer request for the same selection came in; this one was skipped.
|
||||
pub superseded: bool,
|
||||
/// Something the user should know besides the result ([`GPU_FAILED`]).
|
||||
pub notice: Option<String>,
|
||||
}
|
||||
|
||||
/// The notice of the job after which the model runs on the CPU.
|
||||
pub const GPU_FAILED: &str =
|
||||
"The GPU couldn't run the AI mask model, so it runs on the CPU until LightCraft restarts (slower). Details are in the log.";
|
||||
|
||||
/// Where the model is loaded.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Placement {
|
||||
/// On the GPU when there is one (Metal on macOS); a job that fails there is retried on the CPU.
|
||||
Gpu,
|
||||
/// On the CPU: this build has no GPU path, the environment switches the GPU off, or the model
|
||||
/// failed on it.
|
||||
Cpu,
|
||||
}
|
||||
|
||||
impl Placement {
|
||||
fn initial() -> Self {
|
||||
if !lightcraft_segment::GPU_BUILD {
|
||||
return Placement::Cpu;
|
||||
}
|
||||
if let Some(why) = lightcraft_gpu::env_off_reason() {
|
||||
log::info!("SAM 3 runs on the CPU ({why})");
|
||||
return Placement::Cpu;
|
||||
}
|
||||
Placement::Gpu
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a job failed.
|
||||
#[derive(Debug)]
|
||||
enum Failure {
|
||||
/// The model failed (a candle error, or a panic): on the GPU, the CPU may run it.
|
||||
Model(String),
|
||||
/// Anything else (no model files, the photo, a remote worker): another device won't help.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl Failure {
|
||||
fn message(self) -> String {
|
||||
match self {
|
||||
Failure::Model(m) | Failure::Other(m) => m,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Failure {
|
||||
fn from(m: String) -> Self {
|
||||
Failure::Other(m)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Failure {
|
||||
fn from(m: &str) -> Self {
|
||||
Failure::Other(m.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<lightcraft_segment::Error> for Failure {
|
||||
fn from(e: lightcraft_segment::Error) -> Self {
|
||||
if matches!(e, lightcraft_segment::Error::Candle(_)) { Failure::Model(e.to_string()) } else { Failure::Other(e.to_string()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Counters the session reads without locking.
|
||||
@@ -159,7 +237,7 @@ impl Worker {
|
||||
// (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)) {
|
||||
match std::thread::Builder::new().name("sam3".into()).spawn(move || run(&rx, &shared, IDLE_UNLOAD, Placement::initial())) {
|
||||
Ok(t) => {
|
||||
self.thread = Some(t);
|
||||
self.tx = Some(tx.clone());
|
||||
@@ -191,7 +269,7 @@ fn supersedes(later: &Job, job: &Job) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration) {
|
||||
fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration, mut placement: Placement) {
|
||||
let mut state = State::default();
|
||||
let mut queue: VecDeque<Job> = VecDeque::new();
|
||||
loop {
|
||||
@@ -219,41 +297,78 @@ fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration) {
|
||||
let done = Done(if is_detail { &shared.detail } else { &shared.pending });
|
||||
if queue.iter().any(|later| supersedes(later, &job)) {
|
||||
drop(done);
|
||||
let _ = job.reply.send(Outcome { tag: job.tag, result: Ok(None), superseded: true });
|
||||
let _ = job.reply.send(Outcome { tag: job.tag, result: Ok(None), superseded: true, notice: None });
|
||||
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())
|
||||
}
|
||||
// what a retry on the CPU needs (running the job takes them)
|
||||
let retry = (placement == Placement::Gpu && remote::configured().is_none()).then(|| (job.kind.clone(), job.render.clone()));
|
||||
let mut result = attempt(&mut state, shared, &mut job, placement);
|
||||
let mut notice = None;
|
||||
let gpu_failed = match &result {
|
||||
Err(Failure::Model(why)) => Some(why.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let (Some(why), Some((kind, render))) = (gpu_failed, retry) {
|
||||
// the GPU may be the cause (e.g. a kernel its compiler can't build, issue #1974): free
|
||||
// the model there and try once on the CPU
|
||||
log::warn!("SAM 3 failed on the GPU ({why}); trying the CPU");
|
||||
state = State::default();
|
||||
(job.kind, job.render) = (kind, render);
|
||||
match attempt(&mut state, shared, &mut job, Placement::Cpu) {
|
||||
Ok(r) => {
|
||||
log::warn!("SAM 3 runs on the CPU from now on: it failed on the GPU and works on the CPU");
|
||||
placement = Placement::Cpu;
|
||||
notice = Some(GPU_FAILED.to_string());
|
||||
result = Ok(r);
|
||||
}
|
||||
Err(e) => {
|
||||
// the CPU fails too, so the GPU isn't the cause: the next job uses it again
|
||||
log::warn!("SAM 3 failed on the CPU too: {}", e.message());
|
||||
state = State::default();
|
||||
}
|
||||
}
|
||||
}
|
||||
let result = result.map_err(Failure::message);
|
||||
if result.is_err() && !is_detail {
|
||||
state.cache = None;
|
||||
}
|
||||
shared.loaded.store(state.model.is_some() || state.remote.is_some(), Ordering::SeqCst);
|
||||
drop(done);
|
||||
let _ = job.reply.send(Outcome { tag: job.tag, result, superseded: false });
|
||||
let _ = job.reply.send(Outcome { tag: job.tag, result, superseded: false, notice });
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// Run `job` with the model on `placement`. A panic is a [`Failure::Model`] and drops what the
|
||||
/// model kept.
|
||||
fn attempt(state: &mut State, shared: &Shared, job: &mut Job, placement: Placement) -> Result<Option<SegMask>, Failure> {
|
||||
match std::panic::catch_unwind(AssertUnwindSafe(|| run_job(state, shared, job, placement))) {
|
||||
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(Failure::Model("the AI model failed unexpectedly (it will be reloaded on the next try)".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The model for `dir`, loading it on `placement` when needed.
|
||||
fn model<'a>(state: &'a mut State, shared: &Shared, dir: &std::path::Path, placement: Placement) -> Result<&'a mut Sam3, Failure> {
|
||||
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());
|
||||
let m = match placement {
|
||||
Placement::Gpu => Sam3::load(dir)?,
|
||||
Placement::Cpu => Sam3::load_cpu(dir)?,
|
||||
};
|
||||
log::info!("SAM 3 loaded in {:?} ({})", t.elapsed(), if m.device().is_cpu() { "CPU" } else { "GPU" });
|
||||
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())
|
||||
state.model.as_mut().map(|(_, m)| m).ok_or_else(|| "the model did not load".into())
|
||||
}
|
||||
|
||||
/// Render the model input.
|
||||
@@ -275,16 +390,16 @@ fn render(job: &mut Job) -> Result<Input, String> {
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
fn encoded<'a>(state: &'a mut State, shared: &Shared, job: &mut Job, placement: Placement) -> Result<(&'a mut Sam3, &'a mut Encoded), Failure> {
|
||||
model(state, shared, &job.dir, placement)?;
|
||||
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())?;
|
||||
let mut enc = m.encode(&input.rgb, input.w, input.h)?;
|
||||
m.prepare_clicks(&mut enc)?;
|
||||
log::info!("SAM 3 encoded {}×{} in {:?}", input.w, input.h, t.elapsed());
|
||||
state.cache = Some((job.key, enc));
|
||||
}
|
||||
@@ -328,7 +443,7 @@ fn remote_job(state: &mut State, shared: &Shared, job: &mut Job, endpoint: &str)
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
Kind::Panic => return Err("test panic request".into()),
|
||||
Kind::Panic | Kind::FailOnGpu { .. } => return Err("test panic request".into()),
|
||||
};
|
||||
let input = if region.is_some() || client.key != Some(job.key) { render(job)? } else { Input { rgb: Vec::new(), w: 0, h: 0 } };
|
||||
let request = Request { width: input.w, height: input.h, temporary: region.is_some(), operation };
|
||||
@@ -342,37 +457,44 @@ fn remote_job(state: &mut State, shared: &Shared, job: &mut Job, endpoint: &str)
|
||||
}))
|
||||
}
|
||||
|
||||
fn run_job(state: &mut State, shared: &Shared, job: &mut Job) -> Result<Option<SegMask>, String> {
|
||||
fn run_job(state: &mut State, shared: &Shared, job: &mut Job, placement: Placement) -> Result<Option<SegMask>, Failure> {
|
||||
if let Some(endpoint) = remote::configured() {
|
||||
let result = remote_job(state, shared, job, &endpoint);
|
||||
if result.is_err() {
|
||||
state.remote = None;
|
||||
}
|
||||
return result.map_err(|e| format!("Remote SAM 3 failed: {e}. Check the worker and SSH tunnel, then retry."));
|
||||
return result.map_err(|e| Failure::Other(format!("Remote SAM 3 failed: {e}. Check the worker and SSH tunnel, then retry.")));
|
||||
}
|
||||
let side = lightcraft_segment::MASK_SIDE;
|
||||
match std::mem::replace(&mut job.kind, Kind::Prepare) {
|
||||
Kind::Prepare => {
|
||||
encoded(state, shared, job)?;
|
||||
encoded(state, shared, job, placement)?;
|
||||
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())?;
|
||||
let (m, enc) = encoded(state, shared, job, placement)?;
|
||||
let p = m.segment_clicks(enc, &clicks)?;
|
||||
Ok(Some(SegMask::from_logits(side, &p.logits)))
|
||||
}
|
||||
Kind::Text(text) => {
|
||||
let (m, enc) = encoded(state, shared, job)?;
|
||||
let (m, enc) = encoded(state, shared, job, placement)?;
|
||||
Ok(text_logits(m, enc, &text)?.map(|l| SegMask::from_logits(side, &l)))
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::panic)]
|
||||
Kind::Panic => panic!("a test panic"),
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::panic)]
|
||||
Kind::FailOnGpu { panic } => match placement {
|
||||
Placement::Gpu if panic => panic!("a test panic on the GPU"),
|
||||
Placement::Gpu => Err(Failure::Model("a test error on the GPU".into())),
|
||||
Placement::Cpu => Ok(None),
|
||||
},
|
||||
Kind::Detail { prompt, region } => {
|
||||
let input = render(job)?;
|
||||
let m = model(state, shared, &job.dir)?;
|
||||
let m = model(state, shared, &job.dir, placement)?;
|
||||
let t = web_time::Instant::now();
|
||||
let mut enc = m.encode(&input.rgb, input.w, input.h).map_err(|e| e.to_string())?;
|
||||
let mut enc = m.encode(&input.rgb, input.w, input.h)?;
|
||||
let (rw, rh) = (region[2] - region[0], region[3] - region[1]);
|
||||
if !(rw > 0.0 && rh > 0.0) {
|
||||
return Ok(None);
|
||||
@@ -388,7 +510,7 @@ fn run_job(state: &mut State, shared: &Shared, job: &mut Job) -> Result<Option<S
|
||||
if !inside.iter().any(|c| c.positive) {
|
||||
return Ok(None);
|
||||
}
|
||||
Some(m.segment_clicks(&mut enc, &inside).map_err(|e| e.to_string())?.logits)
|
||||
Some(m.segment_clicks(&mut enc, &inside)?.logits)
|
||||
}
|
||||
Prompt::Text(text) => text_logits(m, &mut enc, &text)?,
|
||||
};
|
||||
@@ -438,7 +560,7 @@ mod tests {
|
||||
jtx.send(job(Tag::Wait, &tx)).unwrap();
|
||||
shared.pending.store(2, Ordering::SeqCst);
|
||||
drop(jtx);
|
||||
run(&jrx, &shared, Duration::from_secs(60));
|
||||
run(&jrx, &shared, Duration::from_secs(60), Placement::Cpu);
|
||||
let first = rx.recv().unwrap();
|
||||
assert!(first.result.unwrap_err().contains("failed unexpectedly"));
|
||||
let second = rx.recv().unwrap();
|
||||
@@ -447,6 +569,72 @@ mod tests {
|
||||
assert!(!shared.encoding.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
/// Run `kinds` in turn on a worker that starts on `placement`; their outcomes.
|
||||
fn run_kinds(placement: Placement, kinds: Vec<Kind>) -> Vec<Outcome> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let (jtx, jrx) = mpsc::channel();
|
||||
let shared = Shared::default();
|
||||
shared.pending.store(kinds.len(), Ordering::SeqCst);
|
||||
for kind in kinds {
|
||||
let mut j = job(Tag::Wait, &tx);
|
||||
j.kind = kind;
|
||||
jtx.send(j).unwrap();
|
||||
}
|
||||
drop(jtx);
|
||||
run(&jrx, &shared, Duration::from_secs(60), placement);
|
||||
assert_eq!(shared.pending.load(Ordering::SeqCst), 0, "counters reset");
|
||||
assert!(!shared.encoding.load(Ordering::SeqCst));
|
||||
rx.try_iter().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_job_that_panics_on_the_gpu_runs_on_the_cpu_and_so_do_the_next_ones() {
|
||||
// issue #1974: candle panics compiling a Metal kernel the GPU's compiler can't build
|
||||
let out = run_kinds(Placement::Gpu, vec![Kind::FailOnGpu { panic: true }, Kind::FailOnGpu { panic: true }]);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert!(matches!(out[0].result, Ok(None)), "retried on the CPU: {:?}", out[0].result.as_ref().err());
|
||||
assert_eq!(out[0].notice.as_deref(), Some(GPU_FAILED));
|
||||
assert!(matches!(out[1].result, Ok(None)), "the next job starts on the CPU");
|
||||
assert_eq!(out[1].notice, None, "said once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_model_error_on_the_gpu_is_retried_on_the_cpu_too() {
|
||||
let out = run_kinds(Placement::Gpu, vec![Kind::FailOnGpu { panic: false }]);
|
||||
assert!(matches!(out[0].result, Ok(None)));
|
||||
assert_eq!(out[0].notice.as_deref(), Some(GPU_FAILED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_job_that_fails_on_the_cpu_too_keeps_the_gpu() {
|
||||
let out = run_kinds(Placement::Gpu, vec![Kind::Panic, Kind::FailOnGpu { panic: true }]);
|
||||
assert!(out[0].result.as_ref().unwrap_err().contains("failed unexpectedly"));
|
||||
assert_eq!(out[0].notice, None);
|
||||
// not the GPU's fault: the next job tries the GPU first again
|
||||
assert!(matches!(out[1].result, Ok(None)));
|
||||
assert_eq!(out[1].notice.as_deref(), Some(GPU_FAILED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_worker_on_the_cpu_never_tries_the_gpu() {
|
||||
// no GPU in this build, or LIGHTCRAFT_GPU=0 / LIGHTCRAFT_GPU_BACKEND=off
|
||||
let out = run_kinds(Placement::Cpu, vec![Kind::FailOnGpu { panic: true }]);
|
||||
assert!(matches!(out[0].result, Ok(None)));
|
||||
assert_eq!(out[0].notice, None);
|
||||
if !lightcraft_segment::GPU_BUILD {
|
||||
assert_eq!(Placement::initial(), Placement::Cpu);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_model_files_are_not_retried_on_the_cpu() {
|
||||
let out = run_kinds(Placement::Gpu, vec![Kind::Prepare]);
|
||||
assert!(out[0].result.as_ref().unwrap_err().contains("not found"));
|
||||
assert_eq!(out[0].notice, None);
|
||||
let missing = lightcraft_segment::Error::Missing(PathBuf::from("models"));
|
||||
assert!(matches!(Failure::from(missing), Failure::Other(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_request_for_the_same_selection_supersedes_a_queued_one() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
@@ -464,7 +652,7 @@ mod tests {
|
||||
drop(jtx);
|
||||
let shared = Shared::default();
|
||||
shared.pending.store(3, Ordering::SeqCst);
|
||||
run(&jrx, &shared, Duration::from_secs(60));
|
||||
run(&jrx, &shared, Duration::from_secs(60), Placement::Cpu);
|
||||
let out: Vec<Outcome> = rx.try_iter().collect();
|
||||
assert_eq!(out.len(), 3);
|
||||
assert!(out[0].superseded && out[1].superseded && !out[2].superseded);
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! change when [`XmpPrefs::auto_write`] is on): rating, colour label, title, caption, copyright,
|
||||
//! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop
|
||||
//! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`).
|
||||
//! A sidecar is written only beside an original that is an existing file at an absolute path;
|
||||
//! otherwise the save fails.
|
||||
//!
|
||||
//! An existing sidecar is never replaced wholesale (issue #92): LightCraft's properties
|
||||
//! ([`OWNED`], [`OWNED_IF_STATED`]) are merged into it ([`lightcraft_meta::merge_xmp`]) and
|
||||
@@ -15,7 +17,10 @@
|
||||
//! **Shared names** (Stem naming): `IMG_0001.CR3` and `IMG_0001.JPG` would share `IMG_0001.xmp`.
|
||||
//! When two catalogued files map to one stem sidecar, it belongs to one of them (a raw first, then
|
||||
//! the first by file name — [`Session::sidecar_naming`]); the others use `<file>.xmp`
|
||||
//! (`IMG_0001.JPG.xmp`) for writing and reading, so neither overwrites the other.
|
||||
//! (`IMG_0001.JPG.xmp`) for writing and reading, so neither overwrites the other. An import ranks
|
||||
//! the files it is adding against the library the same way ([`ImportStems`]): a JPEG or a TIFF
|
||||
//! arriving beside its raw's `IMG_0001.xmp` reads its own `<file>.xmp`, never the raw's packet
|
||||
//! (issue #1905).
|
||||
//!
|
||||
//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata, except
|
||||
//! the capture time: a time embedded in the file wins, and the sidecar's (`exif:DateTimeOriginal`,
|
||||
@@ -23,7 +28,7 @@
|
||||
//! settings are restored from `lc:settings` when present, else mapped from interoperable `crs:`
|
||||
//! fields ([`crate::crs`]). For raw/DNG files without a sidecar the file's embedded XMP is used.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -66,6 +71,25 @@ pub fn sidecar_path(original: &str, naming: SidecarNaming) -> PathBuf {
|
||||
/// accepting `.XMP` and `.Xmp`, and a full name whose photo extension differs in case, such as
|
||||
/// `IMG_1.cr3.xmp` beside `IMG_1.CR3`, which a case-sensitive file system would otherwise miss).
|
||||
pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option<PathBuf> {
|
||||
sidecar_candidates(original, naming).find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// Find the sidecar for a file an import is adding, by the ownership [`ImportStems`] worked out
|
||||
/// (issue #1905): [`find_sidecar`] with the library's naming, except that a file which does not
|
||||
/// own its stem sidecar reads its own `<file>.xmp` (the name it writes) and never the packet of
|
||||
/// the sibling that owns the stem one.
|
||||
pub(crate) fn find_sidecar_for_import(stems: &ImportStems, naming: SidecarNaming, original: &str) -> Option<PathBuf> {
|
||||
if stems.owns(original) {
|
||||
return find_sidecar(original, naming);
|
||||
}
|
||||
let shared = xmp_cases(sidecar_path(original, SidecarNaming::Stem));
|
||||
sidecar_candidates(original, SidecarNaming::Full).filter(|p| !shared.contains(p)).find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// The names `original`'s sidecar can have, in the order they are looked for: under `naming`,
|
||||
/// then under the other naming (the full name also with the photo extension in the other case),
|
||||
/// each as `.xmp`, `.XMP` and `.Xmp`.
|
||||
pub(crate) fn sidecar_candidates(original: &str, naming: SidecarNaming) -> impl Iterator<Item = PathBuf> {
|
||||
let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem };
|
||||
let ext = Path::new(original).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let base = Path::new(original).with_extension("");
|
||||
@@ -73,15 +97,19 @@ pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option<PathBuf> {
|
||||
let full_variants: Vec<String> = [recased(ext.to_lowercase()), recased(ext.to_uppercase())].into_iter().flatten().collect();
|
||||
[naming, other]
|
||||
.into_iter()
|
||||
.flat_map(|n| {
|
||||
.flat_map(move |n| {
|
||||
let mut v = vec![sidecar_path(original, n)];
|
||||
if n == SidecarNaming::Full {
|
||||
v.extend(full_variants.iter().map(|o| sidecar_path(o, n)));
|
||||
}
|
||||
v
|
||||
})
|
||||
.flat_map(|p| [p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")])
|
||||
.find(|p| p.is_file())
|
||||
.flat_map(xmp_cases)
|
||||
}
|
||||
|
||||
/// The three ways one sidecar name can be written: `.xmp`, `.XMP` and `.Xmp`.
|
||||
fn xmp_cases(p: PathBuf) -> [PathBuf; 3] {
|
||||
[p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")]
|
||||
}
|
||||
|
||||
/// What a sidecar (or embedded XMP packet) says about a photo. `None` = not stated.
|
||||
@@ -520,6 +548,16 @@ pub(crate) fn op_photos(op: &Op, out: &mut Vec<PhotoId>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a file stands in the order a shared stem sidecar is owned by: a raw first, then the file
|
||||
/// name in lower case, then the catalog id (a file an import is adding has no id yet, and ranks
|
||||
/// last among files of its kind and name: see [`ImportStems`]).
|
||||
type Rank = (bool, String, u64);
|
||||
|
||||
/// The rank of the file at `path`, of kind `kind`, catalogued as `id`.
|
||||
fn rank_of(path: &str, kind: MediaKind, id: u64) -> Rank {
|
||||
(kind != MediaKind::Raw, path.to_lowercase(), id)
|
||||
}
|
||||
|
||||
/// Which photo owns each stem sidecar that several catalogued files map to (see
|
||||
/// [`Session::sidecar_naming`]).
|
||||
pub(crate) struct StemOwners(HashMap<String, PhotoId>);
|
||||
@@ -530,10 +568,10 @@ impl StemOwners {
|
||||
/// owners were worked out before, for every write).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn of(cat: &lightcraft_catalog::Catalog) -> StemOwners {
|
||||
let mut best: HashMap<String, ((bool, String, u64), PhotoId, usize)> = HashMap::new();
|
||||
let mut best: HashMap<String, (Rank, PhotoId, usize)> = HashMap::new();
|
||||
for p in cat.photos().filter(|p| p.copy_of.is_none()) {
|
||||
let Some(path) = file_path(p) else { continue };
|
||||
let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0);
|
||||
let rank = rank_of(path, p.kind, p.id.0);
|
||||
let e = best.entry(stem_key(path)).or_insert_with(|| (rank.clone(), p.id, 0));
|
||||
e.2 += 1;
|
||||
if rank < e.0 {
|
||||
@@ -558,7 +596,7 @@ impl StemOwners {
|
||||
/// The owners of the shared stems in `wanted`.
|
||||
fn where_wanted(cat: &lightcraft_catalog::Catalog, wanted: &std::collections::HashSet<String>) -> StemOwners {
|
||||
// per stem: (raw first, then file name, then id) of the best candidate; and how many
|
||||
let mut best: HashMap<String, ((bool, String, u64), PhotoId, usize)> = HashMap::new();
|
||||
let mut best: HashMap<String, (Rank, PhotoId, usize)> = HashMap::new();
|
||||
let mut key = String::new();
|
||||
for p in cat.photos().filter(|p| p.copy_of.is_none()) {
|
||||
let Some(path) = file_path(p) else { continue };
|
||||
@@ -566,7 +604,7 @@ impl StemOwners {
|
||||
if !wanted.contains(key.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0);
|
||||
let rank = rank_of(path, p.kind, p.id.0);
|
||||
let e = best.entry(key.clone()).or_insert_with(|| (rank.clone(), p.id, 0));
|
||||
e.2 += 1;
|
||||
if rank < e.0 {
|
||||
@@ -585,6 +623,66 @@ impl StemOwners {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which of the files an import is adding may read the shared stem sidecar (`IMG_0001.xmp`) of
|
||||
/// their stem, and which read their own `<file>.xmp` (`IMG_0001.TIF.xmp`) instead (issue #1905:
|
||||
/// before, a JPEG or a TIFF imported beside its raw read the raw's packet and took its rating,
|
||||
/// metadata and develop settings). The order is the one [`StemOwners`] applies to writing, with
|
||||
/// the arriving files ranked against the library and against each other: a raw first, then the
|
||||
/// file name in lower case. A file that is not catalogued yet has no id, and the id it draws when
|
||||
/// the import commits is above every id in the library, so it loses a tie against a catalogued
|
||||
/// file; between arriving files the order they arrive in stands for it.
|
||||
pub(crate) struct ImportStems {
|
||||
/// The arriving files that own the stem sidecar of their stem, by path as it arrives.
|
||||
owners: HashSet<String>,
|
||||
}
|
||||
|
||||
impl ImportStems {
|
||||
/// Work out [`ImportStems`] for the files `incoming`, in the order they arrive. `None` for a
|
||||
/// library already looked over (a job prepared twice): there is nothing to rank against, so
|
||||
/// every arriving file reads as it did before. The library is passed over once, and only the
|
||||
/// stems the arriving files map to are looked at closer.
|
||||
pub(crate) fn for_import<'a>(cat: Option<&lightcraft_catalog::Catalog>, incoming: impl Iterator<Item = (&'a str, MediaKind)>) -> ImportStems {
|
||||
// per stem: the best of the library's files, and the best arriving file with its path
|
||||
let mut best: HashMap<String, (Option<Rank>, Option<(Rank, &'a str)>)> = HashMap::new();
|
||||
let mut arriving_paths = HashSet::new();
|
||||
for (arriving, (path, kind)) in incoming.enumerate() {
|
||||
arriving_paths.insert(path);
|
||||
let e = best.entry(stem_key(path)).or_default();
|
||||
let rank = rank_of(path, kind, arriving as u64);
|
||||
if e.1.as_ref().is_none_or(|(b, _)| rank < *b) {
|
||||
e.1 = Some((rank, path));
|
||||
}
|
||||
}
|
||||
if let Some(cat) = cat {
|
||||
let mut key = String::new();
|
||||
for p in cat.photos().filter(|p| p.copy_of.is_none()) {
|
||||
// the arriving file's own record (a trashed photo imported again) is not a rival
|
||||
let Some(path) = file_path(p).filter(|path| !arriving_paths.contains(*path)) else { continue };
|
||||
stem_key_into(&mut key, path);
|
||||
let Some(e) = best.get_mut(key.as_str()) else { continue };
|
||||
let rank = rank_of(path, p.kind, p.id.0);
|
||||
if e.0.as_ref().is_none_or(|b| rank < *b) {
|
||||
e.0 = Some(rank);
|
||||
}
|
||||
}
|
||||
}
|
||||
let owners = best
|
||||
.into_values()
|
||||
.filter_map(|(in_library, arriving)| {
|
||||
let (rank, path) = arriving?;
|
||||
let wins = in_library.as_ref().is_none_or(|b| (rank.0, rank.1.as_str()) < (b.0, b.1.as_str()));
|
||||
wins.then(|| path.to_string())
|
||||
})
|
||||
.collect();
|
||||
ImportStems { owners }
|
||||
}
|
||||
|
||||
/// Whether the arriving file at `original` owns the stem sidecar of its stem.
|
||||
pub(crate) fn owns(&self, original: &str) -> bool {
|
||||
self.owners.contains(original)
|
||||
}
|
||||
}
|
||||
|
||||
/// How the sidecar of each of photos `ids` is named in library `cat`, whose naming is `naming`
|
||||
/// ([`Session::sidecar_namings`], for a thread that has the library and not the session).
|
||||
pub(crate) fn namings_in(cat: &lightcraft_catalog::Catalog, naming: SidecarNaming, ids: &[PhotoId]) -> Vec<SidecarNaming> {
|
||||
@@ -626,6 +724,10 @@ impl Session {
|
||||
return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name)));
|
||||
}
|
||||
let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?;
|
||||
// A relative path would be taken from the working directory.
|
||||
if Path::new(orig).is_relative() || !Path::new(orig).is_file() {
|
||||
return Err(EngineError::Other(format!("{}: the original {orig} is missing; no XMP sidecar is written", p.file_name)));
|
||||
}
|
||||
let naming = owners.naming(p, self.xmp.naming);
|
||||
let preferred = sidecar_path(orig, naming);
|
||||
let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem };
|
||||
@@ -780,6 +882,30 @@ impl Session {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn sidecars_are_written_only_beside_an_existing_original() {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-sidecar-local-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("IMG_1.jpg"), b"original").unwrap();
|
||||
let originals = [
|
||||
(r"\\192.0.2.1\share\IMG_1.jpg".to_string(), false),
|
||||
(dir.join("IMG_2.jpg").to_string_lossy().to_string(), false),
|
||||
(dir.join("IMG_1.jpg").to_string_lossy().to_string(), true),
|
||||
];
|
||||
for (original, written) in originals {
|
||||
let _ = std::fs::remove_file(dir.join("IMG_1.xmp"));
|
||||
let mut s = Session::new();
|
||||
let p = Photo::new(PhotoId(1), Source::File { path: original.clone() }, "IMG_1.jpg", "JPEG", 4, 3, "2026-01-01T00:00:00");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
assert_eq!(s.save_sidecar(PhotoId(1)).is_ok(), written, "{original}");
|
||||
assert_eq!(dir.join("IMG_1.xmp").is_file(), written, "{original}");
|
||||
assert!(!dir.join("IMG_2.xmp").exists(), "{original}");
|
||||
assert!(!Path::new(r"\\192.0.2.1\share\IMG_1.xmp").exists(), "{original}");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A sidecar written by Lightroom Classic (names flat, paths in `lr:hierarchicalSubject`)
|
||||
/// gives the photo its keywords as paths.
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Who owns a stem sidecar, worked out for the photos asked about only (`StemOwners::among`).
|
||||
//! Who owns a stem sidecar, worked out for the photos asked about only (`StemOwners::among`), and
|
||||
//! for the files an import is adding (`ImportStems`).
|
||||
//!
|
||||
//! A library can hold millions of photos; a sidecar that is written for one of them, or a folder
|
||||
//! that is synchronized, must not cost a table of them all.
|
||||
@@ -37,6 +38,11 @@ const PATHS: [&str; 26] = [
|
||||
"/pics/dots/a.jpg",
|
||||
];
|
||||
|
||||
/// The kind a file of this name is catalogued as: the `.cr3` ones are raws.
|
||||
fn kind_of(path: &str) -> MediaKind {
|
||||
if path.to_lowercase().ends_with("cr3") { MediaKind::Raw } else { MediaKind::Image }
|
||||
}
|
||||
|
||||
fn library() -> (Catalog, Vec<PhotoId>) {
|
||||
let mut c = Catalog::new();
|
||||
let mut ids = Vec::new();
|
||||
@@ -44,9 +50,7 @@ fn library() -> (Catalog, Vec<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.to_string() }, name, "X", 6, 4, "2026-01-01");
|
||||
if path.to_lowercase().ends_with("cr3") {
|
||||
p.kind = MediaKind::Raw;
|
||||
}
|
||||
p.kind = kind_of(path);
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
@@ -114,6 +118,72 @@ fn owners_among_some_photos_hold_nothing_of_the_rest() {
|
||||
assert_eq!(namings(&some, &c, &first), [SidecarNaming::Stem, SidecarNaming::Full, SidecarNaming::Stem]);
|
||||
}
|
||||
|
||||
/// Given a library where files share stems in every way a name can, when files arrive in an
|
||||
/// import, then each of them may read the shared stem sidecar exactly when it would own it once
|
||||
/// it is catalogued: the arriving files are ranked against the library's files and against each
|
||||
/// other, as the write rule ranks them (issue #1905).
|
||||
#[test]
|
||||
fn an_arriving_file_owns_the_stem_sidecar_exactly_when_it_would_once_catalogued() {
|
||||
let (c, _) = library();
|
||||
// the arrivals: the same names under an upper-case folder, so each shares its stem with the
|
||||
// library's files (the key lower-cases the path) without being one of them
|
||||
let incoming: Vec<(String, MediaKind)> = PATHS
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (dir, name) = p.rsplit_once('/').unwrap_or(("", p));
|
||||
(format!("{}/{name}", dir.to_uppercase()), kind_of(p))
|
||||
})
|
||||
.collect();
|
||||
// the library with these arrivals in it, the ids drawn in the order they arrive
|
||||
let arriving_in = |base: &Catalog, add: &[usize]| -> Catalog {
|
||||
let mut cat = base.clone();
|
||||
for &k in add {
|
||||
let (path, kind) = &incoming[k];
|
||||
let id = cat.alloc_photo_id();
|
||||
let name = path.rsplit('/').next().unwrap_or(path);
|
||||
let mut p = Photo::new(id, Source::File { path: path.clone() }, name, "X", 6, 4, "2026-01-01");
|
||||
p.kind = *kind;
|
||||
cat.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
cat
|
||||
};
|
||||
let owns_once_catalogued = |cat: &Catalog, k: usize| -> bool {
|
||||
let path = &incoming[k].0;
|
||||
let p = cat.photos().find(|p| matches!(&p.source, Source::File { path: x } if x == path)).unwrap();
|
||||
StemOwners::of(cat).naming(p, SidecarNaming::Stem) == SidecarNaming::Stem
|
||||
};
|
||||
let as_arriving = |ks: &[usize]| -> Vec<(&str, MediaKind)> { ks.iter().map(|k| (incoming[*k].0.as_str(), incoming[*k].1)).collect() };
|
||||
let all: Vec<usize> = (0..incoming.len()).collect();
|
||||
for i in 0..incoming.len() {
|
||||
let alone = ImportStems::for_import(Some(&c), as_arriving(&[i]).into_iter());
|
||||
assert_eq!(alone.owns(&incoming[i].0), owns_once_catalogued(&arriving_in(&c, &[i]), i), "{} alone", incoming[i].0);
|
||||
for j in 0..incoming.len() {
|
||||
// every pair arriving together
|
||||
if i == j {
|
||||
continue;
|
||||
}
|
||||
let pair = ImportStems::for_import(Some(&c), as_arriving(&[i, j]).into_iter());
|
||||
let cat = arriving_in(&c, &[i, j]);
|
||||
for k in [i, j] {
|
||||
assert_eq!(
|
||||
pair.owns(&incoming[k].0),
|
||||
owns_once_catalogued(&cat, k),
|
||||
"{} with {}",
|
||||
incoming[k].0,
|
||||
incoming[if k == i { j } else { i }].0
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// a job prepared twice (a library already looked over) has nothing to rank against: every
|
||||
// one of them is ranked among the arrivals alone, as it would be in a library of just them
|
||||
let alone = ImportStems::for_import(None, as_arriving(&all).into_iter());
|
||||
let only_arrivals = arriving_in(&Catalog::new(), &all);
|
||||
for k in &all {
|
||||
assert_eq!(alone.owns(&incoming[*k].0), owns_once_catalogued(&only_arrivals, *k), "{} without a library", incoming[*k].0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The key written into a buffer (no allocation for a plain path) is the key: for every path
|
||||
/// above, for shapes a path from a file can have, and for every string of up to six of the
|
||||
/// characters that decide a key (a letter in each case, a dot, both separators, a letter that is
|
||||
|
||||
@@ -903,7 +903,7 @@ fn shadow(img: &mut Rgba8, tl: &crate::text::TextLayout, x: f64, y: f64, sigma:
|
||||
let my0 = (y - pad).floor().max(-pad);
|
||||
let mx1 = (x + tl.width as f64 + pad).ceil().min(iw + pad);
|
||||
let my1 = (y + tl.height as f64 + pad).ceil().min(ih + pad);
|
||||
if !(mx1 > mx0 && my1 > my0) || !mx0.is_finite() || !my0.is_finite() {
|
||||
if !(mx1 > mx0 && my1 > my0 && mx0.is_finite() && my0.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let (w, h) = ((mx1 - mx0) as usize, (my1 - my0) as usize);
|
||||
|
||||
@@ -115,7 +115,7 @@ fn color(v: &Value) -> std::result::Result<Option<[u8; 3]>, String> {
|
||||
Value::String(s) if s.is_empty() || s.eq_ignore_ascii_case("none") => Ok(None),
|
||||
Value::String(s) => {
|
||||
let h = s.strip_prefix('#').unwrap_or(s);
|
||||
if h.len() != 6 || !h.is_ascii() {
|
||||
if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return Err(bad());
|
||||
}
|
||||
let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).ok_or_else(bad);
|
||||
@@ -747,6 +747,8 @@ mod tests {
|
||||
json!({"repeat": 1}),
|
||||
json!({"fadeColor": "#12345"}),
|
||||
json!({"fadeColor": "#ééé"}),
|
||||
json!({"fadeColor": "#+f+f+f"}),
|
||||
json!({"fadeColor": "-12345"}),
|
||||
json!({"fadeColor": [300, 0, 0]}),
|
||||
json!({"fadeColor": [1, 2]}),
|
||||
json!({"speed": 2}),
|
||||
|
||||
+167
-12
@@ -38,7 +38,7 @@
|
||||
//! not cancelled midway.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_catalog::{Catalog, Filter, Op, Photo, PhotoId, Sort, Source};
|
||||
@@ -203,18 +203,37 @@ pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges {
|
||||
let namings = crate::sidecar::namings_in(&labels, naming, &ids);
|
||||
let photos: Vec<(Arc<Photo>, SidecarNaming)> =
|
||||
ids.iter().zip(namings).filter_map(|(id, naming)| labels.photo(*id).map(|p| (Arc::clone(p), naming))).collect();
|
||||
// The folder is walked once, and its listing answers for the library's photos in it: on a
|
||||
// network share that is one request for a folder's names where it was several for each
|
||||
// photo. Then both halves wait on the disk at once: the new files are probed (on their own
|
||||
// threads) beside the checks of the photos, which ask the disk only what the listing can't say.
|
||||
// (how many photos there are to check is known before the walk: the count shows while it runs)
|
||||
progress.to_check.store(photos.len(), std::sync::atomic::Ordering::Relaxed);
|
||||
let mut listing = Listing::default();
|
||||
let (files, _) = crate::import::expand_noting(
|
||||
std::slice::from_ref(&folder),
|
||||
scan.skip(),
|
||||
crate::walk::Limits::default(),
|
||||
Some(&mut |dir, names, complete| listing.note(dir, names, complete)),
|
||||
);
|
||||
let listing = &listing;
|
||||
// (no threads on the web: one after the other there)
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let (out, checks) = (crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files), check_photos(&photos, &labels, progress));
|
||||
let (out, checks) = (crate::import::scan_files(scan, files, &progress.files), check_photos(&photos, &labels, listing, progress));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let (out, checks) = std::thread::scope(|sc| {
|
||||
let photos = &photos;
|
||||
let labels = &labels;
|
||||
let checks = sc.spawn(move || check_photos(photos, labels, progress));
|
||||
let out = crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files);
|
||||
let checks = sc.spawn(move || check_photos(photos, labels, listing, progress));
|
||||
let out = crate::import::scan_files(scan, files, &progress.files);
|
||||
// (a check that panicked counts as nothing found; the scan still reports the rest)
|
||||
(out, checks.join().unwrap_or_default())
|
||||
});
|
||||
log::debug!(
|
||||
"synchronize: {} folders listed; the disk was asked {} times about the library's photos",
|
||||
listing.folders.len(),
|
||||
listing.asked.load(std::sync::atomic::Ordering::Relaxed)
|
||||
);
|
||||
let mut changes = FolderChanges { path: folder, seen, ..Default::default() };
|
||||
for ((p, _), check) in photos.iter().zip(checks) {
|
||||
let Some(file) = crate::cmd::missing::checked_path(p) else { continue };
|
||||
@@ -335,11 +354,136 @@ enum Check {
|
||||
News,
|
||||
}
|
||||
|
||||
fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check {
|
||||
/// The listings of the folders a scan walked: what is in each, by name. It answers "is this file
|
||||
/// there" for the library's photos and their sidecars without asking the disk, which on a
|
||||
/// network share is a round trip for every name (and a photo without a sidecar took five).
|
||||
///
|
||||
/// It answers only what holds on any file system. A name is there when the folder lists exactly
|
||||
/// that name ([`asks_the_disk`] aside). It is not there when the folder was listed to its end,
|
||||
/// every name in the folder is ASCII, the name asked about is plain ASCII ([`plain_name`]), and
|
||||
/// no name in the folder is that name in another case. Anything else is not answered, and the
|
||||
/// disk is asked as before: a name that differs in case only, a link, a folder that was not
|
||||
/// walked or not to its end, and every name that is not listed in a folder that holds a name
|
||||
/// outside ASCII. (File systems hold names outside ASCII equal in more ways than can be listed
|
||||
/// here: by case, where the capital sharp s is `SS`; by how accents are written; and some pass
|
||||
/// over marks altogether.)
|
||||
///
|
||||
/// Where the listing and a look-up would differ, the listing is the folder as the scan saw it:
|
||||
/// a file it lists is there though it could not be opened by its path (a folder that may be
|
||||
/// listed but not searched, a path longer than the system takes), and a share that drops after
|
||||
/// the walk does not turn every photo the listing answers for into a missing one.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Listing {
|
||||
folders: HashMap<PathBuf, Listed>,
|
||||
/// How often the disk was asked after all (for the tests, and the log).
|
||||
pub(crate) asked: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
/// One folder's listing.
|
||||
struct Listed {
|
||||
/// name → what it is.
|
||||
names: HashMap<String, crate::walk::EntryKind>,
|
||||
/// The names, lower-cased (only kept for a folder whose names are all ASCII): what a plain
|
||||
/// name asked about could be in another case.
|
||||
lower: std::collections::HashSet<String>,
|
||||
/// Every entry was read, and every name is ASCII: a plain name that is not listed, in any
|
||||
/// case, is not there.
|
||||
sure: bool,
|
||||
}
|
||||
|
||||
/// Whether the disk is asked about `name` whatever the listing holds: a name Windows takes for
|
||||
/// a device (`NUL`, `aux.jpg`: there in every folder, or not openable where it is listed).
|
||||
fn asks_the_disk(name: &str) -> bool {
|
||||
let stem = name.split('.').next().unwrap_or_default().trim_end_matches(' ').to_ascii_lowercase();
|
||||
// (`COM` and `LPT` with any one digit, the superscript ones Windows also takes among them)
|
||||
let numbered = ["com", "lpt"].iter().any(|port| {
|
||||
let mut rest = stem.strip_prefix(port).unwrap_or_default().chars();
|
||||
matches!((rest.next(), rest.next()), (Some(d), None) if d.is_ascii_digit() || ['\u{b9}', '\u{b2}', '\u{b3}'].contains(&d))
|
||||
});
|
||||
numbered || ["con", "prn", "aux", "nul", "conin$", "conout$", "clock$"].contains(&stem.as_str())
|
||||
}
|
||||
|
||||
/// Whether `name` can only be found under itself in some case: ASCII, and nothing in it that a
|
||||
/// file system resolves another way (a short name with `~`, a stream after `:`, dots or spaces
|
||||
/// at its end, which Windows drops).
|
||||
fn plain_name(name: &str) -> bool {
|
||||
name.is_ascii() && !name.contains(['~', ':']) && !name.ends_with(['.', ' ']) && !name.is_empty()
|
||||
}
|
||||
|
||||
impl Listing {
|
||||
/// Take the listing of `folder` from the walk ([`crate::walk::files_in_noting`]).
|
||||
pub(crate) fn note(&mut self, folder: &Path, entries: Vec<(std::ffi::OsString, crate::walk::EntryKind)>, complete: bool) {
|
||||
let mut sure = complete;
|
||||
let mut names = HashMap::with_capacity(entries.len());
|
||||
let mut lower = std::collections::HashSet::new();
|
||||
for (name, kind) in entries {
|
||||
match name.into_string() {
|
||||
Ok(name) => {
|
||||
sure &= name.is_ascii();
|
||||
lower.insert(name.to_ascii_lowercase());
|
||||
names.insert(name, kind);
|
||||
}
|
||||
// (a name that is no text is not one a photo's path can be compared with)
|
||||
Err(_) => sure = false,
|
||||
}
|
||||
}
|
||||
if !sure {
|
||||
lower.clear();
|
||||
}
|
||||
self.folders.insert(folder.to_path_buf(), Listed { names, lower, sure });
|
||||
}
|
||||
|
||||
/// What the listing of `path`'s folder says of it: its kind when it is listed under exactly
|
||||
/// this name, `Some(None)` when it is surely not there, `None` when the listing can't say.
|
||||
fn kind(&self, path: &Path) -> Option<Option<crate::walk::EntryKind>> {
|
||||
let name = path.file_name()?.to_str()?;
|
||||
// (the path ends in that name: no `/.` or separator after it, which the disk would resolve)
|
||||
if !path.to_str()?.ends_with(name) {
|
||||
return None;
|
||||
}
|
||||
if asks_the_disk(name) {
|
||||
return None;
|
||||
}
|
||||
let folder = self.folders.get(path.parent()?)?;
|
||||
if let Some(kind) = folder.names.get(name) {
|
||||
return Some(Some(*kind));
|
||||
}
|
||||
(folder.sure && plain_name(name) && !folder.lower.contains(&name.to_ascii_lowercase())).then_some(None)
|
||||
}
|
||||
|
||||
/// `Path::exists`, where the listing can say.
|
||||
pub(crate) fn exists(&self, path: &Path) -> Option<bool> {
|
||||
match self.kind(path)? {
|
||||
Some(crate::walk::EntryKind::Other) => None,
|
||||
Some(_) => Some(true),
|
||||
None => Some(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// `Path::is_file`, where the listing can say.
|
||||
pub(crate) fn is_file(&self, path: &Path) -> Option<bool> {
|
||||
match self.kind(path)? {
|
||||
Some(crate::walk::EntryKind::Other) => None,
|
||||
Some(kind) => Some(kind == crate::walk::EntryKind::File),
|
||||
None => Some(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// The listing's answer, or the disk's where it has none.
|
||||
fn or_ask(&self, answer: Option<bool>, ask: impl FnOnce() -> bool) -> bool {
|
||||
answer.unwrap_or_else(|| {
|
||||
self.asked.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
ask()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog, listing: &Listing) -> Check {
|
||||
let Some(file) = crate::cmd::missing::checked_path(p) else { return Check::Nothing };
|
||||
if !Path::new(file).exists() {
|
||||
let file = Path::new(file);
|
||||
if !listing.or_ask(listing.exists(file), || file.exists()) {
|
||||
Check::Missing
|
||||
} else if p.copy_of.is_none() && sidecar_has_news(labels, p, naming) {
|
||||
} else if p.copy_of.is_none() && sidecar_has_news(labels, p, naming, listing) {
|
||||
Check::News
|
||||
} else {
|
||||
Check::Nothing
|
||||
@@ -348,12 +492,12 @@ fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check {
|
||||
|
||||
/// [`check_photo`] for each photo, in order, on several threads (each file is a wait on the
|
||||
/// disk, not work for the CPU). Photos not reached before a cancel count as nothing found.
|
||||
fn check_photos(photos: &[(Arc<Photo>, SidecarNaming)], labels: &Catalog, progress: &SyncProgress) -> Vec<Check> {
|
||||
fn check_photos(photos: &[(Arc<Photo>, SidecarNaming)], labels: &Catalog, listing: &Listing, progress: &SyncProgress) -> Vec<Check> {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
progress.to_check.store(photos.len(), Relaxed);
|
||||
let cancel = &progress.files.cancel;
|
||||
let check = |p: &Photo, naming: SidecarNaming| {
|
||||
let c = check_photo(p, naming, labels);
|
||||
let c = check_photo(p, naming, labels, listing);
|
||||
progress.checked.fetch_add(1, Relaxed);
|
||||
c
|
||||
};
|
||||
@@ -421,10 +565,17 @@ impl Session {
|
||||
|
||||
/// Whether `p`'s XMP sidecar file was saved after the library last had its say about the photo
|
||||
/// (imported, or edited here) and says something the library doesn't.
|
||||
fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming) -> bool {
|
||||
fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming, listing: &Listing) -> bool {
|
||||
let Source::File { path } = &p.source else { return false };
|
||||
let Some(file) = crate::sidecar::find_sidecar(path, naming) else { return false };
|
||||
let Ok(meta) = std::fs::metadata(&file) else { return false };
|
||||
// ([`crate::sidecar::find_sidecar`], each name looked up in the listing before the disk)
|
||||
// (a name the listing holds is read for its date next, and one that can't be read is passed
|
||||
// over for the next name, as a name that is no file was)
|
||||
let found = crate::sidecar::sidecar_candidates(path, naming).find_map(|c| {
|
||||
let there = listing.or_ask(listing.is_file(&c), || c.is_file());
|
||||
let meta = there.then(|| std::fs::metadata(&c).ok()).flatten().filter(std::fs::Metadata::is_file)?;
|
||||
Some((c, meta))
|
||||
});
|
||||
let Some((file, meta)) = found else { return false };
|
||||
if meta.len() > MAX_SIDECAR_BYTES {
|
||||
return false;
|
||||
}
|
||||
@@ -708,3 +859,7 @@ impl SyncCommit {
|
||||
self.report
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sync/tests_listing.rs"]
|
||||
mod tests_listing;
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Synchronize Folder answers "is the photo's file there" and "has it a sidecar" from the
|
||||
//! listing of the folder its scan has just made, instead of asking the disk about every photo.
|
||||
//!
|
||||
//! On a network share each question is a round trip (about 5 ms a file on a hard disk over SMB,
|
||||
//! measured), and a photo without a sidecar took five of them; the listing has every name.
|
||||
//! The listing answers only what it can be sure of on any file system: where a name could match
|
||||
//! another way (case, letters outside ASCII, a link), the disk is asked as before.
|
||||
use super::*;
|
||||
use crate::walk::EntryKind::{File, Folder, Other};
|
||||
|
||||
fn listing(folders: &[(&str, &[(&str, crate::walk::EntryKind)], bool)]) -> Listing {
|
||||
let mut l = Listing::default();
|
||||
for (dir, names, complete) in folders {
|
||||
l.note(Path::new(dir), names.iter().map(|(n, k)| (std::ffi::OsString::from(n), *k)).collect(), *complete);
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
/// Given the listing of a folder, when it is asked about a name, then it says "there" for a
|
||||
/// name it lists, "not there" for one nothing in the folder could be taken for, and nothing
|
||||
/// (ask the disk) wherever a file system could see it otherwise.
|
||||
#[test]
|
||||
fn the_listing_answers_only_what_it_is_sure_of() {
|
||||
let l = listing(&[
|
||||
(
|
||||
"/pics/trip",
|
||||
&[("IMG_1.CR3", File), ("IMG_1.xmp", File), ("img_2.jpg", File), ("sub", Folder), ("link.jpg", Other), (".hidden.xmp", File)],
|
||||
true,
|
||||
),
|
||||
("/pics/cut", &[("IMG_1.CR3", File)], false),
|
||||
("/pics/names", &[("IMG_1.CR3", File), ("Größe.JPG", File)], true),
|
||||
// (the capital sharp s, which a file system that folds case takes for `SS`)
|
||||
("/pics/sharp", &[("IMG_1.CR3", File), ("STRA\u{1e9e}E.jpg", File)], true),
|
||||
("/pics/devices", &[("aux.jpg", File), ("COM\u{b9}.jpg", File), ("IMG_1.CR3", File)], true),
|
||||
]);
|
||||
let (there, gone, ask) = (Some(true), Some(false), None);
|
||||
for (path, exists, is_file) in [
|
||||
("/pics/trip/IMG_1.CR3", there, there),
|
||||
("/pics/trip/IMG_1.xmp", there, there),
|
||||
("/pics/trip/.hidden.xmp", there, there),
|
||||
("/pics/trip/sub", there, gone),
|
||||
("/pics/trip/IMG_9.CR3", gone, gone),
|
||||
("/pics/trip/IMG_1.XMP", ask, ask),
|
||||
("/pics/trip/IMG_2.JPG", ask, ask),
|
||||
("/pics/trip/link.jpg", ask, ask),
|
||||
("/pics/trip/SUB", ask, ask),
|
||||
// a folder the walk did not list to its end, or not at all
|
||||
("/pics/cut/IMG_1.CR3", there, there),
|
||||
("/pics/cut/IMG_2.CR3", ask, ask),
|
||||
("/pics/other/IMG_1.CR3", ask, ask),
|
||||
// a folder that holds a name outside ASCII: what it lists is there, and nothing is surely not there (file
|
||||
// systems hold such names equal in more ways than one)
|
||||
("/pics/names/IMG_1.CR3", there, there),
|
||||
("/pics/names/Größe.JPG", there, there),
|
||||
("/pics/names/IMG_2.CR3", ask, ask),
|
||||
("/pics/names/größe.JPG", ask, ask),
|
||||
("/pics/trip/Größe.JPG", ask, ask),
|
||||
("/pics/sharp/strasse.jpg", ask, ask),
|
||||
("/pics/sharp/STRASSE.xmp", ask, ask),
|
||||
("/pics/sharp/IMG_2.CR3", ask, ask),
|
||||
// names Windows resolves another way: a short name, a stream, dots or spaces at the end, a device
|
||||
("/pics/trip/IMG_10~1.CR3", ask, ask),
|
||||
("/pics/trip/IMG_1.CR3::$DATA", ask, ask),
|
||||
("/pics/trip/IMG_1.CR3.", ask, ask),
|
||||
("/pics/trip/IMG_1.CR3 ", ask, ask),
|
||||
("/pics/devices/aux.jpg", ask, ask),
|
||||
("/pics/devices/NUL", ask, ask),
|
||||
("/pics/devices/nul.xmp", ask, ask),
|
||||
("/pics/devices/COM1 .jpg", ask, ask),
|
||||
("/pics/devices/COM\u{b9}.jpg", ask, ask),
|
||||
("/pics/trip/con", ask, ask),
|
||||
("/pics/trip/PRN.txt", ask, ask),
|
||||
("/pics/trip/com2.jpg", ask, ask),
|
||||
("/pics/trip/LPT9.xmp", ask, ask),
|
||||
("/pics/trip/lpt0", ask, ask),
|
||||
("/pics/trip/nul.tar.gz", ask, ask),
|
||||
("/pics/trip/CONIN$", ask, ask),
|
||||
("/pics/trip/com10.jpg", gone, gone),
|
||||
("/pics/trip/computer.jpg", gone, gone),
|
||||
("/pics/devices/IMG_1.CR3", there, there),
|
||||
("/pics/devices/auxiliary.jpg", ask, ask),
|
||||
("/pics/trip/auxiliary.jpg", gone, gone),
|
||||
// a path that does not end in its file's name is the disk's to resolve
|
||||
("/pics/trip/IMG_1.CR3/", ask, ask),
|
||||
("/pics/trip/IMG_1.CR3/.", ask, ask),
|
||||
("/pics/trip/", ask, ask),
|
||||
("", ask, ask),
|
||||
] {
|
||||
assert_eq!((l.exists(Path::new(path)), l.is_file(Path::new(path))), (exists, is_file), "{path:?}");
|
||||
}
|
||||
// a name that is no text: nothing in its folder is surely not there
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let mut l = Listing::default();
|
||||
l.note(
|
||||
Path::new("/pics/raw"),
|
||||
vec![(std::ffi::OsString::from("IMG_1.CR3"), File), (std::ffi::OsString::from_vec(vec![0x66, 0xff, 0x6f]), File)],
|
||||
true,
|
||||
);
|
||||
assert_eq!((l.exists(Path::new("/pics/raw/IMG_1.CR3")), l.exists(Path::new("/pics/raw/IMG_2.CR3"))), (there, ask));
|
||||
}
|
||||
}
|
||||
|
||||
fn photo(c: &mut Catalog, path: &Path) -> Arc<Photo> {
|
||||
let id = c.alloc_photo_id();
|
||||
let name = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
c.apply(lightcraft_catalog::Op::AddPhoto {
|
||||
photo: Box::new(Photo::new(id, Source::File { path: path.to_string_lossy().to_string() }, &name, "JPEG", 6, 4, "2020-01-01T00:00:00")),
|
||||
})
|
||||
.unwrap();
|
||||
Arc::clone(c.photo(id).unwrap())
|
||||
}
|
||||
|
||||
/// Given a folder on disk whose photos are there or gone, with sidecars under every name a
|
||||
/// sidecar can have, when each photo is checked with the folder's listing, then the answer is
|
||||
/// the one the disk gives; and for photos with plain names the disk was asked nothing but the
|
||||
/// sidecars' dates.
|
||||
#[test]
|
||||
fn a_check_from_the_listing_is_the_check_from_the_disk() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-listing-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||||
let news = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="5"/></rdf:RDF></x:xmpmeta>"#;
|
||||
for name in [
|
||||
"plain.jpg",
|
||||
"stem.jpg",
|
||||
"stem.xmp",
|
||||
"full.jpg",
|
||||
"full.jpg.xmp",
|
||||
"upper.jpg",
|
||||
"upper.XMP",
|
||||
"sub/deep.jpg",
|
||||
"sub/deep.xmp",
|
||||
"Größe.jpg",
|
||||
"Größe.xmp",
|
||||
"dir.jpg.xmp",
|
||||
] {
|
||||
std::fs::write(dir.join(name), if name.ends_with("mp") || name.ends_with("MP") { news.as_bytes() } else { b"jpeg" }).unwrap();
|
||||
}
|
||||
let mut c = Catalog::new();
|
||||
let photos: Vec<Arc<Photo>> =
|
||||
["plain.jpg", "stem.jpg", "full.jpg", "upper.jpg", "sub/deep.jpg", "Größe.jpg", "gone.jpg", "sub/gone.jpg", "dir.jpg"]
|
||||
.iter()
|
||||
.map(|n| photo(&mut c, &dir.join(n)))
|
||||
.collect();
|
||||
// (`dir.jpg` is gone, though a sidecar of its name is there)
|
||||
let mut l = Listing::default();
|
||||
let walked =
|
||||
crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| l.note(d, names, complete));
|
||||
assert_eq!(walked.files.len(), 12);
|
||||
let nothing = Listing::default();
|
||||
for naming in [SidecarNaming::Stem, SidecarNaming::Full] {
|
||||
for p in &photos {
|
||||
let from_disk = check_photo(p, naming, &c, ¬hing);
|
||||
assert_eq!(check_photo(p, naming, &c, &l), from_disk, "{:?} {naming:?}", p.file_name);
|
||||
}
|
||||
}
|
||||
let expected = [Check::Nothing, Check::News, Check::News, Check::News, Check::News, Check::News, Check::Missing, Check::Missing, Check::Missing];
|
||||
assert_eq!(photos.iter().map(|p| check_photo(p, SidecarNaming::Stem, &c, &l)).collect::<Vec<_>>(), expected);
|
||||
|
||||
// what the disk was asked about the same photos, with the listing and without: fewer questions with it
|
||||
let asked = |l: &Listing| {
|
||||
for p in &photos {
|
||||
check_photo(p, SidecarNaming::Stem, &c, l);
|
||||
}
|
||||
l.asked.load(std::sync::atomic::Ordering::Relaxed)
|
||||
};
|
||||
let mut with = Listing::default();
|
||||
crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| with.note(d, names, complete));
|
||||
let (with, without) = (asked(&with), asked(&Listing::default()));
|
||||
// (without: each photo's file, and the names of its sidecar up to the one that is there: 20 questions where
|
||||
// names are found in any case, 21 where they are not)
|
||||
assert!(without >= 20, "{without}");
|
||||
// (with: this folder holds `Größe.*`, so what it does not list is still asked about: the nine names of the
|
||||
// sidecar `plain.jpg` has not (`.xmp`, `.XMP` and `.Xmp` of `plain`, `plain.jpg` and `plain.JPG`), the three
|
||||
// `full.jpg` has not, `upper.xmp`, and the two photos that are gone. What it lists is not asked about, nor
|
||||
// anything in `sub`, whose names are all ASCII)
|
||||
assert_eq!(with, 15, "asked {with} times with the listing, {without} without");
|
||||
|
||||
// a folder of plain names only: the disk is asked nothing at all
|
||||
let sub = dir.join("sub");
|
||||
let mut only_plain = Listing::default();
|
||||
crate::walk::files_in_noting(&sub, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| only_plain.note(d, names, complete));
|
||||
let mut c2 = Catalog::new();
|
||||
for (name, expected) in [("deep.jpg", Check::News), ("gone.jpg", Check::Missing), ("gone.jpg.xmp", Check::Missing)] {
|
||||
let p = photo(&mut c2, &sub.join(name));
|
||||
assert_eq!(check_photo(&p, SidecarNaming::Stem, &c2, &only_plain), expected, "{name}");
|
||||
}
|
||||
assert_eq!(only_plain.asked.load(std::sync::atomic::Ordering::Relaxed), 0, "every answer came from the listing");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Constrain Crop changes the stored crop, as in Lightroom (issue #1927): with it on, the crop the
|
||||
//! Crop tool shows (the stored one) never takes in the blank corners a Geometry warp leaves, so
|
||||
//! ticking it, or moving a Geometry slider while it is ticked, visibly tightens the crop.
|
||||
|
||||
use lightcraft_geom::{CropGeometry, Rect};
|
||||
use lightcraft_pipeline::geometry::Frame;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn crop(s: &Session) -> CropGeometry {
|
||||
s.develop_of(s.active().unwrap()).unwrap().crop.geometry
|
||||
}
|
||||
|
||||
/// Does the stored crop already fit the warped photo (the render's constraint would leave it alone)?
|
||||
fn fits(s: &Session) -> bool {
|
||||
let p = s.catalog.photo(s.active().unwrap()).unwrap();
|
||||
let mut d = (*p.develop).clone();
|
||||
d.geometry.constrain_crop = true;
|
||||
let f = Frame::with_lens(p.width as usize, p.height as usize, &d, true, p.embedded_lens.as_ref());
|
||||
let (a, b) = (f.crop.rect, p.develop.crop.geometry.rect);
|
||||
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-9)
|
||||
}
|
||||
|
||||
fn smaller(a: Rect, b: Rect) -> bool {
|
||||
a.width() < b.width() - 1e-3 && a.height() < b.height() - 1e-3
|
||||
}
|
||||
|
||||
fn constrain(s: &mut Session, on: bool) {
|
||||
s.execute("develop.merge", &json!({"settings": {"geometry": {"constrain_crop": on}}, "label": "Constrain Crop"})).unwrap();
|
||||
}
|
||||
|
||||
fn vertical(s: &mut Session, v: f64) {
|
||||
s.execute("develop.set", &json!({"control": "geometry.vertical", "value": v})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_geometry_slider_tightens_the_crop_while_constrained() {
|
||||
let mut s = Session::with_demo();
|
||||
let full = crop(&s).rect;
|
||||
constrain(&mut s, true);
|
||||
assert_eq!(crop(&s).rect, full, "no warp yet: nothing to cut off");
|
||||
vertical(&mut s, -60.0);
|
||||
assert!(smaller(crop(&s).rect, full), "the stored crop shrank: {:?}", crop(&s).rect);
|
||||
assert!(fits(&s), "and holds no blank corner");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(crop(&s).rect, full, "undo puts the crop back with the slider");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticking_constrain_after_the_warp_tightens_the_crop() {
|
||||
let mut s = Session::with_demo();
|
||||
let full = crop(&s).rect;
|
||||
vertical(&mut s, -60.0);
|
||||
assert_eq!(crop(&s).rect, full, "unconstrained: the crop keeps the blank corners");
|
||||
assert!(!fits(&s));
|
||||
constrain(&mut s, true);
|
||||
assert!(smaller(crop(&s).rect, full) && fits(&s), "{:?}", crop(&s).rect);
|
||||
// unticking keeps the tighter crop, as in Lightroom
|
||||
let tight = crop(&s).rect;
|
||||
constrain(&mut s, false);
|
||||
assert_eq!(crop(&s).rect, tight);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crop_drawn_by_hand_is_kept_as_drawn() {
|
||||
let mut s = Session::with_demo();
|
||||
constrain(&mut s, true);
|
||||
vertical(&mut s, -60.0);
|
||||
s.execute("crop.set", &json!({"rect": [0.0, 0.0, 1.0, 1.0]})).unwrap();
|
||||
assert_eq!(crop(&s).rect, Rect::new(0.0, 0.0, 1.0, 1.0), "the crop edit itself is not refitted");
|
||||
}
|
||||
|
||||
/// Review of #1990: with Auto Sync, a Geometry edit on the active photo fits each selected photo's
|
||||
/// own crop; the active photo's fitted crop is never copied over another photo's (smaller) crop.
|
||||
#[test]
|
||||
fn auto_sync_fits_each_photos_own_crop() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
|
||||
let (a, b) = (ids[0], ids[1]);
|
||||
// B: a small crop off-centre; both constrained
|
||||
s.selection = crate::Selection { ids: vec![b], active: Some(b) };
|
||||
s.execute("crop.set", &json!({"rect": [0.05, 0.1, 0.45, 0.5]})).unwrap();
|
||||
constrain(&mut s, true);
|
||||
s.selection = crate::Selection { ids: vec![a], active: Some(a) };
|
||||
constrain(&mut s, true);
|
||||
let b_before = s.develop_of(b).unwrap().crop.geometry.rect;
|
||||
// A active, both selected, Auto Sync on: a Geometry edit on A
|
||||
s.selection = crate::Selection { ids: vec![a, b], active: Some(a) };
|
||||
s.execute("develop.autoSync", &json!({"on": true})).unwrap();
|
||||
vertical(&mut s, -60.0);
|
||||
let (da, db) = (s.develop_of(a).unwrap(), s.develop_of(b).unwrap());
|
||||
assert_eq!(db.geometry.vertical, -60.0, "the edit reached B");
|
||||
let b_after = db.crop.geometry.rect;
|
||||
assert_ne!(b_after, da.crop.geometry.rect, "B keeps a crop of its own");
|
||||
let inside = |r: Rect, o: Rect| r.x0 >= o.x0 - 1e-9 && r.y0 >= o.y0 - 1e-9 && r.x1 <= o.x1 + 1e-9 && r.y1 <= o.y1 + 1e-9;
|
||||
assert!(inside(b_after, b_before), "B's crop only shrinks within itself: {b_before:?} -> {b_after:?}");
|
||||
s.selection = crate::Selection { ids: vec![b], active: Some(b) };
|
||||
assert!(fits(&s), "and fits B's warp");
|
||||
}
|
||||
|
||||
/// Review of #1990 (CodeRabbit): the same with a slider drag (an interaction), the path the
|
||||
/// window uses: the drag fits A's crop live, and its end must not carry that crop over to B.
|
||||
#[test]
|
||||
fn auto_sync_after_a_slider_drag_fits_each_photos_own_crop() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
|
||||
let (a, b) = (ids[0], ids[1]);
|
||||
s.selection = crate::Selection { ids: vec![b], active: Some(b) };
|
||||
s.execute("crop.set", &json!({"rect": [0.05, 0.1, 0.45, 0.5]})).unwrap();
|
||||
constrain(&mut s, true);
|
||||
s.selection = crate::Selection { ids: vec![a], active: Some(a) };
|
||||
constrain(&mut s, true);
|
||||
let b_before = s.develop_of(b).unwrap().crop.geometry.rect;
|
||||
s.selection = crate::Selection { ids: vec![a, b], active: Some(a) };
|
||||
s.execute("develop.autoSync", &json!({"on": true})).unwrap();
|
||||
s.execute("develop.beginInteraction", &json!({"label": "Vertical"})).unwrap();
|
||||
for v in [-20.0, -40.0, -60.0] {
|
||||
vertical(&mut s, v);
|
||||
}
|
||||
s.execute("develop.endInteraction", &json!({})).unwrap();
|
||||
let (da, db) = (s.develop_of(a).unwrap(), s.develop_of(b).unwrap());
|
||||
assert_eq!((da.geometry.vertical, db.geometry.vertical), (-60.0, -60.0));
|
||||
let b_after = db.crop.geometry.rect;
|
||||
assert_ne!(b_after, da.crop.geometry.rect, "B keeps a crop of its own");
|
||||
let inside = |r: Rect, o: Rect| r.x0 >= o.x0 - 1e-9 && r.y0 >= o.y0 - 1e-9 && r.x1 <= o.x1 + 1e-9 && r.y1 <= o.y1 + 1e-9;
|
||||
assert!(inside(b_after, b_before), "{b_before:?} -> {b_after:?}");
|
||||
for photo in [a, b] {
|
||||
s.selection = crate::Selection { ids: vec![photo], active: Some(photo) };
|
||||
assert!(fits(&s), "each crop fits its photo's warp");
|
||||
}
|
||||
}
|
||||
|
||||
/// Review of #1990 (CodeRabbit): only the Geometry and Optics eyes change the warp; turning
|
||||
/// another section's eye off leaves a crop drawn by hand as it is.
|
||||
#[test]
|
||||
fn an_unrelated_section_eye_leaves_a_hand_drawn_crop() {
|
||||
let mut s = Session::with_demo();
|
||||
constrain(&mut s, true);
|
||||
vertical(&mut s, -60.0);
|
||||
s.execute("crop.set", &json!({"rect": [0.0, 0.0, 1.0, 1.0]})).unwrap();
|
||||
s.execute("develop.sectionEnabled", &json!({"section": "light", "enabled": false})).unwrap();
|
||||
assert_eq!(crop(&s).rect, Rect::new(0.0, 0.0, 1.0, 1.0), "the Light eye is no warp change");
|
||||
s.execute("develop.sectionEnabled", &json!({"section": "geometry", "enabled": false})).unwrap();
|
||||
s.execute("develop.sectionEnabled", &json!({"section": "geometry", "enabled": true})).unwrap();
|
||||
assert!(fits(&s), "the Geometry eye is one: the crop fits again");
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Issue #2253: a crop ratio keeps the orientation asked for. A named ratio ("2x3", "4x5",
|
||||
//! "original") takes the crop's present orientation, landscape or portrait after X, as in
|
||||
//! Lightroom; a custom `[w, h]` is used as given, so 2 × 3 is a portrait crop.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// The demo photo, landscape, with its crop's width and height in pixels.
|
||||
fn crop_px(s: &Session) -> (f64, f64) {
|
||||
let p = s.catalog.photo(s.active().unwrap()).unwrap();
|
||||
let (w, h) = (p.width as f64, p.height as f64);
|
||||
let (w, h) = if p.develop.orientation.swaps_axes() { (h, w) } else { (w, h) };
|
||||
let r = p.develop.crop.geometry.rect_px(w, h);
|
||||
(r.width(), r.height())
|
||||
}
|
||||
|
||||
fn landscape_photo() -> Session {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.catalog.photos().find(|p| p.width > p.height && !p.develop.orientation.swaps_axes()).map(|p| p.id).unwrap();
|
||||
s.selection = crate::Selection { ids: vec![id], active: Some(id) };
|
||||
s
|
||||
}
|
||||
|
||||
fn aspect(s: &mut Session, p: Value) {
|
||||
s.execute("crop.aspect", &p).unwrap();
|
||||
}
|
||||
|
||||
fn ratio(s: &Session) -> f64 {
|
||||
let (w, h) = crop_px(s);
|
||||
w / h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_ratio_follows_the_crop_and_x_keeps_it_portrait() {
|
||||
let mut s = landscape_photo();
|
||||
aspect(&mut s, json!({"aspect": "2x3"}));
|
||||
assert!((ratio(&s) - 1.5).abs() < 0.01, "landscape crop: {}", ratio(&s));
|
||||
s.execute("crop.rotateAspect", &json!({})).unwrap();
|
||||
assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "X turns it portrait: {}", ratio(&s));
|
||||
// another named ratio after X stays portrait (it used to turn back to the image's landscape)
|
||||
aspect(&mut s, json!({"aspect": "4x5"}));
|
||||
assert!((ratio(&s) - 0.8).abs() < 0.01, "still portrait: {}", ratio(&s));
|
||||
aspect(&mut s, json!({"aspect": "16x9"}));
|
||||
assert!((ratio(&s) - 9.0 / 16.0).abs() < 0.01, "{}", ratio(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_ratio_is_used_as_given() {
|
||||
let mut s = landscape_photo();
|
||||
aspect(&mut s, json!({"aspect": [2, 3]}));
|
||||
assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "custom 2 × 3 is portrait: {}", ratio(&s));
|
||||
aspect(&mut s, json!({"aspect": [3, 2]}));
|
||||
assert!((ratio(&s) - 1.5).abs() < 0.01, "{}", ratio(&s));
|
||||
}
|
||||
|
||||
/// The window reapplies the last ratio to an untouched photo with `orientation: "crop"`: on a
|
||||
/// landscape photo it lands landscape, whatever photo it was chosen on.
|
||||
#[test]
|
||||
fn orientation_overrides_and_is_checked() {
|
||||
let mut s = landscape_photo();
|
||||
aspect(&mut s, json!({"aspect": [2, 3], "orientation": "crop"}));
|
||||
assert!((ratio(&s) - 1.5).abs() < 0.01, "{}", ratio(&s));
|
||||
aspect(&mut s, json!({"aspect": "2x3", "orientation": "given"}));
|
||||
assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "{}", ratio(&s));
|
||||
let e = s.execute("crop.aspect", &json!({"aspect": "2x3", "orientation": "sideways"})).unwrap_err();
|
||||
assert!(e.to_string().contains("orientation"), "{e}");
|
||||
}
|
||||
|
||||
/// A bad `orientation` is refused on every path, the lock-as-is ones ("current", an unlocked
|
||||
/// "toggle") included, and whether it is a wrong word or not a string at all; nothing changes.
|
||||
#[test]
|
||||
fn a_bad_orientation_is_refused_before_anything_is_locked() {
|
||||
let mut s = landscape_photo();
|
||||
let before = s.develop_of(s.selection.active.unwrap()).unwrap().crop;
|
||||
for aspect in [json!("current"), json!("toggle"), json!("2x3"), json!([2, 3]), json!("free")] {
|
||||
for orientation in [json!("sideways"), json!(1), json!(true), json!(null)] {
|
||||
let e = s.execute("crop.aspect", &json!({"aspect": aspect, "orientation": orientation})).unwrap_err();
|
||||
assert!(e.to_string().contains("orientation"), "{aspect} {orientation}: {e}");
|
||||
assert_eq!(s.develop_of(s.selection.active.unwrap()).unwrap().crop, before, "{aspect} {orientation}: untouched");
|
||||
}
|
||||
}
|
||||
// the valid words still lock as is
|
||||
aspect(&mut s, json!({"aspect": "current", "orientation": "crop"}));
|
||||
assert!(s.develop_of(s.selection.active.unwrap()).unwrap().crop.aspect.is_some());
|
||||
}
|
||||
@@ -98,6 +98,43 @@ fn dng_export_rejects_non_raw_photos() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Review of #1966: a preset without `format` keeps its `bitDepth` until the format written is
|
||||
/// known. With `path: a.png` it writes the 16-bit PNG it asks for; a format without that depth
|
||||
/// gets its own, as stored settings do, while a depth the call itself passes is still refused.
|
||||
#[test]
|
||||
fn a_preset_depth_is_judged_against_the_format_written() {
|
||||
use crate::export::ExportPreset;
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
s.export_presets.push(ExportPreset { name: "Deep".into(), params: json!({"bitDepth": 16, "longEdge": 64}) });
|
||||
let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.png"})).unwrap();
|
||||
let o = ExportOptions::from_params(&p).unwrap();
|
||||
assert_eq!((o.format, o.bit_depth), (ExportFormat::Png, Some(16)));
|
||||
assert_eq!(export_photo(&mut s, id, &o, 1).unwrap().bytes[24], 16, "a 16-bit PNG");
|
||||
let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.jpg"})).unwrap();
|
||||
assert_eq!(ExportOptions::from_params(&p).unwrap().bit_depth, None);
|
||||
let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.jpg", "bitDepth": 16})).unwrap();
|
||||
assert!(ExportOptions::from_params(&p).unwrap_err().to_string().contains("`bitDepth` must be 8 for jpeg"));
|
||||
// such a preset can be saved: its depth is judged when it exports; with a format, at once
|
||||
s.execute("export.savePreset", &json!({"name": "Deep 2", "params": {"bitDepth": 16}})).unwrap();
|
||||
assert!(s.execute("export.savePreset", &json!({"name": "Deep 3", "params": {"format": "jpeg", "bitDepth": 16}})).is_err());
|
||||
}
|
||||
|
||||
/// Review of #1966: savePreset without `params` saves the previous export, a stored setting. A
|
||||
/// depth written before #1952 at the format's own depth is dropped, as Export with Previous does,
|
||||
/// instead of refusing the save; without `format`, the depth waits for the format written.
|
||||
#[test]
|
||||
fn a_preset_saved_from_the_previous_export_drops_its_unfit_depth() {
|
||||
let mut s = Session::with_demo();
|
||||
s.last_export = Some(json!({"format": "jpeg", "bitDepth": 16, "quality": 80, "dir": "/out"}));
|
||||
s.execute("export.savePreset", &json!({"name": "Old"})).unwrap();
|
||||
assert_eq!(s.export_presets[0].params, json!({"format": "jpeg", "quality": 80}));
|
||||
s.last_export = Some(json!({"bitDepth": 16, "dir": "/out"}));
|
||||
s.execute("export.savePreset", &json!({"name": "Deep"})).unwrap();
|
||||
let p = s.export_params(&json!({"preset": "Deep", "path": "/out/a.png"})).unwrap();
|
||||
assert_eq!(ExportOptions::from_params(&p).unwrap().bit_depth, Some(16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_conflicts_and_subfolders() {
|
||||
use crate::export::{Destination, export_batch};
|
||||
@@ -991,7 +1028,8 @@ fn same_folder_as_original_exports_next_to_each_photo() {
|
||||
let exists = |p: &str| std::path::Path::new(p).exists();
|
||||
let files = crate::export::export_batch(&mut s, &ids, &o, &to, &mut crate::export::write_file, &exists).unwrap();
|
||||
let written: Vec<&str> = files.iter().map(|f| f["path"].as_str().unwrap()).collect();
|
||||
let mut want = vec![a.join("Exports/IMG_1.jpg").to_string_lossy().to_string(), b.join("Exports/IMG_1.jpg").to_string_lossy().to_string()];
|
||||
// `export` joins the subfolder and name with `/` (`join_path`), on Windows too.
|
||||
let mut want = vec![format!("{}/Exports/IMG_1.jpg", a.to_string_lossy()), format!("{}/Exports/IMG_1.jpg", b.to_string_lossy())];
|
||||
let mut got: Vec<String> = written.iter().map(|p| p.to_string()).collect();
|
||||
want.sort();
|
||||
got.sort();
|
||||
@@ -1024,21 +1062,24 @@ fn same_folder_as_original_refuses_photos_without_a_folder() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
|
||||
let mut written = Vec::new();
|
||||
let mut write = |p: &str, _: &[u8]| -> Result<(), String> {
|
||||
written.push(p.to_string());
|
||||
Ok(())
|
||||
// the writer lives in a block of its own, so its borrow of `written` has ended by the time
|
||||
// the assertion below reads it
|
||||
let files = {
|
||||
let mut write = |p: &str, _: &[u8]| -> Result<(), String> {
|
||||
written.push(p.to_string());
|
||||
Ok(())
|
||||
};
|
||||
let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err();
|
||||
assert!(err.contains("no folder on disk"), "{err}");
|
||||
// without stopping: one error per photo, nothing written
|
||||
let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap();
|
||||
crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap()
|
||||
};
|
||||
let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err();
|
||||
assert!(err.contains("no folder on disk"), "{err}");
|
||||
// without stopping: one error per photo, nothing written
|
||||
let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap();
|
||||
let files = crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap();
|
||||
assert_eq!(files.len(), 2);
|
||||
for (f, id) in files.iter().zip(&ids) {
|
||||
assert_eq!(f["photo"], id.0, "{f}");
|
||||
assert!(f["error"].as_str().unwrap().contains("no folder on disk"), "{f}");
|
||||
}
|
||||
drop(write);
|
||||
assert!(written.is_empty(), "{written:?}");
|
||||
|
||||
// a photo on disk whose folder is gone (moved, an unplugged drive)
|
||||
|
||||
@@ -627,3 +627,17 @@ fn recognition_cannot_be_turned_on_without_a_chosen_model() {
|
||||
assert!(s.execute("faces.enable", &json!({"enabled": true})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cpu_only_switch_is_kept_and_bad_values_are_refused() {
|
||||
let d = temp("cpu-only");
|
||||
let mut s = session(&d);
|
||||
assert_eq!(s.execute("faces.gpu", &json!({})).unwrap()["cpuOnly"], false);
|
||||
assert_eq!(s.execute("faces.gpu", &json!({"cpuOnly": true})).unwrap()["cpuOnly"], true);
|
||||
// kept with the models, so a new session starts on the CPU too
|
||||
let mut again = session(&d);
|
||||
assert_eq!(again.execute("faces.models.list", &json!({})).unwrap()["gpu"]["cpuOnly"], true);
|
||||
assert!(again.execute("faces.gpu", &json!({"cpuOnly": "yes"})).is_err());
|
||||
assert_eq!(again.execute("faces.gpu", &json!({"cpuOnly": false})).unwrap()["cpuOnly"], false);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
@@ -115,3 +115,95 @@ fn negative_indices_are_refused() {
|
||||
assert!(s.execute("version.rename", &json!({"index": -1, "name": "B"})).is_err());
|
||||
assert_eq!(s.catalog.photo(id).unwrap().versions[0].name, "A", "version 0 was not renamed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_version_delete_preserves_the_version() {
|
||||
let mut s = Session::with_demo();
|
||||
s.execute("version.create", &json!({"name": "A"})).unwrap();
|
||||
let before = s.catalog.to_snapshot();
|
||||
let result = s.execute("version.delete", &json!({"index": 0.9}));
|
||||
assert!(result.is_err(), "index 0.9 must be refused; got {result:?}; catalog unchanged: {}", s.catalog.to_snapshot() == before);
|
||||
assert_eq!(s.catalog.to_snapshot(), before, "the version must remain unchanged");
|
||||
}
|
||||
|
||||
/// Invalid command indices must leave objects, settings, History and undo unchanged.
|
||||
#[test]
|
||||
fn invalid_command_indices_leave_the_catalog_and_history_unchanged() {
|
||||
let (mut s, _) = three_edits();
|
||||
for name in ["A", "B"] {
|
||||
s.execute("version.create", &json!({"name": name})).unwrap();
|
||||
}
|
||||
s.execute("pointColor.pick", &json!({"x": 0.5, "y": 0.08})).unwrap();
|
||||
for x in [0.3, 0.6] {
|
||||
s.execute("redeye.add", &json!({"center": [x, 0.4], "rx": 0.03, "ry": 0.02, "pet": true})).unwrap();
|
||||
}
|
||||
let before = s.catalog.to_snapshot();
|
||||
let history = s.execute("history.list", &json!({})).unwrap();
|
||||
let undo_len = s.undo.len();
|
||||
// The integer beyond u64 is parsed as a float by serde_json; it must not saturate to usize::MAX.
|
||||
let oversized: serde_json::Value = serde_json::from_str("18446744073709551616").unwrap();
|
||||
for command in [
|
||||
"version.delete",
|
||||
"version.rename",
|
||||
"version.update",
|
||||
"version.restore",
|
||||
"pointColor.delete",
|
||||
"redeye.delete",
|
||||
"redeye.catchlight",
|
||||
"history.restore",
|
||||
] {
|
||||
for index in [json!(0.5), json!(1.9), json!(-1), json!(-0.5), json!("0"), json!(null), json!(u64::MAX), oversized.clone()] {
|
||||
let result = s.execute(command, &json!({"index": index, "name": "Changed"}));
|
||||
assert!(result.is_err(), "{command} accepted {index}: {result:?}");
|
||||
assert_eq!(s.catalog.to_snapshot(), before, "{command} changed the catalog for {index}");
|
||||
assert_eq!(s.execute("history.list", &json!({})).unwrap(), history, "{command} moved the History cursor for {index}");
|
||||
assert_eq!(s.undo.len(), undo_len, "{command} added an undo step for {index}");
|
||||
}
|
||||
assert!(s.execute(command, &json!({"name": "Changed"})).is_err(), "{command} accepted a missing index");
|
||||
assert_eq!(s.catalog.to_snapshot(), before);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whole floating-point JSON numbers remain valid for existing control and MCP callers.
|
||||
#[test]
|
||||
fn whole_command_indices_accept_integer_and_float_numbers() {
|
||||
let (mut s, n) = three_edits();
|
||||
let id = s.active().unwrap();
|
||||
s.execute("history.restore", &json!({"index": (n - 3) as f64})).unwrap();
|
||||
assert_eq!((exposure(&s), list(&mut s)), (1.0, (n, Some(n - 3))));
|
||||
s.execute("history.restore", &json!({"index": n - 1})).unwrap();
|
||||
assert_eq!(exposure(&s), 3.0);
|
||||
for name in ["A", "B", "C"] {
|
||||
s.execute("version.create", &json!({"name": name})).unwrap();
|
||||
}
|
||||
s.execute("version.rename", &json!({"index": 2.0, "name": "Third"})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().versions[2].name, "Third");
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": -2.0})).unwrap();
|
||||
s.execute("version.update", &json!({"index": 1.0})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().versions[1].settings.light.exposure, -2.0);
|
||||
s.execute("version.restore", &json!({"index": 0.0})).unwrap();
|
||||
assert_eq!(exposure(&s), 3.0);
|
||||
s.execute("version.delete", &json!({"index": 1})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().versions.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["A", "Third"]);
|
||||
s.execute("version.delete", &json!({"index": 1.0})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().versions.len(), 1);
|
||||
s.execute("pointColor.pick", &json!({"x": 0.5, "y": 0.08})).unwrap();
|
||||
s.execute("pointColor.delete", &json!({"index": 0.0})).unwrap();
|
||||
assert!(s.develop_of(id).unwrap().point_colors.is_empty());
|
||||
s.execute("redeye.add", &json!({"center": [0.3, 0.4], "rx": 0.03, "ry": 0.02, "pet": true})).unwrap();
|
||||
s.execute("redeye.catchlight", &json!({"index": 0.0})).unwrap();
|
||||
assert!(s.develop_of(id).unwrap().red_eye[0].catchlight.is_some());
|
||||
s.execute("redeye.delete", &json!({"index": 0.0})).unwrap();
|
||||
assert!(s.develop_of(id).unwrap().red_eye.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_history_restore_preserves_the_cursor() {
|
||||
let (mut s, _) = three_edits();
|
||||
let before = s.execute("history.list", &json!({})).unwrap();
|
||||
let result = s.execute("history.restore", &json!({"index": 1.9}));
|
||||
let after = s.execute("history.list", &json!({})).unwrap();
|
||||
assert!(result.is_err(), "index 1.9 must be refused; got {result:?}; History unchanged: {}", before == after);
|
||||
assert_eq!(after, before);
|
||||
assert_eq!(exposure(&s), 3.0);
|
||||
}
|
||||
@@ -778,6 +778,50 @@ fn assisted_culling_groups_bursts_and_scores_focus() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Assisted culling: two look-alike shots one second apart across midnight form a burst at the end of
|
||||
/// every month, including those with fewer than 31 days.
|
||||
#[test]
|
||||
fn assisted_culling_groups_bursts_across_month_end() {
|
||||
let dir = temp_dir("cull-month-end");
|
||||
for (name, (w, h)) in [("a.png", (480, 320)), ("b.png", (482, 321))] {
|
||||
let img = lightcraft_scenes::demo_library()[0].render(w, h);
|
||||
let data: Vec<[u8; 4]> = img
|
||||
.data
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let e = |v: f32| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8;
|
||||
[e(c[0]), e(c[1]), e(c[2]), 255]
|
||||
})
|
||||
.collect();
|
||||
let png = lightcraft_codecs::encode_png(
|
||||
&lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: img.width, height: img.height, data }),
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join(name), png).unwrap();
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.import", &json!({"paths": [dir.to_string_lossy()]})).unwrap();
|
||||
let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().filter_map(Value::as_u64).collect();
|
||||
let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id;
|
||||
let (a, b) = (by_name(&s, "a.png"), by_name(&s, "b.png"));
|
||||
for (before, after) in [
|
||||
("2026-01-31T23:59:59", "2026-02-01T00:00:00"),
|
||||
("2026-02-28T23:59:59", "2026-03-01T00:00:00"),
|
||||
("2026-04-30T23:59:59", "2026-05-01T00:00:00"),
|
||||
("2026-06-30T23:59:59", "2026-07-01T00:00:00"),
|
||||
("2026-09-30T23:59:59", "2026-10-01T00:00:00"),
|
||||
("2026-11-30T23:59:59", "2026-12-01T00:00:00"),
|
||||
("2026-12-31T23:59:59", "2027-01-01T00:00:00"),
|
||||
] {
|
||||
s.commit("t", lightcraft_catalog::Op::SetCaptured { id: a, captured: Some(before.into()) }).unwrap();
|
||||
s.commit("t", lightcraft_catalog::Op::SetCaptured { id: b, captured: Some(after.into()) }).unwrap();
|
||||
let r = s.execute("photo.analyze", &json!({"ids": ids})).unwrap();
|
||||
assert_eq!(r["groups"], 1, "shots 1 s apart ({before} / {after}) must form a burst: {r}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Find Dust Spots: soft dark spots on a smooth sky are found and healed (one undo step).
|
||||
#[test]
|
||||
fn dust_spots_are_found_and_healed() {
|
||||
@@ -1088,6 +1132,25 @@ fn import_can_replace_a_duplicate_from_recently_deleted() {
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// A trashed photo imported again as a new one owns its stem sidecar: its own old record is not a
|
||||
/// rival for `a.xmp` (issue #1905's ranking), so the fresh photo reads it.
|
||||
#[test]
|
||||
fn a_fresh_reimport_reads_its_own_stem_sidecar() {
|
||||
let (mut s, src, id) = trashed_photo("trash-fresh-sidecar");
|
||||
std::fs::write(
|
||||
src.join("a.xmp"),
|
||||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="3"/></rdf:RDF></x:xmpmeta>"#,
|
||||
)
|
||||
.unwrap();
|
||||
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_eq!(s.catalog.photo(lightcraft_catalog::PhotoId(new)).unwrap().rating, 3, "the fresh photo reads a.xmp: {r}");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_on_deleted_is_an_error() {
|
||||
let mut s = Session::new().with_fs();
|
||||
@@ -1748,3 +1811,94 @@ fn presets_apply_the_imported_profile_they_name() {
|
||||
assert_eq!(apply(&mut s, "Portra Soft").profile.id, "lc.vivid");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// A raw container LightCraft shows by its embedded JPEG: a TIFF whose IFD0 points at one.
|
||||
fn write_raw(path: &Path) {
|
||||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
|
||||
let px: Vec<u8> = (0..48 * 32).flat_map(|i| [(i % 251) as u8, 91, 201]).collect();
|
||||
let j = encode_jpeg(&EncodeImage::new(48, 32, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
|
||||
let off = 8 + 2 + 24 + 4;
|
||||
let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec();
|
||||
for (tag, v) in [(513u16, off as u32), (514, j.len() as u32)] {
|
||||
f.extend_from_slice(&tag.to_le_bytes());
|
||||
f.extend_from_slice(&[4, 0, 1, 0, 0, 0]);
|
||||
f.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
f.extend_from_slice(&[0, 0, 0, 0]);
|
||||
f.extend_from_slice(&j);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, f).unwrap();
|
||||
}
|
||||
|
||||
/// An edited TIFF of a raw: the same name, a TIFF (not a raw) to LightCraft.
|
||||
fn write_tiff(path: &Path, seed: u8) {
|
||||
use lightcraft_codecs::{EncodeImage, EncodeMeta, Samples, TiffCompression, encode_tiff};
|
||||
let (w, h) = (48usize, 32usize);
|
||||
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % w * 5) as u8, (i / w * 7) as u8, seed]).collect();
|
||||
let bytes = encode_tiff(&EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&px)), TiffCompression::Deflate, &EncodeMeta::default()).unwrap();
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
/// Issue #1905 (the import side of #1523): the stem sidecar `IMG_1.xmp` belongs to the raw first,
|
||||
/// so a TIFF or JPEG arriving beside a raw of the same name reads its own `IMG_1.TIF.xmp`, never
|
||||
/// the raw's packet, with or without the raw. Before, the arriving file took the raw's rating,
|
||||
/// label, metadata and develop settings.
|
||||
#[test]
|
||||
fn a_file_arriving_beside_its_raws_stem_reads_its_own_sidecar() {
|
||||
let sidecar = |rating: u8| {
|
||||
format!(
|
||||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="{rating}"/></rdf:RDF></x:xmpmeta>"#
|
||||
)
|
||||
};
|
||||
let read = |s: &Session, name: &str| -> u8 { s.catalog.photos().find(|p| p.file_name == name).map(|p| p.rating).unwrap_or_default() };
|
||||
|
||||
// the raw and the edited TIFF of it, same name, in one import
|
||||
let src = temp_dir("stem-pair");
|
||||
write_raw(&src.join("IMG_1.NEF"));
|
||||
write_tiff(&src.join("IMG_1.TIF"), 7);
|
||||
std::fs::write(src.join("IMG_1.xmp"), sidecar(5)).unwrap();
|
||||
std::fs::write(src.join("IMG_1.TIF.xmp"), sidecar(2)).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (2, 0), "{r}");
|
||||
assert_eq!(r["sidecars"], 2, "{r}");
|
||||
assert_eq!(read(&s, "IMG_1.NEF"), 5, "the raw reads the stem sidecar, which is its own");
|
||||
assert_eq!(read(&s, "IMG_1.TIF"), 2, "the TIFF reads its own sidecar, never the raw's");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
|
||||
// the TIFF with no sidecar of its own: nothing of the raw's packet comes across
|
||||
let src = temp_dir("stem-none");
|
||||
write_raw(&src.join("IMG_2.NEF"));
|
||||
write_tiff(&src.join("IMG_2.TIF"), 8);
|
||||
std::fs::write(src.join("IMG_2.xmp"), sidecar(4)).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (2, 0), "{r}");
|
||||
assert_eq!(r["sidecars"], 1, "{r}");
|
||||
assert_eq!(read(&s, "IMG_2.NEF"), 4);
|
||||
assert_eq!(read(&s, "IMG_2.TIF"), 0, "the raw's packet is not the TIFF's");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
|
||||
// the raw already in the library, the edited TIFF arriving later
|
||||
use lightcraft_catalog::{MediaKind, Op, Photo, Source};
|
||||
let src = temp_dir("stem-after");
|
||||
write_raw(&src.join("IMG_3.NEF"));
|
||||
write_tiff(&src.join("IMG_3.TIF"), 9);
|
||||
std::fs::write(src.join("IMG_3.xmp"), sidecar(3)).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
let raw = {
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let path = src.join("IMG_3.NEF").to_string_lossy().into_owned();
|
||||
let mut p = Photo::new(id, Source::File { path }, "IMG_3.NEF", "NEF", 48, 32, "2026-01-14T05:58:48");
|
||||
p.kind = MediaKind::Raw;
|
||||
p
|
||||
};
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(raw) }).unwrap();
|
||||
let r = s.execute("library.import", &json!({"paths": [src.join("IMG_3.TIF").to_string_lossy()]})).unwrap();
|
||||
assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (1, 0), "{r}");
|
||||
assert_eq!(r["sidecars"], 0, "{r}");
|
||||
assert_eq!(read(&s, "IMG_3.TIF"), 0, "the catalogued raw owns IMG_3.xmp");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
@@ -336,3 +336,23 @@ fn deleting_an_album_folder_preserves_photos_and_restores_its_subtree() {
|
||||
drop(reopened);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A huge `start` is a validation error, not an arithmetic overflow in the numbering.
|
||||
#[test]
|
||||
fn rename_huge_start_is_a_bad_param() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
|
||||
let names_before: Vec<String> = s.catalog.photos().map(|p| p.file_name.clone()).collect();
|
||||
for cmd in ["photo.renamePreview", "photo.rename"] {
|
||||
let e = s.execute(cmd, &json!({"template": "{seq}", "start": u64::MAX, "ids": ids})).unwrap_err();
|
||||
assert!(e.to_string().contains("start out of range"), "{cmd}: {e}");
|
||||
}
|
||||
let names_after: Vec<String> = s.catalog.photos().map(|p| p.file_name.clone()).collect();
|
||||
assert_eq!(names_before, names_after, "a rejected rename changes nothing");
|
||||
// the last start whose whole sequence fits is accepted, one more is not
|
||||
let fits = usize::MAX as u64 - 2;
|
||||
let r = s.execute("photo.renamePreview", &json!({"template": "{seq}", "start": fits, "ids": ids}));
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
let r = s.execute("photo.renamePreview", &json!({"template": "{seq}", "start": fits + 1, "ids": ids}));
|
||||
assert!(r.is_err(), "{r:?}");
|
||||
}
|
||||
@@ -191,3 +191,28 @@ fn close_fails_when_nothing_can_be_saved() {
|
||||
s.close_library().unwrap();
|
||||
assert_eq!(s.unsaved(), None);
|
||||
}
|
||||
|
||||
/// Issue #2266: a presets.json that can't be written isn't only logged: the preset counts as
|
||||
/// unsaved (the top bar warns, the web backup refuses) until a later save writes it.
|
||||
#[test]
|
||||
fn a_preset_that_cannot_be_written_counts_as_unsaved_until_it_is() {
|
||||
let files = Flaky::default();
|
||||
let mut s = Session::new();
|
||||
let stores = LibraryStores { dir: "flaky".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false };
|
||||
s.open_library_in(stores, true).unwrap();
|
||||
assert!(s.unsaved().is_none());
|
||||
files.set_failing(true);
|
||||
s.execute("preset.create", &json!({"name": "Mine"})).unwrap();
|
||||
let (n, why) = s.unsaved().expect("the preset is unsaved");
|
||||
assert_eq!(n, 1);
|
||||
assert!(why.contains("presets") && why.contains("disk full"), "{why}");
|
||||
let stored = |f: &Flaky| f.files.files.lock().unwrap().get("presets.json").cloned().unwrap_or_default();
|
||||
assert!(!String::from_utf8_lossy(&stored(&files)).contains("Mine"));
|
||||
// still failing: still unsaved
|
||||
s.persist().unwrap();
|
||||
assert!(s.unsaved().is_some());
|
||||
files.set_failing(false);
|
||||
s.persist().unwrap();
|
||||
assert!(s.unsaved().is_none(), "written once the disk works again");
|
||||
assert!(String::from_utf8_lossy(&stored(&files)).contains("Mine"));
|
||||
}
|
||||
@@ -142,6 +142,25 @@ fn preferences_persist_with_the_library() {
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_merge_settings_persist_with_the_library() {
|
||||
let lib = temp_dir("merge");
|
||||
let opts = json!({"align": false, "deghost": "high"});
|
||||
{
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
s.last_merge.insert("merge.hdr".into(), opts.clone());
|
||||
s.save_prefs().unwrap();
|
||||
s.close_library().unwrap();
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert_eq!(s.last_merge.get("merge.hdr"), Some(&opts));
|
||||
assert!(!s.last_merge.contains_key("merge.panorama"));
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_copyright_and_creator_fill_gaps_on_import() {
|
||||
let dir = temp_dir("copyright");
|
||||
|
||||
@@ -341,3 +341,23 @@ fn every_print_command_is_registered_with_a_description() {
|
||||
assert!(c.params.len() > 3, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_options_are_print_settings_and_template_fields() {
|
||||
let mut s = Session::with_demo();
|
||||
let g = run(&mut s, "print.get", json!({}));
|
||||
assert_eq!(g["settings"]["page"], json!({"background": [255, 255, 255], "pageNumbers": false, "pageInfo": false, "cropMarks": false}));
|
||||
let g = run(&mut s, "print.set", json!({"page": {"pageNumbers": true, "cropMarks": true}}));
|
||||
assert_eq!((g["settings"]["page"]["pageNumbers"].as_bool(), g["settings"]["page"]["pageInfo"].as_bool()), (Some(true), Some(false)));
|
||||
assert_eq!(g["settings"]["page"]["background"], json!([255, 255, 255]), "a patch merges");
|
||||
fails(&mut s, "print.set", json!({"page": {"pageInfo": "yes"}}));
|
||||
fails(&mut s, "print.set", json!({"page": {"pageNumber": true}}));
|
||||
// a template keeps them
|
||||
run(&mut s, "print.template.save", json!({"name": "Marked"}));
|
||||
run(&mut s, "print.reset", json!({}));
|
||||
assert!(!s.print.page.crop_marks);
|
||||
run(&mut s, "print.template.apply", json!({"name": "Marked"}));
|
||||
assert!(s.print.page.page_numbers && s.print.page.crop_marks && !s.print.page.page_info);
|
||||
let set = crate::find_command("print.set").unwrap();
|
||||
assert!(set.params.contains("pageNumbers") && set.params.contains("pageInfo") && set.params.contains("cropMarks"), "{}", set.params);
|
||||
}
|
||||
@@ -248,6 +248,9 @@ fn bad_files_are_reported_and_skipped() {
|
||||
}
|
||||
std::fs::create_dir_all(folder.join("folder.json")).unwrap();
|
||||
std::fs::write(folder.join("notes.txt"), b"ignored").unwrap();
|
||||
// written by hand, not through LightCraft: the folder's time may not have moved since the
|
||||
// save's listing (same clock tick), so the cached listing is dropped as a write would drop it
|
||||
s.slide_templates_cache = None;
|
||||
let r = run(&mut s, "slideshow.templates.list", json!({}));
|
||||
let user: Vec<_> = r["templates"].as_array().unwrap().iter().filter(|t| t["builtin"] == false).collect();
|
||||
assert_eq!(user.len(), 1, "only the good one: {r}");
|
||||
|
||||
@@ -82,3 +82,242 @@ fn a_content_aware_spot_has_no_source() {
|
||||
assert_eq!(serde_json::to_value(sp).unwrap()["mode"], "fill");
|
||||
assert!(s.execute("spot.update", &json!({"mode": "nonsense"})).is_err());
|
||||
}
|
||||
|
||||
fn target_session() -> Session {
|
||||
let mut s = Session::with_demo();
|
||||
s.execute("mask.add", &json!({"kind": "colorRange"})).unwrap();
|
||||
s.execute("mask.add", &json!({"kind": "radial"})).unwrap();
|
||||
s.execute("mask.addComponent", &json!({"kind": "colorRange"})).unwrap();
|
||||
s.execute("mask.saveEffect", &json!({"id": 1, "name": "Existing"})).unwrap();
|
||||
s.execute("mask.adjust", &json!({"values": {"exposure": 1.0}})).unwrap();
|
||||
for x in [0.3, 0.7] {
|
||||
s.execute("spot.add", &json!({"points": [[x, 0.5]], "source": [0.1, 0.0]})).unwrap();
|
||||
}
|
||||
// A rejected command must preserve a redo branch as well as existing undo steps.
|
||||
s.execute("mask.rename", &json!({"name": "Temporary"})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn target_state(s: &Session) -> serde_json::Value {
|
||||
let photo = s.catalog.photo(s.active().unwrap()).unwrap();
|
||||
json!({
|
||||
"develop": photo.develop,
|
||||
"history": photo.history,
|
||||
"selection": s.selection,
|
||||
"activeMask": s.active_mask,
|
||||
"activeSpot": s.active_spot,
|
||||
"undo": format!("{:?}", s.undo),
|
||||
"redo": format!("{:?}", s.redo),
|
||||
"commits": s.commits(),
|
||||
"effects": s.mask_effects,
|
||||
"rememberedEffects": s.mask_tool_effects,
|
||||
"journal": s.journal,
|
||||
"revision": s.catalog.revision,
|
||||
})
|
||||
}
|
||||
|
||||
fn rejects_target(s: &mut Session, command: &str, params: &serde_json::Value) {
|
||||
let before = target_state(s);
|
||||
let result = s.execute(command, params);
|
||||
let after = target_state(s);
|
||||
assert!(result.is_err(), "{command} {params} returned {result:?}; state unchanged: {}", before == after);
|
||||
assert_eq!(after, before, "{command} {params} changed edits, selection or history on rejection");
|
||||
for field in ["id", "component", "index"] {
|
||||
if let Some(value) = params.get(field)
|
||||
&& !value.is_null()
|
||||
&& value.as_u64().is_none()
|
||||
{
|
||||
assert!(
|
||||
result.unwrap_err().to_string().contains(&format!("`{field}`")),
|
||||
"{command} must reject the malformed {field} before doing other work"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn malformed_targets() -> Vec<serde_json::Value> {
|
||||
vec![json!(-1), json!(0.5), json!("1"), json!(true), json!([]), json!({}), json!(u64::MAX)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_mask_ids_are_rejected_without_changes() {
|
||||
let mut s = target_session();
|
||||
let commands = [
|
||||
("mask.delete", json!({})),
|
||||
("mask.rename", json!({"name": "Wrong mask"})),
|
||||
("mask.invert", json!({})),
|
||||
("mask.visible", json!({"visible": false})),
|
||||
("mask.duplicate", json!({})),
|
||||
("mask.move", json!({"to": 0})),
|
||||
("mask.adjust", json!({"values": {"exposure": 1.0}})),
|
||||
("mask.update", json!({"shape": {"kind": "brush", "strokes": []}})),
|
||||
("mask.addComponent", json!({"kind": "brush"})),
|
||||
("mask.component", json!({"component": 0, "action": "invert"})),
|
||||
("mask.brushStroke", json!({"points": [[0.5, 0.5]]})),
|
||||
("mask.sampleColor", json!({"x": 0.5, "y": 0.5})),
|
||||
("mask.objectPoint", json!({"x": 0.5, "y": 0.5})),
|
||||
("mask.refineDetail", json!({})),
|
||||
];
|
||||
for (command, mut params) in commands {
|
||||
for target in malformed_targets().into_iter().chain([json!(4_294_967_297_u64)]) {
|
||||
params["id"] = target;
|
||||
rejects_target(&mut s, command, ¶ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_mask_selection_is_rejected_without_changes() {
|
||||
let mut s = target_session();
|
||||
for target in malformed_targets().into_iter().filter(|v| !v.is_null()).chain([json!(4_294_967_297_u64)]) {
|
||||
rejects_target(&mut s, "mask.select", &json!({"id": target}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflowing_mask_id_does_not_wrap_to_an_existing_mask() {
|
||||
let mut s = target_session();
|
||||
rejects_target(&mut s, "mask.delete", &json!({"id": 4_294_967_297_u64}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_mask_effect_targets_are_rejected_without_changes() {
|
||||
let mut s = target_session();
|
||||
for (command, mut params) in [
|
||||
("mask.saveEffect", json!({"name": "Wrong effect"})),
|
||||
("mask.updateEffect", json!({"name": "Existing"})),
|
||||
("mask.applyEffect", json!({"name": "Existing"})),
|
||||
] {
|
||||
for target in malformed_targets().into_iter().chain([json!(4_294_967_297_u64)]) {
|
||||
params["id"] = target;
|
||||
rejects_target(&mut s, command, ¶ms);
|
||||
}
|
||||
}
|
||||
for target in malformed_targets().into_iter().chain([json!([1, -1]), json!([1, null]), json!([1, 4_294_967_297_u64])]) {
|
||||
rejects_target(&mut s, "mask.applyEffect", &json!({"name": "Existing", "ids": target}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_effect_targets_preserve_explicit_defaults_and_lists() {
|
||||
let mut s = target_session();
|
||||
s.execute("mask.saveEffect", &json!({"id": 1, "name": "Explicit"})).unwrap();
|
||||
assert_eq!(s.mask_effects.last().unwrap().adjust.exposure, 0.0);
|
||||
for (name, target) in [("Omitted", json!({})), ("Null", json!({"id": null}))] {
|
||||
let mut params = target;
|
||||
params["name"] = json!(name);
|
||||
s.execute("mask.saveEffect", ¶ms).unwrap();
|
||||
assert_eq!(s.mask_effects.last().unwrap().adjust.exposure, 1.0);
|
||||
params["name"] = json!("Existing");
|
||||
s.execute("mask.updateEffect", ¶ms).unwrap();
|
||||
assert_eq!(s.mask_effects[0].adjust.exposure, 1.0);
|
||||
}
|
||||
s.execute("mask.updateEffect", &json!({"id": 1, "name": "Existing"})).unwrap();
|
||||
assert_eq!(s.mask_effects[0].adjust.exposure, 0.0);
|
||||
for mut params in [json!({}), json!({"id": null}), json!({"ids": null})] {
|
||||
s.execute("mask.adjust", &json!({"values": {"exposure": 1.0}})).unwrap();
|
||||
params["name"] = json!("Existing");
|
||||
s.execute("mask.applyEffect", ¶ms).unwrap();
|
||||
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks[1].adjust.exposure, 0.0);
|
||||
}
|
||||
s.execute("mask.adjust", &json!({"id": 1, "values": {"exposure": 1.0}})).unwrap();
|
||||
s.execute("mask.adjust", &json!({"id": 2, "values": {"exposure": 2.0}})).unwrap();
|
||||
s.execute("mask.applyEffect", &json!({"name": "Existing", "ids": [1, 2]})).unwrap();
|
||||
assert!(s.develop_of(s.active().unwrap()).unwrap().masks.iter().all(|m| m.adjust.exposure == 0.0));
|
||||
rejects_target(&mut s, "mask.applyEffect", &json!({"name": "Existing", "ids": []}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_mask_components_are_rejected_without_changes() {
|
||||
let mut s = target_session();
|
||||
for (command, mut params) in [
|
||||
("mask.update", json!({"shape": {"kind": "brush", "strokes": []}})),
|
||||
("mask.component", json!({"action": "invert"})),
|
||||
("mask.sampleColor", json!({"x": 0.5, "y": 0.5})),
|
||||
("mask.refineDetail", json!({})),
|
||||
] {
|
||||
for target in malformed_targets().into_iter().chain([json!(4_294_967_296_u64)]) {
|
||||
params["component"] = target;
|
||||
rejects_target(&mut s, command, ¶ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_spot_indices_are_rejected_without_changes() {
|
||||
let mut s = target_session();
|
||||
for (command, mut params) in [("spot.delete", json!({})), ("spot.update", json!({"size": 0.1})), ("spot.refreshSource", json!({}))] {
|
||||
for target in malformed_targets().into_iter().chain([json!(4_294_967_296_u64)]) {
|
||||
params["index"] = target;
|
||||
rejects_target(&mut s, command, ¶ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_spot_selection_is_rejected_without_changes() {
|
||||
let mut s = target_session();
|
||||
for target in malformed_targets().into_iter().filter(|v| !v.is_null()).chain([json!(4_294_967_296_u64)]) {
|
||||
rejects_target(&mut s, "spot.select", &json!({"index": target}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_and_spot_targets_preserve_explicit_defaults_and_selection_clearing() {
|
||||
use lightcraft_develop::MaskShape;
|
||||
let mut s = target_session();
|
||||
for target in [json!({}), json!({"id": null})] {
|
||||
let mut params = target;
|
||||
params["name"] = json!("Selected mask");
|
||||
s.execute("mask.rename", ¶ms).unwrap();
|
||||
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks[1].name, "Selected mask");
|
||||
}
|
||||
s.execute("mask.rename", &json!({"id": 1, "name": "Explicit mask"})).unwrap();
|
||||
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks[0].name, "Explicit mask");
|
||||
assert_eq!(s.active_mask, Some(2));
|
||||
for params in [json!({"shape": {"kind": "brush", "strokes": []}}), json!({"component": null, "shape": {"kind": "brush", "strokes": []}})] {
|
||||
s.execute("mask.update", ¶ms).unwrap();
|
||||
let d = s.develop_of(s.active().unwrap()).unwrap();
|
||||
assert!(matches!(d.masks[1].components[0].shape, MaskShape::Brush { .. }));
|
||||
assert!(matches!(d.masks[1].components[1].shape, MaskShape::ColorRange { .. }));
|
||||
}
|
||||
s.execute("mask.update", &json!({"id": 1, "component": 0, "shape": {"kind": "sky"}})).unwrap();
|
||||
assert!(matches!(s.develop_of(s.active().unwrap()).unwrap().masks[0].components[0].shape, MaskShape::Sky));
|
||||
for params in [json!({"points": [[0.2, 0.2]]}), json!({"id": null, "points": [[0.4, 0.4]]})] {
|
||||
s.execute("mask.brushStroke", ¶ms).unwrap();
|
||||
}
|
||||
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks.len(), 2);
|
||||
for params in [json!({"x": 0.5, "y": 0.5}), json!({"component": null, "x": 0.5, "y": 0.5})] {
|
||||
s.execute("mask.sampleColor", ¶ms).unwrap();
|
||||
let d = s.develop_of(s.active().unwrap()).unwrap();
|
||||
let MaskShape::ColorRange { samples, .. } = &d.masks[1].components[1].shape else { panic!("color range") };
|
||||
assert_eq!(samples.len(), 1);
|
||||
}
|
||||
for params in [json!({"size": 0.04}), json!({"index": null, "size": 0.04})] {
|
||||
s.execute("spot.update", ¶ms).unwrap();
|
||||
assert_eq!(spots(&s)[1].size, 0.04);
|
||||
}
|
||||
s.execute("spot.update", &json!({"index": 0, "size": 0.03})).unwrap();
|
||||
assert_eq!(spots(&s)[0].size, 0.03);
|
||||
assert_eq!(s.active_spot, Some(1));
|
||||
rejects_target(&mut s, "mask.component", &json!({"component": null, "action": "delete"}));
|
||||
for params in [json!({"id": null}), json!({})] {
|
||||
s.execute("mask.select", &json!({"id": 1})).unwrap();
|
||||
assert_eq!(s.active_mask, Some(1));
|
||||
s.execute("mask.select", ¶ms).unwrap();
|
||||
assert_eq!(s.active_mask, None);
|
||||
}
|
||||
for params in [json!({"index": null}), json!({})] {
|
||||
s.execute("spot.select", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s.active_spot, Some(0));
|
||||
s.execute("spot.select", ¶ms).unwrap();
|
||||
assert_eq!(s.active_spot, None);
|
||||
}
|
||||
rejects_target(&mut s, "mask.delete", &json!({}));
|
||||
rejects_target(&mut s, "spot.delete", &json!({}));
|
||||
// An omitted mask still creates a brush mask when no mask is selected.
|
||||
s.execute("mask.brushStroke", &json!({"points": [[0.5, 0.5]]})).unwrap();
|
||||
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks.len(), 3);
|
||||
}
|
||||
@@ -47,10 +47,35 @@ fn hidden(p: &Path) -> bool {
|
||||
p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'))
|
||||
}
|
||||
|
||||
/// What a folder's listing says an entry is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EntryKind {
|
||||
File,
|
||||
Folder,
|
||||
/// A link, or an entry whose kind the listing did not say.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// What [`files_in_noting`] hands over for each folder it lists: the folder, the name and kind
|
||||
/// of every entry in it (hidden ones too), and whether that is all of them (not when the walk
|
||||
/// stopped inside the folder, or an entry could not be read).
|
||||
pub type NoteFolder<'a> = &'a mut dyn FnMut(&Path, Vec<(std::ffi::OsString, EntryKind)>, bool);
|
||||
|
||||
/// The files below folder `root` that `keep` accepts. Hidden entries (names starting with `.`)
|
||||
/// are left out, the folder `skip` (e.g. the library's own) is never entered, and the walk stops
|
||||
/// at `limits` (see [`Walked::truncated`]). Unreadable folders are skipped.
|
||||
pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(&Path) -> bool) -> Walked {
|
||||
pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, keep: impl FnMut(&Path) -> bool) -> Walked {
|
||||
walk(root, skip, limits, keep, None)
|
||||
}
|
||||
|
||||
/// [`files_in`], handing the listing of every folder it reads to `note`: who walks a folder to
|
||||
/// find its files has, with that, the answer to "is this name there" for everything in it, and
|
||||
/// need not ask the disk again (a round trip for each name on a network share).
|
||||
pub fn files_in_noting(root: &Path, skip: Option<&Path>, limits: Limits, keep: impl FnMut(&Path) -> bool, note: NoteFolder) -> Walked {
|
||||
walk(root, skip, limits, keep, Some(note))
|
||||
}
|
||||
|
||||
fn walk(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(&Path) -> bool, mut note: Option<NoteFolder>) -> Walked {
|
||||
let mut out = Walked::default();
|
||||
// canonical folders reached through a link, so a link loop is walked at most once
|
||||
let mut linked: HashSet<PathBuf> = HashSet::new();
|
||||
@@ -77,17 +102,41 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl
|
||||
}
|
||||
let Ok(rd) = std::fs::read_dir(&path) else { continue };
|
||||
let mut children: Vec<(PathBuf, bool)> = Vec::new();
|
||||
for e in rd.flatten() {
|
||||
// (the folder's listing, for who asked for it; all of it unless something below says otherwise)
|
||||
let mut listed = note.is_some().then(Vec::new);
|
||||
let mut complete = true;
|
||||
for e in rd {
|
||||
let Ok(e) = e else {
|
||||
complete = false;
|
||||
continue;
|
||||
};
|
||||
if out.entries >= limits.max_entries {
|
||||
out.truncated = true;
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
out.entries = out.entries.saturating_add(1);
|
||||
let p = e.path();
|
||||
if hidden(&p) {
|
||||
// (a hidden entry's kind is only asked for when the listing is wanted; every other
|
||||
// entry's is asked once)
|
||||
let is_hidden = hidden(&p);
|
||||
if is_hidden && listed.is_none() {
|
||||
continue;
|
||||
}
|
||||
let Ok(ft) = e.file_type() else { continue };
|
||||
let ft = e.file_type();
|
||||
if let Some(listed) = listed.as_mut() {
|
||||
let kind = match &ft {
|
||||
Ok(t) if t.is_symlink() => EntryKind::Other,
|
||||
Ok(t) if t.is_dir() => EntryKind::Folder,
|
||||
Ok(t) if t.is_file() => EntryKind::File,
|
||||
_ => EntryKind::Other,
|
||||
};
|
||||
listed.push((e.file_name(), kind));
|
||||
}
|
||||
if is_hidden {
|
||||
continue;
|
||||
}
|
||||
let Ok(ft) = ft else { continue };
|
||||
let dir = if ft.is_symlink() {
|
||||
// a link (or a Windows junction): a folder is entered once, a file is a file
|
||||
match std::fs::metadata(&p) {
|
||||
@@ -105,6 +154,9 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl
|
||||
};
|
||||
children.push((p, dir));
|
||||
}
|
||||
if let (Some(note), Some(listed)) = (note.as_mut(), listed) {
|
||||
note(&path, listed, complete);
|
||||
}
|
||||
children.sort();
|
||||
let below = depth.saturating_add(1);
|
||||
stack.extend(children.into_iter().rev().map(|(p, d)| (p, below, d)));
|
||||
@@ -123,6 +175,43 @@ mod tests {
|
||||
d
|
||||
}
|
||||
|
||||
/// Given a folder with files, a folder inside it, a hidden file and a link, when it is walked
|
||||
/// with its listings noted, then the walk finds what it finds without them, and each folder's
|
||||
/// listing holds every entry by name with its kind (hidden ones too, a link as neither file nor
|
||||
/// folder) and says it is all of them; a walk that stops inside a folder says it is not.
|
||||
#[test]
|
||||
fn a_walk_notes_each_folders_listing() {
|
||||
let root = tmp("noting");
|
||||
std::fs::create_dir_all(root.join("sub")).unwrap();
|
||||
for name in ["a.jpg", ".hidden", "sub/b.jpg"] {
|
||||
std::fs::write(root.join(name), b"x").unwrap();
|
||||
}
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(root.join("a.jpg"), root.join("link.jpg")).unwrap();
|
||||
let mut noted: Vec<(PathBuf, Vec<(String, EntryKind)>, bool)> = Vec::new();
|
||||
let walked = files_in_noting(&root, None, Limits::default(), |_| true, &mut |dir, entries, complete| {
|
||||
let mut entries: Vec<(String, EntryKind)> = entries.into_iter().map(|(n, k)| (n.to_string_lossy().to_string(), k)).collect();
|
||||
entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
noted.push((dir.to_path_buf(), entries, complete));
|
||||
});
|
||||
assert_eq!(walked, files_in(&root, None, Limits::default(), |_| true), "the same walk, noted or not");
|
||||
let named = |n: &str, k: EntryKind| (n.to_string(), k);
|
||||
let mut top = vec![named(".hidden", EntryKind::File), named("a.jpg", EntryKind::File), named("sub", EntryKind::Folder)];
|
||||
if cfg!(unix) {
|
||||
top.insert(2, named("link.jpg", EntryKind::Other));
|
||||
}
|
||||
assert_eq!(noted, [(root.clone(), top, true), (root.join("sub"), vec![named("b.jpg", EntryKind::File)], true)]);
|
||||
|
||||
// stopped inside the first folder: its listing is not all of it
|
||||
let mut cut = Vec::new();
|
||||
let walked = files_in_noting(&root, None, Limits { max_depth: 8, max_entries: 2 }, |_| true, &mut |dir, entries, complete| {
|
||||
cut.push((dir.to_path_buf(), entries.len(), complete))
|
||||
});
|
||||
assert!(walked.truncated);
|
||||
assert_eq!(cut.first().map(|(dir, n, complete)| (dir.clone(), *n, *complete)), Some((root.clone(), 2, false)));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_come_in_path_order_without_hidden_ones_or_the_skipped_folder() {
|
||||
let d = tmp("order");
|
||||
|
||||
Loaded 100 of 235 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user