mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-22 23:24:17 -04:00
Compare commits
353
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36ea99fdc8 | ||
|
|
3dc3b955df | ||
|
|
511908ab06 | ||
|
|
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 | ||
|
|
00dced3dae | ||
|
|
4212907656 | ||
|
|
fd80e4eb2f | ||
|
|
d6f63f244f | ||
|
|
fd9d045c75 | ||
|
|
a22f9a6d46 | ||
|
|
51d8018e27 | ||
|
|
f0b5f0339a | ||
|
|
88ca6ed572 | ||
|
|
780872b158 | ||
|
|
809259a37d | ||
|
|
bc39923608 | ||
|
|
d9ed118c1d | ||
|
|
fc6719d1db | ||
|
|
888a54f52a | ||
|
|
a82a001d20 | ||
|
|
2f1acef58f | ||
|
|
a39e85da72 | ||
|
|
a94fc6bc84 | ||
|
|
3f69729d69 | ||
|
|
0270d30eda | ||
|
|
956420ea5f | ||
|
|
e44e7157fc | ||
|
|
c877e8e9ee | ||
|
|
296e2ce73c | ||
|
|
a77eafb32a | ||
|
|
aed214da7e | ||
|
|
73ee54fe62 | ||
|
|
11351ef141 | ||
|
|
202fcbb525 | ||
|
|
9ad2c1766b | ||
|
|
d5b2cd1474 | ||
|
|
97959610be | ||
|
|
99eba2e069 | ||
|
|
0fb809c671 | ||
|
|
e4519aa710 | ||
|
|
26e43cd691 | ||
|
|
480e96c030 | ||
|
|
2756414f46 | ||
|
|
3c0300fcbb | ||
|
|
b5733c7649 | ||
|
|
3d003e8c64 | ||
|
|
dec7fd4eb2 | ||
|
|
dc562c654a | ||
|
|
31f4ad43fe | ||
|
|
ef216d2110 | ||
|
|
bcffde1a06 | ||
|
|
68ef6ff3b3 | ||
|
|
f906998174 | ||
|
|
1130bda456 | ||
|
|
f9076514e7 | ||
|
|
48d69bfae5 | ||
|
|
2953b63576 | ||
|
|
15becc9eb4 | ||
|
|
a68da45fc3 | ||
|
|
35f347397d | ||
|
|
62da9e8581 | ||
|
|
e5c6e17bdb | ||
|
|
fb2a460532 | ||
|
|
9b9a96a1b5 | ||
|
|
e65b97e239 | ||
|
|
af0c1638fe | ||
|
|
ac17c580f5 | ||
|
|
2b766142bc | ||
|
|
ead24aded9 | ||
|
|
975b08c334 | ||
|
|
2bc2f60277 | ||
|
|
c854e172e4 | ||
|
|
540a281ccd | ||
|
|
61ec5c596c | ||
|
|
f625c74c7a | ||
|
|
2b86ca0537 | ||
|
|
d598c304aa | ||
|
|
3ee61f28ed | ||
|
|
0242a75b5a | ||
|
|
80a8acaa30 | ||
|
|
6f12b28f28 | ||
|
|
ab64e9a563 | ||
|
|
fdad5a4ad4 | ||
|
|
6a66860ea9 | ||
|
|
8d91abe603 | ||
|
|
ac84cd31ee | ||
|
|
a2911a5086 | ||
|
|
951d52788a | ||
|
|
cb8b797cd4 | ||
|
|
146cb7b975 | ||
|
|
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 }}
|
||||
@@ -0,0 +1,20 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
@@ -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 and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
|
||||
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4, 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>
|
||||
|
||||
+6
-8
@@ -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,12 +299,12 @@ 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 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
|
||||
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
|
||||
- **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md);
|
||||
- **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法;
|
||||
- **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。**
|
||||
@@ -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>
|
||||
|
||||
+5
-3
@@ -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) |
|
||||
@@ -96,7 +96,7 @@ while lower-layer errors stay English. Detail: [localization-parity.md](docs/loc
|
||||
| Korean | ko | 0% | none (no Hangul face) |
|
||||
| Vietnamese | vi | 0% | none |
|
||||
|
||||
Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%), Italian (92%).
|
||||
Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%), Italian (92%), Georgian (100%).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -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)
|
||||
@@ -191,6 +191,8 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
|
||||
| 2026-10-14 | minor | 17 uncompressed Olympus and Samsung models verified on one CC0 file each (254 models verified, 18%); headline numbers unchanged |
|
||||
| 2026-10-12 | minor | Mamiya MEF decoded and imported: ZD verified (241); headline numbers unchanged |
|
||||
| 2026-10-12 | minor | Epson ERF decoded: 3 more verified models (240); headline numbers unchanged |
|
||||
| 2026-10-12 | minor | 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");
|
||||
|
||||
@@ -15,6 +15,9 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `assets/camera-profiles/ILCA-99M2.json` | Sony ILCA-99M2 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @PiMinusMax | original work: fitted by `lightcraft-cli calibrate` to 144 usable ILCA-99M2 ARWs (of 150, seven shooting days, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-11 | none |
|
||||
| `assets/camera-profiles/ILCE-7CR.json` | Sony ILCE-7CR camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @AlexChambers | original work: fitted by `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131, September 2025 to September 2026, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-09 | none |
|
||||
| `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none |
|
||||
| `assets/camera-profiles/NIKON_Z_6_2.json` | Nikon Z 6II camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 296 usable Z 6II NEFs of my own (of 299, 2024 to 2026, Standard and Auto picture controls; NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
|
||||
| `assets/camera-profiles/NIKON_Z_8.json` | Nikon Z 8 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 261 usable Z 8 NEFs of my own (of 278, 2023 and 2026, nearly all Standard picture control, lossless 14-bit; NIKKOR Z 24-120mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
|
||||
| `assets/camera-profiles/NIKON_D5300.json` | Nikon D5300 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 264 usable D5300 NEFs of my own (of 273, 2014 to 2017, mostly Standard picture control, lossy 12-bit, mostly the AF-S DX 35mm f/1.8G) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
|
||||
| `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix |
|
||||
| `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none |
|
||||
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
@@ -30,6 +33,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders |
|
||||
| `crates/ui-egui/locales/cs.json`, `crates/ui-egui/locales/cs-formats.json` | Czech interface catalogs | @Losminek with AI assistance (Codex) | original work: translations of English source phrases, reviewed against the app UI conventions; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-11 | catalog coverage, Czech dates and count-neutral wording; independent scope/terminology review; native-speaker proofreading pending |
|
||||
| `crates/ui-egui/locales/it.json`, `crates/ui-egui/locales/it-formats.json` | Italian interface catalogs | @GabrielePecchioli with Claude Code; PhotoCraft, VectorCraft, PdfCraft and WordCraft translation contributors for reused labels | original translations from English source; 410 initial English-key matches reviewed from [PhotoCraft da3da09](https://github.com/storytold/photocraft/blob/da3da0942c14c1159f40c652afcf33584332c733/crates/ui-egui/src/i18n/it.tsv), [VectorCraft 5432821](https://github.com/storytold/vectorcraft/blob/543282148007bcb5633c493a5374cd26b0339c8d/crates/ui-egui/src/i18n/it.tsv), [PdfCraft e8e275c](https://github.com/storytold/pdfcraft/blob/e8e275cbd40ccd0dc6b56f82b16a575571043702/crates/ui-egui/src/i18n/it.tsv) and the Italian WordCraft catalog ([storytold/wordcraft#598](https://github.com/storytold/wordcraft/pull/598)); 46 of them retranslated where the sibling's meaning didn't fit a photo app | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-12 | adapted to LightCraft photo-development context, count-neutral labels for Italian plural agreement, reviewed placeholders |
|
||||
| `crates/ui-egui/locales/ka.json`, `crates/ui-egui/locales/ka-formats.json` | Georgian interface catalogs | Davit Matchakhelidze with Claude; WordCraft, GridCraft, PhotoCraft, FilmCraft and VectorCraft Georgian catalogs for reused labels | original translations from the English source (meaning-based, no vendor localization used); 450 English-key matches reused from the sibling Craft apps' Georgian catalogs | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-12 | adapted to LightCraft photo-library and raw-development context; count-neutral numerals; placeholders checked |
|
||||
|
||||
Notes
|
||||
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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) => {
|
||||
|
||||
@@ -199,6 +199,11 @@ pub struct Meta {
|
||||
/// Left out of the catalog JSON when empty (most photos), so older catalogs read unchanged.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub regions: Vec<lightcraft_meta::Region>,
|
||||
/// Where the camera focused (read from the file's maker notes at import or Reload from Disk; Sony
|
||||
/// bodies whose files have been checked), on the upright (EXIF-oriented) photo. Read-only: never
|
||||
/// written to XMP. Left out of the catalog JSON when unknown, so older catalogs read unchanged.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub focus: Option<lightcraft_meta::FocusPoint>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -638,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
|
||||
@@ -425,6 +467,28 @@ fn empty_regions_are_not_serialized_and_default_when_missing() {
|
||||
assert_eq!(back, with);
|
||||
}
|
||||
|
||||
/// The camera's focus point is optional camera data: absent from the catalog JSON when unknown (so a
|
||||
/// catalog from before it existed reads with none, and writes the same), kept exactly when known.
|
||||
#[test]
|
||||
fn focus_point_is_not_serialized_when_unknown_and_round_trips() {
|
||||
let m = Meta { camera: "SONY ILCE-1".into(), ..Default::default() };
|
||||
let v = serde_json::to_value(&m).unwrap();
|
||||
assert!(v.get("focus").is_none(), "{v}");
|
||||
let back: Meta = serde_json::from_value(v).unwrap();
|
||||
assert!(back.focus.is_none());
|
||||
let mut with = m.clone();
|
||||
with.focus = Some(lightcraft_meta::FocusPoint {
|
||||
rect: lightcraft_meta::Rect { x0: 0.36, y0: 0.33, x1: 0.38, y1: 0.36 },
|
||||
mode: Some("AF-C".into()),
|
||||
area: Some("Wide".into()),
|
||||
tracking: None,
|
||||
});
|
||||
let json = serde_json::to_string(&with).unwrap();
|
||||
assert!(json.contains("\"focus\"") && !json.contains("tracking"), "{json}");
|
||||
let back: Meta = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, with);
|
||||
}
|
||||
|
||||
// ---- random sort: Given a seed, the order is a stable, reproducible shuffle of the matching photos
|
||||
|
||||
fn many(c: &mut Catalog, n: usize) -> Vec<PhotoId> {
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ impl Unit {
|
||||
pub struct TaskInfo {
|
||||
pub id: u64,
|
||||
/// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`,
|
||||
/// `faces`, `denoise`, `findMissing`, `applePhotos`, `cache`.
|
||||
/// `faces`, `denoise`, `findMissing`, `applePhotos`, `cache`, `focus`.
|
||||
pub kind: &'static str,
|
||||
/// English; the frontend translates it.
|
||||
pub label: String,
|
||||
|
||||
@@ -31,7 +31,11 @@ use lightcraft_raw::{
|
||||
/// 4: a tone curve from black-and-white camera JPEGs (#577).
|
||||
/// 5: dark JPEG luminance pairs for the camera toe (#1307).
|
||||
/// 6: the ILCA-99M2 profile.
|
||||
pub const LOOK_VERSION: u32 = 6;
|
||||
/// 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.
|
||||
/// 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 {
|
||||
@@ -76,14 +80,18 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool {
|
||||
/// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's
|
||||
/// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the
|
||||
/// as-shot white, the camera transform at it and the fitted look.
|
||||
/// `lens` is the file's own lens-correction warp ([`lens_geometry`], read before the caller removes it from
|
||||
/// `OpcodeList3`): the camera applied it to its JPEG, so the sensor proxy is warped the same way before pairing.
|
||||
/// `embedded` is the file's embedded lens vignetting, for the DNG tone fit.
|
||||
pub(crate) fn starting_colour(
|
||||
raw: &mut RawImage,
|
||||
bytes: &[u8],
|
||||
lens: Option<&lightcraft_develop::EmbeddedLens>,
|
||||
lens: Option<&LensGeometry>,
|
||||
embedded: Option<&lightcraft_develop::EmbeddedLens>,
|
||||
) -> (Xy, CameraTransform, Option<CameraLook>) {
|
||||
let (xy, t, look) = starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t), |raw, t| monochrome_look(raw, bytes, t));
|
||||
let (xy, t, look) = starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t, lens), |raw, t| monochrome_look(raw, bytes, t, lens));
|
||||
if look.is_none() && dng_tone_eligible(raw, &t) {
|
||||
let look = fit_dng_tone(raw, bytes, &t, lens);
|
||||
let look = fit_dng_tone(raw, bytes, &t, embedded);
|
||||
return (xy, t, look);
|
||||
}
|
||||
(xy, t, look)
|
||||
@@ -100,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
|
||||
@@ -154,7 +161,8 @@ pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option<C
|
||||
// the white the source shows, decided before `starting_colour` may fill in measured matrices
|
||||
let scale = crate::files::WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata);
|
||||
let white = scale.dng_white(&raw.color, lightcraft_raw::color::as_shot_white_xy(raw));
|
||||
let (_, t, look) = starting_colour(raw, bytes, None);
|
||||
// (a merge frame arrives without its lens warp: `lightcraft_merge` removes it before calling)
|
||||
let (_, t, look) = starting_colour(raw, bytes, None, None);
|
||||
dng_colour(&raw.color, &white, &t, look.as_ref())
|
||||
}
|
||||
|
||||
@@ -252,11 +260,11 @@ fn dng_tone_curve(tone: &CameraTone) -> Option<ToneCurve> {
|
||||
}
|
||||
|
||||
/// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]).
|
||||
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> (Option<CameraLook>, bool) {
|
||||
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> (Option<CameraLook>, bool) {
|
||||
if !transform.matrix_is_fallback || !file_local_look(raw.format) {
|
||||
return (None, false);
|
||||
}
|
||||
let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, 0.0) else { return (None, false) };
|
||||
let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, 0.0, lens) else { return (None, false) };
|
||||
let black_and_white = monochrome(&reference);
|
||||
// A camera profile pooled from many photos knows colours this photo shows too little of;
|
||||
// try its colour first and fit tone/chroma per photo (picture styles vary).
|
||||
@@ -416,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| {
|
||||
@@ -427,7 +435,7 @@ fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens:
|
||||
}
|
||||
None
|
||||
};
|
||||
let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION) else { return refuse("preview") };
|
||||
let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION, None) else { return refuse("preview") };
|
||||
// As the loader renders it: the profile's tables around the baseline exposure.
|
||||
let xy = lightcraft_raw::color::as_shot_white_xy(raw);
|
||||
if let Some(tables) = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)) {
|
||||
@@ -492,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.
|
||||
@@ -569,7 +578,14 @@ fn structure_agrees(sensor: &Rgb32f, reference: &Rgb32f) -> bool {
|
||||
/// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s
|
||||
/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020),
|
||||
/// and per proxy pixel whether any sensor sample it covers is clipped.
|
||||
fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize, min_fraction: f64) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> {
|
||||
fn proxies(
|
||||
raw: &RawImage,
|
||||
bytes: &[u8],
|
||||
transform: &CameraTransform,
|
||||
size: usize,
|
||||
min_fraction: f64,
|
||||
lens: Option<&LensGeometry>,
|
||||
) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> {
|
||||
let edge = (2 * size).max(384) as u32;
|
||||
let decoded = crate::files::decode_raw_preview(
|
||||
bytes,
|
||||
@@ -604,6 +620,12 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz
|
||||
let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2;
|
||||
let mut sensor = sensor_proxy(raw, k, edge as usize)?;
|
||||
let mut clipped = clip_mask(&sensor);
|
||||
// The camera may have corrected its JPEG with the lens warp the file carries (Nikon Z lenses always, at up to 9 %
|
||||
// zoom; other cameras when their correction setting is on): the sensor moved into that geometry is a candidate,
|
||||
// kept below when it lines up with the JPEG better than the uncorrected one.
|
||||
let warped = lens
|
||||
.filter(|_| raw.format != RawFormat::Cr3)
|
||||
.and_then(|l| Some((into_lens_geometry(&sensor, raw, k, l)?, into_lens_geometry(&clipped, raw, k, l)?)));
|
||||
let gain = 2f32.powf(transform.baseline_exposure as f32);
|
||||
let to_working = |p: [f32; 3]| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain));
|
||||
if raw.format == RawFormat::Cr3 {
|
||||
@@ -617,7 +639,15 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz
|
||||
(sensor, clipped) = align_cr3_framing(sensor, clipped, &reference);
|
||||
}
|
||||
let mut sensor = fit(&sensor, size, size, Filter::Box);
|
||||
let reference = reference_at(&reference, &sensor);
|
||||
let mut reference_small = reference_at(&reference, &sensor);
|
||||
if let Some((warped, warped_clipped)) = warped {
|
||||
let candidate = fit(&warped, size, size, Filter::Box);
|
||||
let candidate_reference = reference_at(&reference, &candidate);
|
||||
if alignment(&candidate, &candidate_reference) > alignment(&sensor, &reference_small) {
|
||||
(sensor, reference_small, clipped) = (candidate, candidate_reference, warped_clipped);
|
||||
}
|
||||
}
|
||||
let reference = reference_small;
|
||||
// A proxy pixel is clipped when any sample it averages is (outside the CR3 framing the mask is
|
||||
// NaN: those pixels are left out of the pairs anyway). Resized to exactly the proxy's size, like
|
||||
// the reference, so the mask pairs pixel for pixel.
|
||||
@@ -779,12 +809,12 @@ fn align_cr3_framing(sensor: Rgb32f, clipped: Rgb32f, reference: &Rgb32f) -> (Rg
|
||||
/// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the
|
||||
/// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for formats with their own
|
||||
/// colour matrices, other files or unusable previews.
|
||||
pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option<Vec<([f64; 3], [f64; 3])>> {
|
||||
pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8], lens: Option<&LensGeometry>) -> Option<Vec<([f64; 3], [f64; 3])>> {
|
||||
if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) {
|
||||
return None;
|
||||
}
|
||||
let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw));
|
||||
let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, 0.0)?;
|
||||
let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, 0.0, lens)?;
|
||||
let to_camera = transform.matrix.inverse()?;
|
||||
// clipped sensor pixels don't record the scene's colour (see `fit_look`)
|
||||
let sensor = without_clipped(&sensor, &clipped).unwrap_or(sensor);
|
||||
@@ -799,6 +829,102 @@ pub(crate) fn fit_profile(pairs: &[([f64; 3], [f64; 3])]) -> Option<(Mat3, Optio
|
||||
Some((matrix, fit_hue_sat(pairs, &matrix)))
|
||||
}
|
||||
|
||||
/// A raw's lens-correction warp, as its camera applied it to the embedded JPEG: the first `WarpRectilinear` of
|
||||
/// `OpcodeList3` (its green plane; the in-file corrections LightCraft reads have no tangential terms). `None` without
|
||||
/// one, or for a DNG 1.6 `WarpRectilinear2` chain (phone DNGs, which carry colour matrices and are not fitted).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct LensGeometry {
|
||||
/// `kr0..kr3`: source radius = r * (kr0 + kr1 r^2 + kr2 r^4 + kr3 r^6), r over the farthest corner distance.
|
||||
k: [f64; 4],
|
||||
/// The optical centre, relative to the active area (as the opcode has it).
|
||||
center: [f64; 2],
|
||||
}
|
||||
|
||||
pub(crate) fn lens_geometry(list3: &[lightcraft_raw::Opcode]) -> Option<LensGeometry> {
|
||||
use lightcraft_raw::Opcode;
|
||||
let ops: Vec<&Opcode> = lightcraft_raw::opcodes::applied_ops(list3).collect();
|
||||
if ops.iter().any(|op| matches!(op, Opcode::WarpRectilinear2 { .. })) {
|
||||
return None;
|
||||
}
|
||||
ops.into_iter().find_map(|op| match op {
|
||||
Opcode::WarpRectilinear { planes, center } => {
|
||||
let p = planes.get(1).or(planes.first())?;
|
||||
let k = [p[0], p[1], p[2], p[3]];
|
||||
(k.iter().chain(center).all(|v| v.is_finite()) && (0.0..=1.0).contains(¢er[0]) && (0.0..=1.0).contains(¢er[1]))
|
||||
.then_some(LensGeometry { k, center: *center })
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// How well two same-size proxies line up: the correlation of their high-passed log luminance over the pixels both
|
||||
/// have (a tone curve or colour change leaves it alone, a geometric offset lowers it). `-1` without enough pixels.
|
||||
fn alignment(a: &Rgb32f, b: &Rgb32f) -> f64 {
|
||||
let (w, h) = (a.width, a.height);
|
||||
if (b.width, b.height) != (w, h) || w < 3 || h < 3 {
|
||||
return -1.0;
|
||||
}
|
||||
let lum = |img: &Rgb32f| img.data.iter().map(|p| (luminance_2020(*p).max(1e-4) as f64).ln()).collect::<Vec<f64>>();
|
||||
let high = |l: &[f64]| -> Vec<f64> {
|
||||
(0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
if x == 0 || y == 0 || x + 1 == w || y + 1 == h {
|
||||
return f64::NAN;
|
||||
}
|
||||
let around = [i - w - 1, i - w, i - w + 1, i - 1, i + 1, i + w - 1, i + w, i + w + 1];
|
||||
l[i] - around.iter().map(|&j| l[j]).sum::<f64>() / 8.0
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let (ha, hb) = (high(&lum(a)), high(&lum(b)));
|
||||
let pairs: Vec<(f64, f64)> = ha.into_iter().zip(hb).filter(|(x, y)| x.is_finite() && y.is_finite()).collect();
|
||||
if pairs.len() < 64 {
|
||||
return -1.0;
|
||||
}
|
||||
let n = pairs.len() as f64;
|
||||
let (ma, mb) = (pairs.iter().map(|p| p.0).sum::<f64>() / n, pairs.iter().map(|p| p.1).sum::<f64>() / n);
|
||||
let cov: f64 = pairs.iter().map(|p| (p.0 - ma) * (p.1 - mb)).sum();
|
||||
let (va, vb): (f64, f64) = (pairs.iter().map(|p| (p.0 - ma).powi(2)).sum(), pairs.iter().map(|p| (p.1 - mb).powi(2)).sum());
|
||||
let r = cov / (va * vb).sqrt().max(1e-12);
|
||||
if r.is_finite() { r } else { -1.0 }
|
||||
}
|
||||
|
||||
/// A `k`-binned sensor proxy of `raw` ([`sensor_proxy`]: the default crop, or the active area when the crop is
|
||||
/// smaller than a block, at `1/k`) moved into the camera JPEG's geometry: each pixel samples the proxy where `lens`
|
||||
/// maps it, and is NaN where that falls outside the proxy (the pairs leave it out). `None` for a proxy of another
|
||||
/// size (the demosaiced fallback of linear raws).
|
||||
fn into_lens_geometry(img: &Rgb32f, raw: &RawImage, k: usize, lens: &LensGeometry) -> Option<Rgb32f> {
|
||||
let a = raw.active_area;
|
||||
let c = raw.crop.clipped(a.width, a.height);
|
||||
let c = if c.width < k || c.height < k { lightcraft_raw::Rect::new(0, 0, a.width, a.height) } else { c };
|
||||
let (w, h) = (img.width, img.height);
|
||||
if k == 0 || w != c.width / k || h != c.height / k || w == 0 || h == 0 || a.width < 2 || a.height < 2 {
|
||||
return None;
|
||||
}
|
||||
let (aw, ah) = (a.width as f64 - 1.0, a.height as f64 - 1.0);
|
||||
let (cx, cy) = (lens.center[0] * aw, lens.center[1] * ah);
|
||||
let m = [(0.0, 0.0), (aw, 0.0), (0.0, ah), (aw, ah)].iter().map(|&(x, y)| (x - cx).hypot(y - cy)).fold(0.0, f64::max).max(1e-9);
|
||||
let [k0, k1, k2, k3] = lens.k;
|
||||
let kf = k as f64;
|
||||
let (x0, y0) = (c.x as f64, c.y as f64);
|
||||
let mut out = Rgb32f::new(w, h);
|
||||
for (i, px) in out.data.iter_mut().enumerate() {
|
||||
// the proxy pixel's centre in active-area pixel-index coordinates, mapped to the sensor
|
||||
let (ox, oy) = (x0 + ((i % w) as f64 + 0.5) * kf - 0.5, y0 + ((i / w) as f64 + 0.5) * kf - 0.5);
|
||||
let (dx, dy) = ((ox - cx) / m, (oy - cy) / m);
|
||||
let r2 = dx * dx + dy * dy;
|
||||
let f = k0 + r2 * (k1 + r2 * (k2 + r2 * k3));
|
||||
let (sx, sy) = ((cx + m * dx * f + 0.5 - x0) / kf, (cy + m * dy * f + 0.5 - y0) / kf);
|
||||
*px = if sx >= 0.5 && sy >= 0.5 && sx <= w as f64 - 0.5 && sy <= h as f64 - 0.5 {
|
||||
img.sample_bilinear(sx as f32, sy as f32)
|
||||
} else {
|
||||
[f32::NAN; 3]
|
||||
};
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option<Rgb32f> {
|
||||
Some(match raw.develop_binned(k, SENSOR_CLIP).ok()? {
|
||||
Some(sensor) => sensor,
|
||||
@@ -1296,11 +1422,11 @@ fn monochrome(reference: &Rgb32f) -> bool {
|
||||
/// `transform` is the colour the photo gets otherwise (the spectral matrices or the fallback's); the look keeps it and
|
||||
/// adds the tone curve alone ([`fit_tone_only`]), as [`fit_look`] tries the photo's own colour after a profile's. The
|
||||
/// chroma curve stays neutral: the JPEG has no colourfulness to learn.
|
||||
fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
|
||||
fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> Option<CameraLook> {
|
||||
if !file_local_look(raw.format) {
|
||||
return None;
|
||||
}
|
||||
let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, 0.0)?;
|
||||
let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, 0.0, lens)?;
|
||||
if !monochrome(&reference) {
|
||||
return None;
|
||||
}
|
||||
@@ -2334,6 +2460,93 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The lens warp the colour fit follows is the first `WarpRectilinear`'s green plane; no warp, a DNG 1.6 chain
|
||||
/// (phone DNGs, which carry colour matrices), a centre outside the area or a non-finite term give none.
|
||||
#[test]
|
||||
fn lens_geometry_reads_the_first_rectilinear_warp() {
|
||||
use lightcraft_raw::Opcode;
|
||||
let warp = |k0: f64, c: [f64; 2]| Opcode::WarpRectilinear {
|
||||
planes: vec![[9.0, 0.0, 0.0, 0.0, 0.0, 0.0], [k0, 0.1, -0.02, 0.003, 0.0, 0.0], [9.0, 0.0, 0.0, 0.0, 0.0, 0.0]],
|
||||
center: c,
|
||||
};
|
||||
assert_eq!(lens_geometry(&[warp(0.97, [0.5, 0.4])]), Some(LensGeometry { k: [0.97, 0.1, -0.02, 0.003], center: [0.5, 0.4] }));
|
||||
let single = Opcode::WarpRectilinear { planes: vec![[1.02, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] };
|
||||
assert_eq!(lens_geometry(std::slice::from_ref(&single)).map(|g| g.k[0]), Some(1.02));
|
||||
assert!(lens_geometry(&[]).is_none());
|
||||
assert!(lens_geometry(&[warp(1.0, [1.5, 0.5])]).is_none());
|
||||
assert!(lens_geometry(&[warp(f64::NAN, [0.5, 0.5])]).is_none());
|
||||
let mut p = [0.0; lightcraft_raw::opcodes::WR2_LEN];
|
||||
p[0] = 1.0;
|
||||
p[18] = 1e6;
|
||||
let chain = Opcode::WarpRectilinear2 { planes: vec![p], center: [0.5, 0.5], reciprocal: false, optional: false };
|
||||
assert!(lens_geometry(&[chain, single]).is_none());
|
||||
}
|
||||
|
||||
/// A binned sensor proxy moved into the camera JPEG's geometry: the identity warp leaves it as it is, a warp
|
||||
/// whose source radius is 0.8 of the output's samples nearer the centre, output pixels whose source falls outside
|
||||
/// the proxy are NaN (left out of the pairs), and a proxy of another size is left alone.
|
||||
#[test]
|
||||
fn the_sensor_proxy_follows_the_cameras_lens_warp() {
|
||||
let mut raw = raw_of(RawFormat::Nef, "NIKON CORPORATION", "NIKON Z 6_2", Default::default());
|
||||
raw.active_area = lightcraft_raw::Rect::new(0, 0, 64, 48);
|
||||
raw.crop = raw.active_area;
|
||||
// a 1/4 proxy whose pixels hold their own coordinates
|
||||
let mut img = Rgb32f::new(16, 12);
|
||||
for (i, p) in img.data.iter_mut().enumerate() {
|
||||
*p = [(i % 16) as f32, (i / 16) as f32, 1.0];
|
||||
}
|
||||
let geometry = |k0: f64| LensGeometry { k: [k0, 0.0, 0.0, 0.0], center: [0.5, 0.5] };
|
||||
let same = into_lens_geometry(&img, &raw, 4, &geometry(1.0)).unwrap();
|
||||
assert!(same.data.iter().zip(&img.data).all(|(a, b)| (0..3).all(|c| (a[c] - b[c]).abs() < 1e-4)));
|
||||
// the corner pixel's centre (1.5, 1.5) maps to (31.5 - 0.8 * 30, 23.5 - 0.8 * 22) = (7.5, 5.9) on the sensor
|
||||
let zoomed = into_lens_geometry(&img, &raw, 4, &geometry(0.8)).unwrap();
|
||||
assert!((zoomed.data[0][0] - 1.5).abs() < 1e-4 && (zoomed.data[0][1] - 1.1).abs() < 1e-4, "{:?}", zoomed.data[0]);
|
||||
let outside = into_lens_geometry(&img, &raw, 4, &geometry(1.3)).unwrap();
|
||||
assert!(outside.data[0][0].is_nan() && outside.data[6 * 16 + 8][0].is_finite());
|
||||
assert!(into_lens_geometry(&Rgb32f::new(15, 12), &raw, 4, &geometry(1.0)).is_none());
|
||||
}
|
||||
|
||||
/// A raw with an in-file lens correction (Fujifilm, Panasonic, the A7R IVA, Nikon) may have a lens-corrected
|
||||
/// camera JPEG: the proxies the fit pairs line up with it at least as well as the uncorrected sensor, on every
|
||||
/// corpus file the colour fit uses that carries such a warp, and better on those whose JPEG is corrected.
|
||||
#[test]
|
||||
fn corpus_lens_corrected_jpegs_pair_with_the_warped_sensor() {
|
||||
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw");
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
eprintln!("skip: no corpus at {}", dir.display());
|
||||
return;
|
||||
};
|
||||
let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
|
||||
paths.sort();
|
||||
let (mut checked, mut gain) = (0, 0.0);
|
||||
for path in paths {
|
||||
let Ok(bytes) = std::fs::read(&path) else { continue };
|
||||
let Ok(mut raw) = lightcraft_raw::decode(&bytes) else { continue };
|
||||
let Some(lens) = lens_geometry(&raw.opcodes.list3) else { continue };
|
||||
if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) {
|
||||
continue;
|
||||
}
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let (Some((plain, reference, _)), Some((chosen, chosen_reference, _))) =
|
||||
(proxies(&raw, &bytes, &t, PROXY, 0.0, None), proxies(&raw, &bytes, &t, PROXY, 0.0, Some(&lens)))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let (before, after) = (alignment(&plain, &reference), alignment(&chosen, &chosen_reference));
|
||||
eprintln!("{}: alignment {before:.3} -> {after:.3}", path.display());
|
||||
// (chosen on camera RGB, measured after the colour transform: a near tie may flip by a little)
|
||||
assert!(after >= before - 0.01, "{}: {before:.3} -> {after:.3}", path.display());
|
||||
gain += after - before;
|
||||
checked += 1;
|
||||
}
|
||||
eprintln!("{checked} corpus raws with an in-file lens warp, mean gain {:.3}", gain / checked.max(1) as f64);
|
||||
assert!(checked == 0 || gain > 0.0);
|
||||
}
|
||||
|
||||
/// Step 1: a file's own colour matrices win over everything, including a spectral row for its model; no JPEG fit
|
||||
/// is tried.
|
||||
#[test]
|
||||
@@ -2381,7 +2594,7 @@ mod tests {
|
||||
assert!(crate::camera_profiles::get("ILCE-7M4").is_some());
|
||||
let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M4").unwrap();
|
||||
let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default());
|
||||
let (_, t, look) = starting_colour(&mut raw, &[], None);
|
||||
let (_, t, look) = starting_colour(&mut raw, &[], None, None);
|
||||
assert!(look.is_none());
|
||||
assert_eq!(raw.color.color_matrix, camera.color_matrix.map(Some));
|
||||
assert!(!t.matrix_is_fallback);
|
||||
@@ -2440,13 +2653,13 @@ mod tests {
|
||||
#[test]
|
||||
fn a_camera_outside_the_table_keeps_the_fallback() {
|
||||
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default());
|
||||
let (_, t, look) = starting_colour(&mut raw, &[], None);
|
||||
let (_, t, look) = starting_colour(&mut raw, &[], None, None);
|
||||
assert!(look.is_none() && t.matrix_is_fallback);
|
||||
assert_eq!(raw.color, lightcraft_raw::ColorData::default());
|
||||
// without a model there is no table row
|
||||
let mut raw = raw_of(RawFormat::Cr2, "Canon", "", Default::default());
|
||||
raw.metadata.model = None;
|
||||
assert!(starting_colour(&mut raw, &[], None).1.matrix_is_fallback);
|
||||
assert!(starting_colour(&mut raw, &[], None, None).1.matrix_is_fallback);
|
||||
}
|
||||
|
||||
/// The colour precedence on public raws (each skipped without its corpus file): a photo whose JPEG fit is
|
||||
@@ -2465,13 +2678,14 @@ mod tests {
|
||||
continue;
|
||||
};
|
||||
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
|
||||
let lens = lens_geometry(&raw.opcodes.list3);
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let own = lightcraft_raw::color::has_matrix(&raw.color);
|
||||
let (make, model) = (raw.metadata.make.as_deref(), raw.metadata.model.as_deref().unwrap_or_default());
|
||||
let measured = lightcraft_raw::spectral::find(make, model).is_some();
|
||||
let covered = measured || lightcraft_raw::kelvin_presets::covers(make, model);
|
||||
let before = raw.color.clone();
|
||||
let (_, t, fitted) = starting_colour(&mut raw, &bytes, None);
|
||||
let (_, t, fitted) = starting_colour(&mut raw, &bytes, lens.as_ref(), None);
|
||||
let spectral = !own && fitted.is_none() && measured;
|
||||
eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some());
|
||||
assert_eq!(raw.color != before, spectral, "{name}");
|
||||
@@ -2579,7 +2793,7 @@ mod tests {
|
||||
assert_eq!(raw.format, RawFormat::Cr3);
|
||||
assert_eq!(raw.info().developed_size(), (6000, 4000));
|
||||
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let look = fit_preview(&raw, &bytes, &transform).0;
|
||||
let look = fit_preview(&raw, &bytes, &transform, None).0;
|
||||
eprintln!("R100 guarded colour fit accepted: {}", look.is_some());
|
||||
assert!(look.is_some(), "this R100 corpus photo has enough matching colour after camera-framing alignment");
|
||||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
@@ -2612,7 +2826,7 @@ mod tests {
|
||||
let raw = lightcraft_raw::decode(&bytes).unwrap();
|
||||
assert_eq!(raw.metadata.model.as_deref(), Some(model));
|
||||
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let look = fit_preview(&raw, &bytes, &transform).0.expect("fit accepted");
|
||||
let look = fit_preview(&raw, &bytes, &transform, None).0.expect("fit accepted");
|
||||
let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap());
|
||||
for (row, want) in look.matrix.0.iter().zip(expected.0) {
|
||||
for (got, want) in row.iter().zip(want) {
|
||||
@@ -2632,6 +2846,18 @@ mod tests {
|
||||
corpus_uses_the_bundled_profile("arw-sony-a99m2.arw", "ILCA-99M2");
|
||||
}
|
||||
|
||||
/// The public Z 6II NEF wasn't one of the photos its bundled profile was fitted to.
|
||||
#[test]
|
||||
fn corpus_z6ii_uses_the_bundled_profile() {
|
||||
corpus_uses_the_bundled_profile("nef-nikon-z6ii-lossless.nef", "NIKON Z 6_2");
|
||||
}
|
||||
|
||||
/// The public Z 8 NEF (Z 14-24 f/2.8 S) wasn't one of the photos its bundled profile was fitted to.
|
||||
#[test]
|
||||
fn corpus_z8_uses_the_bundled_profile() {
|
||||
corpus_uses_the_bundled_profile("nef-nikon-z8-lossless.nef", "NIKON Z 8");
|
||||
}
|
||||
|
||||
/// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white
|
||||
/// balance is relative to the as-shot look.
|
||||
#[test]
|
||||
@@ -2981,9 +3207,9 @@ mod tests {
|
||||
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let look = fit_preview(&raw, &bytes, &transform).0.expect("a camera look");
|
||||
let look = fit_preview(&raw, &bytes, &transform, None).0.expect("a camera look");
|
||||
// the look fitted to the JPEG, as before this change
|
||||
let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, 0.0).unwrap();
|
||||
let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, 0.0, None).unwrap();
|
||||
let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get);
|
||||
let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone())));
|
||||
let fitted = fit_look(&sensor, &reference, &clipped, colour).unwrap();
|
||||
@@ -3573,12 +3799,12 @@ mod tests {
|
||||
tiff
|
||||
};
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let look = monochrome_look(&raw, &file([1.0; 3]), &t).expect("a tone curve from the black-and-white JPEG");
|
||||
let look = monochrome_look(&raw, &file([1.0; 3]), &t, None).expect("a tone curve from the black-and-white JPEG");
|
||||
assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "on the transform's colour");
|
||||
let curve = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
|
||||
let mid = curve.apply(0.25);
|
||||
assert!((mid - (1.0 - (-0.75f32).exp())).abs() < 0.05, "the camera's curve at 0.25: {mid}");
|
||||
assert!(monochrome_look(&raw, &file([1.0, 0.9, 0.8]), &t).is_none(), "a tinted JPEG is not black and white");
|
||||
assert!(monochrome_look(&raw, &file([1.0, 0.9, 0.8]), &t, None).is_none(), "a tinted JPEG is not black and white");
|
||||
}
|
||||
|
||||
/// The black-and-white fit has the partial look's checks: a JPEG of another picture, or one the default curve
|
||||
@@ -3734,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};
|
||||
@@ -3756,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");
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,9 @@ pub const BUNDLED: &[(&str, &str)] = &[
|
||||
("ILCA-99M2", include_str!("../../../assets/camera-profiles/ILCA-99M2.json")),
|
||||
("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")),
|
||||
("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")),
|
||||
("NIKON D5300", include_str!("../../../assets/camera-profiles/NIKON_D5300.json")),
|
||||
("NIKON Z 6_2", include_str!("../../../assets/camera-profiles/NIKON_Z_6_2.json")),
|
||||
("NIKON Z 8", include_str!("../../../assets/camera-profiles/NIKON_Z_8.json")),
|
||||
("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")),
|
||||
("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")),
|
||||
];
|
||||
@@ -195,10 +198,12 @@ impl Pool {
|
||||
/// NEF or RAF without colour matrices, no usable camera JPEG, too little colour).
|
||||
pub fn add(&mut self, bytes: &[u8]) -> Result<Option<String>, String> {
|
||||
let mut raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
|
||||
// colour only: geometric lens corrections would stop the sensor proxy from binning
|
||||
// colour only: geometric lens corrections would stop the sensor proxy from binning (the pairs still follow
|
||||
// the warp the camera applied to its JPEG)
|
||||
let lens = crate::camera_preview::lens_geometry(&raw.opcodes.list3);
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let Some(model) = raw.metadata.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) else { return Ok(None) };
|
||||
let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes) else { return Ok(None) };
|
||||
let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes, lens.as_ref()) else { return Ok(None) };
|
||||
let step = pairs.len().div_ceil(PAIRS_PER_FILE).max(1);
|
||||
let entry = self.models.entry(model.to_owned()).or_default();
|
||||
entry.0 += 1;
|
||||
|
||||
@@ -240,6 +240,10 @@ fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::P
|
||||
m.aperture = m.aperture.or(src.aperture);
|
||||
m.iso = m.iso.or(src.iso);
|
||||
m.gps = m.gps.or(src.gps);
|
||||
// the camera's focus point: photos imported before it was read get it here
|
||||
if m.focus.is_none() {
|
||||
m.focus = src.focus.clone();
|
||||
}
|
||||
let mut ops = Vec::new();
|
||||
if m != before {
|
||||
ops.push(Op::SetMeta { id, meta: Box::new(m) });
|
||||
@@ -309,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
|
||||
@@ -397,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
|
||||
),
|
||||
@@ -441,6 +430,27 @@ mod tests {
|
||||
assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty());
|
||||
}
|
||||
|
||||
/// Reload from Disk gives a photo imported before focus points were read its camera focus point,
|
||||
/// and never replaces one it has.
|
||||
#[test]
|
||||
fn reload_fills_a_missing_focus_point() {
|
||||
let fp = |x: f64| lightcraft_meta::FocusPoint {
|
||||
rect: lightcraft_meta::Rect { x0: x, y0: 0.4, x1: x + 0.02, y1: 0.42 },
|
||||
mode: Some("AF-C".into()),
|
||||
area: None,
|
||||
tracking: None,
|
||||
};
|
||||
let mut ph = Photo::new(PhotoId(1), Source::File { path: "/x.arw".into() }, "x.arw", "ARW", 8640, 5760, "2026-10-06T00:00:00");
|
||||
ph.captured = Some("2026-10-04T08:16:11".into());
|
||||
let mut info = crate::media::ProbeInfo { captured: ph.captured.clone(), ..Default::default() };
|
||||
info.meta.focus = Some(fp(0.3));
|
||||
let ops = fill_missing_meta(PhotoId(1), &ph, &info);
|
||||
let Some(Op::SetMeta { meta, .. }) = ops.first() else { panic!("{ops:?}") };
|
||||
assert_eq!(meta.focus, Some(fp(0.3)));
|
||||
ph.meta.focus = Some(fp(0.1));
|
||||
assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty(), "a known focus point is kept");
|
||||
}
|
||||
|
||||
fn lens() -> lightcraft_develop::EmbeddedLens {
|
||||
lightcraft_develop::EmbeddedLens {
|
||||
warp: Some(lightcraft_develop::EmbeddedWarp { planes: [[1.0, -0.1, 0.03, 0.0, 0.0, 0.0]; 3], ..Default::default() }),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Focus points for photos already in the library: `photo.readFocusPoints` (Photo ▸ Read Focus Points) and the
|
||||
//! per-frame `focus.pump` that keeps the background job going. The job itself is `crate::focus_scan`.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{CommandSpec, cmd};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
crate::focus_scan::read_points(s, p)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn read_points(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Err(super::bad("photo.readFocusPoints", "reading focus points needs the photos' files on disk"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn pump(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
crate::focus_scan::pump(s, p)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn pump(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(serde_json::json!({"active": false}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"photo.readFocusPoints",
|
||||
"Read Focus Points",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?, wait?: false} → {queued, running, last?: {total, done, found, none, unavailable, bytesRead, seconds, cancelled}} — read the camera's focus point for photos imported before it was read (Sony α1 and A7S III raws and JPEGs), from the few KB of each file that hold it, on a background job with a row in the activity stack. Without `ids`, every photo in the library not looked at yet; with `ids`, those photos, looked at again. Photos that have a point keep it. No undo step and no sidecar write. `wait: true` returns when all are read (scripts)",
|
||||
super::always,
|
||||
read_points
|
||||
),
|
||||
cmd!(
|
||||
query "focus.pump",
|
||||
"Read Focus Points in the Background",
|
||||
[],
|
||||
None,
|
||||
"{pace?: pause | light | normal} → {active, running?: {done, total, found}, last?, lookedAt} — cheap to call every frame (the app does): takes in what the background focus-point reader has read and commits it in batches; reads nothing new while paused and rests between files when light. Starts the reader by itself once per library opening when photos are left to look at",
|
||||
super::always,
|
||||
pump
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
+113
-17
@@ -150,8 +150,77 @@ fn masks_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec<Mas
|
||||
Ok(json!({"activeMask": active}))
|
||||
}
|
||||
|
||||
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`)"))
|
||||
/// Most pieces a tapered ⇧-click segment is cut into.
|
||||
const MAX_TAPER_STEPS: usize = 64;
|
||||
|
||||
/// The strokes a ⇧-click adds: `to` drawn on from point `from`, the last point of stroke `last` (a straight segment
|
||||
/// to `to`'s first point, then the rest of `to`). When the brush's size, feather or flow changed since `last`, the
|
||||
/// segment is cut into pieces whose values step from `last`'s to `to`'s, so the line tapers; strokes combine by their
|
||||
/// maximum, so the pieces join without building up.
|
||||
fn connected_strokes(from: Point, last: &BrushStroke, to: BrushStroke) -> Vec<BrushStroke> {
|
||||
let Some(&first) = to.points.first() else { return vec![to] };
|
||||
let same = |a: f64, b: f64| (a - b).abs() <= 1e-9;
|
||||
// pen pressure along the line: the last dab's at its start, the click's at its end (none when neither has any)
|
||||
let pressured = !last.pressure.is_empty() || !to.pressure.is_empty();
|
||||
let (p_from, p_to) = (last.pressure_at(0), to.pressure_at(0));
|
||||
if same(last.size, to.size) && same(last.feather, to.feather) && same(last.flow, to.flow) {
|
||||
let mut points = Vec::with_capacity(to.points.len() + 1);
|
||||
points.push(from);
|
||||
points.extend(to.points.iter().copied());
|
||||
let pressure = if pressured { std::iter::once(p_from).chain((0..to.points.len()).map(|i| to.pressure_at(i))).collect() } else { Vec::new() };
|
||||
return vec![BrushStroke { points, pressure, ..to }];
|
||||
}
|
||||
// about 5% of size, or 2 points of feather or flow, per piece
|
||||
let ratio = if last.size > 0.0 && to.size > 0.0 { (to.size / last.size).ln().abs() / 1.05f64.ln() } else { 0.0 };
|
||||
let needed = [ratio, (to.feather - last.feather).abs() / 2.0, (to.flow - last.flow).abs() / 2.0]
|
||||
.into_iter()
|
||||
.filter(|v| v.is_finite())
|
||||
.fold(1.0f64, f64::max);
|
||||
let steps = (needed.ceil() as usize).clamp(1, MAX_TAPER_STEPS);
|
||||
let lerp = |a: f64, b: f64, t: f64| a + (b - a) * t;
|
||||
let at = |t: f64| Point::new(lerp(from.x, first.x, t), lerp(from.y, first.y, t));
|
||||
let mut out: Vec<BrushStroke> = (0..steps)
|
||||
.map(|i| {
|
||||
let (t0, t1) = (i as f64 / steps as f64, (i + 1) as f64 / steps as f64);
|
||||
let p = |t: f64| (f64::from(p_from) + (f64::from(p_to) - f64::from(p_from)) * t) as f32;
|
||||
BrushStroke {
|
||||
points: vec![at(t0), at(t1)],
|
||||
size: lerp(last.size, to.size, t1),
|
||||
feather: lerp(last.feather, to.feather, t1),
|
||||
flow: lerp(last.flow, to.flow, t1),
|
||||
pressure: if pressured { vec![p(t0), p(t1)] } else { Vec::new() },
|
||||
..to.clone()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if to.points.len() > 1 {
|
||||
out.push(to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -233,7 +302,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Brush Stroke",
|
||||
[],
|
||||
None,
|
||||
"{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool, pressure?: [p,…] pen pressure 0..1 per point (scales each dab's size and flow; omitted: full)}",
|
||||
"{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool, pressure?: [p,…] pen pressure 0..1 per point (scales each dab's size and flow; omitted: full), connect?: bool — start from the last point of the mask's last stroke (⇧-click), size / feather / flow tapering from that stroke's to these} → {activeMask, connected}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let pts: Vec<Point> = p
|
||||
@@ -255,10 +324,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
auto_mask: bool_or(p, "autoMask", false),
|
||||
pressure: pressure_list(p.get("pressure"), pts_len)?,
|
||||
};
|
||||
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");
|
||||
masks_edit(s, "mask.brushStroke", "Brush", |masks, active| {
|
||||
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)) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
@@ -271,7 +342,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) =
|
||||
m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. }))
|
||||
{
|
||||
strokes.push(stroke);
|
||||
// the previous stroke's end and its brush values (not its points)
|
||||
let anchor = strokes.last().filter(|_| connect).and_then(|l| {
|
||||
Some((
|
||||
*l.points.last()?,
|
||||
BrushStroke {
|
||||
points: Vec::new(),
|
||||
size: l.size,
|
||||
feather: l.feather,
|
||||
flow: l.flow,
|
||||
// the pressure the last dab was painted with, when the stroke recorded any
|
||||
pressure: if l.pressure.is_empty() { Vec::new() } else { vec![l.pressure_at(l.points.len().saturating_sub(1))] },
|
||||
..BrushStroke::default()
|
||||
},
|
||||
))
|
||||
});
|
||||
match anchor {
|
||||
Some((from, last)) => {
|
||||
strokes.extend(connected_strokes(from, &last, stroke));
|
||||
connected = true;
|
||||
}
|
||||
None => strokes.push(stroke),
|
||||
}
|
||||
} else {
|
||||
m.components.push(MaskComponent {
|
||||
name: None,
|
||||
@@ -281,13 +373,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
})?;
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
o.insert("connected".into(), json!(connected));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
),
|
||||
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")?;
|
||||
@@ -305,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"))?;
|
||||
@@ -314,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)?;
|
||||
@@ -406,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()
|
||||
@@ -528,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| {
|
||||
@@ -608,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()))?),
|
||||
@@ -803,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,
|
||||
};
|
||||
@@ -964,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"));
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ pub(crate) mod face_detect;
|
||||
mod face_models;
|
||||
mod face_recognize;
|
||||
pub mod filters;
|
||||
mod focus;
|
||||
pub mod geocode;
|
||||
pub mod keywords;
|
||||
pub mod library;
|
||||
@@ -37,6 +38,7 @@ pub mod previews;
|
||||
mod print;
|
||||
mod query;
|
||||
pub mod recheck;
|
||||
pub mod reload;
|
||||
mod slideshow;
|
||||
mod sync;
|
||||
mod xmp;
|
||||
@@ -174,6 +176,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(face_models::specs());
|
||||
v.extend(face_detect::specs());
|
||||
v.extend(face_recognize::specs());
|
||||
v.extend(focus::specs());
|
||||
v.extend(activity::specs());
|
||||
v
|
||||
})
|
||||
@@ -196,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)
|
||||
}
|
||||
@@ -221,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 = {
|
||||
|
||||
@@ -30,6 +30,16 @@ pub fn load(s: &mut Session) {
|
||||
creator: "LightCraft Demo".into(),
|
||||
..Default::default()
|
||||
};
|
||||
// one demo photo carries a camera focus point (as a Sony raw would), on the bloom's centre, so
|
||||
// View ▸ Show Focus Point and Zoom to Focus Point can be tried without a camera file
|
||||
if sc.name == "Cosmos bloom" {
|
||||
p.meta.focus = Some(lightcraft_meta::FocusPoint {
|
||||
rect: lightcraft_geom::Rect { x0: 0.42, y0: 0.433, x1: 0.50, y1: 0.487 },
|
||||
mode: Some("AF-S".into()),
|
||||
area: Some("Flexible Spot".into()),
|
||||
tracking: None,
|
||||
});
|
||||
}
|
||||
p.rating = [0, 3, 4, 5, 2, 0, 4, 3][sc.id as usize % 8];
|
||||
p.flag = match sc.id % 6 {
|
||||
1 => Flag::Pick,
|
||||
|
||||
@@ -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).
|
||||
@@ -238,7 +256,7 @@ const SCAN_MAX_FILE: u64 = 64 << 20;
|
||||
|
||||
/// The photos the detector has already searched for faces, so each is searched once, however many times LightCraft
|
||||
/// starts. Kept beside the library (`face-scanned.bin`: a header naming the detector, then photo ids, appended to).
|
||||
#[derive(Default)]
|
||||
/// The focus-point backfill keeps its own record the same way ([`Scanned::new`] with its magic and decoder tag).
|
||||
pub(crate) struct Scanned {
|
||||
set: std::collections::HashSet<u64>,
|
||||
unsaved: Vec<u64>,
|
||||
@@ -246,9 +264,23 @@ pub(crate) struct Scanned {
|
||||
file_matches: bool,
|
||||
/// A photo was taken out ([`Scanned::remove`]): the record is written fresh at the next save.
|
||||
rewrite: bool,
|
||||
magic: [u8; 4],
|
||||
/// What made the record: a file made by anything else is started afresh.
|
||||
tag: String,
|
||||
}
|
||||
|
||||
impl Default for Scanned {
|
||||
fn default() -> Self {
|
||||
Scanned::new(*SCAN_MAGIC, SCAN_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
impl Scanned {
|
||||
/// An empty record (in memory until [`Scanned::reset`] gives it a path) whose file starts with `magic` and `tag`.
|
||||
pub fn new(magic: [u8; 4], tag: &str) -> Scanned {
|
||||
Scanned { set: Default::default(), unsaved: Vec::new(), path: None, file_matches: false, rewrite: false, magic, tag: tag.to_string() }
|
||||
}
|
||||
|
||||
pub fn path(&self) -> Option<&Path> {
|
||||
self.path.as_deref()
|
||||
}
|
||||
@@ -292,12 +324,13 @@ impl Scanned {
|
||||
}
|
||||
}
|
||||
|
||||
fn header() -> Vec<u8> {
|
||||
fn header(&self) -> Vec<u8> {
|
||||
let mut h = Vec::new();
|
||||
h.extend_from_slice(SCAN_MAGIC);
|
||||
h.extend_from_slice(&self.magic);
|
||||
h.push(SCAN_VERSION);
|
||||
h.extend_from_slice(&(SCAN_TAG.len() as u16).to_le_bytes());
|
||||
h.extend_from_slice(SCAN_TAG.as_bytes());
|
||||
let tag = self.tag.as_bytes().get(..usize::from(u16::MAX)).unwrap_or(self.tag.as_bytes());
|
||||
h.extend_from_slice(&(tag.len() as u16).to_le_bytes());
|
||||
h.extend_from_slice(tag);
|
||||
h
|
||||
}
|
||||
|
||||
@@ -307,11 +340,14 @@ impl Scanned {
|
||||
return;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(path) else { return };
|
||||
let head = Self::header();
|
||||
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
|
||||
@@ -326,11 +362,10 @@ 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();
|
||||
let mut buf = self.header();
|
||||
buf.extend(self.set.iter().flat_map(|id| id.to_le_bytes()));
|
||||
let part = path.with_extension("part");
|
||||
std::fs::write(&part, &buf)?;
|
||||
@@ -472,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));
|
||||
|
||||
@@ -41,6 +41,7 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
|
||||
// paths from `lr:hierarchicalSubject`, names from `dc:subject` that aren't their levels
|
||||
keywords: lightcraft_catalog::keywords::from_file(&m.keywords, &m.hierarchical_keywords),
|
||||
regions: m.regions.clone(),
|
||||
focus: m.focus.clone(),
|
||||
};
|
||||
(meta, m.capture_time.as_ref().map(|d| d.to_iso()))
|
||||
}
|
||||
@@ -423,13 +424,17 @@ fn load_bytes_now(
|
||||
// (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`).
|
||||
let info = raw.info();
|
||||
let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes));
|
||||
// (the camera look still pairs the sensor with the camera JPEG through the warp the camera applied to it)
|
||||
let lens_geometry = crate::camera_preview::lens_geometry(&raw.opcodes.list3);
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
// Which scale white balance is edited on, and the as-shot white on it, from the file's own colour tags or
|
||||
// the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera)
|
||||
let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata);
|
||||
// the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted
|
||||
let ((xy, t, camera_look), mattes) =
|
||||
rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens.as_ref()), || dng_mattes(&bytes, &info));
|
||||
let ((xy, t, camera_look), mattes) = rayon::join(
|
||||
|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens_geometry.as_ref(), lens.as_ref()),
|
||||
|| dng_mattes(&bytes, &info),
|
||||
);
|
||||
drop(bytes);
|
||||
// Previews and thumbnails bin the mosaic straight to (about) the size they need; only
|
||||
// larger levels (exports, 1:1) demosaic the whole sensor.
|
||||
@@ -702,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 {
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
//! Reading the camera's focus point for photos already in the library (issue #1480).
|
||||
//!
|
||||
//! Import reads a photo's focus point ([`lightcraft_meta::focus`]); photos imported before that have none. This job
|
||||
//! fills them in, cheaply enough to run over a whole library on a spinning disk: it opens only the photos from a body
|
||||
//! whose focus data is decoded (by the camera the catalog stores, so nothing else is opened), and reads only the few
|
||||
//! kilobytes of each file that hold the focus data ([`lightcraft_meta::focus::read_header`]: about 32 KB of a 75 MB α1
|
||||
//! ARW), one file at a time on one worker thread.
|
||||
//!
|
||||
//! - **Commits** go in batches of [`COMMIT_EVERY`] photos (or every [`COMMIT_AFTER`]), as LightCraft's own bookkeeping
|
||||
//! ([`Session::apply_system`]): no undo step, no sidecar write (the focus point is never written to XMP). Only
|
||||
//! `meta.focus` changes, and only on a photo that still has none, still in the library at the same path.
|
||||
//! - **"Looked, found nothing"** (manual focus, a centre-only Multi report) is kept beside the library in
|
||||
//! `focus-scanned.bin` (photo ids, appended to, headed by the decoder's tag): it is cheap to append, never touches the
|
||||
//! catalog, and a decoder that learns more bodies has another tag, so its record starts afresh and those photos are
|
||||
//! looked at again. A photo whose file can't be read (offline drive, file gone or replaced mid-read) is not recorded:
|
||||
//! it is tried again the next time the library opens.
|
||||
//! - **Runs** off the UI thread with a row in the activity stack ("Reading focus points", n of N, ✕ stops it). The UI's
|
||||
//! per-frame `focus.pump` gives it the pace: nothing new while the user drags, types or scrolls, a short rest between
|
||||
//! files while they are around.
|
||||
//! - **Starts** by itself once per library opening (from `focus.pump`) when photos are left to look at, and on demand
|
||||
//! with `photo.readFocusPoints`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId, Source};
|
||||
use lightcraft_meta::FocusPoint;
|
||||
use lightcraft_meta::focus::HeaderRead;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::activity::{Cancel, TaskGuard};
|
||||
use crate::faces_index::Scanned;
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The record of photos looked at with nothing found, beside the library.
|
||||
pub(crate) const RECORD_FILE: &str = "focus-scanned.bin";
|
||||
const RECORD_MAGIC: [u8; 4] = *b"LCFP";
|
||||
/// Photos committed at once.
|
||||
pub(crate) const COMMIT_EVERY: usize = 200;
|
||||
/// What has been read is committed at least this often, so points appear while a long run goes on.
|
||||
const COMMIT_AFTER: Duration = Duration::from_secs(3);
|
||||
/// The rest between files while the user is around (`light` pace): their own reads of the same disk go first.
|
||||
const LIGHT_REST: Duration = Duration::from_millis(40);
|
||||
/// How often a paused worker looks at its pace again.
|
||||
const PAUSE_POLL: Duration = Duration::from_millis(50);
|
||||
/// Results taken in per pump (the rest wait for the next frame).
|
||||
const MAX_DRAIN: usize = 5000;
|
||||
|
||||
/// The file extensions read (Sony bodies also shoot HEIF, which carries no maker note this decoder reads).
|
||||
const EXTENSIONS: [&str; 3] = ["arw", "jpg", "jpeg"];
|
||||
|
||||
/// How hard the worker may work, from what the user is doing (`focus.pump`'s `pace`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub(crate) enum Pace {
|
||||
Pause = 0,
|
||||
Light = 1,
|
||||
Normal = 2,
|
||||
}
|
||||
|
||||
impl Pace {
|
||||
pub fn parse(name: Option<&str>) -> Pace {
|
||||
match name {
|
||||
Some("pause") => Pace::Pause,
|
||||
Some("light") => Pace::Light,
|
||||
_ => Pace::Normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The focus point in the file at `path`, read from its header only.
|
||||
pub fn read_focus(path: &str) -> std::result::Result<HeaderRead, String> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let len = file.metadata().map_err(|e| format!("{path}: {e}"))?.len();
|
||||
lightcraft_meta::focus::read_header(len, &mut |off, buf| {
|
||||
file.seek(SeekFrom::Start(off))?;
|
||||
file.read_exact(buf)
|
||||
})
|
||||
.map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
/// What the worker says about one photo.
|
||||
enum Outcome {
|
||||
/// The header was read: the point it holds, or none.
|
||||
Read { id: PhotoId, focus: Option<FocusPoint>, bytes: u64 },
|
||||
/// The file couldn't be read (offline, gone, changed under the read): tried again another time.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// What a run did (the last one is kept for `focus.status` and `photo.readFocusPoints {wait}`).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct Summary {
|
||||
pub total: u64,
|
||||
pub done: u64,
|
||||
pub found: u64,
|
||||
pub none: u64,
|
||||
pub unavailable: u64,
|
||||
pub bytes: u64,
|
||||
pub seconds: f64,
|
||||
pub cancelled: bool,
|
||||
}
|
||||
|
||||
impl Summary {
|
||||
fn json(&self) -> Value {
|
||||
json!({
|
||||
"total": self.total,
|
||||
"done": self.done,
|
||||
"found": self.found,
|
||||
"none": self.none,
|
||||
"unavailable": self.unavailable,
|
||||
"bytesRead": self.bytes,
|
||||
"seconds": self.seconds,
|
||||
"cancelled": self.cancelled,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Job {
|
||||
identity: Arc<()>,
|
||||
cancel: Arc<AtomicBool>,
|
||||
pace: Arc<AtomicU8>,
|
||||
rx: mpsc::Receiver<(Outcome, String)>,
|
||||
/// Its row in the activity stack (dropping the job removes it).
|
||||
guard: TaskGuard,
|
||||
/// The path each photo was read at: a photo moved or relinked since is left for another run.
|
||||
paths: HashMap<PhotoId, String>,
|
||||
pending: Vec<(PhotoId, Option<FocusPoint>)>,
|
||||
committed_at: Instant,
|
||||
started: Instant,
|
||||
summary: Summary,
|
||||
}
|
||||
|
||||
impl Drop for Job {
|
||||
fn drop(&mut self) {
|
||||
// the worker stops before its next file (and when it can't hand a result over)
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The backfill's state in the session.
|
||||
pub(crate) struct FocusScan {
|
||||
record: Scanned,
|
||||
/// The library opening the record and the automatic start belong to.
|
||||
library: Option<Arc<()>>,
|
||||
auto_checked: bool,
|
||||
job: Option<Job>,
|
||||
last: Option<Summary>,
|
||||
/// The pace the UI last gave (a job started between two pumps starts at it).
|
||||
pace: Pace,
|
||||
}
|
||||
|
||||
impl Default for FocusScan {
|
||||
fn default() -> Self {
|
||||
FocusScan {
|
||||
record: Scanned::new(RECORD_MAGIC, &lightcraft_meta::focus::decoder_tag()),
|
||||
library: None,
|
||||
auto_checked: false,
|
||||
job: None,
|
||||
last: None,
|
||||
pace: Pace::Normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Follow the session's library: another one (or none) drops a running job (its results belong to the old library and
|
||||
/// are read again there next time) and loads that library's record.
|
||||
fn follow_library(s: &mut Session) {
|
||||
if s.focus_scan.library.as_ref().is_some_and(|l| Arc::ptr_eq(l, &s.library_identity)) {
|
||||
return;
|
||||
}
|
||||
s.focus_scan.job = None;
|
||||
s.focus_scan.auto_checked = false;
|
||||
let path = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join(RECORD_FILE));
|
||||
s.focus_scan.record.reset(path);
|
||||
s.focus_scan.library = Some(Arc::clone(&s.library_identity));
|
||||
}
|
||||
|
||||
/// The photos to read: in the library, no focus point yet, from a body whose focus data is decoded, a raw or JPEG file;
|
||||
/// with `ids`, those of them; without, every one not already looked at. In path order (neighbours on disk together).
|
||||
fn candidates(s: &Session, ids: Option<&[PhotoId]>) -> Vec<(PhotoId, String)> {
|
||||
let wanted = |ph: &lightcraft_catalog::Photo| -> Option<(PhotoId, String)> {
|
||||
if !ph.in_library() || ph.meta.focus.is_some() || !lightcraft_meta::focus::camera_supported(&ph.meta.camera) {
|
||||
return None;
|
||||
}
|
||||
let Source::File { path } = &ph.source else { return None };
|
||||
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
|
||||
EXTENSIONS.contains(&ext.as_str()).then(|| (ph.id, path.clone()))
|
||||
};
|
||||
let mut out: Vec<(PhotoId, String)> = match ids {
|
||||
Some(ids) => ids.iter().filter_map(|id| s.catalog.photo(*id)).filter_map(|ph| wanted(ph)).collect(),
|
||||
None => s.catalog.photos().filter(|ph| !s.focus_scan.record.contains(ph.id.0)).filter_map(|ph| wanted(ph)).collect(),
|
||||
};
|
||||
out.sort_by(|a, b| a.1.cmp(&b.1));
|
||||
out.dedup_by_key(|c| c.0);
|
||||
out
|
||||
}
|
||||
|
||||
/// Start reading `list` on a worker thread.
|
||||
fn start(s: &mut Session, list: Vec<(PhotoId, String)>, pace: Pace) -> std::result::Result<(), String> {
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let pace = Arc::new(AtomicU8::new(pace as u8));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let paths: HashMap<PhotoId, String> = list.iter().cloned().collect();
|
||||
let total = list.len() as u64;
|
||||
let (worker_cancel, worker_pace) = (cancel.clone(), pace.clone());
|
||||
std::thread::Builder::new()
|
||||
.name("focus-points".into())
|
||||
.spawn(move || {
|
||||
for (id, path) in list {
|
||||
loop {
|
||||
if worker_cancel.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if worker_pace.load(Ordering::Relaxed) != Pace::Pause as u8 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(PAUSE_POLL);
|
||||
}
|
||||
if worker_pace.load(Ordering::Relaxed) == Pace::Light as u8 {
|
||||
std::thread::sleep(LIGHT_REST);
|
||||
}
|
||||
// a decoder bug in one file is that file's problem, not the job's end
|
||||
let outcome = match crate::guard::catch("reading a focus point", || read_focus(&path)) {
|
||||
Ok(Ok(r)) => Outcome::Read { id, focus: r.focus, bytes: r.bytes_read },
|
||||
Ok(Err(e)) => {
|
||||
log::debug!("focus point: {e}");
|
||||
Outcome::Unavailable
|
||||
}
|
||||
Err(_) => Outcome::Unavailable,
|
||||
};
|
||||
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
if tx.send((outcome, name)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("could not start reading focus points: {e}"))?;
|
||||
let guard = s.activity.start("focus", "Reading focus points", Cancel::Flag(cancel.clone()));
|
||||
guard.progress(0, total);
|
||||
let now = Instant::now();
|
||||
s.focus_scan.job = Some(Job {
|
||||
identity: Arc::clone(&s.library_identity),
|
||||
cancel,
|
||||
pace,
|
||||
rx,
|
||||
guard,
|
||||
paths,
|
||||
pending: Vec::new(),
|
||||
committed_at: now,
|
||||
started: now,
|
||||
summary: Summary { total, ..Default::default() },
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commit what has been read: the points found (one system op, no undo step, no sidecar write) and the photos with none
|
||||
/// (to the record).
|
||||
fn commit(s: &mut Session) {
|
||||
let Some(job) = s.focus_scan.job.as_mut() else { return };
|
||||
let pending = std::mem::take(&mut job.pending);
|
||||
job.committed_at = Instant::now();
|
||||
if !Arc::ptr_eq(&job.identity, &s.library_identity) {
|
||||
return;
|
||||
}
|
||||
let mut ops = Vec::new();
|
||||
let mut none = Vec::new();
|
||||
for (id, focus) in pending {
|
||||
let Some(ph) = s.catalog.photo(id) else { continue };
|
||||
let same_file = matches!((&ph.source, job.paths.get(&id)), (Source::File { path }, Some(read)) if path == read);
|
||||
// deleted, moved, relinked or given a point meanwhile: leave it
|
||||
if !same_file || !ph.in_library() || ph.meta.focus.is_some() {
|
||||
continue;
|
||||
}
|
||||
match focus {
|
||||
Some(f) => {
|
||||
let mut meta = ph.meta.clone();
|
||||
meta.focus = Some(f);
|
||||
ops.push(Op::SetMeta { id, meta: Box::new(meta) });
|
||||
}
|
||||
None => none.push(id.0),
|
||||
}
|
||||
}
|
||||
for id in none {
|
||||
s.focus_scan.record.insert(id);
|
||||
}
|
||||
if let Err(e) = s.focus_scan.record.save() {
|
||||
log::warn!("could not save the list of photos read for focus points: {e}");
|
||||
}
|
||||
if ops.is_empty() {
|
||||
return;
|
||||
}
|
||||
match s.apply_system(Op::Batch { ops }) {
|
||||
// the focus point is never written to XMP: no sidecar is rewritten for it
|
||||
Ok(()) => s.skip_auto_write = true,
|
||||
Err(e) => log::warn!("could not store focus points: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take in what the worker has read, commit when a batch is due, and end the job when it is done or cancelled.
|
||||
/// Returns whether a job is still running.
|
||||
fn step(s: &mut Session, pace: Pace) -> bool {
|
||||
let Some(job) = s.focus_scan.job.as_mut() else { return false };
|
||||
job.pace.store(pace as u8, Ordering::Relaxed);
|
||||
let mut finished = job.cancel.load(Ordering::Relaxed);
|
||||
let mut last_name = None;
|
||||
for _ in 0..MAX_DRAIN {
|
||||
match job.rx.try_recv() {
|
||||
Ok((outcome, name)) => {
|
||||
job.summary.done += 1;
|
||||
last_name = Some(name);
|
||||
match outcome {
|
||||
Outcome::Read { id, focus, bytes } => {
|
||||
job.summary.bytes = job.summary.bytes.saturating_add(bytes);
|
||||
if focus.is_some() {
|
||||
job.summary.found += 1;
|
||||
} else {
|
||||
job.summary.none += 1;
|
||||
}
|
||||
job.pending.push((id, focus));
|
||||
}
|
||||
Outcome::Unavailable => job.summary.unavailable += 1,
|
||||
}
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => break,
|
||||
// the worker ended (all read, cancelled, or it could not go on)
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
finished = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
finished |= job.summary.done >= job.summary.total;
|
||||
job.guard.progress(job.summary.done, job.summary.total);
|
||||
if let Some(name) = last_name {
|
||||
job.guard.detail(&name);
|
||||
}
|
||||
if finished || job.pending.len() >= COMMIT_EVERY || (!job.pending.is_empty() && job.committed_at.elapsed() >= COMMIT_AFTER) {
|
||||
commit(s);
|
||||
}
|
||||
if finished && let Some(job) = s.focus_scan.job.take() {
|
||||
let mut summary = job.summary.clone();
|
||||
summary.cancelled = job.cancel.load(Ordering::Relaxed) && summary.done < summary.total;
|
||||
summary.seconds = job.started.elapsed().as_secs_f64();
|
||||
log::info!(
|
||||
"focus points: read {} of {} photos ({} found, {} without, {} unavailable, {} KB) in {:.1} s",
|
||||
summary.done,
|
||||
summary.total,
|
||||
summary.found,
|
||||
summary.none,
|
||||
summary.unavailable,
|
||||
summary.bytes / 1024,
|
||||
summary.seconds
|
||||
);
|
||||
s.focus_scan.last = Some(summary);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn status(s: &Session) -> Value {
|
||||
let running = s.focus_scan.job.as_ref().map(|j| json!({"done": j.summary.done, "total": j.summary.total, "found": j.summary.found}));
|
||||
json!({"active": running.is_some(), "running": running, "last": s.focus_scan.last.as_ref().map(Summary::json), "lookedAt": s.focus_scan.record.len()})
|
||||
}
|
||||
|
||||
/// `focus.pump {pace?}`: cheap to call every frame.
|
||||
pub(crate) fn pump(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
follow_library(s);
|
||||
let pace = Pace::parse(p.get("pace").and_then(Value::as_str));
|
||||
s.focus_scan.pace = pace;
|
||||
if !step(s, pace) && !s.focus_scan.auto_checked {
|
||||
// once per library opening: start by itself when photos are left to look at (a few KB each)
|
||||
s.focus_scan.auto_checked = true;
|
||||
let list = candidates(s, None);
|
||||
if !list.is_empty()
|
||||
&& let Err(e) = start(s, list, pace)
|
||||
{
|
||||
log::warn!("{e}");
|
||||
}
|
||||
}
|
||||
Ok(status(s))
|
||||
}
|
||||
|
||||
/// `photo.readFocusPoints {ids?, wait?}`.
|
||||
pub(crate) fn read_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.readFocusPoints";
|
||||
follow_library(s);
|
||||
if s.focus_scan.job.is_some() {
|
||||
return Ok(json!({"queued": 0, "running": true, "status": status(s)}));
|
||||
}
|
||||
let ids: Option<Vec<PhotoId>> = match p.get("ids") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(
|
||||
v.as_array()
|
||||
.ok_or_else(|| crate::cmd::bad(C, "`ids` must be a list of photo ids"))?
|
||||
.iter()
|
||||
.map(|v| v.as_u64().map(PhotoId).ok_or_else(|| crate::cmd::bad(C, "`ids` must be a list of photo ids")))
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
),
|
||||
};
|
||||
let list = candidates(s, ids.as_deref());
|
||||
let queued = list.len();
|
||||
if queued == 0 {
|
||||
return Ok(json!({"queued": 0, "running": false}));
|
||||
}
|
||||
let wait = p.get("wait").and_then(Value::as_bool) == Some(true);
|
||||
start(s, list, if wait { Pace::Normal } else { s.focus_scan.pace }).map_err(|e| crate::cmd::bad(C, e))?;
|
||||
// a run asked for counts as this library opening's automatic one
|
||||
s.focus_scan.auto_checked = true;
|
||||
if !wait {
|
||||
return Ok(json!({"queued": queued, "running": true}));
|
||||
}
|
||||
// a script's call: read them all now (the batches still commit as they come)
|
||||
while step(s, Pace::Normal) {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
let last = s.focus_scan.last.as_ref().map(Summary::json).unwrap_or(Value::Null);
|
||||
Ok(json!({"queued": queued, "running": false, "last": last}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn record_len(s: &Session) -> usize {
|
||||
s.focus_scan.record.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn record_contains(s: &Session, id: PhotoId) -> bool {
|
||||
s.focus_scan.record.contains(id.0)
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
+82
-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;
|
||||
@@ -38,6 +39,8 @@ mod faces_index;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod faces_worker;
|
||||
pub mod files;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod focus_scan;
|
||||
pub mod fonts;
|
||||
pub mod guard;
|
||||
pub mod import;
|
||||
@@ -54,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;
|
||||
@@ -182,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.
|
||||
@@ -236,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>,
|
||||
@@ -252,6 +262,9 @@ pub struct Session {
|
||||
/// The loaded recognition model and the face embeddings made with it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) faces: faces_index::FacesState,
|
||||
/// Reading focus points for photos imported before they were read (`photo.readFocusPoints`, `focus.pump`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) focus_scan: focus_scan::FocusScan,
|
||||
/// Copied develop settings (partial JSON) for Paste.
|
||||
pub clipboard: Option<Value>,
|
||||
/// The folder on disk the [`LibrarySource::Folder`] view browses.
|
||||
@@ -293,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
|
||||
@@ -423,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,
|
||||
@@ -431,6 +449,8 @@ impl Session {
|
||||
face_catalog: Default::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
faces: Default::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
focus_scan: Default::default(),
|
||||
clipboard: None,
|
||||
meta_clipboard: None,
|
||||
browse: None,
|
||||
@@ -452,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(),
|
||||
@@ -867,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)
|
||||
@@ -908,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()
|
||||
@@ -930,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(())
|
||||
}
|
||||
|
||||
@@ -941,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<()> {
|
||||
@@ -1235,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;
|
||||
@@ -1250,6 +1317,8 @@ mod tests_face_recognize;
|
||||
mod tests_face_tiles;
|
||||
#[cfg(test)]
|
||||
mod tests_filesystem_history;
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests_focus_scan;
|
||||
#[cfg(test)]
|
||||
mod tests_folders;
|
||||
#[cfg(test)]
|
||||
@@ -1275,6 +1344,8 @@ mod tests_mask_effects;
|
||||
#[cfg(test)]
|
||||
mod tests_merge;
|
||||
#[cfg(test)]
|
||||
mod tests_move_to_share;
|
||||
#[cfg(test)]
|
||||
mod tests_organize;
|
||||
#[cfg(test)]
|
||||
mod tests_persist;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -433,12 +433,31 @@ impl MoveFs for RealFs {
|
||||
}
|
||||
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let bytes = std::fs::read(a)?;
|
||||
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(b)?;
|
||||
f.write_all(&bytes).and_then(|_| f.sync_all()).inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(b);
|
||||
})
|
||||
if self.exists(b) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display())));
|
||||
}
|
||||
// Streamed (a large raw or video is never read into memory whole), into a hidden name
|
||||
// beside `b` that only takes `b`'s name once complete: a share that drops mid-copy, so
|
||||
// the partial file can't be removed either, leaves no truncated file under the photo's
|
||||
// name for a retry to take for the photo.
|
||||
let name = b.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
// a free name: one left by a copy the share cut off (it couldn't be removed then) doesn't
|
||||
// block the next
|
||||
let partial = (0..1000u32)
|
||||
.map(|k| b.with_file_name(format!(".{name}.lc-partial-{}-{k}", std::process::id())))
|
||||
.find(|p| !self.exists(p))
|
||||
.ok_or_else(|| std::io::Error::other(format!("no free temporary name beside {}", b.display())))?;
|
||||
let copied = (|| {
|
||||
let mut from = std::fs::File::open(a)?;
|
||||
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&partial)?;
|
||||
std::io::copy(&mut from, &mut f)?;
|
||||
f.sync_all()
|
||||
})();
|
||||
let placed = copied.and_then(|()| self.rename_no_replace(&partial, b));
|
||||
if placed.is_err() {
|
||||
let _ = std::fs::remove_file(&partial);
|
||||
}
|
||||
placed
|
||||
}
|
||||
|
||||
fn remove_file(&self, p: &Path) -> std::io::Result<()> {
|
||||
@@ -897,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);
|
||||
}
|
||||
@@ -313,6 +313,44 @@ fn permanently_deleting_explicit_targets_keeps_the_surviving_selection() {
|
||||
assert_eq!(s.catalog.to_snapshot(), snapshot);
|
||||
}
|
||||
|
||||
/// `mask.brushStroke {connect: true}` (a ⇧-click) starts from the last point of the mask's last stroke: one straight
|
||||
/// stroke when the brush is unchanged, pieces whose size, feather and flow step from the last stroke's to the new
|
||||
/// values when it changed, and a plain dab when there is nothing to connect to.
|
||||
#[test]
|
||||
fn connected_brush_strokes_start_where_the_last_one_ended() {
|
||||
use lightcraft_develop::MaskShape;
|
||||
use lightcraft_geom::Point;
|
||||
let mut s = demo();
|
||||
let id = s.active().unwrap();
|
||||
let r = s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.5]], "size": 0.02, "connect": true})).unwrap();
|
||||
assert_eq!(r["connected"], false, "nothing to connect to: a dab");
|
||||
let strokes = |s: &Session| {
|
||||
let d = s.develop_of(id).unwrap();
|
||||
let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") };
|
||||
strokes.clone()
|
||||
};
|
||||
assert_eq!(strokes(&s)[0].points.len(), 1);
|
||||
let r = s.execute("mask.brushStroke", &json!({"points": [[0.6, 0.5]], "size": 0.02, "connect": true})).unwrap();
|
||||
assert_eq!(r["connected"], true);
|
||||
let line = strokes(&s);
|
||||
assert_eq!(line.len(), 2);
|
||||
assert_eq!(line[1].points, vec![Point::new(0.2, 0.5), Point::new(0.6, 0.5)]);
|
||||
// a bigger, softer brush: the segment tapers from the last stroke's values to the new ones
|
||||
let r = s.execute("mask.brushStroke", &json!({"points": [[0.6, 0.9]], "size": 0.04, "feather": 80, "connect": true})).unwrap();
|
||||
assert_eq!(r["connected"], true);
|
||||
let all = strokes(&s);
|
||||
let taper = &all[2..];
|
||||
assert!(taper.len() > 4, "{} pieces", taper.len());
|
||||
assert_eq!(taper[0].points[0], Point::new(0.6, 0.5), "from the last stroke's end");
|
||||
assert_eq!(*taper.last().unwrap().points.last().unwrap(), Point::new(0.6, 0.9), "to the click");
|
||||
assert!(taper.windows(2).all(|w| w[1].size >= w[0].size && w[1].feather >= w[0].feather && w[0].points[1] == w[1].points[0]));
|
||||
assert!((taper.last().unwrap().size - 0.04).abs() < 1e-12 && (taper.last().unwrap().feather - 80.0).abs() < 1e-9);
|
||||
assert!(taper[0].size > 0.02 && taper[0].size < 0.04);
|
||||
// all of it is one undo step
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(strokes(&s).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_strokes_keep_pen_pressure_and_refuse_bad_lists() {
|
||||
let mut s = demo();
|
||||
@@ -330,6 +368,35 @@ fn brush_strokes_keep_pen_pressure_and_refuse_bad_lists() {
|
||||
assert!(!json.contains("pressure"), "and stores none: {json}");
|
||||
}
|
||||
|
||||
/// A ⇧-click line carries pen pressure: the last dab's at its start and the click's at its end, stepping along a
|
||||
/// tapered line; with no pressure on either side there is none.
|
||||
#[test]
|
||||
fn connected_brush_strokes_carry_pen_pressure() {
|
||||
use lightcraft_develop::MaskShape;
|
||||
let mut s = demo();
|
||||
let id = s.active().unwrap();
|
||||
let strokes = |s: &Session| {
|
||||
let d = s.develop_of(id).unwrap();
|
||||
let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") };
|
||||
strokes.clone()
|
||||
};
|
||||
s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.5]], "size": 0.02, "pressure": [0.4]})).unwrap();
|
||||
s.execute("mask.brushStroke", &json!({"points": [[0.6, 0.5]], "size": 0.02, "pressure": [1.0], "connect": true})).unwrap();
|
||||
assert_eq!(strokes(&s)[1].pressure, vec![0.4, 1.0]);
|
||||
s.execute("mask.brushStroke", &json!({"points": [[0.6, 0.9]], "size": 0.04, "pressure": [0.2], "connect": true})).unwrap();
|
||||
let taper = &strokes(&s)[2..];
|
||||
assert!(taper.iter().all(|t| t.pressure.len() == 2));
|
||||
assert!((taper[0].pressure[0] - 1.0).abs() < 1e-6 && (taper.last().unwrap().pressure[1] - 0.2).abs() < 1e-6);
|
||||
assert!(taper.windows(2).all(|w| w[1].pressure[0] <= w[0].pressure[0]));
|
||||
// a mouse: no pressure anywhere
|
||||
let mut m = demo();
|
||||
m.execute("mask.brushStroke", &json!({"points": [[0.2, 0.5]], "size": 0.02})).unwrap();
|
||||
m.execute("mask.brushStroke", &json!({"points": [[0.6, 0.5]], "size": 0.02, "connect": true})).unwrap();
|
||||
let d = m.develop_of(m.active().unwrap()).unwrap();
|
||||
let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") };
|
||||
assert!(strokes[1].pressure.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_crop_and_render() {
|
||||
let mut s = demo();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Loaded 100 of 277 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user