mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-18 13:16:54 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e40d9b1f1 | ||
|
|
b436950ae3 | ||
|
|
8948511eeb | ||
|
|
2a2459f57a | ||
|
|
0675a253dd | ||
|
|
f7be9fbe2c | ||
|
|
57452bfd22 | ||
|
|
f9479063be | ||
|
|
c887ed94cc | ||
|
|
727daf5d67 | ||
|
|
5c94c5e2b6 | ||
|
|
25f211500b | ||
|
|
a20c63521d | ||
|
|
684f85e2e5 | ||
|
|
ec64eb773c | ||
|
|
9d8a78a17e | ||
|
|
0c976f42d5 | ||
|
|
a508ee9014 | ||
|
|
4b3da49486 | ||
|
|
2b8be01d6f | ||
|
|
b62664ad33 | ||
|
|
6b926cdecd | ||
|
|
8ce0684759 | ||
|
|
4b80b031bc | ||
|
|
38d23b621f | ||
|
|
385e8979de | ||
|
|
080f2c4c8d | ||
|
|
f15fb4ee6e | ||
|
|
48bfaa9d0d | ||
|
|
9a9eb013db | ||
|
|
03ffca0d54 | ||
|
|
41e9c742dc | ||
|
|
15af216bd1 | ||
|
|
e1cf321183 | ||
|
|
b6f5df3b3f | ||
|
|
6ee3e4f752 | ||
|
|
83ace1138e | ||
|
|
9ef9992878 | ||
|
|
1b24b5212b | ||
|
|
e9142754a4 | ||
|
|
4a0a8d0794 | ||
|
|
51a0816f99 | ||
|
|
165e11ad6a | ||
|
|
1a052d6115 | ||
|
|
bd632e7eb3 | ||
|
|
8302f57190 | ||
|
|
d9a0f38280 | ||
|
|
7080e6c56a | ||
|
|
2baa95d684 | ||
|
|
9e88c65d33 | ||
|
|
2cbb13a79d | ||
|
|
1353917f15 | ||
|
|
fb924dad16 | ||
|
|
a1f4387e63 | ||
|
|
702b4aa6b0 | ||
|
|
ac57c17b14 | ||
|
|
151afe47b6 | ||
|
|
3ad26e3b51 | ||
|
|
f46c462129 | ||
|
|
b70bf524ea | ||
|
|
6c7d01a0f3 | ||
|
|
107ffe9127 | ||
|
|
3f637410cb | ||
|
|
fee9ff9613 | ||
|
|
6103da5278 | ||
|
|
33c58b5ca0 | ||
|
|
9d46c8ea80 | ||
|
|
1e1f78325c | ||
|
|
35ceaefce2 | ||
|
|
135ae8cf96 | ||
|
|
06ec4044ea | ||
|
|
ac5338360f | ||
|
|
7a45adc913 | ||
|
|
12460d0345 | ||
|
|
9a448fe23b | ||
|
|
2050ca083e | ||
|
|
0aabb48687 | ||
|
|
a62b6473f3 | ||
|
|
2cdff3afb9 | ||
|
|
681dee8c8f | ||
|
|
6c4010cfc4 | ||
|
|
9aef02cac3 | ||
|
|
782ea69f35 | ||
|
|
8d8ea584b8 | ||
|
|
7f486d723f | ||
|
|
3bea3f31c5 | ||
|
|
3a3984075a | ||
|
|
09b6572578 | ||
|
|
43c86ef645 | ||
|
|
9270a31f24 | ||
|
|
0c40ec17c6 | ||
|
|
fe4243ba1f | ||
|
|
d5923e49db | ||
|
|
fc00f1e5bc | ||
|
|
09f21d99ca | ||
|
|
e79dcb6f7f | ||
|
|
e70deecbe5 | ||
|
|
9e6491e5f5 | ||
|
|
03b901cdde | ||
|
|
d7df9d8aa6 | ||
|
|
47f9306fd0 | ||
|
|
7055f0c51b | ||
|
|
2cd470c08e | ||
|
|
b07a2e5b43 | ||
|
|
b9a24da799 | ||
|
|
09bc2ac0c4 | ||
|
|
e77325bd4a | ||
|
|
d4086ba11a | ||
|
|
45bedb0962 | ||
|
|
3d23a708c2 | ||
|
|
8ebca04aa9 | ||
|
|
fa0defc56b | ||
|
|
90df9cfc15 | ||
|
|
786a863f9c | ||
|
|
a4e96991f1 | ||
|
|
b45dfde498 | ||
|
|
b5eed81a25 | ||
|
|
8aecbffb5c | ||
|
|
b4f9d56c93 | ||
|
|
cc5de414dc | ||
|
|
a8afec4455 | ||
|
|
fbe5f80ba4 | ||
|
|
7dc1575efc | ||
|
|
0cb27712c8 | ||
|
|
bf545e0e81 | ||
|
|
08e3e610c0 | ||
|
|
047aeb747a | ||
|
|
50d52a0848 | ||
|
|
2c02d41eaf | ||
|
|
d07eecf66f | ||
|
|
395a52e7f1 | ||
|
|
45d0f4ad93 | ||
|
|
6422219226 | ||
|
|
d91ab25772 | ||
|
|
bd7d47bf0f | ||
|
|
48bd08150e | ||
|
|
5856866f89 | ||
|
|
da9adf82bc | ||
|
|
7313e08141 | ||
|
|
25d198ceb1 | ||
|
|
0b7cd68f8b | ||
|
|
68831a4336 | ||
|
|
b41442c466 | ||
|
|
6237f6e46e | ||
|
|
9f0c049b53 | ||
|
|
3c3dd881ce | ||
|
|
eb2fb2634f | ||
|
|
60224d3fb7 | ||
|
|
06b0ad7af0 | ||
|
|
51d85c1170 | ||
|
|
8a8371395b | ||
|
|
d9413cec0f | ||
|
|
2628b0dd3d | ||
|
|
2868220032 | ||
|
|
7eb3b2e072 | ||
|
|
8f0f95959d | ||
|
|
13b0d856b6 | ||
|
|
e30b121969 | ||
|
|
bd1322a5e4 | ||
|
|
faaa42db95 | ||
|
|
1df68e9a5d | ||
|
|
29c280a2c6 | ||
|
|
4337a6227a | ||
|
|
8a5e89faa6 | ||
|
|
a312c3d0c7 | ||
|
|
4fdb71dfc3 | ||
|
|
72e2eb9644 | ||
|
|
2051c0caf4 | ||
|
|
5824e3afeb | ||
|
|
f373fdb52f | ||
|
|
d38eefc8f7 | ||
|
|
7b3d131bc8 | ||
|
|
cb00e2921b | ||
|
|
d2576b27eb | ||
|
|
870741a5cd | ||
|
|
1cb5af9218 | ||
|
|
9cf7c42f1d | ||
|
|
533566c5ad | ||
|
|
778e8e187d | ||
|
|
9ae984c0ae | ||
|
|
a0997942d3 | ||
|
|
93bd44899b | ||
|
|
f697e43ef7 | ||
|
|
f49b5a4853 | ||
|
|
58bfb4ee8c | ||
|
|
c8e0a34792 | ||
|
|
14207b76c8 | ||
|
|
3c6310e559 | ||
|
|
c0a7f8e7e4 | ||
|
|
6119c00962 | ||
|
|
b926677549 | ||
|
|
3443833532 | ||
|
|
55054b5fcc | ||
|
|
7930dd0adf | ||
|
|
2620460267 | ||
|
|
ba9272be7d | ||
|
|
0940f82d34 | ||
|
|
fbcd0c238e | ||
|
|
0da7d4b0f5 | ||
|
|
90a46d3f89 | ||
|
|
c161eaec73 | ||
|
|
0349b0c0f1 | ||
|
|
909efc0f6d | ||
|
|
3b7870bee1 | ||
|
|
a0d49de047 | ||
|
|
f8563856a3 | ||
|
|
5a6dd79852 | ||
|
|
b3c44cf070 | ||
|
|
abd6f6e591 | ||
|
|
edc441a8d4 | ||
|
|
321e986f7b | ||
|
|
4b76aff35d | ||
|
|
7544b649f2 | ||
|
|
dd2f566daa | ||
|
|
ae9e1896cf | ||
|
|
48e6e668b9 | ||
|
|
b6e9ec97e5 | ||
|
|
c046337ddf | ||
|
|
f091c7fbf0 | ||
|
|
4e2a83ddbd | ||
|
|
e8677ff851 | ||
|
|
44bf13e890 | ||
|
|
7ee4bb2858 | ||
|
|
eaf92dcd9d | ||
|
|
e5e2a8f2eb | ||
|
|
b5b5ce8394 | ||
|
|
8713e26f5a | ||
|
|
4c94e4d63a | ||
|
|
c34facea4d | ||
|
|
a96a621dee | ||
|
|
5ada60c35b | ||
|
|
3423864502 | ||
|
|
7db46219ce | ||
|
|
17d4cd0d78 | ||
|
|
c08322b7bc | ||
|
|
352c849139 | ||
|
|
1520d1c527 | ||
|
|
65c934f374 | ||
|
|
fb297945cd | ||
|
|
6dc11904bd | ||
|
|
315be69dd5 | ||
|
|
9a76e74c24 | ||
|
|
ea0beac9fd | ||
|
|
6266b7a96b | ||
|
|
07074634f6 | ||
|
|
3c1bab7cc1 | ||
|
|
433b6a7184 | ||
|
|
2172d085c6 | ||
|
|
47f4abfed4 | ||
|
|
02c07eb021 | ||
|
|
968baf639e | ||
|
|
c454af8d96 | ||
|
|
7c6a78b05a | ||
|
|
415362df6f | ||
|
|
dbb8911f2a | ||
|
|
8bac8926a3 | ||
|
|
ae80df2eb4 | ||
|
|
5a5faccd4a | ||
|
|
e2444ceb68 | ||
|
|
866c33f7c1 | ||
|
|
34de1a5dfc | ||
|
|
82270711de | ||
|
|
eb11e1af46 | ||
|
|
15eb3a8ba5 | ||
|
|
f114f62179 | ||
|
|
0f955e6c82 | ||
|
|
1d9d92341f | ||
|
|
109498a746 | ||
|
|
7f3d9ea68a | ||
|
|
faf52b5657 | ||
|
|
5e6db41fc2 | ||
|
|
a502a227a9 | ||
|
|
a0823c5e41 | ||
|
|
349dfa0e57 | ||
|
|
2ad42d028a | ||
|
|
4832e08aad | ||
|
|
4b53f620ef | ||
|
|
574d0d3d53 | ||
|
|
a4380b2953 | ||
|
|
8877a812f9 | ||
|
|
b22deaa944 | ||
|
|
f5c6f4bb65 | ||
|
|
f857b4a31f | ||
|
|
b9809ca63c | ||
|
|
d94a8f617a | ||
|
|
043e050237 | ||
|
|
d8cf255c47 | ||
|
|
107302f05c | ||
|
|
1c4cb8307c | ||
|
|
433b84fae7 | ||
|
|
3389ba558b |
No files matched your search
@@ -51,15 +51,68 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
|
||||
- name: Test
|
||||
run: cargo test --workspace --locked
|
||||
run: cargo test --workspace --locked --features heif
|
||||
|
||||
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
|
||||
# error for distributors who leave it out.
|
||||
- name: Test without heif
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked
|
||||
|
||||
# The same tests built with the optional craft-fonts input (crates/text/build.rs), so the
|
||||
# Japanese font tests that skip without it run here. Linux only.
|
||||
- name: Check out craft-fonts
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
|
||||
- name: Test with craft-fonts
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: cargo test --workspace --locked --features heif
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
|
||||
|
||||
- name: Dependency layering
|
||||
run: cargo xtask layers
|
||||
|
||||
# docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
|
||||
# prefs audit). Static: it reads committed files only, no benchmarks run here.
|
||||
- name: Scorecard up to date
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo xtask scorecard --check
|
||||
|
||||
corpus:
|
||||
# Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are
|
||||
# pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache
|
||||
# restore and a download; a missing corpus fails the tests. The cache key changes only when a
|
||||
# pin or manifest does, so normally this restores in seconds (fork PRs read the base cache).
|
||||
name: corpus tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: corpus
|
||||
- name: Restore test corpora
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: corpus/
|
||||
key: corpus-${{ hashFiles('xtask/src/corpus_pins.rs', 'xtask/*.sha256') }}
|
||||
- name: Fetch and verify corpora
|
||||
run: cargo xtask corpus --all
|
||||
- name: Corpus tests
|
||||
run: cargo xtask test-corpus
|
||||
|
||||
wasm:
|
||||
name: wasm32 check
|
||||
runs-on: ubuntu-latest
|
||||
@@ -85,11 +138,13 @@ jobs:
|
||||
target:
|
||||
- { crate: psd, name: parse }
|
||||
- { crate: psd, name: descriptor }
|
||||
- { crate: psd, name: image_source_data }
|
||||
- { crate: codecs, name: decode_any }
|
||||
- { crate: codecs, name: decode_png }
|
||||
- { crate: codecs, name: decode_jpeg }
|
||||
- { crate: codecs, name: decode_tiff }
|
||||
- { crate: codecs, name: decode_exr }
|
||||
- { crate: codecs, name: decode_heif }
|
||||
- { crate: format, name: load }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# 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.
|
||||
# It then runs packaging/freebsd/package.sh, the script the release job runs, and checks the
|
||||
# tarball, so a broken FreeBSD package fails here before it can fail a release. Nothing is
|
||||
# uploaded and no secrets are used.
|
||||
name: FreeBSD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
- .github/workflows/release.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: freebsd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test + package)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build, test and package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y bash 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: |
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked --features heif
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc
|
||||
# The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a GPU
|
||||
# device at once, so the GPU integration tests run one at a time here.
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1
|
||||
# The release package, as release.yml builds it. Free the debug build's disk first.
|
||||
rm -rf target/debug
|
||||
CARGO_BUILD_JOBS=4 bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/photocraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/photocraft$|/bin/photocraft-cli$|/share/applications/ai\.storyteller\.photocraft\.desktop$|/share/doc/photocraft/LICENSE-MIT$'
|
||||
@@ -34,7 +34,9 @@ jobs:
|
||||
run: |
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
|
||||
- name: AppImage AppRun desktop integration
|
||||
run: bash packaging/linux/apprun-test.sh
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -44,6 +46,7 @@ jobs:
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
./packaging/windows/check-icons.ps1
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/photocraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
name: Perf (nightly)
|
||||
|
||||
# The performance scorecard (#221): `cargo xtask perf` on a fixed macOS runner (Apple silicon,
|
||||
# Metal), every night and on demand. It never runs on pull requests, so it can't block one.
|
||||
# It fails when an enforced budget in perf/budgets.toml breaks or a scenario's p50 regresses
|
||||
# more than 15 % against perf/baseline.json from the same machine class (other classes are
|
||||
# never compared). The table goes to the job summary; results.json is uploaded as an artifact.
|
||||
#
|
||||
# To give this runner class its own baseline: run it by hand with "update_baseline", download
|
||||
# the `perf-baseline` artifact and commit it as perf/baseline.json in a PR.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "23 3 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
quick:
|
||||
description: "Quick subset (small documents, no budgets)"
|
||||
type: boolean
|
||||
default: false
|
||||
update_baseline:
|
||||
description: "Also write perf/baseline.json from this run (uploaded as an artifact, not committed)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: perf-nightly
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
perf:
|
||||
name: perf (macOS, Apple GPU)
|
||||
# Pinned image, not -latest: the machine class must stay fixed for comparisons.
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: perf-release
|
||||
|
||||
- name: Run benchmarks
|
||||
id: perf
|
||||
env:
|
||||
QUICK: ${{ inputs.quick }}
|
||||
UPDATE: ${{ inputs.update_baseline }}
|
||||
run: |
|
||||
args=()
|
||||
if [ "$QUICK" = "true" ]; then args+=(--quick); fi
|
||||
if [ "$UPDATE" = "true" ]; then args+=(--update-baseline); fi
|
||||
cargo xtask perf "${args[@]}"
|
||||
|
||||
- name: Job summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f target/perf/summary.md ]; then
|
||||
cat target/perf/summary.md >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "No perf summary: the run failed before writing one (see the log)." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: perf-results
|
||||
path: |
|
||||
target/perf/*.json
|
||||
target/perf/summary.md
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload baseline
|
||||
if: always() && inputs.update_baseline
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: perf-baseline
|
||||
path: perf/baseline.json
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
@@ -30,6 +30,10 @@ env:
|
||||
RUST_BACKTRACE: 1
|
||||
PHOTOCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
# craft-fonts (https://github.com/storytold/craft-fonts): shared font assets every release
|
||||
# embeds (today the Japanese fonts). An optional build input for everyone else; see
|
||||
# docs/development.md › Fonts. Bump deliberately; every desktop build job checks out this commit (not web).
|
||||
CRAFT_FONTS_REF: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
|
||||
jobs:
|
||||
version:
|
||||
@@ -70,6 +74,12 @@ jobs:
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
@@ -90,7 +100,18 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
# Unpacks the CLI zip and checks the binary users get: Developer ID, hardened runtime,
|
||||
# timestamp, and Gatekeeper's notarization lookup (codesign --verify, spctl). Also the DMG's
|
||||
# stapled ticket. Strict when the signing secrets are present, warnings otherwise.
|
||||
- name: Verify signatures and notarization
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: packaging/macos/verify.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-macos
|
||||
@@ -112,11 +133,20 @@ jobs:
|
||||
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:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
@@ -141,6 +171,8 @@ jobs:
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -167,12 +199,18 @@ jobs:
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
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
|
||||
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"
|
||||
@@ -184,6 +222,9 @@ jobs:
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$PHOTOCRAFT_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: |
|
||||
@@ -194,6 +235,9 @@ jobs:
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
# Update information (#349) is embedded, and the .zsync for delta updates sits beside it.
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -239,6 +283,58 @@ jobs:
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
|
||||
# .github/workflows/freebsd.yml.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Passed into the VM by name; never templated into the script below.
|
||||
envs: "PHOTOCRAFT_VERSION PHOTOCRAFT_BUILD_DATE PHOTOCRAFT_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/photocraft-target CARGO_BUILD_JOBS=4
|
||||
if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$PHOTOCRAFT_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/photocraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/photocraft$|/bin/photocraft-cli$|/share/applications/ai\.storyteller\.photocraft\.desktop$|/share/doc/photocraft/LICENSE-MIT$'
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
@@ -264,6 +360,8 @@ jobs:
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
|
||||
# No craft-fonts here: the wasm32 build never embeds it (crates/text/build.rs), since even
|
||||
# one Japanese font takes the wasm over packaging/web/package.sh's 24 MiB gate.
|
||||
- name: Build and zip
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
@@ -274,7 +372,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, flatpak, web]
|
||||
needs: [version, macos, windows, linux, flatpak, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# 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.)
|
||||
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
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: windows-arm64-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
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@v4
|
||||
with:
|
||||
name: photocraft-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@v4
|
||||
with:
|
||||
name: photocraft-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 'PhotoCraft'
|
||||
foreach ($exe in 'photocraft.exe', 'photocraft-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 'photocraft-cli.exe'
|
||||
$out = & $cli --version 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "photocraft-cli --version exited $LASTEXITCODE" }
|
||||
Write-Output "ok photocraft-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 'photocraft.exe')) { throw 'uninstall left photocraft.exe behind' }
|
||||
Write-Output 'ok uninstalled'
|
||||
@@ -8,3 +8,4 @@ target/
|
||||
/secrets
|
||||
/temp
|
||||
/book/output
|
||||
/craft-fonts
|
||||
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md: guide for AI agents and contributors
|
||||
|
||||
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, DrawCraft, EffectCraft, FilmCraft, LightCraft, PrintCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`). Contribute reusable learnings there, never code; repos don't share code. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
|
||||
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, VectorCraft, EffectCraft, FilmCraft, LightCraft, PdfCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`). Contribute reusable learnings there, never code; repos don't share code. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
|
||||
|
||||
## 1. Orientation (5 minutes)
|
||||
|
||||
@@ -11,9 +11,11 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
|
||||
| `docs/contributing.md` | Rules: clean-room, tests, layering, style, commits; the "add a command" checklist |
|
||||
| `docs/control-protocol.md` | JSON control channel: how agents drive and screenshot the running app |
|
||||
| `docs/ui-design.md` | Design tokens, themes, widgets, and how to match Photoshop's look |
|
||||
| `docs/roadmap.md` | Milestones and the **current focus** |
|
||||
| `docs/roadmap.md` | Honest parity assessment (where we're lacking, where we're going), milestones, **current focus** |
|
||||
| `docs/parity.md` | Generated list of every Photoshop menu item, live or missing |
|
||||
| `docs/scorecard.md` | Generated scorecard: performance budgets and numbers, corpus floors, per-area checklists (tools, files, UI, type, automation, reliability, distribution), settings that do nothing |
|
||||
| `crates/<name>/README.md` (where present) | Public API of that crate |
|
||||
| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` |
|
||||
|
||||
## 2. Workspace map
|
||||
|
||||
@@ -21,6 +23,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
|
||||
crates/
|
||||
geom cms color raster L0 foundation (geometry, ICC colour management, pixel formats + blend math, COW tiles)
|
||||
psd codecs L0 standalone format crates (no workspace deps; publishable)
|
||||
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2); the one isolated unsafe crate
|
||||
doc L1 document model (layers, masks, adjustments, effects, smart objects: pure data)
|
||||
ops paint algo text vector L2 history, brush engine, imaging algorithms, type engine, paths/shapes
|
||||
compose gpu format L3 CPU compositor (the oracle), wgpu compositor, .pcraft native format
|
||||
@@ -32,7 +35,7 @@ apps/
|
||||
photocraft desktop app (eframe/wgpu), TCP control server
|
||||
photocraft-cli headless CLI (convert/info/run/batch/commands/mcp)
|
||||
photocraft-web the same app in the browser (trunk + wasm-bindgen)
|
||||
xtask/ cargo xtask layers | wasm | ci | stats | corpus | parity
|
||||
xtask/ cargo xtask layers | wasm | ci | stats | corpus | test-corpus | parity | perf | scorecard
|
||||
```
|
||||
|
||||
**Layering is enforced** by `cargo xtask layers`. A crate may depend only on lower layers. `psd`, `codecs` and `cms` depend on nothing in the workspace. Nothing below `ui-egui` may use egui, eframe, winit or rfd. A new crate must be registered in `xtask/src/layers.rs`.
|
||||
@@ -44,7 +47,7 @@ xtask/ cargo xtask layers | wasm | ci | stats | corpus | p
|
||||
People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it. The shared standard is `../craftrules/standards/never-crash.md`.
|
||||
|
||||
- **Non-test code never panics.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!`. Return the crate's error type and propagate with `?`; use `ok_or(..)?`, `let .. else { return Err(..) }`, `if let`, or `unwrap_or*` where a fallback is truly correct (never one that silently corrupts a document). Unfinished features return an "unsupported" error. The only exception is a provably infallible literal: `#[allow(clippy::expect_used)]` plus `.expect("why it can't fail")`.
|
||||
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`.
|
||||
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop). Only its `src/macos.rs` allows `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
|
||||
- **Input-derived numbers are hostile.** Use `get()` rather than `[i]`/`[a..b]` for indices from files, params, selections or arithmetic on them; slice strings only at char boundaries; use `checked_*`/`saturating_*` for lengths, offsets and counts; guard division by zero and NaN/inf casts; cap allocations sized by input.
|
||||
- **Bound recursion** with depth limits or seen-sets (documents can be deep or cyclic).
|
||||
- **Don't cascade.** Handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)`) and treat thread joins as `Result`s.
|
||||
@@ -54,7 +57,7 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
|
||||
|
||||
1. **Everything is a command.** New user-visible behaviour = a command in the engine (`crates/engine/src/*_cmds.rs`, registered in `commands.rs`) with id, label, menu path, shortcut, params doc, `enabled` and `run`, plus tests. The UI, CLI, control channel and MCP all dispatch commands by id. Use the **exact id from `crates/ui-egui/src/menu_catalog.rs`** and the menu item goes live automatically. Only pure view/window state (zoom, panels, screen mode) belongs to the shell (`menus.rs` `UI_COMMANDS`).
|
||||
2. **No format or colour assumptions.** Bit depth (8/16/32f) and colour model (RGB/Gray/CMYK/Lab…) are runtime data. Never introduce a `u8`-only pixel path in public APIs. Never assume sRGB: colour conversions go through `photocraft-cms` (`Transform`, `transform::cached`). Test at several depths.
|
||||
3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`).
|
||||
3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`). **Never commit font files:** fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: `../craftrules/standards/fonts.md`). PhotoCraft uses it through the optional build input `CRAFT_FONTS_DIR=<craft-fonts checkout>` (read by `crates/text/build.rs`, exposed as `photocraft_text::CRAFT_FONTS`; empty without it, and everything must still work). Build with it: `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo build`; details in `docs/development.md` › Fonts.
|
||||
4. **Tests are the gate.** Every change comes with tests. Format crates use round-trip, synthetic-generator, oracle and fuzz tests. Keep the PSD corpus results and the parity floor (`crates/ui-egui/src/parity.rs`) from regressing.
|
||||
5. **The UI is thin and data-driven.** UI state lives in `ui-egui/src/state.rs` (serde), so the control channel can read and drive it. Colours and radii come from `theme::Tokens`, never hard-coded.
|
||||
6. **Verify UI changes visually.** Render offscreen with `cargo run -p photocraft-ui-egui --example snapshot` (no window, no focus stealing), or launch with `--control` and take `ui.screenshot`. Look at the PNG. Demo images must be public-domain art, never personal photos. When fetching assets, never put a person's name, email or other personal details in requests (User-Agent, headers, URLs); use a generic `Photocraft-dev` User-Agent.
|
||||
@@ -67,9 +70,16 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
|
||||
|
||||
Priorities: important infrastructure first, then low-hanging parity, then the long tail.
|
||||
|
||||
1. `docs/roadmap.md` → **Current focus**.
|
||||
2. `cargo xtask parity` → `docs/parity.md` lists every missing menu item, grouped by menu. Low-hanging fruit is usually a missing command whose algorithm already exists in `algo`, `paint`, `vector` or `text`.
|
||||
3. `log/devlog.md` → the "Still open" bullets of recent entries.
|
||||
0. **Read `docs/roadmap.md` → "Honest parity assessment" first.** It says, dimension by dimension,
|
||||
where PhotoCraft is lacking and the priority order of where we're going. `docs/parity.md`
|
||||
(menu wiring) is not a measure of behaviour. When your work moves a measured number (PSD oracle,
|
||||
round trips, workflow tests, performance), update that section with the dated figure.
|
||||
1. **Check `docs/scorecard.md`** before picking work: each area's `missing` and `partial` rows,
|
||||
the performance scenarios that are over budget or not measurable yet, and the count of
|
||||
settings that do nothing. Its numbers are measured; prefer them to estimates.
|
||||
2. `docs/roadmap.md` → **Current focus**.
|
||||
3. `cargo xtask parity` → `docs/parity.md` lists every missing menu item, grouped by menu. Low-hanging fruit is usually a missing command whose algorithm already exists in `algo`, `paint`, `vector` or `text`.
|
||||
4. `log/devlog.md` → the "Still open" bullets of recent entries.
|
||||
|
||||
When parity rises, raise `FLOOR` in `crates/ui-egui/src/parity.rs` (never lower it).
|
||||
|
||||
@@ -82,8 +92,15 @@ cargo xtask layers
|
||||
cargo xtask wasm # if you touched L0–L6
|
||||
cargo xtask parity # if you added commands; commit the regenerated docs/parity.md
|
||||
cargo test -p photocraft-engine --test panic_hunt -- --ignored # if you added/changed commands: no panic on adversarial input (Rule 9)
|
||||
cargo xtask scorecard # if you moved a number: flip the checklist row in scorecard/*.toml, raise a
|
||||
# corpus floor, fix a dead preference, or meet a budget (then set enforce = true
|
||||
# in perf/budgets.toml); commit the regenerated docs/scorecard.md (CI checks it)
|
||||
cargo xtask perf --quick # if you touched a hot path; `cargo xtask perf --update-baseline` publishes a full run
|
||||
cargo xtask test-corpus # if you touched psd, io, codecs, compose, gpu, text or format (or: --changed decides)
|
||||
```
|
||||
|
||||
**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora.
|
||||
|
||||
Commands must **never panic** on bad input (Rule 9): every `run` closure and the code it calls returns `Err`, not a panic, for any params or document state. New commands come with a graceful-failure test (empty/out-of-range/wrong-type params → `Err`, not a crash).
|
||||
|
||||
Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still open). Sessions can end abruptly (crashes, context limits), so the dev log plus a green tree is how the next agent picks up. Keep the tree building at every step.
|
||||
@@ -101,6 +118,7 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
|
||||
|
||||
- `docs/roadmap.md`: milestones M0–M12, status and the current focus.
|
||||
- `docs/parity.md`: generated Photoshop menu coverage.
|
||||
- `docs/scorecard.md`: generated scorecard (sources: `scorecard/*.toml`, `perf/budgets.toml`, `perf/baseline.json`, corpus floors, prefs audit).
|
||||
- `docs/releasing.md`: cutting a release (`cargo xtask version`, the `release` branch), signing secrets, packaging scripts in `packaging/`.
|
||||
- `../craftrules/release/playbook.md`: how every storytold app builds signed release binaries (the canonical recipe; `docs/release-playbook.md` just points there); `docs/releasing.md` is PhotoCraft's specifics.
|
||||
- `plan/` (local, gitignored): research, parity plan, execution plan, estimates.
|
||||
|
||||
+32
-4
@@ -10,9 +10,12 @@ next to them.
|
||||
|
||||
| Path | Title | Author | Source | License |
|
||||
|---|---|---|---|---|
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | German UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | Korean UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `eraser-background.svg` and `eraser-magic.svg` (108 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `eraser-background.svg` and `eraser-magic.svg` (109 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,lock,minus,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
@@ -20,6 +23,19 @@ next to them.
|
||||
| `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft Coated CMYK (synthetic) ICC profile | PhotoCraft contributors (generated by `photocraft-cms`, `src/synth.rs`) | Original work, see [`crates/cms/README.md`](crates/cms/README.md) | CC0-1.0 |
|
||||
| `assets/app-icon/` (all files) | PhotoCraft app icon (nine-tailed kitsune) | The project owner (drawn in ArtCraft, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) |
|
||||
|
||||
### Optional build input: craft-fonts (not files in this repo)
|
||||
|
||||
Builds made with `CRAFT_FONTS_DIR` (all official releases) embed fonts from
|
||||
[storytold/craft-fonts](https://github.com/storytold/craft-fonts) at a pinned commit; no font file
|
||||
from it is committed here. Per-file authors, sources and licences:
|
||||
[craft-fonts `ATTRIBUTION.md`](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md).
|
||||
|
||||
| Font (embedded at build time) | Use | License |
|
||||
|---|---|---|
|
||||
| BIZ UDPGothic Regular, Bold (Morisawa) | Japanese UI and sans Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-biz-ud-pgothic.txt` |
|
||||
| Shippori Mincho Regular (FONTDASU) | Japanese serif Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-shippori-mincho.txt` |
|
||||
| BIZ UDMincho Regular (Morisawa) | Japanese serif Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-biz-ud-mincho.txt` |
|
||||
|
||||
The other built-in ICC profiles and the generated LUT looks are produced by code at runtime
|
||||
(`crates/cms/src/builtin.rs`, `crates/cms/src/lutfile.rs`) and dedicated to the public domain
|
||||
(CC0-1.0); they are not separate files.
|
||||
@@ -30,6 +46,7 @@ The other built-in ICC profiles and the generated LUT looks are produced by code
|
||||
|---|---|---|---|---|
|
||||
| `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
|
||||
| `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below |
|
||||
| `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 |
|
||||
|
||||
Artwork shown in the screenshots (all public domain, via Wikimedia Commons; details in
|
||||
[`docs/images/SOURCES.md`](docs/images/SOURCES.md)):
|
||||
@@ -48,6 +65,17 @@ Artwork shown in the screenshots (all public domain, via Wikimedia Commons; deta
|
||||
|
||||
## Test data (not committed, not shipped)
|
||||
|
||||
`corpus/` is gitignored. `corpus/psd` holds MIT-licensed test PSDs (ag-psd, psd-tools), listed in
|
||||
`corpus/psd/SOURCES.md`; `cargo xtask corpus --download` fetches PngSuite (public domain). Files
|
||||
copied in by hand must be MIT, BSD or CC0. No test fixtures are committed to the repository.
|
||||
`corpus/` is gitignored and no test fixtures are committed to the repository.
|
||||
`cargo xtask corpus --all` fetches every corpus at the pinned commits in `xtask/src/corpus_pins.rs`
|
||||
and verifies each file against the sha256 lists in `xtask/*.sha256`, with the upstream licence
|
||||
next to the files:
|
||||
|
||||
| Path (fetched) | Title | Author | Source | License |
|
||||
|---|---|---|---|---|
|
||||
| `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
|
||||
| `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi |
|
||||
| `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) |
|
||||
| `corpus/heif/` (9 files, 0.1 MB) | HEIC/HEIF test files: `heic-rs/` checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by macOS `sips`, each `.ref.png` Apple's decode); `pillow-heif/` the 10-bit RGBA `RGBA_10__29x100.heif` and its source `RGBA_16__29x100.png` | Thomas Braun (heic-rs); Pillow-Heif contributors | [heic-rs `tests/fixtures`](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), [pillow-heif `tests/images`](https://github.com/bigcat88/pillow_heif/tree/master/tests/images) | MIT OR Apache-2.0 (heic-rs); BSD-3-Clause (pillow-heif) |
|
||||
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
|
||||
|
||||
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
|
||||
Generated
+208
-28
@@ -8,6 +8,8 @@ version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
|
||||
dependencies = [
|
||||
"enumn",
|
||||
"serde",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -130,6 +132,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
@@ -248,6 +251,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
@@ -574,6 +578,9 @@ name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitstream-io"
|
||||
@@ -1181,6 +1188,7 @@ checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"emath",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1209,6 +1217,8 @@ dependencies = [
|
||||
"pollster 1.0.1",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"ron",
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -1233,6 +1243,8 @@ dependencies = [
|
||||
"log",
|
||||
"nohash-hasher",
|
||||
"profiling",
|
||||
"ron",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"unicode-segmentation",
|
||||
"web-sys",
|
||||
@@ -1274,6 +1286,7 @@ dependencies = [
|
||||
"objc2-ui-kit 0.3.2",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"smithay-clipboard",
|
||||
"web-time",
|
||||
"winit",
|
||||
@@ -1342,6 +1355,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1391,6 +1405,17 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumn"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "epaint"
|
||||
version = "0.36.2"
|
||||
@@ -1409,6 +1434,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"profiling",
|
||||
"self_cell",
|
||||
"serde",
|
||||
"skrifa",
|
||||
"smallvec",
|
||||
"unicode-general-category",
|
||||
@@ -1556,6 +1582,12 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
@@ -1609,6 +1641,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1997,6 +2030,15 @@ dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heic-rs"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.3"
|
||||
@@ -2614,6 +2656,16 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memory-stats"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c73f5c649995a115e1a0220b35e4df0a1294500477f97a91d0660fb5abeb574a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -3204,6 +3256,16 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
@@ -3303,6 +3365,17 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
@@ -3348,13 +3421,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fmv-macos-events",
|
||||
"log",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"open",
|
||||
"photocraft-automation",
|
||||
"photocraft-codecs",
|
||||
@@ -3366,15 +3442,19 @@ dependencies = [
|
||||
"photocraft-geom",
|
||||
"photocraft-io",
|
||||
"photocraft-raster",
|
||||
"photocraft-tablet",
|
||||
"photocraft-ui-egui",
|
||||
"rfd",
|
||||
"ron",
|
||||
"serde_json",
|
||||
"winresource",
|
||||
"winsafe",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-algo"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3386,7 +3466,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-automation"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"cap-std",
|
||||
@@ -3408,7 +3488,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-automation",
|
||||
"photocraft-codecs",
|
||||
@@ -3421,7 +3501,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cms"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"moxcms",
|
||||
"rayon",
|
||||
@@ -3429,7 +3509,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-codecs"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"exr",
|
||||
"flate2",
|
||||
@@ -3437,8 +3517,10 @@ dependencies = [
|
||||
"image",
|
||||
"image-webp",
|
||||
"jpeg-encoder",
|
||||
"photocraft-heif",
|
||||
"png 0.18.1",
|
||||
"proptest",
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
@@ -3447,7 +3529,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-color"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"serde",
|
||||
@@ -3455,7 +3537,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-compose"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"photocraft-color",
|
||||
@@ -3470,7 +3552,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-doc"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3481,7 +3563,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-engine"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"flate2",
|
||||
@@ -3511,7 +3593,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-format"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"flate2",
|
||||
@@ -3529,14 +3611,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-geom"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-gpu"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"half",
|
||||
"log",
|
||||
@@ -3552,9 +3634,18 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-io"
|
||||
version = "0.2.0"
|
||||
name = "photocraft-heif"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-io"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"photocraft-algo",
|
||||
"photocraft-cms",
|
||||
"photocraft-codecs",
|
||||
"photocraft-color",
|
||||
@@ -3569,19 +3660,20 @@ dependencies = [
|
||||
"photocraft-text",
|
||||
"photocraft-vector",
|
||||
"proptest",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ops"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-doc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-paint"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3592,7 +3684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-plugins"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3607,7 +3699,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-psd"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"photocraft-psd",
|
||||
@@ -3618,7 +3710,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raster"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3628,7 +3720,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raw"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-raw",
|
||||
"png 0.18.1",
|
||||
@@ -3636,17 +3728,31 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-testkit"
|
||||
version = "0.2.0"
|
||||
name = "photocraft-tablet"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation 0.3.2",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-testkit"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"memory-stats",
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
"photocraft-raster",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-text"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"parley",
|
||||
@@ -3660,7 +3766,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ui-egui"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"eframe",
|
||||
@@ -3669,6 +3775,7 @@ dependencies = [
|
||||
"egui_kittest",
|
||||
"log",
|
||||
"photocraft-algo",
|
||||
"photocraft-cms",
|
||||
"photocraft-codecs",
|
||||
"photocraft-color",
|
||||
"photocraft-compose",
|
||||
@@ -3679,16 +3786,20 @@ dependencies = [
|
||||
"photocraft-gpu",
|
||||
"photocraft-io",
|
||||
"photocraft-plugins",
|
||||
"photocraft-psd",
|
||||
"photocraft-raster",
|
||||
"photocraft-testkit",
|
||||
"photocraft-text",
|
||||
"photocraft-vector",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-vector"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-doc",
|
||||
@@ -3699,7 +3810,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4334,6 +4445,20 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ron"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"typeid",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.20.0"
|
||||
@@ -4649,6 +4774,9 @@ name = "smallvec"
|
||||
version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smithay-client-toolkit"
|
||||
@@ -4827,6 +4955,15 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -5084,6 +5221,17 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree_magic_mini"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"nom",
|
||||
"petgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "twox-hash"
|
||||
version = "2.1.4"
|
||||
@@ -5099,6 +5247,12 @@ dependencies = [
|
||||
"rustc-hash 2.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
@@ -6105,6 +6259,12 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winsafe"
|
||||
version = "0.0.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6"
|
||||
|
||||
[[package]]
|
||||
name = "winx"
|
||||
version = "0.36.4"
|
||||
@@ -6121,6 +6281,24 @@ version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wl-clipboard-rs"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"os_pipe",
|
||||
"rustix 1.1.5",
|
||||
"thiserror 2.0.21",
|
||||
"tree_magic_mini",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-wlr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
@@ -6198,9 +6376,11 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+34
-3
@@ -1,13 +1,13 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft-cli", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
rust-version = "1.95"
|
||||
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
@@ -33,6 +33,7 @@ photocraft-plugins = { path = "crates/plugins" }
|
||||
photocraft-engine = { path = "crates/engine" }
|
||||
photocraft-ui-egui = { path = "crates/ui-egui" }
|
||||
photocraft-testkit = { path = "crates/testkit" }
|
||||
photocraft-tablet = { path = "crates/tablet" }
|
||||
|
||||
# external
|
||||
thiserror = "2"
|
||||
@@ -68,3 +69,33 @@ opt-level = 1
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
# The browser build (apps/photocraft-web, `data-cargo-profile` in its index.html). Hosts cap the
|
||||
# size of a single file (Cloudflare Pages/Workers: 25 MiB), and the wasm is downloaded on every
|
||||
# visit, so this profile optimizes for size: fat LTO, and opt-level "s" for everything except the
|
||||
# pixel-processing crates, which keep opt-level 3 so filters and compositing stay fast. panic
|
||||
# stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` is for
|
||||
# the native app shell). packaging/web/package.sh fails the build above 24 MiB. Issue #198:
|
||||
# this took the wasm from 26.3 MiB (release profile) to 18.8 MiB.
|
||||
[profile.wasm-release]
|
||||
inherits = "release"
|
||||
opt-level = "s"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
strip = "debuginfo"
|
||||
|
||||
[profile.wasm-release.package."*"]
|
||||
opt-level = "s"
|
||||
|
||||
[profile.wasm-release.package.photocraft-raster]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-color]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-cms]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-compose]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-algo]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-paint]
|
||||
opt-level = 3
|
||||
@@ -16,3 +16,8 @@ Inter font (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/
|
||||
JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
|
||||
Lucide icons (assets/icons/*.svg, except slice-knife.svg) — ISC; icons derived from Feather also MIT. See assets/icons/LICENSE-lucide.txt.
|
||||
SCOWL English word list (assets/dict/en_US-scowl-50.txt.gz) — SCOWL license. See assets/dict/LICENSE-SCOWL.txt.
|
||||
|
||||
Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in
|
||||
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDPGothic, Shippori Mincho and
|
||||
BIZ UDMincho; not the web build), under the SIL Open Font License 1.1. Their licence texts
|
||||
ship with the package as OFL-<family>.txt.
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<p align="center">
|
||||
<img alt="100% Rust" src="https://img.shields.io/badge/100%25-Rust-b7410e?style=flat-square&logo=rust">
|
||||
<img alt="macOS · Windows · Linux · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-native-2f7bf5?style=flat-square">
|
||||
<img alt="macOS · Windows · Linux · FreeBSD · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-native-2f7bf5?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-3a3a3a?style=flat-square">
|
||||
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
|
||||
</p>
|
||||
@@ -70,7 +70,7 @@
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🗂️ Real PSD files</h3>
|
||||
Open, edit and save layered Photoshop documents. 134 of 135 real-world PSDs round-trip byte for byte.
|
||||
Open, edit and save layered Photoshop documents. Re-saving keeps the render of 307 of the 309 psd-tools test files.
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🤖 Agent-ready</h3>
|
||||
@@ -198,7 +198,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🗂️ Formats</h4>
|
||||
PSD and PSB, plus PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, and the native <code>.pcraft</code> format.
|
||||
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), plus flat PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, HEIC photos from iPhone and Mac (read; in official builds, an optional <code>--features heif</code> build feature), and the native <code>.pcraft</code> format.
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🪄 The everyday essentials</h4>
|
||||
@@ -213,7 +213,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
|
||||
PhotoCraft's PSD support is a standalone crate written from Adobe's public specification and tested against a corpus of real-world files.
|
||||
|
||||
- **Byte-exact round trips:** 134 of 135 corpus files are written back identical, and anything we don't model yet (raw blocks, descriptors, extras) is preserved verbatim instead of being dropped.
|
||||
- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/corpus.rs`).
|
||||
- **Pixels that match:** a composite oracle compares our render with Photoshop's own merged image, covering gradient interpolation (Classic, Perceptual and Linear), layer effects, shape strokes, clipping and fill opacity.
|
||||
- **Large documents:** PSB, 16 and 32-bit files, and CMYK and Lab documents open natively.
|
||||
|
||||
@@ -231,6 +231,9 @@ photocraft-cli run wave.psd \
|
||||
# Apply one action list to a folder of images
|
||||
photocraft-cli batch --actions grade.json --in ./raw --out ./graded
|
||||
|
||||
# Every subcommand explains itself
|
||||
photocraft-cli batch --help
|
||||
|
||||
# Let an agent drive it over MCP (headless, or bridged to the running app)
|
||||
photocraft-cli mcp
|
||||
```
|
||||
@@ -255,19 +258,44 @@ cargo run --release -p photocraft -- image.psd # the desktop app
|
||||
cargo test --workspace # the test suite
|
||||
```
|
||||
|
||||
Japanese fonts for the UI and Type tool come from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input (desktop release builds always include it). Without it PhotoCraft uses your system's CJK fonts:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/craft-fonts ../craft-fonts
|
||||
CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p photocraft
|
||||
```
|
||||
|
||||
New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs/`](docs/).
|
||||
|
||||
Installers for macOS, Windows, Linux and the web are attached to each [GitHub release](https://github.com/storytold/photocraft/releases). On Linux you can pick an AppImage, a `.deb`, an `.rpm`, a tarball or a Flatpak bundle. The bundle needs the freedesktop runtime from [Flathub](https://flathub.org/setup), which `flatpak` offers to install along with it:
|
||||
Installers for macOS, Windows, Linux, FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/photocraft/releases). On Linux you can pick an AppImage, a `.deb`, an `.rpm`, a tarball or a Flatpak bundle. The bundle needs the freedesktop runtime from [Flathub](https://flathub.org/setup), which `flatpak` offers to install along with it:
|
||||
|
||||
```sh
|
||||
flatpak install --user photocraft-<version>-linux-x86_64.flatpak # or -linux-aarch64
|
||||
flatpak run ai.storyteller.photocraft
|
||||
```
|
||||
|
||||
The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it.
|
||||
|
||||
On macOS, the command-line tool comes as `photocraft-cli-<version>-macos-universal.zip`. The binary is signed with the same Developer ID as the app and notarized by Apple. A bare binary can't carry a stapled notarization ticket the way the DMG does, so the first time you run it macOS checks the notarization online. You can confirm it yourself:
|
||||
|
||||
```sh
|
||||
ditto -x -k photocraft-cli-<version>-macos-universal.zip .
|
||||
spctl --assess --type install -vv photocraft-cli-<version>-macos-universal/photocraft-cli
|
||||
# ... accepted, source=Notarized Developer ID
|
||||
```
|
||||
|
||||
On FreeBSD 14 (x86_64), the release has a tarball laid out like `/usr/local`. Install the runtime libraries, then unpack it there:
|
||||
|
||||
```sh
|
||||
pkg install libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
tar -xzf photocraft-<version>-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local
|
||||
photocraft
|
||||
```
|
||||
|
||||
Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Status:** PhotoCraft is in early alpha. The core editing workflow is here, and we're working toward full Photoshop parity, milestone by milestone (see [`docs/roadmap.md`](docs/roadmap.md)). Progress is measured, not guessed: `cargo xtask parity` checks every item in Photoshop's menu tree against the live command registry and writes [`docs/parity.md`](docs/parity.md). Expect rough edges, and please file issues. You can also tell us what broke on [Discord](https://discord.gg/artcraft).
|
||||
> **Status:** PhotoCraft is in early alpha, and we want to be straight about where it stands: much of Photoshop's feature surface exists in some form, but **it is not yet a Photoshop replacement for daily professional work**. The biggest gaps are AI/generative features, about twenty missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity.md`](docs/parity.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap's parity assessment](docs/roadmap.md#honest-parity-assessment-2026-10-05). Expect rough edges, and please file issues (include your OS, document size, layer count and a screenshot). You can also tell us what broke on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -277,6 +305,13 @@ Developer, architecture, automation, format, and security documentation is maint
|
||||
|
||||
Security architecture, threat modeling, parser hardening, fuzzing, and vulnerability reporting are covered in the [security documentation](book/src/security/) and the repository [security policy](SECURITY.md).
|
||||
|
||||
## Test corpora
|
||||
|
||||
PhotoCraft is tested against real files: our own Photoshop-authored oracle PSDs in
|
||||
[photocraft-corpus](https://github.com/storytold/photocraft-corpus) plus the psd-tools, ag-psd and PngSuite sets, pinned and
|
||||
sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests with
|
||||
`cargo xtask test-corpus` (details in [docs/development.md](docs/development.md#test-corpora)).
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
PhotoCraft is one of the **Crafting Apps**: free, open-source creative tools from the
|
||||
@@ -289,7 +324,7 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
|
||||
@@ -343,5 +378,6 @@ Forks and modified versions must remove them.
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
</p>
|
||||
|
||||
## Star history
|
||||
|
||||
ArtCraft
|
||||
[](https://www.star-history.com/?repos=storytold%2Fphotocraft&type=date&legend=top-left)
|
||||
+1
-1
@@ -49,4 +49,4 @@ General bugs without a security impact should use the project's normal issue and
|
||||
|
||||
## Current limitations
|
||||
|
||||
TCP control connections require a 256-bit bearer token before method dispatch and enforce request-line, active-connection, and batch-step limits. These protections do not provide a capability model: an authenticated client still receives the complete exposed control surface. Automation file operations are not restricted to configured workspace roots, and JSON-depth, render, document-memory, command-duration, client-identity, encryption, and audit controls are not yet implemented. See the [security documentation](book/src/security/overview.md) for the source-backed status and hardening roadmap.
|
||||
TCP control connections require a 256-bit bearer token before method dispatch and enforce request-line, active-connection, and batch-step limits. Authentication does not provide per-client or per-tool capabilities, so an authenticated client still receives the exposed control surface. Remote filesystem methods use separately configured read and write workspace roots, and engine commands that still use ambient filesystem paths are denied at the automation boundary. Automation can change individual safe preferences, but whole-preferences updates and filesystem-bearing preference sections are refused; applying the Preferences dialog through automation is also refused. JSON-depth, render, document-memory, command-duration, client-identity, encryption, and audit controls are not yet implemented. See the [security documentation](book/src/security/overview.md) for the source-backed status and hardening roadmap.
|
||||
@@ -5,6 +5,11 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose
|
||||
# it; official builds (CI, packaging/, release) build with `--features heif`.
|
||||
heif = ["photocraft-codecs/heif"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
||||
+111
-68
@@ -14,15 +14,19 @@ pub const USAGE: &str = "\
|
||||
photocraft-cli: headless Photocraft
|
||||
|
||||
USAGE:
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>]
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers]
|
||||
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
|
||||
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
|
||||
photocraft-cli info <file> [--compact]
|
||||
Print the document as JSON (size, mode, depth, layer tree).
|
||||
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>]
|
||||
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers]
|
||||
Open a file, run engine commands in order, save the result. Each --params
|
||||
applies to the preceding --cmd. Prints each command's JSON result.
|
||||
photocraft-cli batch --actions <actions.json> --in <dir> --out <dir> [--format <ext>]
|
||||
Apply an action list ([{\"command\": id, \"params\": {…}}, …]) to every image in a directory.
|
||||
photocraft-cli batch --actions <actions.json> --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers]
|
||||
Apply an action list to every image in a directory. Steps are [id, params] pairs,
|
||||
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
|
||||
(a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An --out folder that is the
|
||||
--in folder is refused, as the results would replace the originals; --in-place allows it.
|
||||
photocraft-cli droplet <file.pcdroplet> <file-or-dir>… [--out <dir>]
|
||||
Run a droplet (File › Automate › Create Droplet) on images and folders.
|
||||
photocraft-cli commands [--json] [--filter <text>]
|
||||
@@ -33,8 +37,12 @@ USAGE:
|
||||
photocraft-cli serve [--port <port>] [--control-token <64-hex> | --control-token-file <path>]
|
||||
[--automation-read-root <dir>] [--automation-write-root <dir>]
|
||||
Keep one headless session open and answer JSON lines ({\"id\",\"method\",\"params\"}) on stdio,
|
||||
or on 127.0.0.1:<port>. Methods: engine.execute, engine.commands, doc.open/new/save/inspect/render/
|
||||
select/close, session.list, batch, methods (docs/control-protocol.md#headless-server).
|
||||
or on 127.0.0.1:<port>. Methods: engine.execute, jobs.list/cancel, engine.commands,
|
||||
doc.open/new/save/inspect/render/select/close, session.list, batch, methods
|
||||
(docs/control-protocol.md#headless-server).
|
||||
|
||||
photocraft-cli <subcommand> --help (or -h) prints this text. A flag the subcommand doesn't take is
|
||||
an error.
|
||||
";
|
||||
|
||||
struct Args {
|
||||
@@ -42,43 +50,67 @@ struct Args {
|
||||
flags: Vec<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
const VALUE_FLAGS: &[&str] = &[
|
||||
"--format",
|
||||
"--quality",
|
||||
"--new",
|
||||
"--cmd",
|
||||
"--params",
|
||||
"--out",
|
||||
"--actions",
|
||||
"--in",
|
||||
"--filter",
|
||||
"--bridge",
|
||||
"--port",
|
||||
"--control-token",
|
||||
"--control-token-file",
|
||||
"--automation-read-root",
|
||||
"--automation-write-root",
|
||||
/// A subcommand: the flags it reads (taking a value, or given bare) and what it runs. A flag
|
||||
/// outside these is a usage error, so a typo such as `--fromat` can't be silently ignored (#423).
|
||||
struct Subcommand {
|
||||
name: &'static str,
|
||||
values: &'static [&'static str],
|
||||
bare: &'static [&'static str],
|
||||
run: fn(&Args, &mut dyn Write, &mut dyn Write) -> R,
|
||||
}
|
||||
|
||||
const SUBCOMMANDS: &[Subcommand] = &[
|
||||
Subcommand { name: "convert", values: &["--format", "--quality"], bare: &["--tiff-layers"], run: convert },
|
||||
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
|
||||
Subcommand { name: "run", values: &["--new", "--cmd", "--params", "--out", "--format", "--quality"], bare: &["--tiff-layers"], run: run_cmds },
|
||||
Subcommand { name: "batch", values: &["--actions", "--in", "--out", "--format", "--quality"], bare: &["--in-place", "--tiff-layers"], run: batch },
|
||||
Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet },
|
||||
Subcommand { name: "commands", values: &["--filter"], bare: &["--json"], run: |a, out, _| commands(a, out) },
|
||||
Subcommand {
|
||||
name: "mcp",
|
||||
values: &["--bridge", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"],
|
||||
bare: &[],
|
||||
run: |a, _, _| mcp(a),
|
||||
},
|
||||
Subcommand {
|
||||
name: "serve",
|
||||
values: &["--port", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"],
|
||||
bare: &[],
|
||||
run: |a, _, err| serve(a, err),
|
||||
},
|
||||
];
|
||||
|
||||
fn parse(args: &[String]) -> Result<Args, String> {
|
||||
/// The subcommand's arguments, or `None` when they ask for help (`--help` or `-h`).
|
||||
fn parse(sub: &Subcommand, args: &[String]) -> Result<Option<Args>, String> {
|
||||
let mut a = Args { positional: Vec::new(), flags: Vec::new() };
|
||||
let unknown = |flag: &str| format!("unknown flag {flag} for {}", sub.name);
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let s = &args[i];
|
||||
while let Some(s) = args.get(i) {
|
||||
if s == "--help" || s == "-h" {
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some((k, v)) = s.split_once('=').filter(|(k, _)| k.starts_with("--")) {
|
||||
if sub.bare.contains(&k) {
|
||||
return Err(format!("{k} takes no value"));
|
||||
}
|
||||
if !sub.values.contains(&k) {
|
||||
return Err(unknown(k));
|
||||
}
|
||||
a.flags.push((k.to_owned(), Some(v.to_owned())));
|
||||
} else if VALUE_FLAGS.contains(&s.as_str()) {
|
||||
} else if sub.values.contains(&s.as_str()) {
|
||||
let v = args.get(i + 1).ok_or_else(|| format!("{s} needs a value"))?;
|
||||
a.flags.push((s.clone(), Some(v.clone())));
|
||||
i += 1;
|
||||
} else if s.starts_with("--") {
|
||||
} else if sub.bare.contains(&s.as_str()) {
|
||||
a.flags.push((s.clone(), None));
|
||||
} else if s.starts_with("--") {
|
||||
return Err(unknown(s));
|
||||
} else {
|
||||
a.positional.push(s.clone());
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Ok(a)
|
||||
Ok(Some(a))
|
||||
}
|
||||
|
||||
impl Args {
|
||||
@@ -98,22 +130,7 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
|
||||
let _ = write!(err, "{USAGE}");
|
||||
return 2;
|
||||
};
|
||||
let parsed = match parse(&args[1..]) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let _ = writeln!(err, "error: {e}\n\n{USAGE}");
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
let r = match cmd.as_str() {
|
||||
"convert" => convert(&parsed, out, err),
|
||||
"info" => info(&parsed, out),
|
||||
"run" => run_cmds(&parsed, out, err),
|
||||
"batch" => batch(&parsed, out, err),
|
||||
"commands" => commands(&parsed, out),
|
||||
"droplet" => droplet(&parsed, out, err),
|
||||
"mcp" => mcp(&parsed),
|
||||
"serve" => serve(&parsed, err),
|
||||
match cmd.as_str() {
|
||||
"-h" | "--help" | "help" => {
|
||||
let _ = write!(out, "{USAGE}");
|
||||
return 0;
|
||||
@@ -122,12 +139,25 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
|
||||
let _ = writeln!(out, "photocraft-cli {}", photocraft_engine::build_info::long_version());
|
||||
return 0;
|
||||
}
|
||||
other => {
|
||||
let _ = writeln!(err, "error: unknown command `{other}`\n\n{USAGE}");
|
||||
_ => {}
|
||||
}
|
||||
let Some(sub) = SUBCOMMANDS.iter().find(|s| s.name == cmd) else {
|
||||
let _ = writeln!(err, "error: unknown command `{cmd}`\n\n{USAGE}");
|
||||
return 2;
|
||||
};
|
||||
let parsed = match parse(sub, &args[1..]) {
|
||||
Ok(Some(p)) => p,
|
||||
// `<subcommand> --help`: usage, without reading inputs or starting a server.
|
||||
Ok(None) => {
|
||||
let _ = write!(out, "{USAGE}");
|
||||
return 0;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = writeln!(err, "error: {e}\n\n{USAGE}");
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
match r {
|
||||
match (sub.run)(&parsed, out, err) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
let _ = writeln!(err, "error: {e}");
|
||||
@@ -137,10 +167,11 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
|
||||
}
|
||||
|
||||
fn export_opts(a: &Args) -> Result<ExportOptions, String> {
|
||||
let mut o = ExportOptions::default();
|
||||
// TIFF output is flat unless asked: scripted conversions keep predictable sizes.
|
||||
let mut o = ExportOptions { tiff_layers: a.has("--tiff-layers"), ..Default::default() };
|
||||
if let Some(q) = a.get("--quality") {
|
||||
let q: u8 = q.parse().map_err(|_| format!("bad --quality `{q}`"))?;
|
||||
o.encode.jpeg_quality = q.clamp(1, 100);
|
||||
o.encode.jpeg_quality =
|
||||
q.parse().ok().filter(|q| (1..=100).contains(q)).ok_or_else(|| format!("bad --quality `{q}`: expected a whole number from 1 to 100"))?;
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
@@ -160,9 +191,10 @@ fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
let [input, output] = a.positional.as_slice() else {
|
||||
return Err("convert needs <in> <out>".into());
|
||||
};
|
||||
let opts = export_opts(a)?;
|
||||
let o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
|
||||
warn_all(err, &o.warnings);
|
||||
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &export_opts(a)?, None).map_err(|e| e.to_string())?;
|
||||
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?;
|
||||
warn_all(err, &ws);
|
||||
Ok(())
|
||||
}
|
||||
@@ -207,6 +239,7 @@ fn command_list(a: &Args) -> Result<Vec<(String, Value)>, String> {
|
||||
}
|
||||
|
||||
fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
let opts = export_opts(a)?;
|
||||
let mut h = Headless::trusted_local();
|
||||
match (a.positional.as_slice(), a.get("--new")) {
|
||||
([file], None) => {
|
||||
@@ -216,7 +249,7 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
}
|
||||
([], Some(new)) => {
|
||||
let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?;
|
||||
h.command_run("file.new", p).map_err(|e| e.to_string())?;
|
||||
h.command_run("file.new", p).map_err(|e| format!("--new: {e}"))?;
|
||||
}
|
||||
_ => return Err("run needs exactly one of <file> or --new <json>".into()),
|
||||
}
|
||||
@@ -229,29 +262,25 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
print_json(out, &json!({"command": id, "result": r}), true)?;
|
||||
}
|
||||
if let Some(o) = a.get("--out") {
|
||||
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &export_opts(a)?).map_err(|e| e.to_string())?;
|
||||
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?;
|
||||
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
|
||||
warn_all(err, &ws);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse an actions file: `[{"command": id, "params": {…}}]` or
|
||||
/// `{"actions": [...]}`; `"id"` is accepted for `"command"`.
|
||||
/// Parse an actions file: the steps of a recorded action or a droplet (`[id, params]` pairs,
|
||||
/// `{"command": id, "params": {…}}` objects or bare ids; `"id"` is accepted for `"command"`), as a
|
||||
/// list or wrapped in `{"actions": […]}`, `{"steps": […]}` or a droplet (#489).
|
||||
pub fn parse_actions(text: &str) -> Result<Vec<(String, Value)>, String> {
|
||||
let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?;
|
||||
let list = match &v {
|
||||
Value::Array(a) => a.clone(),
|
||||
Value::Object(m) => m.get("actions").and_then(Value::as_array).cloned().ok_or("actions JSON: expected an array or {\"actions\": [...]}")?,
|
||||
_ => return Err("actions JSON: expected an array".into()),
|
||||
};
|
||||
list.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, a)| {
|
||||
let id = a.get("command").or_else(|| a.get("id")).and_then(Value::as_str).ok_or_else(|| format!("action {i}: missing \"command\""))?;
|
||||
Ok((id.to_owned(), a.get("params").cloned().unwrap_or_else(|| json!({}))))
|
||||
})
|
||||
.collect()
|
||||
photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(&v), "batch --actions").map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a
|
||||
/// trailing separator, through a symbolic link).
|
||||
fn same_dir(a: &Path, b: &Path) -> bool {
|
||||
matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(a), Ok(b)) if a == b)
|
||||
}
|
||||
|
||||
fn is_input(p: &Path) -> bool {
|
||||
@@ -266,16 +295,29 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
let text = std::fs::read_to_string(actions_path).map_err(|e| format!("{actions_path}: {e}"))?;
|
||||
let actions = parse_actions(&text)?;
|
||||
let opts = export_opts(a)?;
|
||||
// Results are saved as `<out>/<stem>.<ext>`, so an `--out` that is the `--in` folder would
|
||||
// replace the originals (#492).
|
||||
if !a.has("--in-place") && same_dir(&in_dir, &out_dir) {
|
||||
return Err(format!(
|
||||
"--out {} is the --in folder, so the results would replace the originals: choose another --out folder, or pass --in-place to overwrite them",
|
||||
out_dir.display()
|
||||
));
|
||||
}
|
||||
std::fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?;
|
||||
let mut inputs: Vec<PathBuf> =
|
||||
std::fs::read_dir(&in_dir).map_err(|e| format!("{}: {e}", in_dir.display()))?.flatten().map(|e| e.path()).filter(|p| is_input(p)).collect();
|
||||
inputs.sort();
|
||||
let (mut ok, mut failed) = (0, 0);
|
||||
let mut written = photocraft_engine::file_cmds::OutputClaims::default();
|
||||
// `--format .jpg` names outputs `<stem>.jpg`, as `--format jpg` does (#490).
|
||||
let format = a.get("--format").map(|f| f.strip_prefix('.').unwrap_or(f));
|
||||
for input in &inputs {
|
||||
let ext = a.get("--format").map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into());
|
||||
let ext = format.map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into());
|
||||
let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let target = out_dir.join(format!("{stem}.{ext}"));
|
||||
let target_text = target.to_string_lossy();
|
||||
let r = (|| -> Result<Vec<String>, String> {
|
||||
written.check(&target_text)?;
|
||||
let mut h = Headless::trusted_local();
|
||||
h.open(input).map_err(|e| e.to_string())?;
|
||||
for (id, p) in &actions {
|
||||
@@ -287,6 +329,7 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
match r {
|
||||
Ok(ws) => {
|
||||
ok += 1;
|
||||
written.record(&target_text, &input.to_string_lossy());
|
||||
let _ = writeln!(out, "ok {} -> {}", input.display(), target.display());
|
||||
warn_all(err, &ws);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,63 @@ fn usage_and_unknown_command() {
|
||||
assert!(out.starts_with("photocraft-cli "));
|
||||
}
|
||||
|
||||
/// #423: `<subcommand> --help` and `-h` print usage and return at once (no inputs read, no files
|
||||
/// written, no server started), whatever else is on the line.
|
||||
#[test]
|
||||
fn every_subcommand_answers_help() {
|
||||
let d = tmp("help");
|
||||
for sub in ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"] {
|
||||
for help in ["--help", "-h"] {
|
||||
let o = bin().current_dir(&d).arg(sub).arg(help).arg("--out").arg("x.png").stdin(Stdio::null()).output().unwrap();
|
||||
let out = String::from_utf8_lossy(&o.stdout);
|
||||
assert_eq!(o.status.code(), Some(0), "{sub} {help}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(out.contains("USAGE") && out.contains("photocraft-cli batch"), "{sub} {help}: {out}");
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0, "help wrote nothing");
|
||||
}
|
||||
|
||||
/// #423: a flag the subcommand doesn't take is a usage error naming it, before any work.
|
||||
#[test]
|
||||
fn unknown_flags_are_usage_errors() {
|
||||
let d = tmp("flags");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
let actions = d.join("actions.json");
|
||||
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
|
||||
let out_dir = d.join("out");
|
||||
let fails = |cmd: &mut Command, flag: &str| {
|
||||
let o = cmd.output().unwrap();
|
||||
let err = String::from_utf8_lossy(&o.stderr);
|
||||
assert_eq!(o.status.code(), Some(2), "{err}");
|
||||
assert!(err.contains(&format!("unknown flag {flag}")), "{err}");
|
||||
};
|
||||
for typo in [&["--fromat", "jpg"][..], &["--fromat=jpg"][..]] {
|
||||
fails(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(typo), "--fromat");
|
||||
assert!(!out_dir.exists(), "nothing written");
|
||||
}
|
||||
let x = d.join("x.png");
|
||||
fails(bin().arg("run").arg(input.join("a.png")).args(["--cmd", "image.adjustments.invert", "--param", "{}", "--out"]).arg(&x), "--param");
|
||||
assert!(!x.exists());
|
||||
// A flag one subcommand takes is still unknown to another; a bare flag takes no value.
|
||||
fails(bin().args(["info", "--json"]).arg(input.join("a.png")), "--json");
|
||||
let o = bin().args(["commands", "--json=yes"]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(2));
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("--json takes no value"));
|
||||
// Every flag still works in both forms.
|
||||
let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"]));
|
||||
assert!(out.contains("filter.blur.gaussianBlur"), "{out}");
|
||||
ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([
|
||||
"--format=jpg",
|
||||
"--quality",
|
||||
"80",
|
||||
]));
|
||||
assert!(out_dir.join("a.jpg").exists());
|
||||
let (out, _) = ok(bin().arg("info").arg(input.join("a.png")).arg("--compact"));
|
||||
assert!(!out.trim().contains('\n'), "compact JSON is one line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_png_pcraft_png_is_lossless() {
|
||||
let d = tmp("convert");
|
||||
@@ -86,6 +143,37 @@ fn info_prints_layer_tree_json() {
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
/// #518, #523: a truncated JPEG and an animated GIF open, but `info` lists why the image is
|
||||
/// incomplete and `convert` prints it on stderr (and still succeeds); complete files stay quiet.
|
||||
#[test]
|
||||
fn info_and_convert_report_decode_warnings() {
|
||||
let d = tmp("decode-warnings");
|
||||
let noise: Vec<u8> = (0..64 * 48 * 3).map(|i: u32| (i.wrapping_mul(7919) % 251) as u8).collect();
|
||||
let img = photocraft_codecs::Image::from_u8(64, 48, photocraft_codecs::ChannelLayout::Rgb, noise).unwrap();
|
||||
let jpeg = photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).unwrap();
|
||||
// Three 1x1 frames.
|
||||
let mut gif = b"GIF89a\x01\0\x01\0\x80\0\0\0\0\0\xFF\xFF\xFF".to_vec();
|
||||
for _ in 0..3 {
|
||||
gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0");
|
||||
}
|
||||
gif.push(0x3B);
|
||||
let cases = [
|
||||
("whole.jpg", &jpeg[..], None),
|
||||
("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing")),
|
||||
("anim.gif", &gif[..], Some("only the first of 3 frames was imported")),
|
||||
];
|
||||
for (name, bytes, warning) in cases {
|
||||
let file = d.join(name);
|
||||
std::fs::write(&file, bytes).unwrap();
|
||||
let (out, _) = ok(bin().arg("info").arg(&file).arg("--compact"));
|
||||
let v: Value = serde_json::from_str(&out).unwrap();
|
||||
assert_eq!(v["warnings"], json!(warning.into_iter().collect::<Vec<_>>()), "{name}");
|
||||
let (_, err) = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png"))));
|
||||
assert_eq!(err.trim(), warning.map(|w| format!("warning: {w}")).unwrap_or_default(), "{name}");
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_commands_and_save() {
|
||||
let d = tmp("run");
|
||||
@@ -121,6 +209,15 @@ fn run_errors() {
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
let o = bin().args(["run", "--new", "{", "--cmd", "x"]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
// Params must be a JSON object, for `--new` and for each `--params`.
|
||||
let o = bin().args(["run", "--new", "[3]", "--cmd", "layer.new.layer"]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
let e = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(e.contains("--new") && e.contains("must be a JSON object"), "{e}");
|
||||
let o = bin().args(["run", "--new", "{}", "--cmd", "layer.new.layer", "--params", "\"x\""]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
let e = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(e.contains("layer.new.layer") && e.contains("must be a JSON object"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -155,6 +252,157 @@ fn parse_actions_formats() {
|
||||
assert!(photocraft_cli::parse_actions(r#"{"actions":[{"command":"c"}]}"#).unwrap().len() == 1);
|
||||
assert!(photocraft_cli::parse_actions(r#"[{"params":{}}]"#).is_err());
|
||||
assert!(photocraft_cli::parse_actions("7").is_err());
|
||||
// #489: the step shapes a recorded action and a droplet store, bare or wrapped.
|
||||
let want = vec![("a".to_string(), json!({"x":1})), ("b".to_string(), json!({}))];
|
||||
for text in [
|
||||
r#"[["a",{"x":1}],["b"]]"#,
|
||||
r#"[["a",{"x":1}],"b"]"#,
|
||||
r#"{"steps":[["a",{"x":1}],["b",{}]]}"#,
|
||||
r#"{"photocraftDroplet":1,"action":{"steps":[["a",{"x":1}],{"command":"b"}]}}"#,
|
||||
r#"{"actions":[["a",{"x":1}],["b",{}]]}"#,
|
||||
] {
|
||||
assert_eq!(photocraft_cli::parse_actions(text).unwrap(), want, "{text}");
|
||||
}
|
||||
for text in ["[42]", "[[]]", "[[7,{}]]", r#"{"name":"x"}"#] {
|
||||
assert!(photocraft_cli::parse_actions(text).is_err(), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `batch` on `input` with `actions` (saved as `<name>.json`), writing to the folder `<d>/<name>`.
|
||||
fn batch_with(d: &Path, input: &Path, name: &str, actions: &str, extra: &[&str]) -> std::process::Output {
|
||||
let file = d.join(format!("{name}.json"));
|
||||
std::fs::write(&file, actions).unwrap();
|
||||
bin().args(["batch", "--actions"]).arg(&file).arg("--in").arg(input).arg("--out").arg(d.join(name)).args(extra).output().unwrap()
|
||||
}
|
||||
|
||||
/// #489: `batch --actions` takes `[id, params]` steps and a droplet's steps, with the same results
|
||||
/// as the object form; a malformed step fails before anything is written.
|
||||
#[test]
|
||||
fn batch_accepts_recorded_action_steps() {
|
||||
let d = tmp("batch-steps");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
write_png(&input.join("b.png"), 6, 5, 9);
|
||||
let shapes = [
|
||||
("objects", r#"[{"command":"image.adjustments.invert"}]"#),
|
||||
("pairs", r#"[["image.adjustments.invert",{}]]"#),
|
||||
("ids", r#"["image.adjustments.invert"]"#),
|
||||
("droplet", r#"{"photocraftDroplet":1,"action":{"steps":[["image.adjustments.invert",{}]]}}"#),
|
||||
];
|
||||
for (name, actions) in shapes {
|
||||
let o = batch_with(&d, &input, name, actions, &[]);
|
||||
assert!(o.status.success(), "{name}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(String::from_utf8_lossy(&o.stdout).contains("2 succeeded, 0 failed"), "{name}");
|
||||
for f in ["a.png", "b.png"] {
|
||||
assert_eq!(std::fs::read(d.join(name).join(f)).unwrap(), std::fs::read(d.join("objects").join(f)).unwrap(), "{name}/{f}");
|
||||
}
|
||||
}
|
||||
let o = batch_with(&d, &input, "bad", "[42]", &[]);
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
assert!(!d.join("bad").exists(), "nothing written");
|
||||
}
|
||||
|
||||
/// #490: a leading dot on `--format` doesn't double the dot in output names.
|
||||
#[test]
|
||||
fn batch_format_with_leading_dot() {
|
||||
let d = tmp("batch-dot");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
write_png(&input.join("b.png"), 6, 5, 9);
|
||||
let invert = r#"[{"command":"image.adjustments.invert"}]"#;
|
||||
for (name, format) in [("dot", ".jpg"), ("plain", "jpg")] {
|
||||
let o = batch_with(&d, &input, name, invert, &["--format", format]);
|
||||
assert!(o.status.success(), "{format}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
let mut names: Vec<String> = std::fs::read_dir(d.join(name)).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["a.jpg", "b.jpg"], "{format}");
|
||||
assert_eq!(photocraft_codecs::detect(&std::fs::read(d.join(name).join("a.jpg")).unwrap()), Some(photocraft_codecs::Format::Jpeg));
|
||||
}
|
||||
}
|
||||
|
||||
/// #491: `--quality` outside 1-100 is an error for every subcommand, whatever the number's size,
|
||||
/// and nothing is written.
|
||||
#[test]
|
||||
fn quality_outside_1_to_100_is_refused() {
|
||||
let d = tmp("quality");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
let a = input.join("a.png");
|
||||
write_png(&a, 8, 4, 3);
|
||||
let invert = r#"[{"command":"image.adjustments.invert"}]"#;
|
||||
for q in ["0", "101", "255", "256", "1000", "-1", "abc", "50.5", ""] {
|
||||
let out = d.join("q.jpg");
|
||||
let (mut convert, mut run) = (bin(), bin());
|
||||
convert.arg("convert").arg(&a).arg(&out).args(["--quality", q]);
|
||||
run.arg("run").arg(&a).args(["--cmd", "image.adjustments.invert", "--out"]).arg(&out).args(["--quality", q]);
|
||||
for cmd in [&mut convert, &mut run] {
|
||||
let o = cmd.output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1), "--quality {q}");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --quality `{q}`: expected a whole number from 1 to 100")), "--quality {q}");
|
||||
assert!(!out.exists(), "--quality {q} wrote a file");
|
||||
}
|
||||
let o = batch_with(&d, &input, "qb", invert, &["--format", "jpg", "--quality", q]);
|
||||
assert_eq!(o.status.code(), Some(1), "batch --quality {q}");
|
||||
assert!(!d.join("qb").exists(), "batch --quality {q} wrote a folder");
|
||||
}
|
||||
// The documented range still encodes, lower quality making a smaller file.
|
||||
let sizes: Vec<usize> = ["1", "50", "100"]
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let out = d.join(format!("q{q}.jpg"));
|
||||
ok(bin().arg("convert").arg(&a).arg(&out).args(["--quality", q]));
|
||||
std::fs::read(out).unwrap().len()
|
||||
})
|
||||
.collect();
|
||||
assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}");
|
||||
}
|
||||
|
||||
/// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before
|
||||
/// anything is written, unless `--in-place` asks for it.
|
||||
#[test]
|
||||
fn batch_refuses_to_write_over_its_inputs() {
|
||||
let d = tmp("batch-in-place");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![90; 96]).unwrap();
|
||||
std::fs::write(input.join("a.tif"), photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &Default::default()).unwrap()).unwrap();
|
||||
let actions = d.join("actions.json");
|
||||
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
|
||||
let snapshot = || {
|
||||
let mut files: Vec<(PathBuf, Vec<u8>)> =
|
||||
std::fs::read_dir(&input).unwrap().map(|e| e.unwrap().path()).map(|p| (p.clone(), std::fs::read(p).unwrap())).collect();
|
||||
files.sort();
|
||||
files
|
||||
};
|
||||
let before = snapshot();
|
||||
let batch = |out: &Path, extra: &[&str]| {
|
||||
bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out"]).arg(out).args(extra).output().unwrap()
|
||||
};
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(&input, d.join("link")).unwrap();
|
||||
let link = cfg!(unix).then(|| PathBuf::from("link"));
|
||||
for out in [PathBuf::from("in"), PathBuf::from("./in/"), input.clone(), input.join(".")].iter().chain(&link) {
|
||||
for extra in [&[][..], &["--format", "tif"][..]] {
|
||||
let o = batch(out, extra);
|
||||
let err = String::from_utf8_lossy(&o.stderr);
|
||||
assert_eq!(o.status.code(), Some(1), "--out {} {extra:?}: {err}", out.display());
|
||||
assert!(err.contains("is the --in folder") && err.contains("--in-place"), "{err}");
|
||||
assert_eq!(snapshot(), before, "--out {} {extra:?} changed the originals", out.display());
|
||||
}
|
||||
}
|
||||
// A separate folder runs as before, leaving the originals alone.
|
||||
let (out, _) = ok(bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out", "out"]));
|
||||
assert!(out.contains("2 succeeded, 0 failed"), "{out}");
|
||||
assert_eq!(snapshot(), before);
|
||||
// `--in-place` opts in: each result replaces its original.
|
||||
let o = batch(Path::new("in"), &["--in-place"]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let after = snapshot();
|
||||
assert_eq!(after.len(), 2);
|
||||
assert!(after.iter().zip(&before).all(|(a, b)| a.0 == b.0 && a.1 != b.1), "both inputs replaced");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -218,6 +466,38 @@ fn mcp_stdio_handshake_and_tool_call() {
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// `--format` mapping two inputs to one name fails the second instead of overwriting the first (#420).
|
||||
#[test]
|
||||
fn batch_fails_inputs_that_share_an_output_name() {
|
||||
let d = tmp("batch-collide");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
// Same stem in other formats; `A.tif` differs only in case.
|
||||
let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![90; 96]).unwrap();
|
||||
for (name, format) in [("a.bmp", photocraft_codecs::Format::Bmp), ("A.tif", photocraft_codecs::Format::Tiff)] {
|
||||
std::fs::write(input.join(name), photocraft_codecs::encode(&img, format, &Default::default()).unwrap()).unwrap();
|
||||
}
|
||||
let actions = d.join("actions.json");
|
||||
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
|
||||
let out_dir = d.join("out");
|
||||
let o = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(["--format", "png"]).output().unwrap();
|
||||
let (out, err) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
|
||||
assert!(
|
||||
!o.status.success(),
|
||||
"a lost result must fail the run
|
||||
{out}
|
||||
{err}"
|
||||
);
|
||||
assert!(out.contains("1 succeeded, 2 failed"), "{out}");
|
||||
assert_eq!(err.matches("not written").count(), 2, "{err}");
|
||||
assert_eq!(std::fs::read_dir(&out_dir).unwrap().count(), 1);
|
||||
// Keeping each file's format, every input gets its own output.
|
||||
let out_dir = d.join("same");
|
||||
let (out, _) = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir));
|
||||
assert!(out.contains("3 succeeded, 0 failed"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn droplet_runs_an_action_on_files() {
|
||||
let d = tmp("droplet");
|
||||
@@ -237,3 +517,32 @@ fn droplet_runs_an_action_on_files() {
|
||||
let o = bin().arg("droplet").arg(d.join("a.png")).arg(d.join("b.png")).output().unwrap();
|
||||
assert!(!o.status.success(), "not a droplet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiff_output_is_flat_unless_tiff_layers_is_given() {
|
||||
let d = tmp("tiff-layers");
|
||||
let layer_count = |path: &std::path::Path| -> usize {
|
||||
let (info, _) = ok(bin().arg("info").arg(path));
|
||||
let v: Value = serde_json::from_str(&info).unwrap();
|
||||
v["layers"].as_array().unwrap().len()
|
||||
};
|
||||
for (name, flag, layers) in [("flat.tif", None, 1), ("layered.tif", Some("--tiff-layers"), 2)] {
|
||||
let out = d.join(name);
|
||||
let mut cmd = bin();
|
||||
cmd.args(["run", "--new", r#"{"width":16,"height":16}"#]).args(["--cmd", "layer.new.layer", "--params", r#"{"name":"Ink"}"#]);
|
||||
if let Some(flag) = flag {
|
||||
cmd.arg(flag);
|
||||
}
|
||||
ok(cmd.arg("--out").arg(&out));
|
||||
assert_eq!(layer_count(&out), layers, "{name}");
|
||||
}
|
||||
// convert honours the same flag.
|
||||
let src = d.join("layered.tif");
|
||||
let flat = d.join("converted.tif");
|
||||
ok(bin().arg("convert").arg(&src).arg(&flat));
|
||||
assert_eq!(layer_count(&flat), 1);
|
||||
let kept = d.join("kept.tif");
|
||||
ok(bin().arg("convert").arg(&src).arg(&kept).arg("--tiff-layers"));
|
||||
assert_eq!(layer_count(&kept), 2);
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! EXIF orientation end to end through the `photocraft-cli` binary (#285): a
|
||||
//! phone-style JPEG (stored landscape, Orientation = 6) converts to an upright
|
||||
//! portrait, an explicit rotation turns it back, and a `.pcraft` round trip
|
||||
//! changes nothing.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use photocraft_codecs::{ChannelLayout, DecodeOptions, EncodeOptions, Format, Image, SampleType, decode, decode_with, exif_orientation};
|
||||
use serde_json::Value;
|
||||
|
||||
fn bin() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_photocraft-cli"))
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("pc-cli-orient-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn ok(cmd: &mut Command) -> String {
|
||||
let o = cmd.output().unwrap();
|
||||
let (out, err) = (String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned());
|
||||
assert!(o.status.success(), "exit {:?}\nstdout: {out}\nstderr: {err}", o.status);
|
||||
out
|
||||
}
|
||||
|
||||
/// EXIF block: Orientation = `o` (SHORT) and an inline Software tag, little-endian.
|
||||
fn exif(o: u16) -> Vec<u8> {
|
||||
let mut v = b"II*\0\x08\0\0\0\x02\0".to_vec();
|
||||
v.extend_from_slice(&[0x12, 0x01, 3, 0, 1, 0, 0, 0]);
|
||||
v.extend_from_slice(&o.to_le_bytes());
|
||||
v.extend_from_slice(&[0, 0]);
|
||||
v.extend_from_slice(&[0x31, 0x01, 2, 0, 4, 0, 0, 0]);
|
||||
v.extend_from_slice(b"PC1\0");
|
||||
v.extend_from_slice(&[0, 0, 0, 0]);
|
||||
v
|
||||
}
|
||||
|
||||
/// Upright 16×32 portrait: red top-left quarter, green top-right, blue bottom half.
|
||||
fn upright_color(x: usize, y: usize) -> [u8; 3] {
|
||||
match (x < 8, y < 16) {
|
||||
(true, true) => [230, 20, 20],
|
||||
(false, true) => [20, 230, 20],
|
||||
_ => [20, 20, 230],
|
||||
}
|
||||
}
|
||||
|
||||
/// The portrait as a camera stores it for Orientation = 6: 32×16, with EXIF.
|
||||
fn phone_jpeg() -> Vec<u8> {
|
||||
let (w, h) = (16, 32);
|
||||
let mut px = vec![0u8; w * h * 3];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// Upright (x, y) sits at stored (y, w - 1 - x) in a 32-wide image.
|
||||
let (sx, sy) = (y, w - 1 - x);
|
||||
px[(sy * h + sx) * 3..][..3].copy_from_slice(&upright_color(x, y));
|
||||
}
|
||||
}
|
||||
let stored = Image::from_u8(h as u32, w as u32, ChannelLayout::Rgb, px).unwrap();
|
||||
let jpeg =
|
||||
photocraft_codecs::encode(&stored, Format::Jpeg, &EncodeOptions { jpeg_quality: 100, jpeg_chroma_subsampling: false, ..Default::default() }).unwrap();
|
||||
let mut seg = b"Exif\0\0".to_vec();
|
||||
seg.extend_from_slice(&exif(6));
|
||||
let mut out = jpeg[..2].to_vec();
|
||||
out.extend_from_slice(&[0xFF, 0xE1]);
|
||||
out.extend_from_slice(&((seg.len() + 2) as u16).to_be_bytes());
|
||||
out.extend_from_slice(&seg);
|
||||
out.extend_from_slice(&jpeg[2..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// The file as stored (no orientation applied), so its own dimensions and tag can be checked.
|
||||
fn stored(path: &Path) -> Image {
|
||||
decode_with(&std::fs::read(path).unwrap(), &DecodeOptions { keep_orientation: true, ..Default::default() }).unwrap()
|
||||
}
|
||||
|
||||
fn assert_portrait_pixels(img: &Image) {
|
||||
let img = img.convert(ChannelLayout::Rgb, SampleType::U8);
|
||||
for (x, y) in [(3, 4), (12, 4), (3, 28), (12, 28)] {
|
||||
let i = (y * 16 + x) * 3;
|
||||
let got = &img.data()[i..i + 3];
|
||||
let want = upright_color(x, y);
|
||||
assert!(got.iter().zip(want).all(|(a, b)| (i32::from(*a) - i32::from(b)).abs() <= 12), "({x},{y}): {got:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_6_jpeg_converts_upright_and_rotates_back() {
|
||||
let d = tmp("e2e");
|
||||
let jpg = d.join("IMG_0006.JPG");
|
||||
std::fs::write(&jpg, phone_jpeg()).unwrap();
|
||||
|
||||
// convert → PNG: the stored pixels are already upright, and any EXIF says 1.
|
||||
let png = d.join("upright.png");
|
||||
ok(bin().arg("convert").arg(&jpg).arg(&png));
|
||||
let raw = stored(&png);
|
||||
assert_eq!(raw.dimensions(), (16, 32), "stored upright, not landscape");
|
||||
assert_portrait_pixels(&raw);
|
||||
if let Some(e) = raw.meta.exif.as_deref() {
|
||||
assert_eq!(exif_orientation(e), 1, "written EXIF orientation");
|
||||
}
|
||||
assert_eq!(exif_orientation(&std::fs::read(&png).unwrap()), 1);
|
||||
assert_eq!(decode(&std::fs::read(&png).unwrap()).unwrap().dimensions(), (16, 32), "no second turn on reopen");
|
||||
|
||||
// An explicit Image › Image Rotation › 90° Clockwise makes it landscape again.
|
||||
let turned = d.join("turned.png");
|
||||
ok(bin().arg("run").arg(&jpg).args(["--cmd", "image.imageRotation.90cw", "--out"]).arg(&turned));
|
||||
let t = stored(&turned);
|
||||
assert_eq!(t.dimensions(), (32, 16));
|
||||
if let Some(e) = t.meta.exif.as_deref() {
|
||||
assert_eq!(exif_orientation(e), 1);
|
||||
}
|
||||
// The portrait's top-left (red) is now top-right; its bottom (blue) is on the left.
|
||||
let t = t.convert(ChannelLayout::Rgb, SampleType::U8);
|
||||
let at = |x: usize, y: usize| <[u8; 3]>::try_from(&t.data()[(y * 32 + x) * 3..][..3]).unwrap();
|
||||
let near = |a: [u8; 3], b: [u8; 3]| a.iter().zip(b).all(|(a, b)| (i32::from(*a) - i32::from(b)).abs() <= 12);
|
||||
assert!(near(at(28, 3), upright_color(3, 4)), "{:?}", at(28, 3));
|
||||
assert!(near(at(28, 12), upright_color(12, 4)), "{:?}", at(28, 12));
|
||||
assert!(near(at(4, 8), upright_color(8, 28)), "{:?}", at(4, 8));
|
||||
|
||||
// .pcraft save, reopen and save again: identical document and pixels.
|
||||
let (a, b) = (d.join("a.pcraft"), d.join("b.pcraft"));
|
||||
ok(bin().arg("convert").arg(&jpg).arg(&a));
|
||||
ok(bin().arg("convert").arg(&a).arg(&b));
|
||||
// Everything but the file's own path must match.
|
||||
let info = |p: &Path| {
|
||||
let mut v = serde_json::from_str::<Value>(&ok(bin().arg("info").arg(p))).unwrap();
|
||||
v.as_object_mut().unwrap().remove("file");
|
||||
v
|
||||
};
|
||||
let (ia, ib) = (info(&a), info(&b));
|
||||
assert_eq!((ia["width"].as_u64(), ia["height"].as_u64()), (Some(16), Some(32)));
|
||||
assert_eq!(ia, ib);
|
||||
let (pa, pb) = (d.join("a.png"), d.join("b.png"));
|
||||
ok(bin().arg("convert").arg(&a).arg(&pa));
|
||||
ok(bin().arg("convert").arg(&b).arg(&pb));
|
||||
let (xa, xb) = (stored(&pa), stored(&pb));
|
||||
assert_eq!(xa.dimensions(), (16, 32));
|
||||
assert_eq!(xa.to_rgba8(), xb.to_rgba8());
|
||||
assert_eq!(xa.meta.exif, xb.meta.exif);
|
||||
assert_eq!(xa.to_rgba8(), stored(&png).to_rgba8(), "the .pcraft round trip matches the direct conversion");
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
@@ -6,6 +6,11 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose
|
||||
# it; official builds (CI, packaging/, release) build with `--features heif`.
|
||||
heif = ["photocraft-codecs/heif"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
||||
@@ -4,16 +4,27 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>PhotoCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="photocraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-cargo-features="heif" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
|
||||
#photocraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#photocraft_loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
#photocraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 0 16px; text-align: center; color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
#photocraft_loading p { margin: 0 0 8px; max-width: 420px; line-height: 1.5; }
|
||||
#photocraft_loading a { color: #e0e0e0; pointer-events: auto; }
|
||||
/* The app removes this element once it runs. If the WebAssembly never downloads or doesn't match
|
||||
this page, no Rust runs to say so, so the page itself shows what to do after a while. */
|
||||
#photocraft_stalled { color: #8c8c8c; visibility: hidden; animation: photocraft-stalled 0s 20s forwards; }
|
||||
@keyframes photocraft-stalled { to { visibility: visible; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="photocraft_canvas"></canvas>
|
||||
<div id="photocraft_loading">Loading PhotoCraft…</div>
|
||||
<div id="photocraft_loading">
|
||||
<p>Loading PhotoCraft…</p>
|
||||
<p id="photocraft_stalled">This is taking longer than usual. If PhotoCraft doesn't start, its files may have failed to
|
||||
download or come from a different version of this page. <a href="">Reload</a> to try again; the browser
|
||||
console lists what failed.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -28,3 +28,18 @@ fn main() {
|
||||
fn main() {
|
||||
eprintln!("photocraft-web only runs in the browser: build it with `trunk build --release` in apps/photocraft-web");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// A WebAssembly module that never downloads (or doesn't match the page) runs no Rust, so the
|
||||
/// page itself replaces the endless "Loading" with what may have gone wrong and a way to
|
||||
/// retry, without a hand-written script.
|
||||
#[test]
|
||||
fn loading_page_offers_a_reload_when_startup_stalls() {
|
||||
let html = include_str!("../index.html");
|
||||
let (_, hint) = html.split_once("<p id=\"photocraft_stalled\">").expect("stalled-startup hint in the loading element");
|
||||
assert!(hint.contains("failed to") && hint.contains("<a href=\"\">Reload</a>"), "{hint}");
|
||||
assert!(html.contains("animation: photocraft-stalled"), "the hint appears after a delay");
|
||||
assert!(!html.contains("<script"), "trunk injects the generated loader; the page has no script of its own");
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,8 @@ type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop
|
||||
/// brushes (.abr) and gradients (.grd), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm",
|
||||
"dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
|
||||
"pcraft", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam",
|
||||
"pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
|
||||
];
|
||||
const CANVAS_ID: &str = "photocraft_canvas";
|
||||
|
||||
@@ -69,7 +69,7 @@ pub fn start() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Pen pressure, tilt and twist from Pointer Events (eframe forwards none of them for pens) into
|
||||
/// Pen pressure, tilt, twist and the eraser button from Pointer Events (eframe forwards none of them for pens) into
|
||||
/// the app's stylus feed. The sample is kept through `pointerup` so the stroke's last points keep
|
||||
/// their pressure; hovering, a mouse, or leaving the canvas clears it.
|
||||
fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::stylus::StylusFeed) {
|
||||
@@ -83,7 +83,15 @@ fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::sty
|
||||
return;
|
||||
}
|
||||
let pen = e.pointer_type() == "pen" && e.buttons() != 0 && ty != "pointercancel" && ty != "pointerleave";
|
||||
feed.set(pen.then(|| PenSample { pressure: e.pressure(), tilt_x: e.tilt_x() as f32, tilt_y: e.tilt_y() as f32, rotation: e.twist() as f32 }));
|
||||
// W3C Pointer Events: `buttons` bit 5 (32) is the pen's eraser.
|
||||
let eraser = e.buttons() & 32 != 0;
|
||||
feed.set(pen.then(|| PenSample {
|
||||
pressure: e.pressure(),
|
||||
tilt_x: e.tilt_x() as f32,
|
||||
tilt_y: e.tilt_y() as f32,
|
||||
rotation: e.twist() as f32,
|
||||
eraser,
|
||||
}));
|
||||
});
|
||||
if target.add_event_listener_with_callback(kind, cb.as_ref().unchecked_ref()).is_ok() {
|
||||
cb.forget();
|
||||
@@ -136,6 +144,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
opts.tiff_layers = settings.tiff_layers;
|
||||
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
|
||||
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
|
||||
})),
|
||||
pick_open: Some(Box::new(move || {
|
||||
|
||||
@@ -7,6 +7,11 @@ default-run = "photocraft"
|
||||
description = "Photocraft: an open-source, native image editor"
|
||||
build = "build.rs"
|
||||
|
||||
[features]
|
||||
# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose
|
||||
# it; official builds (CI, packaging/, release) build with `--features heif`.
|
||||
heif = ["photocraft-codecs/heif"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -22,21 +27,40 @@ photocraft-io = { workspace = true }
|
||||
photocraft-format = { workspace = true }
|
||||
photocraft-automation = { workspace = true }
|
||||
photocraft-ui-egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
# Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate).
|
||||
photocraft-tablet = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd = { workspace = true }
|
||||
arboard = "3"
|
||||
arboard = { version = "3.6", features = ["wayland-data-control"] }
|
||||
open = "5"
|
||||
# Reads the saved window layout (eframe persistence) to drop values that would abort startup.
|
||||
ron = "0.12"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Finder double-click / Open With / Dock drops arrive as Apple events; this small audited crate
|
||||
# registers the handler (Objective-C interop needs unsafe, which our workspace forbids) behind a
|
||||
# safe API. Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
# Safe AppKit calls only: turn off AppKit's title bar dragging (mac_window.rs) and find where the
|
||||
# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position). Already in
|
||||
# Cargo.lock (winit, photocraft-tablet); pinned exactly to the same versions.
|
||||
objc2 = "=0.6.4"
|
||||
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSGeometry"] }
|
||||
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSResponder", "NSView", "NSWindow"] }
|
||||
|
||||
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
x11rb = { version = "=0.13.2", default-features = false }
|
||||
|
||||
# Drop position on Windows (`cursor.rs`): safe Win32 wrappers (GetCursorPos), no dependencies of
|
||||
# its own. Pinned exactly: review the diff before bumping.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winsafe = { version = "=0.0.29", default-features = false, features = ["user"] }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for photocraft.exe; build.rs only uses it when targeting Windows.
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Where the desktop app keeps its per-user data: preferences, brush presets, crash-recovery
|
||||
//! autosaves and the GPU startup marker all live under the one directory [`config_dir`] returns.
|
||||
//!
|
||||
//! In order:
|
||||
//! 1. `PHOTOCRAFT_CONFIG_DIR`, when set (tests, agents, custom setups).
|
||||
//! 2. **Portable mode** (#228): a marker file ([`PORTABLE_MARKERS`]) beside the executable puts
|
||||
//! everything in `<exe dir>/PhotoCraftData`, so a portable copy on a USB stick leaves nothing in
|
||||
//! `%APPDATA%`. The Windows portable zip ships with `portable.txt`; the MSI doesn't. If that
|
||||
//! folder can't be written (read-only medium, Program Files), the app warns and falls back.
|
||||
//! 3. The platform convention: macOS `~/Library/Application Support/Photocraft`, Windows
|
||||
//! `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Files beside the executable that switch on portable mode (either one; contents are ignored).
|
||||
pub const PORTABLE_MARKERS: [&str; 2] = ["portable.txt", "PhotoCraft.portable"];
|
||||
/// The data folder created beside the executable in portable mode.
|
||||
pub const PORTABLE_DATA_DIR: &str = "PhotoCraftData";
|
||||
|
||||
/// How [`DataDir::dir`] was chosen.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
/// `PHOTOCRAFT_CONFIG_DIR`.
|
||||
Override,
|
||||
/// `<exe dir>/PhotoCraftData`.
|
||||
Portable,
|
||||
/// The platform's per-user config directory.
|
||||
Platform,
|
||||
}
|
||||
|
||||
/// The resolved data directory.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DataDir {
|
||||
/// `None` only when no platform directory is known either (no `HOME`/`APPDATA`).
|
||||
pub dir: Option<PathBuf>,
|
||||
pub mode: Mode,
|
||||
/// Set when a portable marker was found but its data folder isn't writable.
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
/// The data directory for this process, resolved once (the portable check probes the disk).
|
||||
pub fn current() -> &'static DataDir {
|
||||
static DIR: OnceLock<DataDir> = OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
let exe = std::env::current_exe().ok();
|
||||
let d = resolve(|k| std::env::var_os(k), exe.as_deref().and_then(Path::parent));
|
||||
if let Some(w) = &d.warning {
|
||||
log::warn!("{w}");
|
||||
eprintln!("photocraft: {w}");
|
||||
}
|
||||
log::info!("settings directory ({:?}): {:?}", d.mode, d.dir);
|
||||
d
|
||||
})
|
||||
}
|
||||
|
||||
/// The per-user settings directory (see the module docs).
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
current().dir.clone()
|
||||
}
|
||||
|
||||
/// Resolve the data directory from the environment (`env`) and the executable's folder.
|
||||
pub fn resolve(env: impl Fn(&str) -> Option<OsString>, exe_dir: Option<&Path>) -> DataDir {
|
||||
if let Some(d) = env("PHOTOCRAFT_CONFIG_DIR").filter(|d| !d.is_empty()) {
|
||||
return DataDir { dir: Some(PathBuf::from(d)), mode: Mode::Override, warning: None };
|
||||
}
|
||||
let platform = platform_dir(&env);
|
||||
let Some((exe_dir, marker)) = exe_dir.and_then(|d| portable_marker(d).map(|m| (d, m))) else {
|
||||
return DataDir { dir: platform, mode: Mode::Platform, warning: None };
|
||||
};
|
||||
let data = exe_dir.join(PORTABLE_DATA_DIR);
|
||||
match ensure_writable(&data) {
|
||||
Ok(()) => DataDir { dir: Some(data), mode: Mode::Portable, warning: None },
|
||||
Err(e) => {
|
||||
let fallback = platform.as_ref().map_or_else(|| "nowhere (no user folder found)".to_string(), |p| p.display().to_string());
|
||||
let warning =
|
||||
format!("portable mode ({} found) but {} isn't writable ({e}); settings are kept in {fallback} instead", marker.display(), data.display());
|
||||
DataDir { dir: platform, mode: Mode::Platform, warning: Some(warning) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The platform convention, from `env` (so tests can fake `HOME`/`APPDATA`/`XDG_CONFIG_HOME`).
|
||||
pub fn platform_dir(env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
let home = env("HOME").filter(|h| !h.is_empty()).map(PathBuf::from);
|
||||
if cfg!(target_os = "macos") {
|
||||
return home.map(|h| h.join("Library/Application Support/Photocraft"));
|
||||
}
|
||||
if cfg!(windows) {
|
||||
return env("APPDATA").filter(|a| !a.is_empty()).map(|a| PathBuf::from(a).join("Photocraft"));
|
||||
}
|
||||
env("XDG_CONFIG_HOME").filter(|x| !x.is_empty()).map(PathBuf::from).or_else(|| home.map(|h| h.join(".config"))).map(|c| c.join("photocraft"))
|
||||
}
|
||||
|
||||
/// The first portable marker file in `exe_dir`, if any.
|
||||
pub fn portable_marker(exe_dir: &Path) -> Option<PathBuf> {
|
||||
PORTABLE_MARKERS.iter().map(|m| exe_dir.join(m)).find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only).
|
||||
fn ensure_writable(dir: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let probe = dir.join(format!(".write-test-{}", std::process::id()));
|
||||
std::fs::write(&probe, b"")?;
|
||||
// A leftover probe is harmless; don't fail portable mode over it.
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn temp(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("photocraft-app-dirs-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn env_of(pairs: &[(&str, &Path)]) -> impl Fn(&str) -> Option<OsString> + use<> {
|
||||
let m: HashMap<String, OsString> = pairs.iter().map(|(k, v)| (k.to_string(), v.as_os_str().to_owned())).collect();
|
||||
move |k| m.get(k).cloned()
|
||||
}
|
||||
|
||||
/// A fake user folder that every platform's convention resolves under.
|
||||
fn fake_home(root: &Path) -> impl Fn(&str) -> Option<OsString> + use<> {
|
||||
let home = root.join("home");
|
||||
let appdata = root.join("appdata");
|
||||
env_of(&[("HOME", &home), ("APPDATA", &appdata)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_marker_uses_the_platform_dir() {
|
||||
let root = temp("platform");
|
||||
let exe = root.join("bin");
|
||||
std::fs::create_dir_all(&exe).unwrap();
|
||||
let env = fake_home(&root);
|
||||
let d = resolve(&env, Some(&exe));
|
||||
assert_eq!(d.mode, Mode::Platform);
|
||||
assert!(d.warning.is_none());
|
||||
let dir = d.dir.unwrap();
|
||||
assert_eq!(Some(dir.clone()), platform_dir(&env));
|
||||
assert!(!dir.starts_with(&exe));
|
||||
assert!(!exe.join(PORTABLE_DATA_DIR).exists(), "no portable folder without a marker");
|
||||
// The convention per platform.
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(dir, root.join("home/Library/Application Support/Photocraft"));
|
||||
} else if cfg!(windows) {
|
||||
assert_eq!(dir, root.join("appdata").join("Photocraft"));
|
||||
} else {
|
||||
assert_eq!(dir, root.join("home/.config/photocraft"));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn either_marker_keeps_everything_beside_the_exe() {
|
||||
for marker in PORTABLE_MARKERS {
|
||||
let root = temp("portable");
|
||||
let exe = root.join("PhotoCraft");
|
||||
std::fs::create_dir_all(&exe).unwrap();
|
||||
std::fs::write(exe.join(marker), b"").unwrap();
|
||||
let d = resolve(fake_home(&root), Some(&exe));
|
||||
assert_eq!(d.mode, Mode::Portable, "{marker}");
|
||||
assert!(d.warning.is_none());
|
||||
let dir = d.dir.unwrap();
|
||||
assert_eq!(dir, exe.join(PORTABLE_DATA_DIR));
|
||||
assert!(dir.is_dir(), "the data folder is created");
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the write probe is removed");
|
||||
// Every derived path sits under the exe dir.
|
||||
for sub in ["preferences.json", "Presets", "Recovery", crate::gpu_startup::MARKER_FILE] {
|
||||
assert!(dir.join(sub).starts_with(&exe));
|
||||
}
|
||||
assert!(!root.join("home").exists() && !root.join("appdata").exists(), "nothing written to the user folder");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_marker_folder_is_not_a_marker() {
|
||||
let root = temp("dir-marker");
|
||||
let exe = root.join("bin");
|
||||
std::fs::create_dir_all(exe.join("portable.txt")).unwrap();
|
||||
assert_eq!(resolve(fake_home(&root), Some(&exe)).mode, Mode::Platform);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwritable_portable_folder_falls_back_with_a_warning() {
|
||||
let root = temp("unwritable");
|
||||
let exe = root.join("bin");
|
||||
std::fs::create_dir_all(&exe).unwrap();
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
// A file where the data folder should go: it can't be created, on every platform.
|
||||
std::fs::write(exe.join(PORTABLE_DATA_DIR), b"in the way").unwrap();
|
||||
let env = fake_home(&root);
|
||||
let d = resolve(&env, Some(&exe));
|
||||
assert_eq!(d.mode, Mode::Platform);
|
||||
assert_eq!(d.dir, platform_dir(&env));
|
||||
let w = d.warning.unwrap();
|
||||
assert!(w.contains("portable") && w.contains("isn't writable"), "{w}");
|
||||
// No user folder at all: still no panic, just no directory.
|
||||
let d = resolve(|_| None, Some(&exe));
|
||||
assert!(d.dir.is_none() && d.warning.is_some());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_wins_over_portable() {
|
||||
let root = temp("override");
|
||||
let exe = root.join("bin");
|
||||
std::fs::create_dir_all(&exe).unwrap();
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
let custom = root.join("custom");
|
||||
let d = resolve(env_of(&[("PHOTOCRAFT_CONFIG_DIR", &custom)]), Some(&exe));
|
||||
assert_eq!((d.mode, d.dir), (Mode::Override, Some(custom)));
|
||||
assert!(!exe.join(PORTABLE_DATA_DIR).exists());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_exe_dir_or_env_never_panics() {
|
||||
let d = resolve(|_| None, None);
|
||||
assert_eq!((d.mode, d.dir), (Mode::Platform, None));
|
||||
let empty = PathBuf::new();
|
||||
let d = resolve(env_of(&[("PHOTOCRAFT_CONFIG_DIR", &empty), ("HOME", &empty), ("APPDATA", &empty)]), None);
|
||||
assert_eq!(d.dir, None, "empty variables are ignored");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! The app icon, everywhere Windows, macOS and Linux show one (#248).
|
||||
//!
|
||||
//! - **Executable:** `build.rs` embeds `assets/app-icon/photocraft.ico` (16–256 px) as icon
|
||||
//! resource 1, which Explorer, Alt+Tab and an unpinned taskbar button fall back to.
|
||||
//! - **Window:** [`window_icon`] sets the title-bar and taskbar icon at runtime (`WM_SETICON` on
|
||||
//! Windows, `_NET_WM_ICON` on X11, the Dock when running unbundled on macOS).
|
||||
//! - **Start Menu shortcut (MSI):** the taskbar matches a running window to the shortcut that
|
||||
//! launches its executable and shows that shortcut's icon. Windows Installer caches an advertised
|
||||
//! shortcut's icon under its `<Icon Id>`, so that id must carry the target's `.exe` extension
|
||||
//! (ICE50); an extensionless id showed a blank page on the taskbar. The tests below check it.
|
||||
//!
|
||||
//! The process keeps Windows' implicit AppUserModelID (derived from the executable path), which
|
||||
//! is what makes that matching work for both the MSI shortcut and the portable exe. An explicit
|
||||
//! ID would need a Win32 call (`unsafe`, which the workspace forbids) and would then also have to
|
||||
//! be stamped on the shortcut, or the taskbar would stop matching the two.
|
||||
|
||||
/// Window, taskbar and (when running unbundled) Dock icon. macOS gets the padded 1024 px render
|
||||
/// on Apple's icon grid; elsewhere the tighter 256 px hicolor render reads better at small sizes.
|
||||
pub fn window_icon() -> egui::IconData {
|
||||
match eframe::icon_data::from_png_bytes(PNG) {
|
||||
Ok(icon) => icon,
|
||||
Err(e) => {
|
||||
log::warn!("app icon: {e}");
|
||||
egui::IconData::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png");
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ICO: &[u8] = include_bytes!("../../../assets/app-icon/photocraft.ico");
|
||||
const WXS: &str = include_str!("../../../packaging/windows/photocraft.wxs");
|
||||
|
||||
fn u16_at(b: &[u8], i: usize) -> usize {
|
||||
u16::from_le_bytes([b[i], b[i + 1]]) as usize
|
||||
}
|
||||
fn u32_at(b: &[u8], i: usize) -> usize {
|
||||
u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_icon_decodes() {
|
||||
let icon = window_icon();
|
||||
assert!(icon.width >= 256 && icon.width == icon.height, "{}x{}", icon.width, icon.height);
|
||||
assert_eq!(icon.rgba.len(), icon.width as usize * icon.height as usize * 4);
|
||||
assert!(icon.rgba.chunks(4).any(|p| p[3] > 0), "the icon isn't blank");
|
||||
}
|
||||
|
||||
/// The `.ico` build.rs embeds: every size Windows asks for, each a valid PNG or BMP image.
|
||||
#[test]
|
||||
fn exe_icon_has_every_shell_size() {
|
||||
assert_eq!((u16_at(ICO, 0), u16_at(ICO, 2)), (0, 1), "ICONDIR header, type 1 (icon)");
|
||||
let n = u16_at(ICO, 4);
|
||||
let mut sizes = Vec::new();
|
||||
for i in 0..n {
|
||||
let e = 6 + i * 16;
|
||||
let w = if ICO[e] == 0 { 256 } else { ICO[e] as usize };
|
||||
let h = if ICO[e + 1] == 0 { 256 } else { ICO[e + 1] as usize };
|
||||
assert_eq!(w, h);
|
||||
let (len, off) = (u32_at(ICO, e + 8), u32_at(ICO, e + 12));
|
||||
let data = ICO.get(off..off + len).expect("image data inside the file");
|
||||
let png = data.starts_with(b"\x89PNG\r\n\x1a\n");
|
||||
let bmp = data.len() >= 40 && u32_at(data, 0) == 40;
|
||||
assert!(png || bmp, "{w}px entry is neither PNG nor BMP");
|
||||
if png {
|
||||
// IHDR width/height match the directory entry.
|
||||
let pw = u32::from_be_bytes([data[16], data[17], data[18], data[19]]) as usize;
|
||||
assert_eq!(pw, w, "{w}px entry holds a {pw}px PNG");
|
||||
}
|
||||
sizes.push(w);
|
||||
}
|
||||
for want in [16, 24, 32, 48, 64, 256] {
|
||||
assert!(sizes.contains(&want), "photocraft.ico lacks {want}px (has {sizes:?})");
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of `attr="…"` inside `element`.
|
||||
fn attr<'a>(element: &'a str, attr: &str) -> Option<&'a str> {
|
||||
let key = format!(" {attr}=\"");
|
||||
let start = element.find(&key)? + key.len();
|
||||
element[start..].split('"').next()
|
||||
}
|
||||
|
||||
/// Each `<tag …>` element's text up to its closing `>`.
|
||||
fn elements<'a>(xml: &'a str, tag: &str) -> Vec<&'a str> {
|
||||
let open = format!("<{tag} ");
|
||||
xml.match_indices(&open).map(|(i, _)| &xml[i..i + xml[i..].find('>').unwrap()]).collect()
|
||||
}
|
||||
|
||||
/// ICE50 (#248): advertised shortcut icons are cached under their `<Icon Id>`; without the
|
||||
/// target's extension the shell shows a blank page on the Start Menu and the taskbar.
|
||||
#[test]
|
||||
fn msi_shortcut_icons_keep_the_exe_extension() {
|
||||
let icons: Vec<&str> = elements(WXS, "Icon").iter().filter_map(|e| attr(e, "Id")).collect();
|
||||
assert!(!icons.is_empty());
|
||||
for id in &icons {
|
||||
assert!(id.ends_with(".exe") || id.ends_with(".ico"), "<Icon Id=\"{id}\"> needs an .exe or .ico extension");
|
||||
}
|
||||
let shortcuts = elements(WXS, "Shortcut");
|
||||
assert!(!shortcuts.is_empty());
|
||||
for s in shortcuts {
|
||||
let icon = attr(s, "Icon").expect("shortcuts name their icon");
|
||||
assert!(icons.contains(&icon), "shortcut icon {icon} isn't an <Icon>");
|
||||
if attr(s, "Advertise") == Some("yes") {
|
||||
// Every shortcut in the MSI targets photocraft.exe.
|
||||
assert!(icon.ends_with(".exe"), "advertised shortcut icon {icon} must end in .exe like its target (ICE50)");
|
||||
}
|
||||
}
|
||||
let arp = WXS.split("Id=\"ARPPRODUCTICON\" Value=\"").nth(1).and_then(|r| r.split('"').next()).expect("ARPPRODUCTICON");
|
||||
assert!(icons.contains(&arp), "ARPPRODUCTICON {arp} isn't an <Icon>");
|
||||
// The Start Menu shortcut must not set its own AppUserModelID: the process keeps the
|
||||
// implicit one, and a mismatch would split the taskbar button from the shortcut.
|
||||
assert!(!WXS.contains("System.AppUserModel.ID"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Where the pointer is, read from the OS, so a file drop can tell the canvas (place as a layer)
|
||||
//! from the tab strip (open at that tab position) and the panels (open as a document), and the tab
|
||||
//! strip can show where a dragged file would go. winit 0.30's file drops carry no position, and
|
||||
//! during an OS drag the window gets no pointer events, so egui's last pointer is stale.
|
||||
//!
|
||||
//! - **macOS**: `NSEvent.mouseLocation`, converted into the content view of our window under it.
|
||||
//! - **X11**: `QueryPointer` on our window (a second, pure-Rust X connection).
|
||||
//! - **Windows**: `GetCursorPos`, relative to the window's client area.
|
||||
//! - **Wayland**: winit 0.30 delivers no file drops there, so there's nothing to place.
|
||||
//!
|
||||
//! No `unsafe`: every OS call goes through a safe binding.
|
||||
//!
|
||||
//! [`service`] builds the pointer-position service for the app's window, `None` where it can't
|
||||
//! be read.
|
||||
|
||||
pub use platform::service;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod platform {
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSApplication, NSEvent, NSWindow};
|
||||
use photocraft_ui_egui::CursorPosFn;
|
||||
|
||||
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
|
||||
Some(Box::new(|ctx: &egui::Context| in_window_points().map(|(x, y)| egui::pos2(x as f32, y as f32) / ctx.zoom_factor())))
|
||||
}
|
||||
|
||||
/// The pointer in points from the top-left of the content view of our frontmost window under
|
||||
/// it (`None` when it's over none of ours, or off the main thread). Only our own windows are
|
||||
/// searched: a drop that reached us landed on one of them.
|
||||
fn in_window_points() -> Option<(f64, f64)> {
|
||||
let mtm = MainThreadMarker::new()?;
|
||||
let at = NSEvent::mouseLocation();
|
||||
let contains = |w: &NSWindow| {
|
||||
let f = w.frame();
|
||||
(f.origin.x..f.origin.x + f.size.width).contains(&at.x) && (f.origin.y..f.origin.y + f.size.height).contains(&at.y)
|
||||
};
|
||||
let window = NSApplication::sharedApplication(mtm).orderedWindows().iter().find(|w| w.isVisible() && contains(w))?;
|
||||
let view = window.contentView()?;
|
||||
let p = view.convertPoint_fromView(window.convertPointFromScreen(at), None);
|
||||
let y = if view.isFlipped() { p.y } else { view.bounds().size.height - p.y };
|
||||
Some((p.x, y))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod platform {
|
||||
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
|
||||
use photocraft_ui_egui::CursorPosFn;
|
||||
use x11rb::protocol::xproto::ConnectionExt;
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
|
||||
pub fn service(cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
|
||||
let window = match cc.window_handle().ok()?.as_raw() {
|
||||
RawWindowHandle::Xlib(h) => u32::try_from(h.window).ok()?,
|
||||
RawWindowHandle::Xcb(h) => h.window.get(),
|
||||
_ => return None,
|
||||
};
|
||||
// Opened on first use and kept: the pointer is read every frame while files hover.
|
||||
let mut conn: Option<RustConnection> = None;
|
||||
Some(Box::new(move |ctx: &egui::Context| match pointer_in(&mut conn, window) {
|
||||
Ok(p) => p.map(|(x, y)| egui::pos2(f32::from(x), f32::from(y)) / ctx.pixels_per_point()),
|
||||
Err(e) => {
|
||||
log::warn!("couldn't read the pointer position: {e}");
|
||||
conn = None;
|
||||
None
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// The pointer in pixels relative to `window` (`None` when it's on another screen).
|
||||
fn pointer_in(conn: &mut Option<RustConnection>, window: u32) -> Result<Option<(i16, i16)>, String> {
|
||||
let conn = match conn {
|
||||
Some(c) => c,
|
||||
None => conn.insert(RustConnection::connect(None).map_err(|e| e.to_string())?.0),
|
||||
};
|
||||
let reply = conn.query_pointer(window).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
|
||||
Ok(reply.same_screen.then_some((reply.win_x, reply.win_y)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod platform {
|
||||
use photocraft_ui_egui::CursorPosFn;
|
||||
|
||||
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
|
||||
Some(Box::new(|ctx: &egui::Context| {
|
||||
let pt = winsafe::GetCursorPos().map_err(|e| log::warn!("couldn't read the pointer position: {e}")).ok()?;
|
||||
let inner = ctx.input(|i| i.viewport().inner_rect)?;
|
||||
Some(super::client_points(egui::pos2(pt.x as f32, pt.y as f32), inner.min, ctx.pixels_per_point()))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// A screen position in physical pixels to points in the window whose client area starts at
|
||||
/// `inner_min` (egui's `inner_rect.min`: the client origin in pixels over `ppp`). On Windows
|
||||
/// `GetCursorPos` and the client origin are both physical pixels (winit makes the process
|
||||
/// per-monitor DPI aware) and the window draws at one scale wherever it sits, so this holds across
|
||||
/// monitors with different DPIs.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn client_points(screen_px: egui::Pos2, inner_min: egui::Pos2, ppp: f32) -> egui::Pos2 {
|
||||
((screen_px - inner_min * ppp) / ppp).to_pos2()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
mod platform {
|
||||
use photocraft_ui_egui::CursorPosFn;
|
||||
|
||||
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui::pos2;
|
||||
|
||||
#[test]
|
||||
fn client_points_on_a_second_monitor_at_another_scale() {
|
||||
// A 150% monitor left of a 100% primary: the window's client area starts at (-2400, 300) px.
|
||||
let inner_min = pos2(-2400.0, 300.0) / 1.5;
|
||||
assert_eq!(super::client_points(pos2(-2100.0, 450.0), inner_min, 1.5), pos2(200.0, 100.0));
|
||||
// The same window on the 100% primary.
|
||||
assert_eq!(super::client_points(pos2(500.0, 400.0), pos2(300.0, 300.0), 1.0), pos2(200.0, 100.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
//! Crash-safe GPU startup (#4).
|
||||
//!
|
||||
//! A graphics driver that segfaults while the window's wgpu device is created (the Intel Vulkan
|
||||
//! driver `igvk64.dll` on Windows) can't be caught in-process. So before the device exists the app
|
||||
//! writes a *marker* (`gpu-starting.json` in the config directory: backend and, once chosen, the
|
||||
//! adapter) and holds an OS file lock on it; it clears the marker once the first frames have
|
||||
//! rendered. If the next launch finds a marker that no running process holds, the previous start
|
||||
//! died inside the driver, and this one retries with the next safer backend:
|
||||
//!
|
||||
//! - Windows: Vulkan → DX12 → CPU
|
||||
//! - Linux and other Unix: Vulkan → GL → CPU
|
||||
//! - macOS: Metal → CPU
|
||||
//!
|
||||
//! The choice that worked is remembered in `performance.gpuBackend` (Preferences › Performance,
|
||||
//! where it can be reset). With `auto`, Intel adapters on Windows use DX12. `WGPU_BACKEND` still
|
||||
//! overrides everything, and `--safe-gpu` forces the CPU path for one launch.
|
||||
//!
|
||||
//! "CPU" composites on the CPU and draws the window with a software adapter where the platform
|
||||
//! has one (WARP on Windows, llvmpipe over GL elsewhere).
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use eframe::{egui_wgpu, wgpu};
|
||||
use photocraft_engine::prefs::{GpuBackend, RenderingMode};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The marker's file name in the config directory.
|
||||
pub const MARKER_FILE: &str = "gpu-starting.json";
|
||||
/// PCI vendor id of Intel.
|
||||
pub const INTEL: u32 = 0x8086;
|
||||
/// Largest marker read (a marker is a few hundred bytes).
|
||||
const MARKER_MAX: u64 = 64 << 10;
|
||||
|
||||
/// Platform families with different backend chains.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Os {
|
||||
Windows,
|
||||
Mac,
|
||||
/// Linux, the BSDs and other Unix.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Os {
|
||||
pub fn current() -> Self {
|
||||
if cfg!(windows) {
|
||||
Os::Windows
|
||||
} else if cfg!(target_os = "macos") {
|
||||
Os::Mac
|
||||
} else {
|
||||
Os::Other
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a start records before touching the driver.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Marker {
|
||||
/// The plan's backend (`auto`, `dx12`, …), or `env:<value>` under `WGPU_BACKEND`.
|
||||
pub backend: String,
|
||||
/// The chosen adapter's name, backend and driver (empty until the adapter is chosen).
|
||||
pub adapter: String,
|
||||
pub adapter_backend: String,
|
||||
pub driver: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
impl Marker {
|
||||
pub fn to_json(&self) -> String {
|
||||
json!({
|
||||
"backend": self.backend,
|
||||
"adapter": self.adapter,
|
||||
"adapterBackend": self.adapter_backend,
|
||||
"driver": self.driver,
|
||||
"version": self.version,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Parse a marker leniently: anything unreadable is a marker of an unknown backend.
|
||||
pub fn parse(text: &str) -> Self {
|
||||
let v: Value = serde_json::from_str(text).unwrap_or(Value::Null);
|
||||
let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").chars().take(256).collect::<String>();
|
||||
Marker { backend: s("backend"), adapter: s("adapter"), adapter_backend: s("adapterBackend"), driver: s("driver"), version: s("version") }
|
||||
}
|
||||
|
||||
/// The backend the crashed start was using: the adapter's when it got that far with `auto`,
|
||||
/// else the plan's. `None` for a start under `WGPU_BACKEND` (the user's own override).
|
||||
pub fn tried(&self) -> Option<GpuBackend> {
|
||||
let planned = if self.backend.is_empty() { Some(GpuBackend::Auto) } else { GpuBackend::parse(&self.backend) }?;
|
||||
match (planned, GpuBackend::parse(&self.adapter_backend)) {
|
||||
(GpuBackend::Auto, Some(actual)) => Some(actual),
|
||||
_ => Some(planned),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The backend this launch uses and why.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Plan {
|
||||
pub backend: GpuBackend,
|
||||
/// `WGPU_BACKEND`, which picks the backends itself.
|
||||
pub env: Option<String>,
|
||||
/// Why this isn't simply the preference.
|
||||
pub reason: Option<String>,
|
||||
/// Store `backend` in the preferences once the start succeeded (a crash fallback).
|
||||
pub remember: bool,
|
||||
}
|
||||
|
||||
/// A backend the platform has (others fall back to `auto`).
|
||||
pub fn sanitize(pref: GpuBackend, os: Os) -> GpuBackend {
|
||||
use GpuBackend::*;
|
||||
match (os, pref) {
|
||||
(Os::Windows, Metal) | (Os::Mac, Vulkan | Dx12 | Gl) | (Os::Other, Dx12 | Metal) => Auto,
|
||||
(_, b) => b,
|
||||
}
|
||||
}
|
||||
|
||||
/// The next safer backend after a start on `tried` crashed.
|
||||
pub fn next_safer(tried: GpuBackend, os: Os) -> GpuBackend {
|
||||
use GpuBackend::*;
|
||||
match (os, tried) {
|
||||
(Os::Windows, Auto | Vulkan | Gl | Metal) => Dx12,
|
||||
(Os::Windows, Dx12 | Cpu) => Cpu,
|
||||
(Os::Mac, _) => Cpu,
|
||||
(Os::Other, Auto | Vulkan | Dx12 | Metal) => Gl,
|
||||
(Os::Other, Gl | Cpu) => Cpu,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide this launch's backend from the preference, a marker left by a start that crashed,
|
||||
/// `WGPU_BACKEND` and `--safe-gpu`.
|
||||
pub fn plan(pref: GpuBackend, crashed: Option<&Marker>, env: Option<&str>, safe_gpu: bool, os: Os) -> Plan {
|
||||
if let Some(v) = env.map(str::trim).filter(|v| !v.is_empty()) {
|
||||
let backend = if safe_gpu { GpuBackend::Cpu } else { GpuBackend::Auto };
|
||||
return Plan { backend, env: Some(v.to_string()), reason: Some(format!("WGPU_BACKEND={v} overrides the backend")), remember: false };
|
||||
}
|
||||
if safe_gpu {
|
||||
return Plan { backend: GpuBackend::Cpu, env: None, reason: Some("--safe-gpu: CPU renderer for this launch".into()), remember: false };
|
||||
}
|
||||
let pref = sanitize(pref, os);
|
||||
if let Some((m, tried)) = crashed.and_then(|m| Some((m, m.tried()?))) {
|
||||
let next = next_safer(tried, os);
|
||||
let on = if m.adapter.is_empty() { String::new() } else { format!(" ({})", m.adapter) };
|
||||
return Plan {
|
||||
backend: next,
|
||||
env: None,
|
||||
reason: Some(format!("the previous start didn't finish on {}{on}; using {}", tried.name(), next.name())),
|
||||
remember: next != pref,
|
||||
};
|
||||
}
|
||||
Plan { backend: pref, env: None, reason: None, remember: false }
|
||||
}
|
||||
|
||||
/// Resolve the rendering policy before the window exists. Explicit GPU/Automatic selections
|
||||
/// reset a legacy CPU backend to automatic; CPU keeps software-preferred window presentation.
|
||||
pub fn plan_with_mode(pref: GpuBackend, mode: RenderingMode, crashed: Option<&Marker>, env: Option<&str>, safe_gpu: bool, os: Os) -> Plan {
|
||||
let backend = match mode {
|
||||
RenderingMode::Cpu => GpuBackend::Cpu,
|
||||
RenderingMode::Auto | RenderingMode::Gpu if pref == GpuBackend::Cpu => GpuBackend::Auto,
|
||||
_ => pref,
|
||||
};
|
||||
let env = if safe_gpu || mode == RenderingMode::Cpu { None } else { env };
|
||||
let crashed = if mode == RenderingMode::Cpu { None } else { crashed };
|
||||
plan(backend, crashed, env, safe_gpu, os)
|
||||
}
|
||||
|
||||
/// Instance backends for `plan` (`None`: egui's default, which honours `WGPU_BACKEND`).
|
||||
pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
|
||||
if plan.env.is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(match plan.backend {
|
||||
GpuBackend::Auto => return None,
|
||||
GpuBackend::Vulkan => wgpu::Backends::VULKAN,
|
||||
GpuBackend::Dx12 => wgpu::Backends::DX12,
|
||||
GpuBackend::Metal => wgpu::Backends::METAL,
|
||||
GpuBackend::Gl => wgpu::Backends::GL,
|
||||
GpuBackend::Cpu => match os {
|
||||
Os::Windows => wgpu::Backends::DX12,
|
||||
Os::Mac => wgpu::Backends::METAL,
|
||||
Os::Other => wgpu::Backends::GL,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// The DX12 shader compiler (#712). wgpu's default loads `dxcompiler.dll` by name, and the
|
||||
/// Windows DLL search falls through to the current directory and `PATH`, so another program's DXC
|
||||
/// build (a browser's, an SDK's) got loaded and failed device creation or crashed the first shader
|
||||
/// compile. Use a `dxcompiler.dll` shipped beside the executable, by its full path; otherwise FXC
|
||||
/// (`d3dcompiler_47.dll`, part of Windows). `WGPU_DX12_COMPILER` still picks one explicitly.
|
||||
pub fn dx12_compiler(env: Option<&str>, exe_dir: Option<&Path>) -> wgpu::Dx12Compiler {
|
||||
if let Some(c) = env.and_then(|v| v.trim().parse().ok()) {
|
||||
return c;
|
||||
}
|
||||
let beside_exe = exe_dir.map(|d| d.join("dxcompiler.dll")).filter(|p| p.is_file());
|
||||
match beside_exe.as_deref().and_then(Path::to_str) {
|
||||
Some(p) => wgpu::Dx12Compiler::DynamicDxc { dxc_path: p.to_string() },
|
||||
None => wgpu::Dx12Compiler::Fxc,
|
||||
}
|
||||
}
|
||||
|
||||
/// What adapter selection needs to know about an adapter.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Candidate {
|
||||
pub vendor: u32,
|
||||
pub device_type: wgpu::DeviceType,
|
||||
pub backend: wgpu::Backend,
|
||||
/// Can present to the window.
|
||||
pub surface_ok: bool,
|
||||
}
|
||||
|
||||
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
|
||||
/// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for
|
||||
/// Intel on Windows.
|
||||
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8) {
|
||||
let ty = match a.device_type {
|
||||
wgpu::DeviceType::DiscreteGpu => 0,
|
||||
wgpu::DeviceType::IntegratedGpu => 1,
|
||||
wgpu::DeviceType::VirtualGpu => 2,
|
||||
wgpu::DeviceType::Other => 3,
|
||||
wgpu::DeviceType::Cpu => 4,
|
||||
};
|
||||
let ty = match (backend, a.device_type) {
|
||||
(GpuBackend::Cpu, wgpu::DeviceType::Cpu) => 0,
|
||||
(GpuBackend::Cpu, _) => ty + 1,
|
||||
_ => ty,
|
||||
};
|
||||
let be = match a.backend {
|
||||
wgpu::Backend::Dx12 if os == Os::Windows && a.vendor == INTEL => 0,
|
||||
wgpu::Backend::Vulkan | wgpu::Backend::Metal => 1,
|
||||
wgpu::Backend::Dx12 => 2,
|
||||
wgpu::Backend::Gl => 3,
|
||||
wgpu::Backend::BrowserWebGpu | wgpu::Backend::Noop => 4,
|
||||
};
|
||||
(ty, be)
|
||||
}
|
||||
|
||||
/// The adapter to use among `adapters` (`None` when there is none).
|
||||
pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option<usize> {
|
||||
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the
|
||||
/// same vendor was available).
|
||||
pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool {
|
||||
let Some(c) = adapters.get(chosen) else { return false };
|
||||
os == Os::Windows
|
||||
&& c.vendor == INTEL
|
||||
&& c.backend == wgpu::Backend::Dx12
|
||||
&& adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)
|
||||
}
|
||||
|
||||
/// Configure eframe's wgpu setup for `plan`: instance backends, the DX12 shader compiler, adapter
|
||||
/// selection (Intel on Windows, software adapters for `cpu`) and the marker update once the
|
||||
/// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default).
|
||||
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
|
||||
if let egui_wgpu::WgpuSetup::CreateNew(create) = setup {
|
||||
if let Some(b) = backends(plan, os) {
|
||||
create.instance_descriptor.backends = b;
|
||||
}
|
||||
if os == Os::Windows {
|
||||
let exe = std::env::current_exe().ok();
|
||||
let env = std::env::var("WGPU_DX12_COMPILER").ok();
|
||||
create.instance_descriptor.backend_options.dx12.shader_compiler = dx12_compiler(env.as_deref(), exe.as_deref().and_then(Path::parent));
|
||||
}
|
||||
let select = plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows));
|
||||
if select {
|
||||
let backend = plan.backend;
|
||||
create.native_adapter_selector = Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| {
|
||||
let cands: Vec<Candidate> = adapters
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let i = a.get_info();
|
||||
Candidate {
|
||||
vendor: i.vendor,
|
||||
device_type: i.device_type,
|
||||
backend: i.backend,
|
||||
surface_ok: surface.is_none_or(|s| a.is_surface_supported(s)),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let i = pick(&cands, backend, os).ok_or_else(|| "no graphics adapter found".to_string())?;
|
||||
if backend == GpuBackend::Cpu && cands.get(i).is_some_and(|c| c.device_type != wgpu::DeviceType::Cpu) {
|
||||
*note.lock().unwrap_or_else(PoisonError::into_inner) =
|
||||
Some("CPU image rendering; no software graphics adapter is available, so the window uses hardware graphics".into());
|
||||
}
|
||||
if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os) {
|
||||
*note.lock().unwrap_or_else(PoisonError::into_inner) =
|
||||
Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into());
|
||||
}
|
||||
adapters.get(i).cloned().ok_or_else(|| "no graphics adapter found".to_string())
|
||||
}));
|
||||
}
|
||||
}
|
||||
photocraft_ui_egui::gpu_canvas::use_adapter_limits_with(setup, move |adapter| {
|
||||
let info = adapter.get_info();
|
||||
let mut guard = sentinel.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(s) = guard.as_mut() {
|
||||
let mut m = s.marker.clone();
|
||||
m.adapter = info.name.clone();
|
||||
m.adapter_backend = photocraft_ui_egui::gpu_canvas::backend_name(info.backend).to_string();
|
||||
m.driver = [info.driver.as_str(), info.driver_info.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join(" ");
|
||||
if let Err(e) = s.write(m) {
|
||||
log::warn!("GPU startup marker: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// What a previous start left behind.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Previous {
|
||||
/// Nothing: it finished starting (or never ran).
|
||||
Clean,
|
||||
/// Its marker, unlocked: it died before rendering its first frames.
|
||||
Crashed(Marker),
|
||||
/// Another instance is starting right now (it holds the lock).
|
||||
Busy,
|
||||
}
|
||||
|
||||
impl Previous {
|
||||
pub fn crashed(&self) -> Option<&Marker> {
|
||||
match self {
|
||||
Previous::Crashed(m) => Some(m),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The locked marker file of this start.
|
||||
#[derive(Debug)]
|
||||
pub struct Sentinel {
|
||||
path: PathBuf,
|
||||
file: Option<File>,
|
||||
marker: Marker,
|
||||
}
|
||||
|
||||
/// The sentinel, shared with eframe's adapter hook and the app's started hook.
|
||||
pub type SharedSentinel = Arc<Mutex<Option<Sentinel>>>;
|
||||
|
||||
impl Sentinel {
|
||||
/// Open (creating) the marker in `dir` and lock it. Returns what a previous start left and,
|
||||
/// unless another instance holds the lock or the directory isn't writable, this start's
|
||||
/// sentinel (not yet written: see [`Sentinel::write`]).
|
||||
pub fn begin(dir: &Path) -> (Previous, Option<Sentinel>) {
|
||||
let path = dir.join(MARKER_FILE);
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
log::warn!("GPU startup marker: {e}");
|
||||
return (Previous::Clean, None);
|
||||
}
|
||||
let mut file = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&path) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
log::warn!("GPU startup marker {}: {e}", path.display());
|
||||
return (Previous::Clean, None);
|
||||
}
|
||||
};
|
||||
match file.try_lock() {
|
||||
Ok(()) => {}
|
||||
Err(std::fs::TryLockError::WouldBlock) => return (Previous::Busy, None),
|
||||
Err(std::fs::TryLockError::Error(e)) => {
|
||||
log::warn!("GPU startup marker lock: {e}");
|
||||
return (Previous::Clean, None);
|
||||
}
|
||||
}
|
||||
let mut text = String::new();
|
||||
let _ = (&mut file).take(MARKER_MAX).read_to_string(&mut text);
|
||||
let previous = if text.trim().is_empty() { Previous::Clean } else { Previous::Crashed(Marker::parse(&text)) };
|
||||
(previous, Some(Sentinel { path, file: Some(file), marker: Marker::default() }))
|
||||
}
|
||||
|
||||
/// Replace the marker's contents with `m`.
|
||||
pub fn write(&mut self, m: Marker) -> std::io::Result<()> {
|
||||
let f = self.file.as_mut().ok_or_else(|| std::io::Error::other("marker closed"))?;
|
||||
f.set_len(0)?;
|
||||
f.seek(SeekFrom::Start(0))?;
|
||||
f.write_all(m.to_json().as_bytes())?;
|
||||
f.flush()?;
|
||||
self.marker = m;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The start succeeded: clear the marker and release the lock.
|
||||
pub fn finish(mut self) {
|
||||
if let Some(f) = self.file.take() {
|
||||
// Empty means "no crash" even if removing fails (another instance has it open).
|
||||
let _ = f.set_len(0);
|
||||
drop(f);
|
||||
}
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the policy leniently at startup, including old settings without renderingMode.
|
||||
pub fn read_rendering_prefs(path: Option<&Path>) -> (GpuBackend, RenderingMode) {
|
||||
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
let perf = v.get("performance");
|
||||
let backend = perf.and_then(|p| p.get("gpuBackend")).and_then(Value::as_str).and_then(GpuBackend::parse).unwrap_or_default();
|
||||
let use_gpu = perf.and_then(|p| p.get("useGpu")).and_then(Value::as_bool).unwrap_or(true);
|
||||
let explicit = perf.and_then(|p| p.get("renderingMode")).and_then(Value::as_str).and_then(RenderingMode::parse);
|
||||
let mode = explicit.unwrap_or(if !use_gpu || backend == GpuBackend::Cpu { RenderingMode::Cpu } else { RenderingMode::Auto });
|
||||
(backend, mode)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use GpuBackend::*;
|
||||
|
||||
fn crashed(backend: &str, adapter_backend: &str) -> Marker {
|
||||
Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_policy_ignores_hardware_backend_override() {
|
||||
let p = plan_with_mode(Vulkan, RenderingMode::Cpu, None, Some("vulkan"), false, Os::Windows);
|
||||
assert_eq!(p.backend, Cpu);
|
||||
assert!(p.env.is_none());
|
||||
assert_eq!(plan_with_mode(Vulkan, RenderingMode::Cpu, Some(&crashed("vulkan", "vulkan")), None, false, Os::Windows).backend, Cpu);
|
||||
let p = plan_with_mode(Auto, RenderingMode::Gpu, None, Some("vulkan"), true, Os::Windows);
|
||||
assert_eq!(p.backend, Cpu);
|
||||
assert!(p.env.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_marker_uses_the_preference() {
|
||||
for os in [Os::Windows, Os::Mac, Os::Other] {
|
||||
let p = plan(Auto, None, None, false, os);
|
||||
assert_eq!(p, Plan { backend: Auto, env: None, reason: None, remember: false });
|
||||
}
|
||||
assert_eq!(plan(Dx12, None, None, false, Os::Windows).backend, Dx12);
|
||||
assert_eq!(plan(Gl, None, None, false, Os::Other).backend, Gl);
|
||||
// A backend the platform lacks is `auto`.
|
||||
assert_eq!(plan(Metal, None, None, false, Os::Windows).backend, Auto);
|
||||
assert_eq!(plan(Dx12, None, None, false, Os::Mac).backend, Auto);
|
||||
assert_eq!(plan(Dx12, None, None, false, Os::Other).backend, Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crash_moves_down_each_chain_and_is_remembered() {
|
||||
// Windows: Vulkan → DX12 → CPU.
|
||||
let p = plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Windows);
|
||||
assert_eq!((p.backend, p.remember), (Dx12, true));
|
||||
assert!(p.reason.as_deref().unwrap_or("").contains("vulkan (Intel(R) UHD Graphics)"), "{p:?}");
|
||||
assert_eq!(plan(Dx12, Some(&crashed("dx12", "dx12")), None, false, Os::Windows).backend, Cpu);
|
||||
// Crashed before the adapter was chosen (enumeration): from `auto`.
|
||||
assert_eq!(plan(Auto, Some(&crashed("auto", "")), None, false, Os::Windows).backend, Dx12);
|
||||
// `auto` that had already picked DX12 (the Intel default) goes to the CPU.
|
||||
assert_eq!(plan(Auto, Some(&crashed("auto", "dx12")), None, false, Os::Windows).backend, Cpu);
|
||||
// Linux: Vulkan → GL → CPU.
|
||||
assert_eq!(plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Other).backend, Gl);
|
||||
assert_eq!(plan(Gl, Some(&crashed("gl", "gl")), None, false, Os::Other).backend, Cpu);
|
||||
// macOS: Metal → CPU.
|
||||
assert_eq!(plan(Auto, Some(&crashed("auto", "metal")), None, false, Os::Mac).backend, Cpu);
|
||||
// The CPU path stays there, and isn't "remembered" again.
|
||||
let p = plan(Cpu, Some(&crashed("cpu", "dx12")), None, false, Os::Windows);
|
||||
assert_eq!((p.backend, p.remember), (Cpu, false));
|
||||
// An unreadable marker still counts as a crash.
|
||||
assert_eq!(plan(Auto, Some(&Marker::parse("{garbage")), None, false, Os::Windows).backend, Dx12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_beats_everything() {
|
||||
let p = plan(Cpu, Some(&crashed("auto", "vulkan")), Some("vulkan"), false, Os::Windows);
|
||||
assert_eq!(p.env.as_deref(), Some("vulkan"));
|
||||
assert_eq!(p.backend, Auto);
|
||||
assert!(!p.remember);
|
||||
assert_eq!(backends(&p, Os::Windows), None, "egui reads WGPU_BACKEND itself");
|
||||
// A crash under the override doesn't count against `auto` later.
|
||||
let p = plan(Auto, Some(&Marker { backend: "env:vulkan".into(), ..Default::default() }), None, false, Os::Windows);
|
||||
assert_eq!(p.backend, Auto);
|
||||
// Empty means unset.
|
||||
assert_eq!(plan(Auto, None, Some(" "), false, Os::Windows).env, None);
|
||||
// --safe-gpu with the override: CPU canvas, the override's backends.
|
||||
assert_eq!(plan(Auto, None, Some("dx12"), true, Os::Windows).backend, Cpu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_gpu_forces_the_cpu_for_one_launch() {
|
||||
let p = plan(Vulkan, Some(&crashed("vulkan", "vulkan")), None, true, Os::Other);
|
||||
assert_eq!((p.backend, p.remember), (Cpu, false));
|
||||
assert_eq!(backends(&p, Os::Windows), Some(wgpu::Backends::DX12));
|
||||
assert_eq!(backends(&p, Os::Other), Some(wgpu::Backends::GL));
|
||||
assert_eq!(backends(&p, Os::Mac), Some(wgpu::Backends::METAL));
|
||||
}
|
||||
|
||||
fn cand(vendor: u32, device_type: wgpu::DeviceType, backend: wgpu::Backend) -> Candidate {
|
||||
Candidate { vendor, device_type, backend, surface_ok: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intel_on_windows_defaults_to_dx12() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
let intel = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::IntegratedGpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
|
||||
let i = pick(&intel, Auto, Os::Windows).unwrap();
|
||||
assert_eq!(intel[i].backend, B::Dx12);
|
||||
assert!(intel_dx12_applied(&intel, i, Os::Windows));
|
||||
// Elsewhere (and for other vendors) Vulkan stays first.
|
||||
assert_eq!(intel[pick(&intel, Auto, Os::Other).unwrap()].backend, B::Vulkan);
|
||||
let nv = [cand(0x10de, T::DiscreteGpu, B::Dx12), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
|
||||
let i = pick(&nv, Auto, Os::Windows).unwrap();
|
||||
assert_eq!(nv[i].backend, B::Vulkan);
|
||||
assert!(!intel_dx12_applied(&nv, i, Os::Windows));
|
||||
// Intel iGPU + discrete GPU: the discrete one wins (high performance), on Vulkan.
|
||||
let hybrid = [intel[0], intel[1], nv[1]];
|
||||
assert_eq!(pick(&hybrid, Auto, Os::Windows), Some(2));
|
||||
// Adapters that can't present lose to ones that can.
|
||||
let mut only = [cand(0x10de, T::DiscreteGpu, B::Vulkan), intel[1]];
|
||||
only[0].surface_ok = false;
|
||||
assert_eq!(pick(&only, Auto, Os::Windows), Some(1));
|
||||
assert_eq!(pick(&[], Auto, Os::Windows), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_prefers_software_adapters() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
let a = [cand(INTEL, T::IntegratedGpu, B::Dx12), cand(0x1414, T::Cpu, B::Dx12)];
|
||||
assert_eq!(pick(&a, Cpu, Os::Windows), Some(1));
|
||||
// No software adapter: still something.
|
||||
assert_eq!(pick(&a[..1], Cpu, Os::Windows), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapters_must_present_even_when_none_support_the_surface() {
|
||||
let mut a = cand(INTEL, wgpu::DeviceType::IntegratedGpu, wgpu::Backend::Dx12);
|
||||
a.surface_ok = false;
|
||||
assert_eq!(pick(&[a], Auto, Os::Windows), None);
|
||||
assert_eq!(pick(&[a], Cpu, Os::Windows), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_policy_overrides_legacy_cpu_backend() {
|
||||
assert_eq!(plan_with_mode(Cpu, RenderingMode::Gpu, None, None, false, Os::Mac).backend, Auto);
|
||||
assert_eq!(plan_with_mode(Metal, RenderingMode::Cpu, None, None, false, Os::Mac).backend, Cpu);
|
||||
assert_eq!(plan_with_mode(Auto, RenderingMode::Gpu, None, None, true, Os::Mac).backend, Cpu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_round_trip_and_lenient_parse() {
|
||||
let m = Marker {
|
||||
backend: "auto".into(),
|
||||
adapter: "A \"quoted\" GPU".into(),
|
||||
adapter_backend: "vulkan".into(),
|
||||
driver: "1.2".into(),
|
||||
version: "0.1".into(),
|
||||
};
|
||||
assert_eq!(Marker::parse(&m.to_json()), m);
|
||||
assert_eq!(Marker::parse(""), Marker::default());
|
||||
assert_eq!(Marker::parse("[1,2]").tried(), Some(Auto));
|
||||
assert_eq!(Marker::parse(r#"{"backend": 7}"#).tried(), Some(Auto));
|
||||
assert_eq!(Marker::parse(r#"{"backend": "env:dx12"}"#).tried(), None);
|
||||
let long = format!(r#"{{"adapter": "{}"}}"#, "x".repeat(10_000));
|
||||
assert_eq!(Marker::parse(&long).adapter.len(), 256);
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("photocraft-gpu-startup-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentinel_detects_a_crash_and_clears_on_success() {
|
||||
let dir = temp_dir("cycle");
|
||||
// First launch: nothing left behind.
|
||||
let (prev, s) = Sentinel::begin(&dir);
|
||||
assert_eq!(prev, Previous::Clean);
|
||||
let mut s = s.expect("sentinel");
|
||||
s.write(Marker { backend: "auto".into(), ..Default::default() }).unwrap();
|
||||
s.write(Marker { backend: "auto".into(), adapter: "GPU".into(), adapter_backend: "vulkan".into(), ..Default::default() }).unwrap();
|
||||
// While it is starting, a second instance sees it busy, not crashed.
|
||||
let (busy, none) = Sentinel::begin(&dir);
|
||||
assert_eq!(busy, Previous::Busy);
|
||||
assert!(none.is_none());
|
||||
// The process dies in the driver: the lock goes, the marker stays.
|
||||
drop(s);
|
||||
// Another test in this binary spawns a process: between its fork and exec the child briefly
|
||||
// shares this file's flock, so `begin` can see the lock as busy. Retry for a moment.
|
||||
let (mut prev, mut s) = Sentinel::begin(&dir);
|
||||
for _ in 0..50 {
|
||||
if prev != Previous::Busy {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
(prev, s) = Sentinel::begin(&dir);
|
||||
}
|
||||
let m = prev.crashed().cloned().expect("crash detected");
|
||||
assert_eq!((m.adapter.as_str(), m.tried()), ("GPU", Some(Vulkan)));
|
||||
// This start succeeds: the next one finds nothing.
|
||||
let mut s = s.expect("sentinel");
|
||||
s.write(Marker { backend: "dx12".into(), ..Default::default() }).unwrap();
|
||||
s.finish();
|
||||
assert!(!dir.join(MARKER_FILE).exists());
|
||||
let (prev, _s) = Sentinel::begin(&dir);
|
||||
assert_eq!(prev, Previous::Clean);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwritable_config_dir_is_not_fatal() {
|
||||
let dir = temp_dir("file");
|
||||
std::fs::create_dir_all(dir.parent().unwrap()).unwrap();
|
||||
std::fs::write(&dir, b"not a directory").unwrap();
|
||||
let (prev, s) = Sentinel::begin(&dir.join("sub"));
|
||||
assert_eq!(prev, Previous::Clean);
|
||||
assert!(s.is_none());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefs_are_read_leniently() {
|
||||
let dir = temp_dir("prefs");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join("preferences.json");
|
||||
assert_eq!(read_rendering_prefs(None), (Auto, RenderingMode::Auto));
|
||||
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Auto));
|
||||
std::fs::write(&p, r#"{"performance": {"gpuBackend": "dx12", "useGpu": false}, "interface": {}}"#).unwrap();
|
||||
assert_eq!(read_rendering_prefs(Some(&p)), (Dx12, RenderingMode::Cpu));
|
||||
std::fs::write(&p, r#"{"performance": {"gpuBackend": "quantum"}}"#).unwrap();
|
||||
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Auto));
|
||||
std::fs::write(&p, r#"{"performance": {"useGpu": false, "renderingMode": "gpu"}}"#).unwrap();
|
||||
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Gpu));
|
||||
std::fs::write(&p, "{not json").unwrap();
|
||||
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Auto));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #712: never a `dxcompiler.dll` found through the DLL search path (current directory, PATH).
|
||||
#[test]
|
||||
fn dx12_compiler_is_fxc_or_dxc_beside_the_exe() {
|
||||
use wgpu::Dx12Compiler as C;
|
||||
let dir = temp_dir("dxc");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
assert!(matches!(dx12_compiler(None, None), C::Fxc));
|
||||
assert!(matches!(dx12_compiler(None, Some(&dir)), C::Fxc));
|
||||
std::fs::write(dir.join("dxcompiler.dll"), b"MZ").unwrap();
|
||||
let expected = dir.join("dxcompiler.dll");
|
||||
assert!(matches!(dx12_compiler(None, Some(&dir)), C::DynamicDxc { dxc_path } if Path::new(&dxc_path) == expected));
|
||||
// An explicit WGPU_DX12_COMPILER wins; an unknown value is ignored.
|
||||
assert!(matches!(dx12_compiler(Some(" FXC "), Some(&dir)), C::Fxc));
|
||||
assert!(matches!(dx12_compiler(Some("dxc"), None), C::DynamicDxc { dxc_path } if dxc_path == "dxcompiler.dll"));
|
||||
assert!(matches!(dx12_compiler(Some("quantum"), None), C::Fxc));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
// The app's setup replaces wgpu's default (`Auto`: DXC by name from the search path).
|
||||
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
|
||||
let plan = plan(Auto, None, None, false, Os::Windows);
|
||||
configure(&mut setup, &plan, Os::Windows, Arc::new(Mutex::new(None)), Arc::default());
|
||||
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
|
||||
if std::env::var_os("WGPU_DX12_COMPILER").is_none() {
|
||||
let safe = match &create.instance_descriptor.backend_options.dx12.shader_compiler {
|
||||
C::Fxc => true,
|
||||
C::DynamicDxc { dxc_path } => Path::new(dxc_path).is_absolute(),
|
||||
C::StaticDxc | C::Auto => false,
|
||||
};
|
||||
assert!(safe, "{:?}", create.instance_descriptor.backend_options.dx12.shader_compiler);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
//! Linux start-up check for the shared libraries winit and wgpu load at runtime (issue #201).
|
||||
//!
|
||||
//! The windowing (X11/Wayland, xkbcommon) and GPU (Vulkan, EGL) libraries aren't ELF `NEEDED`
|
||||
//! entries: winit and wgpu `dlopen` them when the window opens, and some of those crates panic
|
||||
//! when a library is missing (`xkbcommon-dl`: "libxkbcommon-x11.so could not be loaded"). So,
|
||||
//! before eframe starts, we look for the libraries the current session type needs and, if one is
|
||||
//! missing, print the package to install and exit non-zero instead of crashing (AGENTS.md, Never
|
||||
//! crash).
|
||||
//!
|
||||
//! No `dlopen` here (that needs `unsafe`): we read the dynamic linker's cache (`ldconfig -p`) and
|
||||
//! look in `LD_LIBRARY_PATH` and the usual library directories. Only the cache is conclusive: if
|
||||
//! it can't be read (NixOS, some containers) and a library isn't found in the directories, we
|
||||
//! warn and start anyway. `PHOTOCRAFT_SKIP_LIB_CHECK=1` skips the check.
|
||||
//!
|
||||
//! The parsing and decision logic is pure and tested on every platform; only [`preflight`]
|
||||
//! touches the system, and it is only called on Linux.
|
||||
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The display server winit will connect to, chosen the way winit 0.30 does: Wayland when
|
||||
/// `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set (non-empty), otherwise X11 when `DISPLAY` is.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DisplaySession {
|
||||
Wayland,
|
||||
X11,
|
||||
/// Neither is set: winit reports that itself (an error, not a panic).
|
||||
None,
|
||||
}
|
||||
|
||||
pub fn session_from_env(var: impl Fn(&str) -> Option<String>) -> DisplaySession {
|
||||
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
|
||||
if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") {
|
||||
DisplaySession::Wayland
|
||||
} else if set("DISPLAY") {
|
||||
DisplaySession::X11
|
||||
} else {
|
||||
DisplaySession::None
|
||||
}
|
||||
}
|
||||
|
||||
/// A runtime-loaded library and the package that provides it on each distro family.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Lib {
|
||||
pub soname: &'static str,
|
||||
pub debian: &'static str,
|
||||
pub fedora: &'static str,
|
||||
pub arch: &'static str,
|
||||
pub suse: &'static str,
|
||||
}
|
||||
|
||||
const fn lib(soname: &'static str, debian: &'static str, fedora: &'static str, arch: &'static str, suse: &'static str) -> Lib {
|
||||
Lib { soname, debian, fedora, arch, suse }
|
||||
}
|
||||
|
||||
const XKBCOMMON: Lib = lib("libxkbcommon.so.0", "libxkbcommon0", "libxkbcommon", "libxkbcommon", "libxkbcommon0");
|
||||
const XKBCOMMON_X11: Lib = lib("libxkbcommon-x11.so.0", "libxkbcommon-x11-0", "libxkbcommon-x11", "libxkbcommon-x11", "libxkbcommon-x11-0");
|
||||
const X11: Lib = lib("libX11.so.6", "libx11-6", "libX11", "libx11", "libX11-6");
|
||||
const X11_XCB: Lib = lib("libX11-xcb.so.1", "libx11-xcb1", "libX11-xcb", "libx11", "libX11-xcb1");
|
||||
const XCB: Lib = lib("libxcb.so.1", "libxcb1", "libxcb", "libxcb", "libxcb1");
|
||||
const XCURSOR: Lib = lib("libXcursor.so.1", "libxcursor1", "libXcursor", "libxcursor", "libXcursor1");
|
||||
const XI: Lib = lib("libXi.so.6", "libxi6", "libXi", "libxi", "libXi6");
|
||||
const WAYLAND_CLIENT: Lib = lib("libwayland-client.so.0", "libwayland-client0", "libwayland-client", "wayland", "libwayland-client0");
|
||||
const VULKAN: Lib = lib("libvulkan.so.1", "libvulkan1", "vulkan-loader", "vulkan-icd-loader", "libvulkan1");
|
||||
const EGL: Lib = lib("libEGL.so.1", "libegl1", "libglvnd-egl", "libglvnd", "libEGL1");
|
||||
|
||||
/// One requirement: at least one of these libraries must be present.
|
||||
pub type AnyOf = &'static [Lib];
|
||||
|
||||
/// What winit 0.30 and wgpu `dlopen` for a session. X11: x11-dl opens Xlib, Xcursor, Xlib-xcb
|
||||
/// and XInput2, x11rb opens libxcb, and xkbcommon-dl opens xkbcommon and xkbcommon-x11 (RandR
|
||||
/// goes through x11rb's protocol code, so libXrandr isn't needed). Wayland: wayland-sys opens
|
||||
/// libwayland-client (wayland-cursor is pure Rust) plus xkbcommon. wgpu needs Vulkan or EGL.
|
||||
pub fn requirements(session: DisplaySession) -> Vec<AnyOf> {
|
||||
const GPU: AnyOf = &[VULKAN, EGL];
|
||||
match session {
|
||||
DisplaySession::Wayland => vec![&[WAYLAND_CLIENT], &[XKBCOMMON], GPU],
|
||||
DisplaySession::X11 => vec![&[X11], &[X11_XCB], &[XCB], &[XCURSOR], &[XI], &[XKBCOMMON], &[XKBCOMMON_X11], GPU],
|
||||
DisplaySession::None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ldconfig -p` architecture tag for this build, when it's one we can tell apart from a
|
||||
/// 32-bit library of the same name (`libc6,x86-64` vs plain `libc6` for i386).
|
||||
fn ldconfig_arch_tag() -> Option<&'static str> {
|
||||
if cfg!(target_arch = "x86_64") {
|
||||
Some("x86-64")
|
||||
} else if cfg!(target_arch = "aarch64") {
|
||||
Some("AArch64")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `ldconfig -p` output list `soname` for our architecture? Lines look like
|
||||
/// `\tlibxkbcommon-x11.so.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxkbcommon-x11.so.0`.
|
||||
pub fn ldconfig_has(output: &str, soname: &str, arch_tag: Option<&str>) -> bool {
|
||||
output.lines().any(|line| {
|
||||
let line = line.trim_start();
|
||||
let Some((name, rest)) = line.split_once(' ') else { return false };
|
||||
if name != soname {
|
||||
return false;
|
||||
}
|
||||
match arch_tag {
|
||||
None => true,
|
||||
Some(tag) => {
|
||||
let flags = rest.split_once('(').and_then(|(_, r)| r.split_once(')')).map_or("", |(f, _)| f);
|
||||
flags.split(',').any(|f| f.trim() == tag)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A real `ldconfig -p` listing starts with "N libs found in cache".
|
||||
fn ldconfig_output_is_valid(output: &str) -> bool {
|
||||
output.lines().next().is_some_and(|l| l.contains("libs found in cache"))
|
||||
}
|
||||
|
||||
/// Where we look for libraries, and what we found there.
|
||||
pub struct Probe {
|
||||
/// `ldconfig -p` output, when it ran and looked like a real cache listing.
|
||||
pub ldconfig: Option<String>,
|
||||
/// Directories to check for the soname (`LD_LIBRARY_PATH` first).
|
||||
pub dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
fn has(&self, soname: &str, exists: &impl Fn(&Path) -> bool) -> bool {
|
||||
self.ldconfig.as_deref().is_some_and(|out| ldconfig_has(out, soname, ldconfig_arch_tag())) || self.dirs.iter().any(|d| exists(&d.join(soname)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome of the check.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Verdict {
|
||||
Ok,
|
||||
/// Requirements with none of their alternatives found. `conclusive` is true when the linker
|
||||
/// cache was read, so the libraries really are absent (and winit would fail or panic).
|
||||
Missing {
|
||||
missing: Vec<AnyOf>,
|
||||
conclusive: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn check(session: DisplaySession, probe: &Probe, exists: impl Fn(&Path) -> bool) -> Verdict {
|
||||
let missing: Vec<AnyOf> = requirements(session).into_iter().filter(|any| !any.iter().any(|l| probe.has(l.soname, &exists))).collect();
|
||||
if missing.is_empty() { Verdict::Ok } else { Verdict::Missing { missing, conclusive: probe.ldconfig.is_some() } }
|
||||
}
|
||||
|
||||
/// Distro family, from `/etc/os-release` `ID` and `ID_LIKE`, to name the right packages.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Distro {
|
||||
Debian,
|
||||
Fedora,
|
||||
Arch,
|
||||
Suse,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
pub fn distro_from_os_release(text: &str) -> Distro {
|
||||
let mut ids = Vec::new();
|
||||
for line in text.lines() {
|
||||
let Some((key, value)) = line.split_once('=') else { continue };
|
||||
if key.trim() == "ID" || key.trim() == "ID_LIKE" {
|
||||
ids.extend(value.trim().trim_matches(|c| c == '"' || c == '\'').split_whitespace().map(str::to_ascii_lowercase));
|
||||
}
|
||||
}
|
||||
let any = |names: &[&str]| ids.iter().any(|id| names.contains(&id.as_str()));
|
||||
if any(&["debian", "ubuntu"]) {
|
||||
Distro::Debian
|
||||
} else if any(&["fedora", "rhel", "centos"]) {
|
||||
Distro::Fedora
|
||||
} else if any(&["arch"]) {
|
||||
Distro::Arch
|
||||
} else if any(&["suse", "opensuse"]) || ids.iter().any(|id| id.starts_with("opensuse")) {
|
||||
Distro::Suse
|
||||
} else {
|
||||
Distro::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
fn package(lib: &Lib, distro: Distro) -> &'static str {
|
||||
match distro {
|
||||
Distro::Debian | Distro::Unknown => lib.debian,
|
||||
Distro::Fedora => lib.fedora,
|
||||
Distro::Arch => lib.arch,
|
||||
Distro::Suse => lib.suse,
|
||||
}
|
||||
}
|
||||
|
||||
fn install_command(packages: &[&str], distro: Distro) -> String {
|
||||
let list = packages.join(" ");
|
||||
match distro {
|
||||
Distro::Debian => format!("sudo apt install {list}"),
|
||||
Distro::Fedora => format!("sudo dnf install {list}"),
|
||||
Distro::Arch => format!("sudo pacman -S {list}"),
|
||||
Distro::Suse => format!("sudo zypper install {list}"),
|
||||
Distro::Unknown => format!("Debian/Ubuntu: sudo apt install {list}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The message for missing libraries: what's missing, and the command that installs it.
|
||||
pub fn missing_message(session: DisplaySession, missing: &[AnyOf], distro: Distro, conclusive: bool) -> String {
|
||||
let session_name = match session {
|
||||
DisplaySession::Wayland => "Wayland",
|
||||
DisplaySession::X11 => "X11",
|
||||
DisplaySession::None => "display",
|
||||
};
|
||||
let names: Vec<String> = missing.iter().map(|any| any.iter().map(|l| l.soname).collect::<Vec<_>>().join(" or ")).collect();
|
||||
// For an either/or requirement, suggest its first alternative.
|
||||
let mut packages: Vec<&str> = Vec::new();
|
||||
for p in missing.iter().filter_map(|any| any.first()).map(|l| package(l, distro)) {
|
||||
if !packages.contains(&p) {
|
||||
packages.push(p);
|
||||
}
|
||||
}
|
||||
let mut msg = if conclusive {
|
||||
format!("photocraft: cannot start: the {session_name} session needs libraries that are not installed: {}.\n", names.join(", "))
|
||||
} else {
|
||||
format!("photocraft: warning: could not find these libraries the {session_name} session needs: {}. Starting anyway.\n", names.join(", "))
|
||||
};
|
||||
msg.push_str(&format!("Install them with:\n {}\n", install_command(&packages, distro)));
|
||||
if distro == Distro::Unknown {
|
||||
let fedora: Vec<&str> = missing.iter().filter_map(|any| any.first()).map(|l| l.fedora).collect();
|
||||
msg.push_str(&format!(" Fedora: sudo dnf install {}\n", fedora.join(" ")));
|
||||
}
|
||||
if conclusive {
|
||||
msg.push_str("(Set PHOTOCRAFT_SKIP_LIB_CHECK=1 to start anyway.)\n");
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
/// Library directories to search: `LD_LIBRARY_PATH`, then the usual system and Flatpak paths.
|
||||
fn search_dirs(ld_library_path: Option<&str>) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = ld_library_path.unwrap_or("").split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect();
|
||||
let triplet = if cfg!(target_arch = "aarch64") { "aarch64-linux-gnu" } else { "x86_64-linux-gnu" };
|
||||
for d in ["/app/lib", "/usr/local/lib", "/usr/lib64", "/lib64", "/usr/lib", "/lib"] {
|
||||
dirs.push(PathBuf::from(d));
|
||||
}
|
||||
dirs.push(Path::new("/usr/lib").join(triplet));
|
||||
dirs.push(Path::new("/lib").join(triplet));
|
||||
dirs
|
||||
}
|
||||
|
||||
/// `ldconfig -p`, from the usual locations (`/sbin` isn't on a normal user's PATH on Debian).
|
||||
fn read_ldconfig() -> Option<String> {
|
||||
for cmd in ["/sbin/ldconfig", "/usr/sbin/ldconfig", "ldconfig"] {
|
||||
if let Ok(out) = std::process::Command::new(cmd).arg("-p").output()
|
||||
&& out.status.success()
|
||||
{
|
||||
let text = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
if ldconfig_output_is_valid(&text) {
|
||||
return Some(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Run the check against this system. `Err` carries the message to print before exiting with a
|
||||
/// non-zero status; a warning is printed here and the app starts.
|
||||
pub fn preflight() -> Result<(), String> {
|
||||
if std::env::var_os("PHOTOCRAFT_SKIP_LIB_CHECK").is_some_and(|v| !v.is_empty() && v != "0") {
|
||||
return Ok(());
|
||||
}
|
||||
let session = session_from_env(|k| std::env::var(k).ok());
|
||||
if session == DisplaySession::None {
|
||||
return Ok(());
|
||||
}
|
||||
let probe = Probe { ldconfig: read_ldconfig(), dirs: search_dirs(std::env::var("LD_LIBRARY_PATH").ok().as_deref()) };
|
||||
match check(session, &probe, |p| p.exists()) {
|
||||
Verdict::Ok => Ok(()),
|
||||
Verdict::Missing { missing, conclusive } => {
|
||||
let distro = std::fs::read_to_string("/etc/os-release").map(|t| distro_from_os_release(&t)).unwrap_or(Distro::Unknown);
|
||||
let msg = missing_message(session, &missing, distro, conclusive);
|
||||
if conclusive {
|
||||
Err(msg)
|
||||
} else {
|
||||
eprint!("{msg}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
|
||||
let map: HashMap<String, String> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
|
||||
move |k| map.get(k).cloned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_follows_winit_order() {
|
||||
assert_eq!(session_from_env(env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")])), DisplaySession::Wayland);
|
||||
assert_eq!(session_from_env(env(&[("WAYLAND_SOCKET", "3")])), DisplaySession::Wayland);
|
||||
assert_eq!(session_from_env(env(&[("WAYLAND_DISPLAY", ""), ("DISPLAY", ":99")])), DisplaySession::X11);
|
||||
assert_eq!(session_from_env(env(&[("DISPLAY", "")])), DisplaySession::None);
|
||||
assert_eq!(session_from_env(env(&[])), DisplaySession::None);
|
||||
}
|
||||
|
||||
const CACHE: &str = "1234 libs found in cache `/etc/ld.so.cache'\n\
|
||||
\tlibxkbcommon.so.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxkbcommon.so.0\n\
|
||||
\tlibxkbcommon-x11.so.0 (libc6) => /lib/i386-linux-gnu/libxkbcommon-x11.so.0\n\
|
||||
\tlibX11.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libX11.so.6\n\
|
||||
\tlibX11-xcb.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libX11-xcb.so.1\n\
|
||||
\tlibxcb.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxcb.so.1\n\
|
||||
\tlibXcursor.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libXcursor.so.1\n\
|
||||
\tlibXi.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libXi.so.6\n\
|
||||
\tlibEGL.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libEGL.so.1\n\
|
||||
\tlibvulkan.so.1 (libc6,AArch64) => /lib/aarch64-linux-gnu/libvulkan.so.1\n";
|
||||
|
||||
#[test]
|
||||
fn ldconfig_parsing_matches_name_and_architecture() {
|
||||
assert!(ldconfig_output_is_valid(CACHE));
|
||||
assert!(!ldconfig_output_is_valid("ldconfig: command not found"));
|
||||
assert!(ldconfig_has(CACHE, "libxkbcommon.so.0", Some("x86-64")));
|
||||
// Only the 32-bit build is installed: doesn't count for a 64-bit app.
|
||||
assert!(!ldconfig_has(CACHE, "libxkbcommon-x11.so.0", Some("x86-64")));
|
||||
assert!(ldconfig_has(CACHE, "libxkbcommon-x11.so.0", None));
|
||||
assert!(ldconfig_has(CACHE, "libvulkan.so.1", Some("AArch64")));
|
||||
assert!(!ldconfig_has(CACHE, "libvulkan.so.1", Some("x86-64")));
|
||||
// Prefix of another name, or a missing name, is not a match.
|
||||
assert!(!ldconfig_has(CACHE, "libX11.so", None));
|
||||
assert!(!ldconfig_has(CACHE, "libwayland-client.so.0", None));
|
||||
assert!(!ldconfig_has("", "libX11.so.6", None));
|
||||
assert!(!ldconfig_has("garbage\n\t(\n)) =>", "libX11.so.6", Some("x86-64")));
|
||||
}
|
||||
|
||||
fn cache_for_this_arch(names: &[&str]) -> String {
|
||||
let tag = ldconfig_arch_tag().map(|t| format!("libc6,{t}")).unwrap_or_else(|| "libc6".into());
|
||||
let mut s = format!("{} libs found in cache `/etc/ld.so.cache'\n", names.len());
|
||||
for n in names {
|
||||
s.push_str(&format!("\t{n} ({tag}) => /usr/lib/{n}\n"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
const ALL_X11: &[&str] =
|
||||
&["libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libXcursor.so.1", "libXi.so.6", "libxkbcommon.so.0", "libxkbcommon-x11.so.0", "libEGL.so.1"];
|
||||
|
||||
#[test]
|
||||
fn x11_without_xkbcommon_x11_is_reported() {
|
||||
// Issue #201: Ubuntu server + Xvfb with only libxkbcommon0 installed.
|
||||
let names: Vec<&str> = ALL_X11.iter().copied().filter(|n| *n != "libxkbcommon-x11.so.0").collect();
|
||||
let probe = Probe { ldconfig: Some(cache_for_this_arch(&names)), dirs: vec![] };
|
||||
let v = check(DisplaySession::X11, &probe, |_| false);
|
||||
let Verdict::Missing { missing, conclusive } = v else { panic!("expected missing, got {v:?}") };
|
||||
assert!(conclusive);
|
||||
assert_eq!(missing.len(), 1);
|
||||
assert_eq!(missing[0][0].soname, "libxkbcommon-x11.so.0");
|
||||
let msg = missing_message(DisplaySession::X11, &missing, Distro::Debian, conclusive);
|
||||
assert!(msg.contains("libxkbcommon-x11.so.0"), "{msg}");
|
||||
assert!(msg.contains("sudo apt install libxkbcommon-x11-0"), "{msg}");
|
||||
assert!(msg.contains("PHOTOCRAFT_SKIP_LIB_CHECK"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_systems_pass() {
|
||||
let probe = Probe { ldconfig: Some(cache_for_this_arch(ALL_X11)), dirs: vec![] };
|
||||
assert_eq!(check(DisplaySession::X11, &probe, |_| false), Verdict::Ok);
|
||||
let wayland = cache_for_this_arch(&["libwayland-client.so.0", "libxkbcommon.so.0", "libvulkan.so.1"]);
|
||||
let probe = Probe { ldconfig: Some(wayland), dirs: vec![] };
|
||||
assert_eq!(check(DisplaySession::Wayland, &probe, |_| false), Verdict::Ok);
|
||||
// Wayland doesn't need the X11 libraries; no session needs nothing.
|
||||
assert_eq!(check(DisplaySession::None, &Probe { ldconfig: None, dirs: vec![] }, |_| false), Verdict::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_needs_vulkan_or_egl() {
|
||||
let probe = Probe { ldconfig: Some(cache_for_this_arch(&["libwayland-client.so.0", "libxkbcommon.so.0"])), dirs: vec![] };
|
||||
let Verdict::Missing { missing, .. } = check(DisplaySession::Wayland, &probe, |_| false) else { panic!("expected missing") };
|
||||
assert_eq!(missing.len(), 1);
|
||||
let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Fedora, true);
|
||||
assert!(msg.contains("libvulkan.so.1 or libEGL.so.1"), "{msg}");
|
||||
assert!(msg.contains("sudo dnf install vulkan-loader"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_dirs_count_and_unread_cache_only_warns() {
|
||||
let dirs = search_dirs(Some("/opt/x/lib::/nix/store/abc/lib"));
|
||||
assert_eq!(dirs[0], PathBuf::from("/opt/x/lib"));
|
||||
assert_eq!(dirs[1], PathBuf::from("/nix/store/abc/lib"));
|
||||
// Found by file in a directory (no ldconfig, as on NixOS).
|
||||
let probe = Probe { ldconfig: None, dirs: vec![PathBuf::from("/nix/lib")] };
|
||||
let present = ["/nix/lib/libwayland-client.so.0", "/nix/lib/libxkbcommon.so.0", "/nix/lib/libEGL.so.1"];
|
||||
assert_eq!(check(DisplaySession::Wayland, &probe, |p| present.iter().any(|q| Path::new(q) == p)), Verdict::Ok);
|
||||
// Not found and no cache to confirm it: inconclusive, so a warning rather than an exit.
|
||||
let v = check(DisplaySession::Wayland, &probe, |_| false);
|
||||
let Verdict::Missing { missing, conclusive } = v else { panic!("expected missing") };
|
||||
assert!(!conclusive);
|
||||
let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Unknown, conclusive);
|
||||
assert!(msg.contains("Starting anyway"), "{msg}");
|
||||
assert!(msg.contains("apt install libwayland-client0 libxkbcommon0 libvulkan1"), "{msg}");
|
||||
assert!(msg.contains("dnf install libwayland-client libxkbcommon vulkan-loader"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distro_detection() {
|
||||
assert_eq!(distro_from_os_release("NAME=\"Ubuntu\"\nID=ubuntu\nID_LIKE=debian\n"), Distro::Debian);
|
||||
assert_eq!(distro_from_os_release("ID=linuxmint\nID_LIKE=\"ubuntu debian\"\n"), Distro::Debian);
|
||||
assert_eq!(distro_from_os_release("ID=fedora\n"), Distro::Fedora);
|
||||
assert_eq!(distro_from_os_release("ID=\"rocky\"\nID_LIKE=\"rhel centos fedora\"\n"), Distro::Fedora);
|
||||
assert_eq!(distro_from_os_release("ID=endeavouros\nID_LIKE=arch\n"), Distro::Arch);
|
||||
assert_eq!(distro_from_os_release("ID=\"opensuse-tumbleweed\"\nID_LIKE=\"opensuse suse\"\n"), Distro::Suse);
|
||||
assert_eq!(distro_from_os_release("ID=nixos\n"), Distro::Unknown);
|
||||
assert_eq!(distro_from_os_release(""), Distro::Unknown);
|
||||
assert_eq!(distro_from_os_release("=\n==\nID\n"), Distro::Unknown);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! macOS title bar dragging. The window draws under a transparent title bar (`main.rs`), so AppKit
|
||||
//! would otherwise drag the window from anywhere in that strip, menus included: pressing a menu
|
||||
//! title and dragging down to an item moved the window instead. Turning AppKit's own dragging off
|
||||
//! leaves only the app's drag region (the free gap between the menus and the controls,
|
||||
//! `panels::title_bar`), which starts a drag explicitly (`ViewportCommand::StartDrag`, AppKit's
|
||||
//! `performWindowDragWithEvent:`, which works on an unmovable window). All safe objc2 calls.
|
||||
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::NSApplication;
|
||||
|
||||
/// Stop AppKit dragging the app's windows by their title bar strip.
|
||||
pub fn disable_native_title_drag() {
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
log::warn!("title bar: not on the main thread; AppKit window dragging left on");
|
||||
return;
|
||||
};
|
||||
for window in NSApplication::sharedApplication(mtm).windows().iter() {
|
||||
window.setMovable(false);
|
||||
}
|
||||
}
|
||||
+337
-36
@@ -2,7 +2,11 @@
|
||||
//!
|
||||
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
|
||||
//! --control-token-file <path>] [--automation-read-root <dir>]
|
||||
//! [--automation-write-root <dir>] [files…]`
|
||||
//! [--automation-write-root <dir>] [--safe-gpu] [files…]`
|
||||
//!
|
||||
//! `--safe-gpu` starts with the CPU renderer (no GPU canvas; a software adapter for the window
|
||||
//! where the platform has one) for this launch, e.g. after a graphics driver crash. A start that
|
||||
//! crashes inside the driver also falls back by itself next time (see `gpu_startup`).
|
||||
//!
|
||||
//! `--control <port>` (or `PHOTOCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server.
|
||||
//! The first line must authenticate; subsequent request lines get reply lines.
|
||||
@@ -13,12 +17,25 @@
|
||||
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod app_dirs;
|
||||
mod app_icon;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod control_server;
|
||||
mod crash_guard;
|
||||
mod cursor;
|
||||
mod gpu_startup;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_window;
|
||||
// Pure logic is tested on every platform; only Linux runs the check.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
mod linux_libs;
|
||||
mod monitor_profile;
|
||||
mod services;
|
||||
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", test))]
|
||||
mod tablet;
|
||||
mod ui_state;
|
||||
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_ui_egui::PhotocraftApp;
|
||||
@@ -26,32 +43,103 @@ use photocraft_ui_egui::PhotocraftApp;
|
||||
/// Matches the `.desktop` file and hicolor icon name, so Wayland docks pick up the icon.
|
||||
const APP_ID: &str = "ai.storyteller.photocraft";
|
||||
|
||||
/// Window, taskbar and (when running unbundled) Dock icon. macOS gets the padded 1024 px render
|
||||
/// on Apple's icon grid; elsewhere the tighter 256 px hicolor render reads better at small sizes.
|
||||
fn app_icon() -> egui::IconData {
|
||||
#[cfg(target_os = "macos")]
|
||||
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png");
|
||||
eframe::icon_data::from_png_bytes(PNG).unwrap_or_default()
|
||||
/// The main window: 1440 × 900 (shrunk to fit the monitor, and maximized on the first frame
|
||||
/// when it still doesn't fit, `work_area::fit_window`), centred on the main monitor. Without
|
||||
/// `centered`, Windows cascades each new window from the top-left corner, so it opened at a
|
||||
/// different offset every launch (#419). Wayland compositors place windows themselves.
|
||||
fn native_options() -> eframe::NativeOptions {
|
||||
eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_icon(app_icon::window_icon())
|
||||
.with_app_id(APP_ID)
|
||||
.with_title("PhotoCraft")
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([760.0, 480.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false),
|
||||
centered: true,
|
||||
// eframe saves native window geometry and egui panel/window sizes on exit.
|
||||
// Keep that state beside preferences, including config overrides and portable mode.
|
||||
persistence_path: services::config_dir().map(|dir| dir.join("ui.ron")),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `--control` / `PHOTOCRAFT_CONTROL_PORT` value. An unparseable port is an error that
|
||||
/// names the value (issue #701): silently running with no control server leaves a launcher or
|
||||
/// agent unable to tell a typo from a successful grant.
|
||||
fn parse_control_port(value: &str, source: &str) -> Result<u16, String> {
|
||||
value.trim().parse().map_err(|_| format!("{source}: `{value}` is not a valid port (expected a number from 0 to 65535)"))
|
||||
}
|
||||
|
||||
/// Process exit status for the collected `--control` / `PHOTOCRAFT_CONTROL_PORT` errors: `None`
|
||||
/// when there are none, else 2 (a command-line usage error), never 0.
|
||||
fn control_args_exit_code(errors: &[String]) -> Option<i32> {
|
||||
if errors.is_empty() { None } else { Some(2) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod control_port_tests {
|
||||
use super::{control_args_exit_code, parse_control_port};
|
||||
|
||||
#[test]
|
||||
fn control_port_errors_exit_non_zero() {
|
||||
assert_eq!(control_args_exit_code(&[]), None);
|
||||
let errors = vec![parse_control_port("nope", "--control").unwrap_err()];
|
||||
assert_eq!(control_args_exit_code(&errors), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_port_accepts_valid_numbers() {
|
||||
assert_eq!(parse_control_port("50494", "--control"), Ok(50494));
|
||||
assert_eq!(parse_control_port(" 8080 ", "--control"), Ok(8080));
|
||||
assert_eq!(parse_control_port("0", "PHOTOCRAFT_CONTROL_PORT"), Ok(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_port_rejects_unparseable_values_naming_them() {
|
||||
// Out of range, a following flag eaten as the value, and an empty value all fail,
|
||||
// naming the offending value in the message.
|
||||
let err = parse_control_port("78787", "--control").unwrap_err();
|
||||
assert!(err.contains("78787"), "{err}");
|
||||
assert!(parse_control_port("--safe-gpu", "--control").is_err());
|
||||
assert!(parse_control_port("", "--control").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
crash_guard::install_hook();
|
||||
let mut control_port: Option<u16> = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut control_port: Option<u16> = None;
|
||||
let mut control_arg_errors: Vec<String> = Vec::new();
|
||||
if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) {
|
||||
match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") {
|
||||
Ok(port) => control_port = Some(port),
|
||||
Err(error) => control_arg_errors.push(error),
|
||||
}
|
||||
}
|
||||
let mut control_token = None;
|
||||
let mut control_token_file = None;
|
||||
let mut automation_read_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from);
|
||||
let mut automation_write_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from);
|
||||
let mut files = Vec::new();
|
||||
let mut safe_gpu = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--control" => match args.next() {
|
||||
Some(value) => match parse_control_port(&value, "--control") {
|
||||
Ok(port) => control_port = Some(port),
|
||||
Err(error) => control_arg_errors.push(error),
|
||||
},
|
||||
None => control_arg_errors.push("--control: missing port value (expected `--control <port>`)".to_string()),
|
||||
},
|
||||
"--control-token" => control_token = args.next(),
|
||||
"--control-token-file" => control_token_file = args.next().map(std::path::PathBuf::from),
|
||||
"--automation-read-root" => automation_read_root = args.next().map(std::path::PathBuf::from),
|
||||
"--automation-write-root" => automation_write_root = args.next().map(std::path::PathBuf::from),
|
||||
"--safe-gpu" => safe_gpu = true,
|
||||
"--version" => {
|
||||
println!("photocraft {}", photocraft_engine::build_info::long_version());
|
||||
return Ok(());
|
||||
@@ -62,6 +150,24 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed control port must not silently drop the control server (issue #701): name the
|
||||
// bad value and fail the launch, like `photocraft-cli serve --port` does for the same typo.
|
||||
// Exit status 2 is the usual command-line usage error, so a launcher sees the failure.
|
||||
if let Some(code) = control_args_exit_code(&control_arg_errors) {
|
||||
for error in &control_arg_errors {
|
||||
eprintln!("photocraft: {error}");
|
||||
}
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
// winit and wgpu dlopen the windowing and GPU libraries, and some of those crates panic when
|
||||
// one is missing (issue #201). Name the package to install and exit instead.
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Err(message) = linux_libs::preflight() {
|
||||
eprint!("{message}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let control = if let Some(port) = control_port {
|
||||
let (supplied, token_file) = photocraft_automation::security::token_inputs(control_token, control_token_file);
|
||||
let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) {
|
||||
@@ -96,44 +202,150 @@ fn main() -> eframe::Result {
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = &apple_events;
|
||||
|
||||
// Read the main display's ICC profile while the window opens (colour-managed canvas).
|
||||
// Pen tablet samples on macOS (AppKit event monitor, before winit sees each event) and X11
|
||||
// (started once eframe says which display server it is on). The monitor lives until the event
|
||||
// loop returns.
|
||||
let stylus_feed = photocraft_ui_egui::stylus::StylusFeed::default();
|
||||
#[cfg(target_os = "macos")]
|
||||
let _tablet = tablet::install_macos(&stylus_feed);
|
||||
|
||||
// Read the displays' ICC profiles while the window opens (colour-managed canvas; `None`
|
||||
// where the platform has no reader).
|
||||
let monitor = monitor_profile::detect_async();
|
||||
// Brush presets load in the background; the app attaches them when they arrive.
|
||||
let presets = services::presets_dir().map(photocraft_engine::preset_store::open_dir_async);
|
||||
let mut options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_icon(app_icon())
|
||||
.with_app_id(APP_ID)
|
||||
.with_title("PhotoCraft")
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([760.0, 480.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false),
|
||||
..Default::default()
|
||||
let mut options = native_options();
|
||||
// eframe restores the saved window layout before our code runs; drop values that would crash it.
|
||||
ui_state::sanitize(options.persistence_path.as_deref());
|
||||
// Crash-safe GPU startup (#4): pick the backend (a marker left by a start that died in the
|
||||
// driver moves to a safer one), and lock this start's marker until the first frames render.
|
||||
let t_sentinel = std::time::Instant::now();
|
||||
let os = gpu_startup::Os::current();
|
||||
let (pref, mode) = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref());
|
||||
let (previous, sentinel) = match services::config_dir() {
|
||||
Some(dir) => gpu_startup::Sentinel::begin(&dir),
|
||||
None => (gpu_startup::Previous::Clean, None),
|
||||
};
|
||||
let env_backend = std::env::var("WGPU_BACKEND").ok();
|
||||
let plan = gpu_startup::plan_with_mode(pref, mode, previous.crashed(), env_backend.as_deref(), safe_gpu, os);
|
||||
if let Some(m) = previous.crashed() {
|
||||
log::warn!("the previous start didn't finish (GPU backend {}, adapter {:?}); {}", m.backend, m.adapter, plan.reason.as_deref().unwrap_or(""));
|
||||
}
|
||||
let sentinel: gpu_startup::SharedSentinel = std::sync::Arc::new(std::sync::Mutex::new(sentinel));
|
||||
if let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_mut() {
|
||||
let backend = match &plan.env {
|
||||
Some(v) => format!("env:{v}"),
|
||||
None => plan.backend.name().to_string(),
|
||||
};
|
||||
let marker = gpu_startup::Marker { backend, version: photocraft_engine::build_info::long_version().to_string(), ..Default::default() };
|
||||
if let Err(e) = s.write(marker) {
|
||||
log::warn!("GPU startup marker: {e}");
|
||||
}
|
||||
}
|
||||
let gpu_note: std::sync::Arc<std::sync::Mutex<Option<String>>> = Default::default();
|
||||
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
|
||||
photocraft_ui_egui::gpu_canvas::use_adapter_limits(&mut options.wgpu_options.wgpu_setup);
|
||||
eframe::run_native(
|
||||
"Photocraft",
|
||||
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
|
||||
let sentinel_ms = t_sentinel.elapsed().as_secs_f64() * 1000.0;
|
||||
log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan);
|
||||
let retry_cpu = !safe_gpu && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu;
|
||||
let app_created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let created_in_callback = app_created.clone();
|
||||
let started_sentinel = sentinel.clone();
|
||||
let result = eframe::run_native(
|
||||
"PhotoCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
created_in_callback.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let automation = control.as_ref().map(|(_, _, workspace)| workspace.clone());
|
||||
let mut services = services::native(automation);
|
||||
services.preset_store = presets;
|
||||
#[cfg(target_os = "linux")]
|
||||
let display = tablet::DisplayKind::of(cc);
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
services.is_wayland = display == Some(tablet::DisplayKind::Wayland);
|
||||
}
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if let Ok(Some(icc)) = monitor.recv_timeout(std::time::Duration::from_secs(2)) {
|
||||
app.session.color.monitor_profile = Some(std::sync::Arc::new(icc));
|
||||
// Only the title bar's free gap drags the window, never the menus (mac_window.rs).
|
||||
#[cfg(target_os = "macos")]
|
||||
mac_window::disable_native_title_drag();
|
||||
// Long commands and file opens run as background jobs with progress and Cancel (#210).
|
||||
app.background_jobs = std::env::var_os("PHOTOCRAFT_INLINE_JOBS").is_none();
|
||||
// Displays and their profiles (#569): wait briefly so the first frames already use
|
||||
// the right profile; a slower reading is applied when it arrives, and the shell reads
|
||||
// again when the displays may have changed.
|
||||
if let Some(rx) = monitor {
|
||||
app.services.read_displays = Some(std::sync::Arc::new(monitor_profile::detect_async));
|
||||
match rx.recv_timeout(std::time::Duration::from_secs(2)) {
|
||||
Ok(r) => photocraft_ui_egui::monitor_status::apply(&mut app, r),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => photocraft_ui_egui::monitor_status::pending(&mut app, rx),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
photocraft_ui_egui::monitor_status::apply(&mut app, Err("the display profile reader stopped without an answer".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Preferences › Performance › Use Graphics Processor.
|
||||
if let Some(rs) = cc.wgpu_render_state.clone()
|
||||
&& std::env::var_os("PHOTOCRAFT_CPU_CANVAS").is_none()
|
||||
&& app.session.prefs().performance.use_gpu
|
||||
{
|
||||
app.set_wgpu(rs);
|
||||
// Preferences › Performance › Use Graphics Processor (and the GPU backend: `cpu`
|
||||
// composites on the CPU).
|
||||
let info = &mut app.perf.gpu_info;
|
||||
info.preference = pref.name().to_string();
|
||||
info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() };
|
||||
info.fallback = match (plan.reason.clone(), gpu_note.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()) {
|
||||
(Some(reason), Some(note)) => Some(format!("{reason}. {note}")),
|
||||
(reason, note) => reason.or(note),
|
||||
};
|
||||
info.canvas = "cpu".into();
|
||||
if let Some(rs) = cc.wgpu_render_state.clone() {
|
||||
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
|
||||
let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu;
|
||||
if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu {
|
||||
app.perf.gpu_info.fallback = Some("No compatible hardware graphics adapter; using software graphics.".into());
|
||||
}
|
||||
if !software_window
|
||||
&& plan.backend != photocraft_engine::prefs::GpuBackend::Cpu
|
||||
&& std::env::var_os("PHOTOCRAFT_CPU_CANVAS").is_none()
|
||||
&& app.session.prefs().performance.effective_rendering_mode() != photocraft_engine::prefs::RenderingMode::Cpu
|
||||
{
|
||||
app.set_wgpu(rs);
|
||||
} else {
|
||||
// The window still draws with wgpu: record its errors instead of panicking.
|
||||
let _ = photocraft_ui_egui::gpu_canvas::DeviceHealth::watch(&rs.device);
|
||||
}
|
||||
}
|
||||
let fallback_reason = std::env::var("PHOTOCRAFT_GPU_STARTUP_FAILURE").ok().or_else(|| {
|
||||
(app.perf.gpu_info.canvas == "cpu" && mode != photocraft_engine::prefs::RenderingMode::Cpu)
|
||||
.then(|| app.perf.gpu_info.fallback.clone())
|
||||
.flatten()
|
||||
});
|
||||
if let Some(reason) = fallback_reason {
|
||||
photocraft_ui_egui::gpu_status::queue_fallback_notice(&mut app, &reason);
|
||||
}
|
||||
app.perf.span("gpuSentinel", sentinel_ms);
|
||||
// Once the first frames rendered: clear the marker, and keep a crash fallback.
|
||||
let remember_cpu =
|
||||
std::env::var_os("PHOTOCRAFT_GPU_STARTUP_FAILURE").is_some() || (plan.remember && plan.backend == photocraft_engine::prefs::GpuBackend::Cpu);
|
||||
let remember = plan.remember.then_some(plan.backend);
|
||||
app.on_started(move |app| {
|
||||
if let Some(s) = started_sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
|
||||
s.finish();
|
||||
}
|
||||
if remember_cpu
|
||||
&& let Err(error) = app.run(
|
||||
"prefs.set",
|
||||
serde_json::json!({"values": {
|
||||
"performance.renderingMode": "cpu", "performance.useGpu": false
|
||||
}}),
|
||||
)
|
||||
{
|
||||
log::warn!("couldn't remember CPU recovery: {error}");
|
||||
}
|
||||
if let Some(b) = remember
|
||||
&& app.session.prefs().performance.gpu_backend != b
|
||||
&& let Err(e) = app.run("prefs.set", serde_json::json!({"path": "performance.gpuBackend", "value": b.name()}))
|
||||
{
|
||||
log::warn!("couldn't remember GPU backend {}: {e}", b.name());
|
||||
}
|
||||
});
|
||||
if let Some((port, token, _)) = control {
|
||||
let rx = control_server::start(port, token, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
@@ -142,9 +354,98 @@ fn main() -> eframe::Result {
|
||||
{
|
||||
app.services.os_events = Some(apple_events.connect(&cc.egui_ctx));
|
||||
}
|
||||
// Where file drags and drops are (winit 0.30 doesn't say).
|
||||
app.services.cursor_pos = cursor::service(cc);
|
||||
// Tablet pressure/tilt/eraser (winit drops them): the macOS monitor installed above
|
||||
// and the X11 reader write into this feed.
|
||||
app.stylus.feed = stylus_feed;
|
||||
#[cfg(target_os = "linux")]
|
||||
tablet::spawn_x11(&app.stylus.feed, display);
|
||||
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
|
||||
app.open_paths(&files);
|
||||
// Portable marker found but its data folder isn't writable (#228): say where settings went.
|
||||
if let Some(w) = &app_dirs::current().warning {
|
||||
photocraft_ui_egui::notices::post(&mut app, "Portable mode is off", vec![w.clone()], false, None);
|
||||
}
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
)
|
||||
);
|
||||
// Closed before the first frames rendered: not a driver crash. (A start that failed to
|
||||
// create its device keeps the marker, so the next one tries a safer backend.)
|
||||
if result.is_ok()
|
||||
&& let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()
|
||||
{
|
||||
s.finish();
|
||||
}
|
||||
// Retry in a fresh process: winit event loops cannot be recreated reliably in-process.
|
||||
// Only renderer initialization failures qualify; never restart after editing has begun.
|
||||
if retry_cpu && !app_created.load(std::sync::atomic::Ordering::Relaxed) && matches!(&result, Err(eframe::Error::Wgpu(_))) {
|
||||
let reason = result.as_ref().err().map(ToString::to_string).unwrap_or_default();
|
||||
if let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
|
||||
s.finish();
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let launched = std::process::Command::new(exe)
|
||||
.args(std::env::args_os().skip(1))
|
||||
.arg("--safe-gpu")
|
||||
.env_remove("WGPU_BACKEND")
|
||||
.env("PHOTOCRAFT_GPU_STARTUP_FAILURE", &reason)
|
||||
.spawn();
|
||||
match launched {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(error) => log::error!("could not start CPU compatibility mode: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn window_and_panel_geometry_survive_a_restart() {
|
||||
let options = super::native_options();
|
||||
assert!(options.persist_window);
|
||||
assert_eq!(options.persistence_path, super::services::config_dir().map(|dir| dir.join("ui.ron")));
|
||||
|
||||
#[derive(Default)]
|
||||
struct Storage(std::collections::BTreeMap<String, String>);
|
||||
impl eframe::Storage for Storage {
|
||||
fn get_string(&self, key: &str) -> Option<String> {
|
||||
self.0.get(key).cloned()
|
||||
}
|
||||
fn set_string(&mut self, key: &str, value: String) {
|
||||
self.0.insert(key.into(), value);
|
||||
}
|
||||
fn remove_string(&mut self, key: &str) {
|
||||
self.0.remove(key);
|
||||
}
|
||||
fn flush(&mut self) {}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let input = || egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))), ..Default::default() };
|
||||
let mut output = ctx.run_ui(input(), |ui| {
|
||||
egui::Panel::right("dock").exact_size(410.0).show(ui, |ui| ui.set_min_width(ui.available_width()));
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
let mut storage = Storage::default();
|
||||
ctx.memory(|memory| eframe::set_value(&mut storage, "egui", memory));
|
||||
let restored = egui::Context::default();
|
||||
restored.memory_mut(|memory| *memory = eframe::get_value(&storage, "egui").unwrap());
|
||||
let mut output = restored.run_ui(input(), |ui| {
|
||||
egui::Panel::right("dock").default_size(290.0).show(ui, |ui| ui.set_min_width(ui.available_width()));
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
let panel = egui::containers::panel::PanelState::load(&restored, egui::Id::new("dock")).unwrap();
|
||||
assert_eq!(panel.size().x, 410.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_window_opens_centred_at_its_default_size() {
|
||||
let o = super::native_options();
|
||||
assert!(o.centered, "#419: centred, not cascaded from the top-left corner");
|
||||
assert_eq!(o.viewport.inner_size, Some(egui::vec2(1440.0, 900.0)));
|
||||
// eframe shrinks the start size to the monitor, so the centred position is on-screen.
|
||||
assert_ne!(o.viewport.clamp_size_to_monitor_size, Some(false));
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,145 @@
|
||||
//! The main display's ICC profile, for the colour-managed canvas (Edit › Color Settings ›
|
||||
//! Monitor Profile = `auto`).
|
||||
//! The displays and their ICC profiles, for the colour-managed canvas (Edit › Color Settings ›
|
||||
//! Monitor Profile = `auto`; #569).
|
||||
//!
|
||||
//! macOS: `NSScreen.mainScreen.colorSpace.ICCProfileData`, a documented AppKit API, read through
|
||||
//! `osascript` (AppleScriptObjC) so the app needs no `unsafe` FFI. The query runs on a background
|
||||
//! thread at launch (about 0.4 s) and the profile is applied when it arrives. Other platforms
|
||||
//! return `None` (sRGB, or the profile chosen in Color Settings).
|
||||
//! macOS: every `NSScreen` with its `CGDirectDisplayID`, name, frame and
|
||||
//! `colorSpace.ICCProfileData`, documented AppKit APIs read through `osascript` (AppKit via
|
||||
//! AppleScriptObjC) so the app needs no `unsafe` FFI. A read takes about 0.4 s on a background
|
||||
//! thread; the shell starts one at launch and again when the displays may have changed
|
||||
//! (`photocraft_ui_egui::monitor_status`), and each window then uses the profile of the display
|
||||
//! it is on. Other platforms report no displays (sRGB, or the profile chosen in Color Settings).
|
||||
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
/// Starts reading the main display's profile in the background.
|
||||
pub fn detect_async() -> Receiver<Option<Vec<u8>>> {
|
||||
use photocraft_engine::display_color::Display;
|
||||
|
||||
/// What the platform reader returns: the displays, or why there are none.
|
||||
pub type Detection = Result<Vec<Display>, String>;
|
||||
|
||||
/// Starts reading the displays in the background; `None` when the platform has no reader.
|
||||
pub fn detect_async() -> Option<Receiver<Detection>> {
|
||||
if !cfg!(target_os = "macos") {
|
||||
return None;
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(detect());
|
||||
});
|
||||
rx
|
||||
Some(rx)
|
||||
}
|
||||
|
||||
/// One line per screen, primary (menu-bar) screen first: id, frame (x, y, width, height in
|
||||
/// points, Cocoa's bottom-left origin), the profile as base64, the profile's name in System
|
||||
/// Settings and the display's name, tab-separated. Numbers as integers (text conversion of
|
||||
/// reals follows the user's locale, e.g. "0,0").
|
||||
#[cfg(target_os = "macos")]
|
||||
fn detect() -> Option<Vec<u8>> {
|
||||
let out = std::process::Command::new("/usr/bin/osascript")
|
||||
.args([
|
||||
"-e",
|
||||
"use framework \"AppKit\"",
|
||||
"-e",
|
||||
"return ((current application's NSScreen's mainScreen()'s colorSpace()'s ICCProfileData()'s base64EncodedStringWithOptions:0) as text)",
|
||||
])
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
const SCRIPT: &str = r#"use framework "AppKit"
|
||||
set out to ""
|
||||
repeat with s in (current application's NSScreen's screens() as list)
|
||||
set f to s's frame()
|
||||
set n to ((s's deviceDescription()'s objectForKey:"NSScreenNumber") as integer)
|
||||
set icc to ""
|
||||
set cs to s's colorSpace()
|
||||
set csName to ""
|
||||
if cs is not missing value then
|
||||
set csName to ((cs's localizedName()) as text)
|
||||
set d to cs's ICCProfileData()
|
||||
if d is not missing value then set icc to ((d's base64EncodedStringWithOptions:0) as text)
|
||||
end if
|
||||
set out to out & n & tab & ((item 1 of item 1 of f) as integer) & tab & ((item 2 of item 1 of f) as integer) & tab & ((item 1 of item 2 of f) as integer) & tab & ((item 2 of item 2 of f) as integer) & tab & icc & tab & csName & tab & ((s's localizedName()) as text) & linefeed
|
||||
end repeat
|
||||
return out"#;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn detect() -> Detection {
|
||||
let mut cmd = std::process::Command::new("/usr/bin/osascript");
|
||||
cmd.args(["-e", SCRIPT]);
|
||||
parse_reply(&run_helper(&mut cmd, HELPER_TIMEOUT)?)
|
||||
}
|
||||
|
||||
/// How long a reading may take before the helper is stopped (it normally takes about 0.4 s).
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
const HELPER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// Run the helper and return its standard output; a helper that fails, or runs longer than
|
||||
/// `timeout`, is an error (and is killed and reaped), so a hung reading can't block later ones.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn run_helper(cmd: &mut std::process::Command, timeout: std::time::Duration) -> Result<String, String> {
|
||||
use std::io::Read;
|
||||
use std::process::Stdio;
|
||||
let mut child =
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("couldn't run the display profile reader: {e}"))?;
|
||||
// Drain both pipes while waiting: a reply larger than the pipe buffer would otherwise block
|
||||
// the helper until the deadline.
|
||||
let drain = |p: Option<Box<dyn Read + Send>>| {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
if let Some(mut p) = p {
|
||||
let _ = p.read_to_end(&mut buf);
|
||||
}
|
||||
buf
|
||||
})
|
||||
};
|
||||
let out = drain(child.stdout.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
|
||||
let err = drain(child.stderr.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status,
|
||||
Ok(None) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(20)),
|
||||
Ok(None) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(format!("reading the display profiles took longer than {} s and was stopped", timeout.as_secs()));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(format!("waiting for the display profile reader failed: {e}"));
|
||||
}
|
||||
}
|
||||
};
|
||||
let out = out.join().unwrap_or_default();
|
||||
let err = err.join().unwrap_or_default();
|
||||
if !status.success() {
|
||||
let err = String::from_utf8_lossy(&err);
|
||||
let first = err.lines().next().unwrap_or("").trim();
|
||||
return Err(format!("reading the display profiles failed ({status}){}", if first.is_empty() { String::new() } else { format!(": {first}") }));
|
||||
}
|
||||
let bytes = base64_decode(std::str::from_utf8(&out.stdout).ok()?.trim())?;
|
||||
// An ICC profile starts with its size and carries `acsp` at offset 36.
|
||||
(bytes.len() >= 132 && bytes.get(36..40) == Some(b"acsp")).then_some(bytes)
|
||||
Ok(String::from_utf8_lossy(&out).into_owned())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn detect() -> Option<Vec<u8>> {
|
||||
None
|
||||
fn detect() -> Detection {
|
||||
Err("this platform doesn't report display profiles".into())
|
||||
}
|
||||
|
||||
/// The helper's reply ([`SCRIPT`]) → displays, frames flipped to a top-left origin with y down
|
||||
/// (the coordinates window positions use). Lines that don't parse are skipped.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn parse_reply(out: &str) -> Detection {
|
||||
let mut displays = Vec::new();
|
||||
let mut primary_height = None;
|
||||
for line in out.lines().filter(|l| !l.trim().is_empty()) {
|
||||
let f: Vec<&str> = line.splitn(8, '\t').collect();
|
||||
let [id, x, y, w, h, b64, profile_name, name] = f.as_slice() else { continue };
|
||||
let num = |s: &str| s.trim().parse::<f64>().ok().filter(|v| v.is_finite());
|
||||
let (Some(id), Some(x), Some(y), Some(w), Some(h)) = (id.trim().parse::<u32>().ok(), num(x), num(y), num(w), num(h)) else { continue };
|
||||
// Cocoa frames are relative to the primary screen's bottom-left corner, y up.
|
||||
let top = *primary_height.get_or_insert(h) - (y + h);
|
||||
let name = name.trim();
|
||||
let name = if name.is_empty() { format!("Display {id}") } else { name.to_string() };
|
||||
let b64 = b64.trim();
|
||||
// An ICC profile starts with its size and carries `acsp` at offset 36; anything else is
|
||||
// treated as no profile (the engine then says the display has none).
|
||||
let icc = (!b64.is_empty()).then(|| base64_decode(b64)).flatten().filter(|b| b.len() >= 132 && b.get(36..40) == Some(b"acsp"));
|
||||
let profile_name = Some(profile_name.trim().to_string()).filter(|n| !n.is_empty());
|
||||
displays.push(Display { id, name, frame: [x, top, w, h], profile_name, icc: icc.map(std::sync::Arc::new) });
|
||||
}
|
||||
if displays.is_empty() {
|
||||
let first = out.lines().next().unwrap_or("").trim();
|
||||
return Err(if first.is_empty() { "the display reader returned nothing".into() } else { format!("couldn't read the display list: {first}") });
|
||||
}
|
||||
Ok(displays)
|
||||
}
|
||||
|
||||
/// Standard base64 (RFC 4648, with padding) → bytes; `None` on any invalid character.
|
||||
@@ -87,12 +187,73 @@ mod tests {
|
||||
assert!(base64_decode("a$==").is_none());
|
||||
}
|
||||
|
||||
/// The detected profile (when there is one) parses as an RGB profile.
|
||||
#[test]
|
||||
fn detected_profile_parses() {
|
||||
if let Some(bytes) = detect() {
|
||||
let p = photocraft_engine::color_cmds::profile_from_bytes(&std::sync::Arc::new(bytes)).expect("parses");
|
||||
assert_eq!(format!("{:?}", p.color_space), "Rgb", "{}", p.description);
|
||||
fn helper_replies() {
|
||||
// Only the ICC signature is checked here; parsing is the engine's job.
|
||||
let mut icc = vec![0u8; 132];
|
||||
icc[36..40].copy_from_slice(b"acsp");
|
||||
let b64 = base64_encode(&icc);
|
||||
// The #569 setup: built-in display primary, the external one right of it and higher up.
|
||||
let reply = format!(
|
||||
"1\t0\t0\t1728\t1117\t{b64}\tColor LCD\tBuilt-in Retina Display\n4\t1728\t92\t3008\t1692\t{b64}\tApple_Display_26-04-13.icc\tROG PG32UQX\n"
|
||||
);
|
||||
let d = parse_reply(&reply).unwrap();
|
||||
assert_eq!(d.len(), 2);
|
||||
assert_eq!((d[0].id, d[0].name.as_str(), d[0].frame), (1, "Built-in Retina Display", [0.0, 0.0, 1728.0, 1117.0]));
|
||||
assert_eq!((d[1].id, d[1].frame), (4, [1728.0, -667.0, 3008.0, 1692.0]));
|
||||
assert_eq!(d[1].profile_name.as_deref(), Some("Apple_Display_26-04-13.icc"));
|
||||
assert_eq!(d[1].icc.as_deref(), Some(&icc));
|
||||
// No profile, a bad profile, no name: kept, without a profile or with a fallback name.
|
||||
let d = parse_reply("2\t0\t0\t800\t600\t\t\t\n3\t800\t0\t800\t600\taGVsbG8=\tX\tY\n").unwrap();
|
||||
assert_eq!((d[0].name.as_str(), d[0].icc.is_none(), d[0].profile_name.is_none()), ("Display 2", true, true));
|
||||
assert!(d[1].icc.is_none());
|
||||
// Garbage lines are skipped; nothing usable is an error.
|
||||
assert_eq!(parse_reply("junk\n5\t0\t0\t10\t10\t\t\tZ").unwrap().len(), 1);
|
||||
assert!(parse_reply("").is_err());
|
||||
assert!(parse_reply("execution error: -1728").unwrap_err().contains("-1728"));
|
||||
}
|
||||
|
||||
// Drives /bin/sleep and /bin/sh; the helper itself only runs on macOS.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_hung_helper_is_stopped_and_big_replies_dont_block() {
|
||||
let t0 = std::time::Instant::now();
|
||||
let e = run_helper(std::process::Command::new("/bin/sleep").arg("30"), std::time::Duration::from_millis(300)).unwrap_err();
|
||||
assert!(e.contains("took longer than"), "{e}");
|
||||
assert!(t0.elapsed() < std::time::Duration::from_secs(5), "killed at the deadline");
|
||||
// 1 MB on stdout (far beyond a pipe buffer) arrives whole.
|
||||
let big = run_helper(std::process::Command::new("/bin/sh").args(["-c", "head -c 1048576 /dev/zero | tr '\\0' a"]), std::time::Duration::from_secs(20))
|
||||
.unwrap();
|
||||
assert_eq!(big.len(), 1 << 20);
|
||||
let e = run_helper(std::process::Command::new("/bin/sh").args(["-c", "echo boom >&2; exit 3"]), std::time::Duration::from_secs(5)).unwrap_err();
|
||||
assert!(e.contains("boom"), "{e}");
|
||||
assert!(run_helper(&mut std::process::Command::new("/nonexistent/helper"), std::time::Duration::from_secs(1)).is_err());
|
||||
}
|
||||
|
||||
fn base64_encode(b: &[u8]) -> String {
|
||||
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut s = String::new();
|
||||
for c in b.chunks(3) {
|
||||
let n = c.iter().enumerate().fold(0u32, |n, (i, v)| n | u32::from(*v) << (16 - 8 * i));
|
||||
for i in 0..=c.len() {
|
||||
s.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
|
||||
}
|
||||
}
|
||||
while !s.len().is_multiple_of(4) {
|
||||
s.push('=');
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// On a Mac, every display is read, and the profiles parse as RGB profiles.
|
||||
#[test]
|
||||
fn detected_displays_parse() {
|
||||
if let Ok(displays) = detect() {
|
||||
for d in displays {
|
||||
let Some(icc) = d.icc else { continue };
|
||||
let p = photocraft_engine::color_cmds::profile_from_bytes(&icc).expect("parses");
|
||||
assert_eq!(format!("{:?}", p.color_space), "Rgb", "{}: {}", d.name, p.description);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+295
-65
@@ -3,11 +3,10 @@
|
||||
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType as CS};
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Layer, Size};
|
||||
use photocraft_format::Autosaver;
|
||||
use photocraft_format::RecoveryStore;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_ui_egui::Services;
|
||||
use photocraft_ui_egui::{Recovered, Services};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
@@ -15,15 +14,23 @@ use std::sync::Arc;
|
||||
/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop
|
||||
/// brushes (.abr) and gradients (.grd), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm",
|
||||
"dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
|
||||
"pcraft", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam",
|
||||
"pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
|
||||
];
|
||||
|
||||
/// File › Save As formats: (filter name, extensions). The filter matching the suggested name's
|
||||
/// extension comes first, so a .pcraft document saves as .pcraft by default and everything else
|
||||
/// keeps defaulting to Photoshop.
|
||||
const SAVE_FILTERS: &[(&str, &[&str])] =
|
||||
&[("Photoshop", &["psd", "psb"]), ("PhotoCraft", &["pcraft"]), ("PNG", &["png"]), ("JPEG", &["jpg"]), ("TIFF", &["tif"]), ("OpenEXR", &["exr"])];
|
||||
const SAVE_FILTERS: &[(&str, &[&str])] = &[
|
||||
("Photoshop", &["psd", "psb"]),
|
||||
("PhotoCraft", &["pcraft"]),
|
||||
("PNG", &["png"]),
|
||||
("JPEG", &["jpg"]),
|
||||
("WebP", &["webp"]),
|
||||
("TIFF", &["tif"]),
|
||||
("Targa", &["tga"]),
|
||||
("OpenEXR", &["exr"]),
|
||||
];
|
||||
|
||||
/// [`SAVE_FILTERS`] with the one for `suggested`'s extension first.
|
||||
fn save_filters(suggested: &str) -> Vec<(&'static str, &'static [&'static str])> {
|
||||
@@ -36,24 +43,14 @@ fn save_filters(suggested: &str) -> Vec<(&'static str, &'static [&'static str])>
|
||||
v
|
||||
}
|
||||
|
||||
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else the platform convention
|
||||
/// (macOS `~/Library/Application Support/Photocraft`, Windows `%APPDATA%\Photocraft`, Linux
|
||||
/// `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`).
|
||||
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else `<exe dir>/PhotoCraftData` in
|
||||
/// portable mode, else the platform convention. Everything the app persists lives under it; see
|
||||
/// [`crate::app_dirs`].
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
if let Some(d) = std::env::var_os("PHOTOCRAFT_CONFIG_DIR") {
|
||||
return Some(PathBuf::from(d));
|
||||
}
|
||||
let home = std::env::var_os("HOME").map(PathBuf::from);
|
||||
if cfg!(target_os = "macos") {
|
||||
return home.map(|h| h.join("Library/Application Support/Photocraft"));
|
||||
}
|
||||
if cfg!(windows) {
|
||||
return std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("Photocraft"));
|
||||
}
|
||||
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home.map(|h| h.join(".config"))).map(|c| c.join("photocraft"))
|
||||
crate::app_dirs::config_dir()
|
||||
}
|
||||
|
||||
fn prefs_file() -> Option<PathBuf> {
|
||||
pub fn prefs_file() -> Option<PathBuf> {
|
||||
config_dir().map(|d| d.join("preferences.json"))
|
||||
}
|
||||
|
||||
@@ -67,19 +64,71 @@ fn recovery_dir() -> Option<PathBuf> {
|
||||
config_dir().map(|d| d.join("Recovery"))
|
||||
}
|
||||
|
||||
/// Write `bytes` atomically (temp file + rename) so a crash never leaves half a preferences file.
|
||||
/// Write `bytes` crash-safely (temp file beside the target, fsync, rename, directory fsync; see
|
||||
/// [`photocraft_format::atomic`]). Every document write (Save, Save As, Export, Save for Web) and
|
||||
/// the preferences go through here, so a failed or interrupted save never destroys the old file.
|
||||
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
photocraft_format::atomic_write(path, bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
type SharedRecovery = Rc<RefCell<Option<RecoveryStore>>>;
|
||||
|
||||
/// Run `f` on the recovery store (`Err` without a config directory). The service closures never
|
||||
/// call each other, so the store is never borrowed twice.
|
||||
fn with_store<R>(store: &SharedRecovery, f: impl FnOnce(&mut RecoveryStore) -> R) -> Result<R, String> {
|
||||
let mut slot = store.try_borrow_mut().map_err(|_| "crash recovery is busy".to_string())?;
|
||||
Ok(f(slot.as_mut().ok_or("no config directory")?))
|
||||
}
|
||||
|
||||
/// Crash recovery: background incremental .pcraft autosaves into `dir` (`None`: no config
|
||||
/// directory, so autosaves fail and nothing is recovered). Recovered documents keep their entries
|
||||
/// until a newer autosave replaces them or they're saved or closed (see [`RecoveryStore`]).
|
||||
fn recovery_services(dir: Option<PathBuf>) -> Services {
|
||||
let store: SharedRecovery = Rc::new(RefCell::new(dir.map(RecoveryStore::new)));
|
||||
let (s1, s2, s3) = (store.clone(), store.clone(), store.clone());
|
||||
Services {
|
||||
autosave: Some(Box::new(move |doc: &Arc<Document>, revision: u64, path: Option<&str>| {
|
||||
with_store(&s1, |s| s.autosave(doc, revision, path.map(str::to_string)))
|
||||
})),
|
||||
discard_autosave: Some(Box::new(move |id: u64| {
|
||||
let _ = with_store(&s2, |s| s.discard(id));
|
||||
})),
|
||||
recover: Some(Box::new(move || {
|
||||
let found = with_store(&s3, |s| s.recover()).unwrap_or_default();
|
||||
found.into_iter().map(|(e, doc)| Recovered { key: e.info.key, path: e.info.original_path, doc }).collect()
|
||||
})),
|
||||
adopt_autosave: Some(Box::new(move |id: u64, key: &str| {
|
||||
let _ = with_store(&store, |s| s.adopt(id, key));
|
||||
})),
|
||||
..Default::default()
|
||||
}
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The pixels to paste when the clipboard holds copied files rather than an image (Copy in
|
||||
/// Files, Finder or Explorer puts paths on the clipboard, #338): the first file that decodes,
|
||||
/// upright, as RGBA8. Files that aren't images are skipped after reading only their header.
|
||||
fn image_from_files(paths: &[PathBuf]) -> Option<(u32, u32, Vec<u8>)> {
|
||||
use std::io::Read;
|
||||
paths.iter().find_map(|path| {
|
||||
// text/uri-list lines end in CRLF (RFC 2483, and GTK writes them so), but arboard splits
|
||||
// on LF only, so a path copied in GNOME Files arrives with a trailing '\r'.
|
||||
let path = path.to_str().map_or_else(|| path.clone(), |s| PathBuf::from(s.trim_end_matches('\r')));
|
||||
let mut head = Vec::with_capacity(256);
|
||||
std::fs::File::open(&path).ok()?.take(256).read_to_end(&mut head).ok()?;
|
||||
// TGA has no magic number and is only guessed from the header, so trust the extension
|
||||
// too before reading what may be a large non-image file.
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||
match photocraft_codecs::detect(&head)? {
|
||||
photocraft_codecs::Format::Tga if photocraft_codecs::from_extension(ext) != Some(photocraft_codecs::Format::Tga) => return None,
|
||||
_ => {}
|
||||
}
|
||||
let img = photocraft_codecs::decode(&std::fs::read(&path).ok()?).ok()?;
|
||||
Some((img.width(), img.height(), img.to_rgba8()))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) -> Services {
|
||||
let savers: Rc<RefCell<HashMap<u64, Autosaver>>> = Rc::default();
|
||||
let savers2 = savers.clone();
|
||||
let clip: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
|
||||
let automation_read = automation.clone().map(|workspace| {
|
||||
Box::new(move |path: &str| {
|
||||
let bytes = workspace.read(path).map_err(|error| error.to_string())?;
|
||||
@@ -90,6 +139,8 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
let automation_write = automation.clone().map(|workspace| {
|
||||
Box::new(move |path: &str, bytes: &[u8]| workspace.write(path, bytes).map_err(|error| error.to_string())) as photocraft_ui_egui::AutomationWriteFn
|
||||
});
|
||||
let step: fn(&str, &serde_json::Value) -> photocraft_engine::Result<()> = photocraft_automation::workspace::authorize_desktop_engine_step;
|
||||
let automation_authorize = automation.is_some().then_some(step);
|
||||
let automation_command = automation.map(|_| {
|
||||
Box::new(|id: &str, params: &serde_json::Value| {
|
||||
photocraft_automation::workspace::authorize_desktop_engine_command(id, params).map_err(|error| error.to_string())
|
||||
@@ -104,13 +155,23 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
opts.tiff_layers = settings.tiff_layers;
|
||||
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
|
||||
crate::crash_guard::guard("Export", || photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()))
|
||||
})),
|
||||
pick_open: Some(Box::new(|| {
|
||||
let path = rfd::FileDialog::new().add_filter("All Formats", OPEN_EXTS).add_filter("PhotoCraft", &["pcraft"]).pick_file()?;
|
||||
let bytes = std::fs::read(&path).ok()?;
|
||||
// A read failure goes back to the app, which reports it like any other open failure.
|
||||
let bytes = photocraft_format::read_file(&path).map_err(|e| e.to_string());
|
||||
Some((path.to_string_lossy().to_string(), bytes))
|
||||
})),
|
||||
pick_open_paths: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter("All Formats", OPEN_EXTS)
|
||||
.add_filter("PhotoCraft", &["pcraft"])
|
||||
.pick_files()
|
||||
.map(|paths| paths.into_iter().map(|path| path.to_string_lossy().into_owned()).collect())
|
||||
})),
|
||||
pick_save: Some(Box::new(|suggested: &str| {
|
||||
let p = std::path::Path::new(suggested);
|
||||
let mut d = rfd::FileDialog::new();
|
||||
@@ -122,51 +183,47 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
}
|
||||
Some(d.save_file()?.to_string_lossy().to_string())
|
||||
})),
|
||||
write: Some(Box::new(|path: &str, bytes: &[u8]| std::fs::write(path, bytes).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(|path: &str, bytes: &[u8]| write_atomic(Path::new(path), bytes))),
|
||||
automation_read,
|
||||
automation_write,
|
||||
automation_command,
|
||||
automation_authorize,
|
||||
encode_png: Some(Box::new(|w, h, rgba| {
|
||||
let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec()).map_err(|e| e.to_string())?;
|
||||
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string())
|
||||
})),
|
||||
inbox: None,
|
||||
open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))),
|
||||
clipboard_set_image: Some(Box::new(|w: u32, h: u32, px: &[u8]| {
|
||||
let mut cb = arboard::Clipboard::new().map_err(|e| e.to_string())?;
|
||||
cb.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(px) }).map_err(|e| e.to_string())
|
||||
})),
|
||||
clipboard_get_image: Some(Box::new(|| {
|
||||
let img = arboard::Clipboard::new().ok()?.get_image().ok()?;
|
||||
Some((img.width as u32, img.height as u32, img.bytes.into_owned()))
|
||||
})),
|
||||
clipboard_set_image: Some({
|
||||
let clip = clip.clone();
|
||||
Box::new(move |w: u32, h: u32, px: &[u8]| {
|
||||
let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?;
|
||||
let cb = match slot.as_mut() {
|
||||
Some(c) => c,
|
||||
None => slot.insert(arboard::Clipboard::new().map_err(|e| e.to_string())?),
|
||||
};
|
||||
cb.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(px) }).map_err(|e| e.to_string())
|
||||
})
|
||||
}),
|
||||
clipboard_get_image: Some({
|
||||
let clip = clip.clone();
|
||||
Box::new(move || {
|
||||
let mut slot = clip.try_borrow_mut().ok()?;
|
||||
let cb = match slot.as_mut() {
|
||||
Some(c) => c,
|
||||
None => slot.insert(arboard::Clipboard::new().ok()?),
|
||||
};
|
||||
// Copied files first: Finder also puts the file's icon on the clipboard as an
|
||||
// image, which would otherwise paste instead of the file.
|
||||
if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) {
|
||||
return Some(img);
|
||||
}
|
||||
let img = cb.get_image().ok()?;
|
||||
Some((img.width as u32, img.height as u32, img.bytes.into_owned()))
|
||||
})
|
||||
}),
|
||||
load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())),
|
||||
save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))),
|
||||
// Crash recovery: background incremental .pcraft saves into the recovery directory.
|
||||
autosave: Some(Box::new(move |doc: &Arc<Document>, revision: u64, path: Option<&str>| {
|
||||
let dir = recovery_dir().ok_or("no config directory")?;
|
||||
let mut map = savers.borrow_mut();
|
||||
let saver = map.entry(doc.id.0).or_insert_with(|| Autosaver::new(&dir, &format!("doc-{}", doc.id.0)));
|
||||
saver.request(doc.clone(), revision, path.map(str::to_string), Default::default());
|
||||
Ok(())
|
||||
})),
|
||||
discard_autosave: Some(Box::new(move |id: u64| {
|
||||
if let Some(s) = savers2.borrow_mut().remove(&id) {
|
||||
let _ = s.discard();
|
||||
}
|
||||
})),
|
||||
recover: Some(Box::new(|| {
|
||||
let Some(dir) = recovery_dir() else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for entry in photocraft_format::list_recovery(&dir) {
|
||||
if let Ok(doc) = photocraft_format::recover(&entry) {
|
||||
out.push((entry.info.original_path.clone(), doc));
|
||||
}
|
||||
// Recovered documents autosave again under their new ids.
|
||||
let _ = photocraft_format::discard_recovery(&dir, &entry);
|
||||
}
|
||||
out
|
||||
})),
|
||||
append_text: Some(Box::new(|path: &str, text: &str| {
|
||||
use std::io::Write;
|
||||
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path).map_err(|e| e.to_string())?;
|
||||
@@ -176,6 +233,8 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
os_events: None,
|
||||
// Set by main, which starts loading the store before the window opens.
|
||||
preset_store: None,
|
||||
is_wayland: false,
|
||||
..recovery_services(recovery_dir())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,3 +299,174 @@ pub fn export_flat(doc: &Document, path: &str) -> Result<Vec<u8>, String> {
|
||||
};
|
||||
photocraft_codecs::encode(&img, format, &EncodeOptions::default()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_format::list_recovery;
|
||||
use photocraft_ui_egui::{PhotocraftApp, prefs_ui};
|
||||
use serde_json::json;
|
||||
|
||||
/// "Export As" formats must lead with their own filter, or the save panel appends the first one's extension (`photo.webp.psd`).
|
||||
#[test]
|
||||
fn save_filters_lead_with_every_export_format() {
|
||||
for ext in ["png", "jpg", "webp", "tif", "tga"] {
|
||||
assert!(save_filters(&format!("photo.{ext}"))[0].1.contains(&ext), "{ext}");
|
||||
}
|
||||
}
|
||||
|
||||
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
|
||||
const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
|
||||
const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
|
||||
|
||||
/// One app launch with crash recovery in `dir` (dropping it is the crash: background writes
|
||||
/// already queued finish, nothing else runs).
|
||||
fn launch(dir: &Path) -> PhotocraftApp {
|
||||
PhotocraftApp::new(Session::new(), recovery_services(Some(dir.to_path_buf())))
|
||||
}
|
||||
|
||||
/// The first pixel of each open document, and whether it's unsaved.
|
||||
fn open_docs(app: &PhotocraftApp) -> Vec<([f32; 4], bool)> {
|
||||
app.session.documents().iter().map(|d| (photocraft_compose::flatten(&d.doc).px.first().copied().unwrap_or_default(), d.is_dirty())).collect()
|
||||
}
|
||||
|
||||
fn index_of(app: &PhotocraftApp, color: [f32; 4]) -> usize {
|
||||
open_docs(app).iter().position(|(c, _)| *c == color).unwrap()
|
||||
}
|
||||
|
||||
fn new_doc(app: &mut PhotocraftApp, color: &str) {
|
||||
app.run("file.new", json!({"width": 4, "height": 4})).unwrap();
|
||||
app.run("edit.fill", json!({"color": color})).unwrap();
|
||||
}
|
||||
|
||||
fn autosave(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
prefs_ui::autosave_now(app);
|
||||
prefs_ui::tick(app, ctx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovered_documents_survive_a_second_crash_until_saved_or_closed() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-recovery-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let ctx = egui::Context::default();
|
||||
// Launch 1: two unsaved documents are autosaved, then the app crashes.
|
||||
{
|
||||
let mut app = launch(&dir);
|
||||
new_doc(&mut app, "#ff0000");
|
||||
new_doc(&mut app, "#0000ff");
|
||||
autosave(&mut app, &ctx);
|
||||
}
|
||||
assert_eq!(list_recovery(&dir).len(), 2);
|
||||
// Launch 2 recovers both and crashes again before its first autosave (#444).
|
||||
{
|
||||
let mut app = launch(&dir);
|
||||
let mut docs = open_docs(&app);
|
||||
docs.sort_by(|a, b| a.0[0].total_cmp(&b.0[0]));
|
||||
assert_eq!(docs, [(BLUE, true), (RED, true)], "recovered and still unsaved");
|
||||
prefs_ui::tick(&mut app, &ctx);
|
||||
}
|
||||
assert_eq!(list_recovery(&dir).len(), 2, "recovering keeps the entries on disk");
|
||||
// Launch 3 has both again; autosaving unchanged documents adds nothing, a new document
|
||||
// (whose id may repeat one from an earlier launch) gets an entry of its own.
|
||||
{
|
||||
let mut app = launch(&dir);
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
autosave(&mut app, &ctx);
|
||||
new_doc(&mut app, "#00ff00");
|
||||
autosave(&mut app, &ctx);
|
||||
}
|
||||
assert_eq!(list_recovery(&dir).len(), 3);
|
||||
// Launch 4: closing and saving recovered documents removes their entries, no duplicates.
|
||||
let mut app = launch(&dir);
|
||||
assert_eq!(app.session.documents().len(), 3);
|
||||
let red = index_of(&app, RED);
|
||||
app.run("file.close", json!({"document": red})).unwrap();
|
||||
// Saving marks the revision saved (the file write itself is the save service's job).
|
||||
assert!(app.session.set_active(index_of(&app, BLUE)));
|
||||
let st = app.session.active_mut().unwrap();
|
||||
st.saved_revision = st.revision;
|
||||
prefs_ui::tick(&mut app, &ctx);
|
||||
let left = list_recovery(&dir);
|
||||
assert_eq!(left.len(), 1);
|
||||
assert_eq!(photocraft_compose::flatten(&photocraft_format::recover(&left[0]).unwrap()).px.first().copied(), Some(GREEN));
|
||||
let green = index_of(&app, GREEN);
|
||||
app.run("file.close", json!({"document": green})).unwrap();
|
||||
prefs_ui::tick(&mut app, &ctx);
|
||||
assert!(list_recovery(&dir).is_empty());
|
||||
drop(app);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn temp(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("photocraft-services-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn red_png(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = Image::from_u8(w, h, ChannelLayout::Rgba, [255u8, 0, 0, 255].repeat((w * h) as usize)).unwrap();
|
||||
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
/// A 1×1 uncompressed 32-bit TGA (TGA has no magic number).
|
||||
fn tga_1x1() -> Vec<u8> {
|
||||
let mut b = vec![0u8, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 32, 8];
|
||||
b.extend_from_slice(&[0, 0, 255, 255]); // BGRA red
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_image_file_pastes_its_pixels() {
|
||||
let dir = temp("png");
|
||||
let png = dir.join("red.png");
|
||||
std::fs::write(&png, red_png(3, 2)).unwrap();
|
||||
let (w, h, px) = image_from_files(&[png]).unwrap();
|
||||
assert_eq!((w, h), (3, 2));
|
||||
assert_eq!(px, [255u8, 0, 0, 255].repeat(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crlf_from_a_uri_list_is_ignored() {
|
||||
// arboard splits text/uri-list on LF, so GNOME Files' CRLF list leaves a '\r' behind.
|
||||
let dir = temp("crlf");
|
||||
let png = dir.join("red image.png");
|
||||
std::fs::write(&png, red_png(2, 2)).unwrap();
|
||||
let with_cr = PathBuf::from(format!("{}\r", png.display()));
|
||||
assert_eq!(image_from_files(&[with_cr]).map(|(w, h, _)| (w, h)), Some((2, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_images_are_skipped_for_the_first_image() {
|
||||
let dir = temp("mixed");
|
||||
let (txt, png) = (dir.join("notes.txt"), dir.join("red.png"));
|
||||
std::fs::write(&txt, b"not an image").unwrap();
|
||||
std::fs::write(&png, red_png(4, 1)).unwrap();
|
||||
let missing = dir.join("gone.png");
|
||||
assert_eq!(image_from_files(&[missing.clone(), txt.clone(), dir.clone(), png]).map(|(w, h, _)| (w, h)), Some((4, 1)));
|
||||
assert!(image_from_files(&[missing, txt, dir]).is_none());
|
||||
assert!(image_from_files(&[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_image_is_skipped_not_a_panic() {
|
||||
let dir = temp("damaged");
|
||||
let bad = dir.join("cut.png");
|
||||
std::fs::write(&bad, &red_png(8, 8)[..20]).unwrap();
|
||||
assert!(image_from_files(&[bad]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tga_is_trusted_only_with_its_extension() {
|
||||
let dir = temp("tga");
|
||||
let (tga, bin) = (dir.join("red.tga"), dir.join("red.bin"));
|
||||
std::fs::write(&tga, tga_1x1()).unwrap();
|
||||
std::fs::write(&bin, tga_1x1()).unwrap();
|
||||
assert_eq!(image_from_files(&[tga]), Some((1, 1, vec![255, 0, 0, 255])));
|
||||
assert!(image_from_files(&[bin]).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79).
|
||||
//!
|
||||
//! winit 0.30 drops tablet data on both, so `photocraft-tablet` reads it beside winit (an AppKit
|
||||
//! local event monitor; XInput2 raw events on a second X connection) and this module writes each
|
||||
//! sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
|
||||
//! Pointer Events and automation's simulated pen. Windows needs nothing here: winit forwards
|
||||
//! `WM_POINTER` pressure as touch force. Wayland has no tablet input yet (see the crate docs).
|
||||
|
||||
use photocraft_tablet::Sample;
|
||||
use photocraft_ui_egui::stylus::{PenSample, StylusFeed};
|
||||
|
||||
/// A tablet sample as the UI's pen sample.
|
||||
pub fn pen_sample(s: Sample) -> PenSample {
|
||||
PenSample { pressure: s.pressure, tilt_x: s.tilt_x, tilt_y: s.tilt_y, rotation: s.rotation, eraser: s.eraser }.sanitized()
|
||||
}
|
||||
|
||||
/// Writes tablet samples into `feed` (`None` = a mouse).
|
||||
pub fn sink(feed: StylusFeed) -> impl Fn(Option<Sample>) + Send + 'static {
|
||||
move |s| feed.set(s.map(pen_sample))
|
||||
}
|
||||
|
||||
/// Install the AppKit tablet monitor (main thread, before the event loop). Keep the result until
|
||||
/// the event loop returns. On failure this logs why: pens then paint like a mouse.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn install_macos(feed: &StylusFeed) -> Option<photocraft_tablet::macos::Monitor> {
|
||||
photocraft_tablet::macos::Monitor::install(sink(feed.clone())).map_err(|e| log::warn!("{e}")).ok()
|
||||
}
|
||||
|
||||
/// Start the XInput2 reader when eframe runs on X11. On Wayland, `$DISPLAY` is Xwayland, which
|
||||
/// sees none of this window's input (and a pen sample from another X app would outlive a mouse
|
||||
/// stroke here), so nothing starts.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn spawn_x11(feed: &StylusFeed, display: Option<DisplayKind>) {
|
||||
if display != Some(DisplayKind::X11) {
|
||||
log::info!("tablet pressure: not available on this display server ({display:?}); Wayland is a follow-up to #79");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = photocraft_tablet::x11::spawn(None, sink(feed.clone())) {
|
||||
log::warn!("{e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The windowing system eframe ended up on.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DisplayKind {
|
||||
X11,
|
||||
Wayland,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl DisplayKind {
|
||||
/// From the creation context's display handle.
|
||||
pub fn of(cc: &eframe::CreationContext<'_>) -> Option<Self> {
|
||||
use eframe::wgpu::rwh::{HasDisplayHandle, RawDisplayHandle};
|
||||
Some(match cc.display_handle().ok()?.as_raw() {
|
||||
RawDisplayHandle::Xlib(_) | RawDisplayHandle::Xcb(_) => DisplayKind::X11,
|
||||
RawDisplayHandle::Wayland(_) => DisplayKind::Wayland,
|
||||
_ => DisplayKind::Other,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_tablet::{Update, appkit, xi};
|
||||
use photocraft_ui_egui::PhotocraftApp;
|
||||
use photocraft_ui_egui::canvas::{ToolEvent, tool_event};
|
||||
use photocraft_ui_egui::state::Tool;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[test]
|
||||
fn samples_map_and_sanitize() {
|
||||
let p = pen_sample(Sample { pressure: 0.25, tilt_x: 30.0, tilt_y: -10.0, rotation: 90.0, eraser: true });
|
||||
assert_eq!(p, PenSample { pressure: 0.25, tilt_x: 30.0, tilt_y: -10.0, rotation: 90.0, eraser: true });
|
||||
let p = pen_sample(Sample { pressure: f32::NAN, tilt_x: 500.0, tilt_y: f32::NEG_INFINITY, rotation: -45.0, eraser: false });
|
||||
assert_eq!(p, PenSample { pressure: 1.0, tilt_x: 90.0, tilt_y: 0.0, rotation: 315.0, eraser: false });
|
||||
let feed = StylusFeed::default();
|
||||
let f = sink(feed.clone());
|
||||
f(Some(Sample { pressure: 0.5, ..Default::default() }));
|
||||
assert_eq!(feed.get().map(|p| p.pressure), Some(0.5));
|
||||
f(None);
|
||||
assert_eq!(feed.get(), None);
|
||||
}
|
||||
|
||||
/// The platform path end to end, minus the OS: raw AppKit / XInput2 values → the crate's
|
||||
/// mapping → this module's sink → the stylus feed → the canvas's tool events → `paint.stroke`.
|
||||
/// A light-to-heavy pen stroke must paint thin and faint at the light end and wide and opaque
|
||||
/// at the heavy end (Size and Opacity on Pen Pressure); a mouse paints full strength.
|
||||
fn pressure_stroke(mut next: impl FnMut(f32) -> Option<Sample>) -> (f64, f64, f64, f64) {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
let feed = app.stylus.feed.clone();
|
||||
let sink = sink(feed);
|
||||
app.session.execute("file.new", json!({"width": 240, "height": 80, "background": "white"})).unwrap();
|
||||
app.session.execute("tools.setColors", json!({"foreground": [0.0, 0.0, 0.0, 1.0]})).unwrap();
|
||||
let brush = json!({"size": 30, "hardness": 1.0, "spacing": 0.05, "pressureSize": true, "pressureOpacity": true, "smoothing": {"amount": 0}});
|
||||
app.session.execute("tools.setBrush", json!({ "brush": brush })).unwrap();
|
||||
app.ui.tool = Tool::Brush;
|
||||
let m = egui::Modifiers::NONE;
|
||||
let n = 20;
|
||||
for i in 0..=n {
|
||||
let t = i as f32 / n as f32;
|
||||
sink(next(0.1 + 0.9 * t));
|
||||
let (x, y) = (20.0 + 200.0 * f64::from(t), 40.0);
|
||||
let pressure = app.stylus.pressure();
|
||||
let ev = if i == 0 { ToolEvent::Down { x, y, pressure } } else { ToolEvent::Move { x, y, pressure } };
|
||||
tool_event(&mut app, ev, m);
|
||||
}
|
||||
tool_event(&mut app, ToolEvent::Up { x: 220.0, y: 40.0 }, m);
|
||||
sink(None);
|
||||
// Darkness (1 - luma) along x = 50 (light end) and x = 200 (heavy end), and the painted
|
||||
// half-width there.
|
||||
let mut dark = |x: i32, y: i32| -> f64 {
|
||||
let px: Value = app.session.execute("document.pixel", json!({"x": x, "y": y})).unwrap();
|
||||
1.0 - px[0].as_f64().unwrap()
|
||||
};
|
||||
let (light, heavy) = (dark(50, 40), dark(200, 40));
|
||||
let light_w = (0..40).take_while(|d| dark(50, 40 + d) > 0.02).count() as f64;
|
||||
let heavy_w = (0..40).take_while(|d| dark(200, 40 + d) > 0.02).count() as f64;
|
||||
(light, light_w, heavy, heavy_w)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appkit_tablet_pressure_drives_stroke_width_and_opacity() {
|
||||
let mut st = appkit::State::default();
|
||||
let (light_dark, light_w, heavy_dark, heavy_w) = pressure_stroke(|p| {
|
||||
let e =
|
||||
appkit::RawEvent { kind: appkit::event_type::LEFT_MOUSE_DRAGGED, subtype: appkit::subtype::TABLET_POINT, pressure: p, ..Default::default() };
|
||||
match st.handle(&e) {
|
||||
Update::Set(s) => s,
|
||||
Update::Keep => None,
|
||||
}
|
||||
});
|
||||
assert!(heavy_w > light_w * 2.0, "width responds: {light_w} → {heavy_w}");
|
||||
assert!(heavy_dark > light_dark + 0.3, "opacity responds: {light_dark} → {heavy_dark}");
|
||||
assert!(heavy_dark > 0.9, "full pressure paints (nearly) opaque: {heavy_dark}");
|
||||
|
||||
// A mouse (non-tablet subtype) through the same path: full size and opacity throughout.
|
||||
let mut st = appkit::State::default();
|
||||
let (m_dark, m_w, _, _) = pressure_stroke(|p| {
|
||||
let e = appkit::RawEvent { kind: appkit::event_type::LEFT_MOUSE_DRAGGED, subtype: appkit::subtype::MOUSE, pressure: p, ..Default::default() };
|
||||
match st.handle(&e) {
|
||||
Update::Set(s) => s,
|
||||
Update::Keep => None,
|
||||
}
|
||||
});
|
||||
assert!(m_dark > 0.9 && m_w >= heavy_w - 1.0, "mouse = pressure 1: {m_dark}, {m_w}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xinput_tablet_pressure_drives_stroke_width_and_opacity() {
|
||||
let mut st = xi::State::default();
|
||||
st.set_devices([xi::Device::new(9, "Wacom Intuos Pen stylus", [(2, "Abs Pressure".to_string(), 0.0, 65535.0)])]);
|
||||
let (light_dark, light_w, heavy_dark, heavy_w) = pressure_stroke(|p| match st.handle(9, [(2, f64::from(p) * 65535.0)]) {
|
||||
Update::Set(s) => s,
|
||||
Update::Keep => None,
|
||||
});
|
||||
assert!(heavy_w > light_w * 2.0, "width responds: {light_w} → {heavy_w}");
|
||||
assert!(heavy_dark > light_dark + 0.3, "opacity responds: {light_dark} → {heavy_dark}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_tablet_pressure_off_paints_like_a_mouse() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("prefs.set", json!({"path": "tools.useTabletPressure", "value": false})).unwrap();
|
||||
// The canvas copies the preference each frame; do what it does.
|
||||
app.stylus.use_pressure = app.session.prefs().tools.use_tablet_pressure;
|
||||
sink(app.stylus.feed.clone())(Some(Sample { pressure: 0.2, tilt_x: 40.0, ..Default::default() }));
|
||||
assert_eq!(app.stylus.pressure(), 1.0);
|
||||
assert_eq!(app.stylus.sample(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eraser_end_switches_to_the_eraser_and_back() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
let sink = sink(app.stylus.feed.clone());
|
||||
let mut st = appkit::State::default();
|
||||
let mut send = |e: appkit::RawEvent| {
|
||||
if let Update::Set(s) = st.handle(&e) {
|
||||
sink(s);
|
||||
}
|
||||
};
|
||||
let prox = |device, entering| appkit::RawEvent { kind: appkit::event_type::TABLET_PROXIMITY, device, entering, ..Default::default() };
|
||||
app.ui.tool = Tool::Brush;
|
||||
send(prox(appkit::device::PEN, true));
|
||||
assert!(!photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
|
||||
assert_eq!(app.ui.tool, Tool::Brush, "the tip changes nothing");
|
||||
send(prox(appkit::device::PEN, false));
|
||||
send(prox(appkit::device::ERASER, true));
|
||||
assert!(photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
|
||||
assert_eq!(app.ui.tool, Tool::Eraser);
|
||||
// Mouse moves in between change nothing; the tip coming back restores the Brush.
|
||||
send(appkit::RawEvent { kind: appkit::event_type::MOUSE_MOVED, ..Default::default() });
|
||||
assert!(!photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
|
||||
send(prox(appkit::device::PEN, true));
|
||||
assert!(photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
|
||||
assert_eq!(app.ui.tool, Tool::Brush);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! The saved window geometry and egui layout (`ui.ron`, written by eframe's persistence).
|
||||
//!
|
||||
//! eframe restores this state while it creates the window, before PhotoCraft's code runs, and
|
||||
//! some values it does not check abort startup: a zoom factor of zero, NaN or 1e30 fails a
|
||||
//! `dpi` assertion, and a NaN or huge window size fails surface creation. A truncated or
|
||||
//! hand-edited file would then crash every launch. [`sanitize`] runs first and removes a file
|
||||
//! holding such values (the window and panels open at their defaults; nothing else is lost).
|
||||
//! A file that isn't RON at all is left alone: eframe already ignores it.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Window positions, sizes and panel rects beyond this many points or pixels are corrupt.
|
||||
const MAX_COORD: f64 = 1.0e6;
|
||||
/// egui's zoom factor is the UI scale over the display scale (75% on a 2× display is 0.375,
|
||||
/// 300% on a 1× display is 3).
|
||||
const ZOOM: std::ops::RangeInclusive<f64> = 0.1..=10.0;
|
||||
|
||||
/// Remove `path` if it holds state that would crash startup. Never fails: errors are logged.
|
||||
pub fn sanitize(path: Option<&Path>) {
|
||||
let Some(path) = path else { return };
|
||||
let Ok(text) = std::fs::read_to_string(path) else { return };
|
||||
if let Err(why) = check(&text) {
|
||||
log::warn!("ignoring saved window layout {}: {why}", path.display());
|
||||
if let Err(e) = std::fs::remove_file(path) {
|
||||
log::warn!("could not remove {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Err` names the first value that would crash startup.
|
||||
fn check(text: &str) -> Result<(), String> {
|
||||
// eframe stores a map of key → RON string; a file that doesn't parse is ignored by eframe.
|
||||
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return Ok(()) };
|
||||
for (key, value) in &kv {
|
||||
// eframe also skips an entry that doesn't parse.
|
||||
let Ok(v) = ron::from_str::<ron::Value>(value) else { continue };
|
||||
floats_sane(&v, 0).map_err(|e| format!("{key}: {e}"))?;
|
||||
let zoom = (key == "egui").then(|| field(&v, "options").and_then(|o| field(o, "zoom_factor")).and_then(as_f64)).flatten();
|
||||
if let Some(z) = zoom.filter(|z| !ZOOM.contains(z)) {
|
||||
return Err(format!("egui: zoom factor {z}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every float finite and within [`MAX_COORD`]; depth-limited (the file is untrusted).
|
||||
fn floats_sane(v: &ron::Value, depth: usize) -> Result<(), String> {
|
||||
if depth > 64 {
|
||||
return Err("nested too deeply".into());
|
||||
}
|
||||
match v {
|
||||
ron::Value::Number(_) => match as_f64(v) {
|
||||
Some(f) if !f.is_finite() || f.abs() > MAX_COORD => Err(format!("value {f}")),
|
||||
_ => Ok(()),
|
||||
},
|
||||
ron::Value::Option(Some(inner)) => floats_sane(inner, depth + 1),
|
||||
ron::Value::Seq(items) => items.iter().try_for_each(|i| floats_sane(i, depth + 1)),
|
||||
ron::Value::Map(m) => m.iter().try_for_each(|(k, v)| floats_sane(k, depth + 1).and_then(|()| floats_sane(v, depth + 1))),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A float (integers such as egui's hashed ids are not coordinates).
|
||||
fn as_f64(v: &ron::Value) -> Option<f64> {
|
||||
match v {
|
||||
ron::Value::Number(ron::value::Number::F32(f)) => Some(f64::from(f.get())),
|
||||
ron::Value::Number(ron::value::Number::F64(f)) => Some(f.get()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> {
|
||||
match v {
|
||||
ron::Value::Map(m) => m.get(&ron::Value::String(name.into())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::check;
|
||||
|
||||
fn file(window: &str, zoom: &str) -> String {
|
||||
let egui = format!("(options:(zoom_factor:{zoom},max_passes:2),data:([(18446744073709551615,(ron:\"(x:1.0)\"))]))");
|
||||
format!("{{\"window\":{window:?},\"egui\":{egui:?}}}")
|
||||
}
|
||||
|
||||
const WINDOW: &str = "(inner_position_pixels:Some((x:616.0,y:430.0)),fullscreen:false,inner_size_points:Some((x:1100.0,y:760.0)))";
|
||||
|
||||
#[test]
|
||||
fn a_saved_layout_is_kept() {
|
||||
assert_eq!(check(&file(WINDOW, "1.0")), Ok(()));
|
||||
assert_eq!(check(&file(WINDOW, "0.375")), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_that_abort_startup_are_rejected() {
|
||||
// Each of these aborted the app at launch before the check (dpi / wgpu assertions).
|
||||
for zoom in ["0.0", "NaN", "1e30", "-1.0"] {
|
||||
assert!(check(&file(WINDOW, zoom)).is_err(), "zoom {zoom}");
|
||||
}
|
||||
for size in ["(x:NaN,y:760.0)", "(x:1100.0,y:1e30)", "(x:inf,y:1.0)"] {
|
||||
let w = WINDOW.replace("(x:1100.0,y:760.0)", size);
|
||||
assert!(check(&file(&w, "1.0")).is_err(), "size {size}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_files_are_left_to_eframe() {
|
||||
// eframe ignores files and entries it can't parse, so they can't crash it.
|
||||
for text in ["", "not ron {{{", "{\"window\":\"(((\"}", "[1,2,3]"] {
|
||||
assert_eq!(check(text), Ok(()), "{text:?}");
|
||||
}
|
||||
let deep = format!("{{\"egui\":{:?}}}", "[".repeat(500) + &"]".repeat(500));
|
||||
let _ = check(&deep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_removes_only_a_bad_file() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-ui-state-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let good = dir.join("good.ron");
|
||||
let bad = dir.join("bad.ron");
|
||||
std::fs::write(&good, file(WINDOW, "1.0")).unwrap();
|
||||
std::fs::write(&bad, file(WINDOW, "0.0")).unwrap();
|
||||
super::sanitize(Some(&good));
|
||||
super::sanitize(Some(&bad));
|
||||
super::sanitize(Some(&dir.join("missing.ron")));
|
||||
super::sanitize(None);
|
||||
assert!(good.exists() && !bad.exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,10 @@ craftrules at `assets/app-icons/photocraft/source.png`. License: see `LICENSE.tx
|
||||
- `hicolor/<size>/apps/ai.storyteller.photocraft.png` (16–512) and `hicolor/scalable/...svg`:
|
||||
Linux icon theme.
|
||||
|
||||
The app also sets the window icon and Wayland app ID at runtime (`apps/photocraft/src/main.rs`).
|
||||
The app also sets the window icon and Wayland app ID at runtime (`apps/photocraft/src/app_icon.rs`,
|
||||
`main.rs`). On Windows the taskbar shows the icon of the Start Menu shortcut that launches the
|
||||
exe, so the MSI's `<Icon Id>` keeps the `.exe` extension (ICE50); `app_icon.rs` documents the
|
||||
details and tests the `.ico` sizes and the WiX icon references.
|
||||
|
||||
## Regenerate
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||
<path d="m15 5 4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 377 B |
@@ -13,7 +13,7 @@
|
||||
| `mcp` | Start the MCP server on stdio, optionally bridged to the desktop app |
|
||||
| `serve` | Start the JSON-lines headless server on stdio or loopback TCP |
|
||||
|
||||
Use `photocraft-cli --help` as the executable source of truth. A typical bounded command run is:
|
||||
Use `photocraft-cli --help` (or `photocraft-cli <subcommand> --help`) as the executable source of truth. A typical bounded command run is:
|
||||
|
||||
```sh
|
||||
photocraft-cli run input.psd \
|
||||
@@ -21,6 +21,6 @@ photocraft-cli run input.psd \
|
||||
--out output.png
|
||||
```
|
||||
|
||||
Paths supplied to the current CLI are ordinary operating-system paths. The CLI does not restrict them to a workspace root. Batch output creates the requested output directory, and save operations may create parent directories. Run untrusted action files only in an appropriately isolated account or environment.
|
||||
Paths supplied to the current CLI are ordinary operating-system paths. The CLI does not restrict them to a workspace root. Batch output creates the requested output directory, and save operations may create parent directories. `batch` refuses an output directory that is its input directory (the results would replace the originals) unless `--in-place` is given. Run untrusted action files only in an appropriately isolated account or environment.
|
||||
|
||||
CLI parsing reports malformed flags and JSON as errors, but it is not a security policy layer. Engine commands remain responsible for validating their own parameters.
|
||||
CLI parsing reports malformed flags and JSON as errors, and a flag the subcommand doesn't take (a typo such as `--fromat`) is a usage error (exit status 2) rather than being ignored. It is not a security policy layer. Engine commands remain responsible for validating their own parameters.
|
||||
@@ -11,7 +11,7 @@ Automation requests are untrusted input with potential effects beyond the active
|
||||
| MCP capability scopes | **Partial:** filesystem read/write capabilities only; general tool scopes are absent |
|
||||
| Allowed read/write roots | **Implemented:** separate launch-time roots, with absent authority failing closed |
|
||||
| Symlink-safe capability filesystem | **Implemented:** relative operations use held directory capabilities and reject link escapes |
|
||||
| Request-byte and JSON-depth limits | **Partial:** 1 MiB request-line limit; no explicit JSON-depth policy |
|
||||
| Request-byte and JSON-depth limits | **Partial:** 1 MiB request-line limit on TCP and headless JSON-lines stdio (MCP stdio input is not byte-capped); no explicit JSON-depth policy |
|
||||
| Batch-step limit | **Implemented:** 256 steps for headless and MCP batches |
|
||||
| Reply-size limit | **Implemented:** 8 MiB encoded JSON/MCP result; aggregate batch reply budget |
|
||||
| Headless preview budgets | **Implemented:** 2048-pixel requested edge, 67,108,864 source pixels, 5 MiB encoded PNG |
|
||||
|
||||
@@ -22,6 +22,8 @@ Default `LoadOptions` apply these maximums:
|
||||
| One decompressed blob | 1 GiB |
|
||||
| Total decompressed tiles and blobs | 16 GiB |
|
||||
|
||||
Layer groups may nest at most 100 levels (`MAX_GROUP_DEPTH`). Saving a deeper document fails with an error instead of writing a bundle that can't be opened, and a manifest whose JSON nesting goes beyond what that depth needs is rejected before it is parsed, so a hostile file can't exhaust the stack.
|
||||
|
||||
The ZIP reader bounds decompression by the caller-supplied maximum, rejects encrypted entries and unsupported compression methods, validates offsets with checked arithmetic, and rejects CRC or declared-size mismatches. Corruption and property tests cover truncation, byte mutation, missing content, invalid manifests, excessive sizes, and random bytes.
|
||||
|
||||
These defaults are intentionally finite but can still be expensive on constrained systems. Future hardening should add context-sensitive budgets and explicit limits for entry count, document complexity, and cumulative work, then test directory bundles against symlink and replacement races.
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
# Raster formats
|
||||
|
||||
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. AVIF encoding is optional; AVIF decoding is not implemented in the current crate.
|
||||
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `photocraft-heif` crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate.
|
||||
|
||||
## Layered TIFF
|
||||
|
||||
A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: a TIFF holds one transparency sample, so extra alpha channels are not written; Lab documents are saved flat. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`.
|
||||
|
||||
Capability details such as sample depth, channel layout, alpha, ICC, EXIF, XMP, DPI, and animation behavior are defined by `crates/codecs/src/format.rs`. Do not infer fidelity from the filename extension alone.
|
||||
|
||||
## Exporting documents
|
||||
|
||||
Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_export`, Layers to Files, Layer › Export As) flattens it the same way:
|
||||
|
||||
- Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink.
|
||||
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead.
|
||||
|
||||
## Implemented decode limits
|
||||
|
||||
`DecodeOptions::default()` applies `Limits` before decoded pixel allocation:
|
||||
@@ -19,4 +30,4 @@ Format adapters pass compatible allocation limits into PNG, TIFF, WebP, and `ima
|
||||
|
||||
`Limits::none()` exists for callers with a deliberate reason to relax policy. It remains bounded by representable memory sizes, but it removes the default resource policy and should not be used for untrusted input.
|
||||
|
||||
Animated containers are represented as a single decoded frame by the current codec API. Refer to [`crates/codecs/README.md`](https://github.com/storytold/photocraft/blob/main/crates/codecs/README.md) and source capability declarations for current behavior.
|
||||
Animated containers and multi-page TIFFs are represented as a single decoded frame or page by the current codec API; opening one adds an import warning such as "only the first of 3 frames was imported". A JPEG cut off inside its image data opens with a warning that its data ends early (an error when it holds no image data at all). Refer to [`crates/codecs/README.md`](https://github.com/storytold/photocraft/blob/main/crates/codecs/README.md) and source capability declarations for current behavior.
|
||||
@@ -7,7 +7,10 @@ Automation is a privilege boundary because requests can cause filesystem access,
|
||||
- Desktop control binds to `127.0.0.1` and uses one JSON request/reply per line.
|
||||
- Headless TCP refuses a successfully bound non-loopback address.
|
||||
- Every desktop-control and headless-TCP connection must authenticate with a 256-bit bearer token before method dispatch.
|
||||
- Encoded request lines are limited to 1 MiB, active TCP connections to 16, and headless/MCP batches to 256 steps.
|
||||
- Encoded request lines are limited to 1 MiB on desktop and headless TCP and on the headless
|
||||
JSON-lines stdio server (`photocraft-cli serve`). MCP over stdio has no request-byte cap: the
|
||||
MCP SDK reads its own lines, and only MCP tool results are size-checked. Active TCP connections
|
||||
are limited to 16, and headless/MCP batches to 256 steps.
|
||||
- JSON-lines replies and encoded MCP tool results are limited to 8 MiB; retained batch replies
|
||||
have an aggregate budget and stop later steps when exhausted. The headless stdio JSON-lines
|
||||
transport enforces the same request/reply ceilings as TCP.
|
||||
@@ -16,22 +19,32 @@ Automation is a privilege boundary because requests can cause filesystem access,
|
||||
PNGs are limited to 5 MiB before base64 encoding or file writes. Trusted-local CLI rendering
|
||||
retains its existing behavior.
|
||||
- The MCP bridge bounds incoming replies and does not retry an operation after an oversized
|
||||
reply. Screenshot decoding has separate dimension, pixel, and allocation ceilings.
|
||||
reply. Screenshots are decoded under separate dimension, pixel, and allocation ceilings before
|
||||
downscaling; the 5 MiB PNG limit applies to the returned image.
|
||||
- MCP normally uses stdio and can optionally bridge to loopback control TCP.
|
||||
- The bridge and UI request paths use timeouts.
|
||||
- Engine commands are expected to reject invalid parameters without panicking.
|
||||
- Remote filesystem methods use separately granted read and write directory capabilities.
|
||||
- A save without a `path` (MCP `doc_save`, `serve` `doc.save`, the control channel's `app.save`)
|
||||
writes back only to the document's own PSD, PSB or `.pcraft` file in its own format; any other
|
||||
save needs an explicit `path`, so automation never flattens or converts over the file it opened.
|
||||
- Automation paths must be relative beneath the applicable root. Absolute paths, parent
|
||||
traversal, alternate separators, Windows device names, malformed components, and link escapes
|
||||
are rejected before file effects.
|
||||
- Engine commands that still use ambient paths fail closed at the automation boundary. Synthetic
|
||||
UI input reaches the same command policy, and automation-triggered file hooks do not run
|
||||
user-configured script-event paths.
|
||||
- `prefs.set` applies the engine's dotted-path normalization before checking for filesystem-bearing
|
||||
preference sections, including paths with leading or repeated dots. It refuses whole-preferences
|
||||
updates and those sections; individual safe preference keys remain available.
|
||||
- Applying the Preferences dialog with `ui.dialog.apply` is deliberately refused over automation.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- no per-client or per-tool capabilities;
|
||||
- no general per-client or per-tool capability model beyond filesystem read/write authority;
|
||||
- no automated bulk apply of the Preferences dialog; agents can change individual safe keys with
|
||||
`prefs.set`;
|
||||
- no explicit JSON-depth policy, aggregate document/session-memory accounting, compositor
|
||||
scratch-space accounting, or command-duration/cancellation budget;
|
||||
- one thread per accepted TCP connection;
|
||||
|
||||
@@ -39,15 +39,15 @@ A compact file declares oversized dimensions, excessive channels, nested descrip
|
||||
|
||||
### Filesystem authority abuse
|
||||
|
||||
An automation client supplies an absolute path, traversal sequence, symlink, junction, or replaced path to read or overwrite data outside the intended project. Current automation uses caller-supplied OS paths and has no workspace policy. This scenario is a known limitation.
|
||||
An automation client supplies an absolute path, traversal sequence, symlink, junction, or replaced path to read or overwrite data outside the intended project. Remote filesystem methods use separately configured directory capabilities, and engine commands that still require ambient paths fail closed at the automation boundary. This is not yet a general per-command capability model, and trusted desktop operations can still use ambient paths.
|
||||
|
||||
### Local control takeover
|
||||
|
||||
Another process on the same host connects to the loopback control server, discovers methods, drives UI input, executes commands, or opens/saves files. Loopback reduces network exposure but does not authenticate the client.
|
||||
Another process on the same host connects to the loopback control server, discovers methods, drives UI input, executes commands, or opens/saves files. A 256-bit bearer token is required before method dispatch, but authentication does not grant per-client or per-tool capabilities.
|
||||
|
||||
### Resource exhaustion
|
||||
|
||||
A client opens many TCP connections, sends an extremely long JSON line, creates a huge batch, requests full-size rendering, or repeatedly invokes expensive commands. Current automation transports lack explicit request, batch, and connection budgets.
|
||||
A client opens many TCP connections, sends an extremely long JSON line, creates a huge batch, requests full-size rendering, or repeatedly invokes expensive commands. Request-line, connection, batch, and preview limits mitigate some cases; explicit JSON-depth, document-memory, and command-duration budgets remain absent.
|
||||
|
||||
### Supply-chain compromise
|
||||
|
||||
|
||||
@@ -147,9 +147,12 @@ impl GalleryFilter {
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether the filter reads the foreground / background colours.
|
||||
/// Whether the filter reads the foreground / background colours. Derived
|
||||
/// from the params notation, plus the filters whose pixel code reads the
|
||||
/// colours without listing them (issue #709): Colored Pencil uses the
|
||||
/// background as paper tint, Neon Glow mixes foreground over background.
|
||||
pub fn uses_colours(self) -> bool {
|
||||
self.params_doc().contains("\"foreground\"") || self.params_doc().contains("\"background\"")
|
||||
matches!(self, Self::ColoredPencil | Self::NeonGlow) || self.params_doc().contains("\"foreground\"") || self.params_doc().contains("\"background\"")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+165
-6
@@ -175,8 +175,13 @@ fn transpose(buf: &[f32], w: usize, h: usize, n: usize) -> Vec<f32> {
|
||||
|
||||
/// Gaussian blur by three box passes per axis over a premultiplied window.
|
||||
fn gaussian_boxes(src: &Image, out: Rect, alpha: bool, sigma: f32) -> Vec<f32> {
|
||||
box_passes(src, out, alpha, &boxes_for_gauss(sigma, 3))
|
||||
}
|
||||
|
||||
/// Box blurs of the given (odd) widths, one after another along each axis, over a premultiplied
|
||||
/// window; running sums make the cost independent of the widths.
|
||||
fn box_passes(src: &Image, out: Rect, alpha: bool, boxes: &[usize]) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
let boxes = boxes_for_gauss(sigma, 3);
|
||||
let margin: i32 = boxes.iter().map(|w| (*w as i32 - 1) / 2).sum::<i32>() + 1;
|
||||
let win = Rect::new(out.x0 - margin, out.y0 - margin, out.x1 + margin, out.y1 + margin);
|
||||
let (ww, wh) = (win.width() as usize, win.height() as usize);
|
||||
@@ -197,11 +202,11 @@ fn gaussian_boxes(src: &Image, out: Rect, alpha: bool, sigma: f32) -> Vec<f32> {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
p.chunks_mut(ww * n).enumerate().for_each(|(y, row)| fill(y, row));
|
||||
premultiply(&mut p, n, alpha);
|
||||
for w in &boxes {
|
||||
for w in boxes {
|
||||
box_rows(&mut p, ww, n, (w - 1) / 2);
|
||||
}
|
||||
let mut t = transpose(&p, ww, wh, n);
|
||||
for w in &boxes {
|
||||
for w in boxes {
|
||||
box_rows(&mut t, wh, n, (w - 1) / 2);
|
||||
}
|
||||
let p = transpose(&t, wh, ww, n);
|
||||
@@ -217,8 +222,12 @@ fn gaussian_boxes(src: &Image, out: Rect, alpha: bool, sigma: f32) -> Vec<f32> {
|
||||
|
||||
pub(crate) fn boxed(src: &Image, out: Rect, ctx: &Ctx, radius: f32) -> Vec<f32> {
|
||||
let r = radius.max(0.0).round() as usize;
|
||||
let k = vec![1.0 / (2 * r + 1) as f32; 2 * r + 1];
|
||||
conv_sep(src, out, &k, &k, ctx.alpha)
|
||||
// Running sums cost the same at any radius; the direct kernel is quicker for the smallest.
|
||||
if r <= 4 {
|
||||
let k = vec![1.0 / (2 * r + 1) as f32; 2 * r + 1];
|
||||
return conv_sep(src, out, &k, &k, ctx.alpha);
|
||||
}
|
||||
box_passes(src, out, ctx.alpha, &[2 * r + 1])
|
||||
}
|
||||
|
||||
fn average_samples(src: &Image, out: Rect, ctx: &Ctx, mut offsets: impl FnMut(f32, f32, &mut Vec<(f32, f32)>)) -> Vec<f32> {
|
||||
@@ -305,11 +314,102 @@ pub(crate) fn radial(src: &Image, out: Rect, ctx: &Ctx, amount: f32, method: Rad
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn surface(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold: f32) -> Vec<f32> {
|
||||
/// [`surface`] for 8-bit samples (every colour sample in the window is `k / 255`; `None`
|
||||
/// otherwise): a 256-bin histogram of each channel over the window slides along the row, adding
|
||||
/// and removing one column per step, so a pixel costs O(radius + levels) instead of O(radius^2).
|
||||
/// Each level is weighted with the same expression as the direct sum.
|
||||
fn surface_8bit(src: &Image, out: Rect, ctx: &Ctx, r: i32, t: f32, reach: usize) -> Option<Vec<f32>> {
|
||||
let n = src.ch;
|
||||
let cc = if ctx.alpha { n - 1 } else { n };
|
||||
let win = out.inflate(r);
|
||||
let (ww, wh) = (win.width() as usize, win.height() as usize);
|
||||
// Level of every colour sample in the window; `SKIP` where the pixel is transparent (the
|
||||
// direct sum skips those). Outside the source reads as 0, as `Image::get` does.
|
||||
const SKIP: u16 = 256;
|
||||
let mut lv = vec![0u16; ww * wh * cc];
|
||||
for y in win.y0..win.y1 {
|
||||
for x in win.x0..win.x1 {
|
||||
let i = ((y - win.y0) as usize * ww + (x - win.x0) as usize) * cc;
|
||||
let skip = ctx.alpha && src.get(x, y, n - 1) <= 0.0;
|
||||
for c in 0..cc {
|
||||
let v = src.get(x, y, c);
|
||||
let k = (v * 255.0).round();
|
||||
if !(0.0..=255.0).contains(&k) || (k / 255.0).to_bits() != v.to_bits() {
|
||||
return None;
|
||||
}
|
||||
lv[i + c] = if skip { SKIP } else { k as u16 };
|
||||
}
|
||||
}
|
||||
}
|
||||
let (ow, oh) = (out.width() as usize, out.height() as usize);
|
||||
let side = (2 * r + 1) as usize;
|
||||
let mut res = vec![0.0f32; ow * oh * n];
|
||||
let mut hist = [0u32; 257];
|
||||
for oy in 0..oh {
|
||||
for c in 0..cc {
|
||||
hist.fill(0);
|
||||
// Window of output column 0: window rows oy..oy + side, columns 0..side.
|
||||
let col = |hist: &mut [u32; 257], wx: usize, add: bool| {
|
||||
for wy in oy..oy + side {
|
||||
let k = lv[(wy * ww + wx) * cc + c] as usize;
|
||||
if add {
|
||||
hist[k] += 1;
|
||||
} else {
|
||||
hist[k] -= 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
for wx in 0..side {
|
||||
col(&mut hist, wx, true);
|
||||
}
|
||||
for ox in 0..ow {
|
||||
let p = src.px(out.x0 + ox as i32, out.y0 + oy as i32);
|
||||
let v0 = p[c];
|
||||
let k0 = (v0 * 255.0).round() as usize;
|
||||
let (mut acc, mut wsum) = (0.0f64, 0.0f64);
|
||||
for (k, &cnt) in hist.iter().enumerate().take(256.min(k0 + reach + 1)).skip(k0.saturating_sub(reach)) {
|
||||
if cnt == 0 {
|
||||
continue;
|
||||
}
|
||||
let v = k as f32 / 255.0;
|
||||
let w = (1.0 - (v - v0).abs() / t).max(0.0);
|
||||
acc += f64::from(v * w) * f64::from(cnt);
|
||||
wsum += f64::from(w) * f64::from(cnt);
|
||||
}
|
||||
res[(oy * ow + ox) * n + c] = if wsum > 0.0 { (acc / wsum) as f32 } else { v0 };
|
||||
if ox + 1 < ow {
|
||||
col(&mut hist, ox, false);
|
||||
col(&mut hist, ox + side, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ctx.alpha {
|
||||
for ox in 0..ow {
|
||||
res[(oy * ow + ox) * n + n - 1] = src.px(out.x0 + ox as i32, out.y0 + oy as i32)[n - 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(res)
|
||||
}
|
||||
|
||||
pub(crate) fn surface(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold: f32) -> Vec<f32> {
|
||||
let r = radius.max(0.0).round() as i32;
|
||||
let t = (threshold.max(1.0) / 255.0) * 2.5;
|
||||
// Levels within the threshold of the centre (the only ones with any weight), on each side.
|
||||
let reach = ((t * 255.0).ceil() as usize).min(255);
|
||||
let side = (2 * r + 1) as usize;
|
||||
if side * side > 2 * reach + 1 + 4 * side
|
||||
&& let Some(res) = surface_8bit(src, out, ctx, r, t, reach)
|
||||
{
|
||||
return res;
|
||||
}
|
||||
surface_direct(src, out, ctx, r, t)
|
||||
}
|
||||
|
||||
/// [`surface`] by summing the whole (2r + 1)^2 window of every pixel.
|
||||
fn surface_direct(src: &Image, out: Rect, ctx: &Ctx, r: i32, t: f32) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
let cc = if ctx.alpha { n - 1 } else { n };
|
||||
let mut res = Vec::with_capacity(out.width() as usize * out.height() as usize * n);
|
||||
for y in out.y0..out.y1 {
|
||||
for x in out.x0..out.x1 {
|
||||
@@ -361,4 +461,63 @@ mod tests {
|
||||
assert!(err < 0.02, "max error {err}");
|
||||
assert_eq!(boxes_for_gauss(10.0, 3).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_passes_match_the_direct_box_kernel() {
|
||||
// Noisy RGBA with varying alpha, so premultiplication and every channel are exercised.
|
||||
for r in [5usize, 17, 60] {
|
||||
let (w, h, m) = (40, 24, r as i32 + 2);
|
||||
let src_rect = Rect::new(-m, -m, w + m, h + m);
|
||||
let mut img = Image::new(src_rect, 4);
|
||||
let mut s = 0x9e37_79b9u32;
|
||||
for v in img.data.iter_mut() {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
*v = (s % 256) as f32 / 255.0;
|
||||
}
|
||||
let out = Rect::new(0, 0, w, h);
|
||||
let k = vec![1.0 / (2 * r + 1) as f32; 2 * r + 1];
|
||||
let direct = conv_sep(&img, out, &k, &k, true);
|
||||
let ctx = Ctx { bounds: src_rect, mode: crate::ColorMode::Rgb, alpha: true };
|
||||
let fast = boxed(&img, out, &ctx, r as f32);
|
||||
let err = direct.iter().zip(&fast).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(err < 1e-5, "radius {r}: max error {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_blur_8bit_histogram_matches_the_direct_sum() {
|
||||
// 8-bit levels with some fully transparent pixels, which the sum skips.
|
||||
let src_rect = Rect::new(-14, -14, 40, 34);
|
||||
let mut img = Image::new(src_rect, 4);
|
||||
let mut s = 0x6c07_8965u32;
|
||||
for px in img.data.as_chunks_mut::<4>().0 {
|
||||
for v in px.iter_mut() {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
*v = (s % 256) as f32 / 255.0;
|
||||
}
|
||||
if s.is_multiple_of(7) {
|
||||
px[3] = 0.0;
|
||||
}
|
||||
}
|
||||
let ctx = Ctx { bounds: src_rect, mode: crate::ColorMode::Rgb, alpha: true };
|
||||
// The output reaches the source edge, where samples read as transparent.
|
||||
let out = Rect::new(-2, 0, 26, 20);
|
||||
for (r, threshold) in [(1, 2.0), (4, 15.0), (12, 60.0), (12, 255.0)] {
|
||||
let t = (threshold / 255.0) * 2.5;
|
||||
let reach = ((t * 255.0f32).ceil() as usize).min(255);
|
||||
let fast = surface_8bit(&img, out, &ctx, r, t, reach).expect("8-bit input");
|
||||
let direct = surface_direct(&img, out, &ctx, r, t);
|
||||
let err = direct.iter().zip(&fast).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(err < 1e-5, "r {r} threshold {threshold}: max error {err}");
|
||||
}
|
||||
// Not 8-bit: the histogram declines and the direct sum is used.
|
||||
// (0, 0) is inside every window above.
|
||||
let i = (14 * src_rect.width() as usize + 14) * 4;
|
||||
img.data[i] = 0.5 / 255.0;
|
||||
assert!(surface_8bit(&img, out, &ctx, 4, 0.1, 26).is_none());
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,29 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Most points a Camera Raw point curve accepts from commands and the control channel.
|
||||
pub const MAX_CURVE_POINTS: usize = 16;
|
||||
|
||||
/// Checks a point curve from untrusted input: empty (linear) or 2..=16 finite points in 0..=255
|
||||
/// with inputs increasing by at least one level. Deserialization stays lenient on purpose:
|
||||
/// earlier editors saved curves this rejects, and [`curve_lut`] already sorts and de-duplicates
|
||||
/// them, so stored Smart Filters keep rendering.
|
||||
pub fn validate_curve(points: &[[f32; 2]]) -> Result<(), String> {
|
||||
if points.len() == 1 {
|
||||
return Err("a nonempty curve needs at least two points".into());
|
||||
}
|
||||
if points.len() > MAX_CURVE_POINTS {
|
||||
return Err(format!("at most {MAX_CURVE_POINTS} curve points"));
|
||||
}
|
||||
if !points.iter().flatten().all(|v| v.is_finite() && (0.0..=255.0).contains(v)) {
|
||||
return Err("curve coordinates must be finite and in 0..=255".into());
|
||||
}
|
||||
if points.windows(2).any(|w| w[1][0] - w[0][0] < 1.0) {
|
||||
return Err("curve inputs must increase by at least one level".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
use crate::photo_util::{hash01, linear_to_srgb, par_rows, srgb_to_linear};
|
||||
|
||||
/// One colour grading wheel.
|
||||
@@ -437,6 +460,33 @@ pub fn band_weights(h: f32) -> [f32; 8] {
|
||||
}
|
||||
|
||||
impl CameraRaw {
|
||||
/// Rejects malformed point curves in new settings (see [`validate_curve`]).
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
for (name, curve) in [
|
||||
("pointCurve", &self.point_curve),
|
||||
("pointCurveRed", &self.point_curve_red),
|
||||
("pointCurveGreen", &self.point_curve_green),
|
||||
("pointCurveBlue", &self.point_curve_blue),
|
||||
] {
|
||||
validate_curve(curve).map_err(|e| format!("{name}: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// [`validate`](Self::validate) for an edit of `stored` settings: a curve left as stored is
|
||||
/// accepted, so editing another control of an older Smart Filter still works.
|
||||
pub fn validate_changes(&self, stored: &CameraRaw) -> Result<(), String> {
|
||||
for (name, curve, kept) in [
|
||||
("pointCurve", &self.point_curve, &stored.point_curve),
|
||||
("pointCurveRed", &self.point_curve_red, &stored.point_curve_red),
|
||||
("pointCurveGreen", &self.point_curve_green, &stored.point_curve_green),
|
||||
("pointCurveBlue", &self.point_curve_blue, &stored.point_curve_blue),
|
||||
] {
|
||||
if curve != kept {
|
||||
validate_curve(curve).map_err(|e| format!("{name}: {e}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn wb_neutral(&self) -> bool {
|
||||
self.temperature == 0.0 && self.tint == 0.0 && self.exposure == 0.0
|
||||
}
|
||||
@@ -869,6 +919,44 @@ fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_curves_are_validated_but_saved_curves_still_load_and_render() {
|
||||
for field in ["pointCurve", "pointCurveRed", "pointCurveGreen", "pointCurveBlue"] {
|
||||
for points in [
|
||||
serde_json::json!([[128, 128]]),
|
||||
serde_json::json!([[255, 255], [0, 0]]),
|
||||
serde_json::json!([[0, 0], [0, 255]]),
|
||||
serde_json::json!([[0, -1], [255, 255]]),
|
||||
serde_json::json!([[0, 0], [256, 255]]),
|
||||
serde_json::json!([[0, 0], [1e30, 255]]),
|
||||
serde_json::json!((0..17).map(|i| [i * 15, i * 15]).collect::<Vec<_>>()),
|
||||
] {
|
||||
let p: CameraRaw = serde_json::from_value(serde_json::json!({field: points})).unwrap();
|
||||
assert!(p.validate().is_err(), "{field}: {points}");
|
||||
}
|
||||
for points in
|
||||
[serde_json::json!([]), serde_json::json!([[0, 0], [255, 255]]), serde_json::json!((0..16).map(|i| [i * 17, i * 17]).collect::<Vec<_>>())]
|
||||
{
|
||||
let p: CameraRaw = serde_json::from_value(serde_json::json!({field: points})).unwrap();
|
||||
assert!(p.validate().is_ok(), "{field}: {points}");
|
||||
}
|
||||
}
|
||||
assert_eq!(serde_json::from_str::<CameraRaw>("{}").unwrap(), CameraRaw::default());
|
||||
// An older editor could stack two points on one input and exceed 16 points; such a
|
||||
// saved Smart Filter must still develop instead of silently disappearing.
|
||||
let mut legacy: Vec<[f32; 2]> = (0..20).map(|i| [i as f32 * 12.0, i as f32 * 12.0]).collect();
|
||||
legacy.insert(5, [60.0, 200.0]);
|
||||
let p: CameraRaw = serde_json::from_value(serde_json::json!({"pointCurve": legacy})).unwrap();
|
||||
assert!(p.validate().is_err());
|
||||
let edited = CameraRaw { exposure: 1.0, ..p.clone() };
|
||||
assert!(edited.validate_changes(&p).is_ok(), "an unchanged stored curve stays editable");
|
||||
let broken = CameraRaw { point_curve: vec![[10.0, 10.0]], ..p.clone() };
|
||||
assert!(broken.validate_changes(&p).is_err(), "a changed curve is validated");
|
||||
let mut px = img(4, 4);
|
||||
develop(&mut px, 4, 4, &p, false);
|
||||
assert!(px.iter().flatten().all(|v| v.is_finite()));
|
||||
}
|
||||
|
||||
fn img(w: usize, h: usize) -> Vec<[f32; 4]> {
|
||||
(0..w * h)
|
||||
.map(|i| {
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
//!
|
||||
//! Buffers are interleaved `w × h × ch` normalised floats (any model/depth); deterministic.
|
||||
|
||||
use crate::inpaint::{CompleteParams, complete};
|
||||
use crate::poisson::membrane_fill;
|
||||
use crate::inpaint::{CompleteParams, complete_with};
|
||||
use crate::poisson::membrane_fill_with;
|
||||
|
||||
/// Options for [`fill`].
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -161,11 +161,27 @@ fn box_blur(w: usize, h: usize, ch: usize, v: &[f32], r: usize) -> Vec<f32> {
|
||||
/// Returns the full buffer (unchanged outside the hole). Falls back to the membrane fill when
|
||||
/// nothing can be sampled.
|
||||
pub fn fill(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], source: &[bool], opts: &FillOptions) -> Vec<f32> {
|
||||
// Never cancelled; the fallback (the input unchanged) is unreachable.
|
||||
fill_with(w, h, ch, img, hole, source, opts, &photocraft_raster::Interrupt::NONE).unwrap_or_else(|_| img.to_vec())
|
||||
}
|
||||
|
||||
/// [`fill`] that can be cancelled (see [`crate::inpaint::complete_with`]) and reports progress.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn fill_with(
|
||||
w: usize,
|
||||
h: usize,
|
||||
ch: usize,
|
||||
img: &[f32],
|
||||
hole: &[bool],
|
||||
source: &[bool],
|
||||
opts: &FillOptions,
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
) -> Result<Vec<f32>, photocraft_raster::Cancelled> {
|
||||
assert_eq!(img.len(), w * h * ch);
|
||||
assert_eq!(hole.len(), w * h);
|
||||
assert_eq!(source.len(), w * h);
|
||||
if !hole.iter().any(|h| *h) {
|
||||
return img.to_vec();
|
||||
return Ok(img.to_vec());
|
||||
}
|
||||
let params = CompleteParams { seed: opts.seed, ..Default::default() };
|
||||
let gap = 2 * params.patch_radius + 2;
|
||||
@@ -208,7 +224,11 @@ pub fn fill(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], source: &
|
||||
}
|
||||
oy += v.h + gap;
|
||||
}
|
||||
let filled = complete(mw, mh, ch, &mimg, &unknown, ¶ms);
|
||||
ctl.check()?;
|
||||
// The completion is nearly all the work: it reports 0–90 %.
|
||||
let cancel = || ctl.cancelled();
|
||||
let progress = |f: f32| ctl.progress(f * 0.9);
|
||||
let filled = complete_with(mw, mh, ch, &mimg, &unknown, ¶ms, &photocraft_raster::Interrupt::new(&cancel, &progress))?;
|
||||
let mut out = img.to_vec();
|
||||
match filled {
|
||||
Some(f) => {
|
||||
@@ -221,16 +241,28 @@ pub fn fill(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], source: &
|
||||
}
|
||||
}
|
||||
}
|
||||
None => out = membrane_fill(w, h, ch, img, hole),
|
||||
None => out = membrane_fill_with(w, h, ch, img, hole, ctl)?,
|
||||
}
|
||||
ctl.check()?;
|
||||
if opts.color_adaptation > 0.0 {
|
||||
adapt_colors(w, h, ch, img, hole, &mut out, opts.color_adaptation);
|
||||
adapt_colors(w, h, ch, img, hole, &mut out, opts.color_adaptation, ctl)?;
|
||||
}
|
||||
out
|
||||
ctl.progress(1.0);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Pull the fill's low frequencies towards the membrane interpolation of the hole boundary.
|
||||
fn adapt_colors(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], out: &mut [f32], k: f32) {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn adapt_colors(
|
||||
w: usize,
|
||||
h: usize,
|
||||
ch: usize,
|
||||
img: &[f32],
|
||||
hole: &[bool],
|
||||
out: &mut [f32],
|
||||
k: f32,
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
) -> Result<(), photocraft_raster::Cancelled> {
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
@@ -240,7 +272,9 @@ fn adapt_colors(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], out:
|
||||
}
|
||||
}
|
||||
let r = ((x1 - x0).max(y1 - y0) / 6).clamp(1, 32);
|
||||
let membrane = membrane_fill(w, h, ch, img, hole);
|
||||
let membrane = membrane_fill_with(w, h, ch, img, hole, ctl)?;
|
||||
ctl.progress(0.97);
|
||||
ctl.check()?;
|
||||
let low = box_blur(w, h, ch, out, r);
|
||||
for (i, &hl) in hole.iter().enumerate() {
|
||||
if hl {
|
||||
@@ -250,6 +284,7 @@ fn adapt_colors(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], out:
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -263,6 +298,26 @@ mod tests {
|
||||
(img, hole)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_fill_stops_and_progress_reaches_one() {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
let (w, h) = (60, 40);
|
||||
let (img, hole) = stripes(w, h);
|
||||
let source = vec![true; w * h];
|
||||
let yes = || true;
|
||||
let cancelled = photocraft_raster::Interrupt::cancel_only(&yes);
|
||||
assert!(fill_with(w, h, 1, &img, &hole, &source, &FillOptions::default(), &cancelled).is_err());
|
||||
let last = AtomicU32::new(0);
|
||||
let no = || false;
|
||||
let progress = |f: f32| {
|
||||
assert!(f >= f32::from_bits(last.load(Ordering::Relaxed)), "progress went backwards");
|
||||
last.store(f.to_bits(), Ordering::Relaxed);
|
||||
};
|
||||
let out = fill_with(w, h, 1, &img, &hole, &source, &FillOptions::default(), &photocraft_raster::Interrupt::new(&no, &progress)).unwrap();
|
||||
assert_eq!(f32::from_bits(last.load(Ordering::Relaxed)), 1.0);
|
||||
assert_eq!(out, fill(w, h, 1, &img, &hole, &source, &FillOptions::default()), "same result as the plain fill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fills_only_the_hole_with_known_texture() {
|
||||
let (w, h) = (48, 32);
|
||||
|
||||
@@ -172,7 +172,17 @@ pub(crate) fn gauss_box_radii(sigma: f32) -> [usize; 3] {
|
||||
let wl = wl.max(1);
|
||||
let wu = wl + 2;
|
||||
let m = ((12.0 * sigma * sigma - n * (wl * wl) as f32 - 4.0 * n * wl as f32 - 3.0 * n) / (-4.0 * wl as f32 - 4.0)).round().clamp(0.0, n) as usize;
|
||||
std::array::from_fn(|i| if i < m { (wl as usize - 1) / 2 } else { (wu as usize - 1) / 2 })
|
||||
let mut radii = std::array::from_fn(|i| if i < m { (wl as usize - 1) / 2 } else { (wu as usize - 1) / 2 });
|
||||
// Kovesi's rounded triple degenerates below sigma ~ 0.577: `wl` floors to 1 and every
|
||||
// radius becomes 0, so the passes would be no-ops and callers with documented small
|
||||
// sigmas (Camera Raw `sharpenRadius` 0.5, colour noise, HDR Toning `radius` 1) would
|
||||
// silently change nothing (issue #706). One radius-1 pass is the smallest real blur;
|
||||
// it only engages where the ideal widths are already at their floor, leaving larger
|
||||
// sigmas exactly as the paper prescribes.
|
||||
if radii.iter().all(|&r| r == 0) {
|
||||
radii[0] = 1;
|
||||
}
|
||||
radii
|
||||
}
|
||||
|
||||
/// Approximate Gaussian blur (three box passes) of an interleaved buffer.
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Bounded RGB histograms of straight, normalized float RGBA samples.
|
||||
//!
|
||||
//! No colour conversion is performed: bins describe the supplied sample domain, not an
|
||||
//! assumed sRGB/ICC display space. 256 bins are a display resolution, not an input bit depth.
|
||||
pub const BINS: usize = 256;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RgbHistogram {
|
||||
pub channels: [[u64; BINS]; 3],
|
||||
pub samples: u64,
|
||||
pub transparent: u64,
|
||||
pub invalid: u64,
|
||||
/// Finite samples outside 0..=1 are included in the edge bins and counted separately.
|
||||
pub underflow: [u64; 3],
|
||||
pub overflow: [u64; 3],
|
||||
/// Exact endpoints, before display-bin rounding. A near-white value is not clipped.
|
||||
pub shadows: [u64; 3],
|
||||
pub highlights: [u64; 3],
|
||||
}
|
||||
|
||||
impl Default for RgbHistogram {
|
||||
fn default() -> Self {
|
||||
Self { channels: [[0; BINS]; 3], samples: 0, transparent: 0, invalid: 0, underflow: [0; 3], overflow: [0; 3], shadows: [0; 3], highlights: [0; 3] }
|
||||
}
|
||||
}
|
||||
|
||||
impl RgbHistogram {
|
||||
/// Each visible pixel counts once, including partially transparent pixels. Non-finite
|
||||
/// pixels are rejected as a whole; transparent RGB cannot contribute phantom peaks.
|
||||
pub fn from_rgba(pixels: &[[f32; 4]]) -> Self {
|
||||
let mut h = Self::default();
|
||||
for pixel in pixels {
|
||||
if !pixel[3].is_finite() {
|
||||
h.invalid = h.invalid.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
if pixel[3] <= 0.0 {
|
||||
h.transparent = h.transparent.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
if !pixel.iter().all(|v| v.is_finite()) {
|
||||
h.invalid = h.invalid.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
h.samples = h.samples.saturating_add(1);
|
||||
let (low, high) = clipping(*pixel);
|
||||
for (c, ((((bins, below), above), shadows), highlights)) in
|
||||
h.channels.iter_mut().zip(&mut h.underflow).zip(&mut h.overflow).zip(&mut h.shadows).zip(&mut h.highlights).enumerate()
|
||||
{
|
||||
let Some(value) = pixel.get(c) else { continue };
|
||||
if low & (1 << c) != 0 {
|
||||
*shadows = shadows.saturating_add(1);
|
||||
}
|
||||
if high & (1 << c) != 0 {
|
||||
*highlights = highlights.saturating_add(1);
|
||||
}
|
||||
if *value < 0.0 {
|
||||
*below = below.saturating_add(1);
|
||||
} else if *value > 1.0 {
|
||||
*above = above.saturating_add(1);
|
||||
}
|
||||
// Match the preview encoder's nearest-bin rule, before it loses float precision.
|
||||
let bin = (value.clamp(0.0, 1.0) * (BINS - 1) as f32 + 0.5) as usize;
|
||||
if let Some(count) = bins.get_mut(bin) {
|
||||
*count = count.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
}
|
||||
|
||||
/// Clipped channels of one visible straight RGBA pixel as bit masks (bit 0=R, 1=G, 2=B):
|
||||
/// `(shadows, highlights)` at exact endpoints ≤0 / ≥1, not rounded edge bins.
|
||||
/// Transparent and non-finite pixels clip nothing.
|
||||
pub fn clipping(p: [f32; 4]) -> (u8, u8) {
|
||||
if p[3] <= 0.0 || !p.iter().all(|v| v.is_finite()) {
|
||||
return (0, 0);
|
||||
}
|
||||
let mut shadows = 0;
|
||||
let mut highlights = 0;
|
||||
for (c, v) in p.iter().take(3).enumerate() {
|
||||
if *v <= 0.0 {
|
||||
shadows |= 1 << c;
|
||||
}
|
||||
if *v >= 1.0 {
|
||||
highlights |= 1 << c;
|
||||
}
|
||||
}
|
||||
(shadows, highlights)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_bins_and_channel_totals() {
|
||||
let pixels: Vec<_> = (0..BINS).map(|i| [i as f32 / 255.0, (255 - i) as f32 / 255.0, 0.5, 0.25]).collect();
|
||||
let h = RgbHistogram::from_rgba(&pixels);
|
||||
assert_eq!(h.samples, 256);
|
||||
assert!(h.channels[0].iter().all(|&v| v == 1));
|
||||
assert!(h.channels[1].iter().all(|&v| v == 1));
|
||||
assert_eq!(h.channels[2][128], 256);
|
||||
for channel in h.channels {
|
||||
assert_eq!(channel.iter().sum::<u64>(), h.samples);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_transparent_invalid_and_hdr_are_explicit() {
|
||||
assert_eq!(RgbHistogram::from_rgba(&[]), RgbHistogram::default());
|
||||
let pixels = [[0.7, 0.9, 0.4, 0.0], [f32::NAN, 0.0, 0.0, 1.0], [0.0, f32::INFINITY, 0.0, 1.0], [0.0, 0.0, 0.0, f32::NAN], [-2.0, 4.0, 0.5, 1.0]];
|
||||
let original = pixels;
|
||||
let h = RgbHistogram::from_rgba(&pixels);
|
||||
assert_eq!((h.samples, h.transparent, h.invalid), (1, 1, 3));
|
||||
assert_eq!(h.underflow, [1, 0, 0]);
|
||||
assert_eq!(h.overflow, [0, 1, 0]);
|
||||
assert_eq!((h.channels[0][0], h.channels[1][255], h.channels[2][128]), (1, 1, 1));
|
||||
assert_eq!(pixels[4], original[4]);
|
||||
assert!(pixels[1][0].is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipping_indicators_use_exact_samples_not_edge_bin_rounding() {
|
||||
let h = RgbHistogram::from_rgba(&[[0.0001, 0.9999, 0.5, 1.0], [-0.2, 1.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0]]);
|
||||
assert_eq!(h.shadows, [1, 0, 1]);
|
||||
assert_eq!(h.highlights, [0, 1, 0]);
|
||||
assert_eq!(h.channels[0][0], 2);
|
||||
assert_eq!(h.channels[1][255], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_and_gray_at_all_depths_use_the_same_float_contract() {
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
|
||||
for sample in SampleType::ALL {
|
||||
let mut surface = Surface::new(PixelFormat::new(mode, sample, true));
|
||||
for (x, v) in [0.0, 0.25, 0.75, 1.0].into_iter().enumerate() {
|
||||
let px = if mode == ColorMode::Rgb { vec![v, v, v, 1.0] } else { vec![v, 1.0] };
|
||||
surface.write_pixel(x as i32, 0, &px);
|
||||
}
|
||||
let mut pixels = [[0.0; 4]; 4];
|
||||
surface.read_rgba_into(Rect::new(0, 0, 4, 1), &mut pixels);
|
||||
let h = RgbHistogram::from_rgba(&pixels);
|
||||
assert_eq!(h.samples, 4, "{mode:?}/{sample:?}");
|
||||
for c in &h.channels {
|
||||
for bin in [0, 64, 191, 255] {
|
||||
assert_eq!(c[bin], 1, "{mode:?}/{sample:?}, bin {bin}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_clipping_endpoints_not_quantized_neighbours() {
|
||||
assert_eq!(clipping([0.0001, 0.9999, 0.5, 1.0]), (0, 0));
|
||||
assert_eq!(clipping([-0.1, 1.0, 0.0, 1.0]), (5, 2));
|
||||
assert_eq!(clipping([0.0, 1.0, 0.0, 0.0]), (0, 0));
|
||||
assert_eq!(clipping([0.0, f32::INFINITY, 0.0, 1.0]), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exposure_of_an_eight_bit_gradient_reproduces_the_quantization_comb() {
|
||||
let mut pixels: Vec<_> = (0..256).map(|i| [i as f32 / 255.0, i as f32 / 255.0, i as f32 / 255.0, 1.0]).collect();
|
||||
assert!(RgbHistogram::from_rgba(&pixels).channels[0].iter().all(|&v| v == 1));
|
||||
crate::camera_raw::develop(&mut pixels, 256, 1, &crate::camera_raw::CameraRaw { exposure: 0.5, ..Default::default() }, true);
|
||||
let h = RgbHistogram::from_rgba(&pixels);
|
||||
assert!(h.channels[0][10..230].iter().filter(|&&v| v == 0).count() > 10);
|
||||
assert_eq!(h.samples, 256);
|
||||
assert!(h.overflow[0] > 0);
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,8 @@ struct Solver<'a> {
|
||||
valid_list: Vec<(i32, i32)>,
|
||||
/// Target centres (patch overlaps the hole), row-major.
|
||||
targets: Vec<bool>,
|
||||
/// Cancellation: checked per row band; a cancelled solve's output is discarded.
|
||||
ctl: photocraft_raster::Interrupt<'a>,
|
||||
}
|
||||
|
||||
impl Solver<'_> {
|
||||
@@ -211,6 +213,9 @@ impl Solver<'_> {
|
||||
let w = self.w;
|
||||
let reverse = pass % 2 == 1;
|
||||
let band_fn = |bi: usize, nn: &mut [(i32, i32)], co: &mut [f32]| {
|
||||
if self.ctl.cancelled() {
|
||||
return;
|
||||
}
|
||||
let mut rng = Rng::new(seed ^ (bi as u64).wrapping_mul(0x2545_F491_4F6C_DD1D) ^ ((pass as u64) << 40));
|
||||
let y0 = bi * BAND;
|
||||
let rows = nn.len() / w;
|
||||
@@ -292,6 +297,9 @@ impl Solver<'_> {
|
||||
let img = &*self.img;
|
||||
let row = |y: usize| -> Vec<(usize, Vec<f32>)> {
|
||||
let mut out = Vec::new();
|
||||
if self.ctl.cancelled() {
|
||||
return out;
|
||||
}
|
||||
for x in 0..w {
|
||||
if !self.hole[y * w + x] {
|
||||
continue;
|
||||
@@ -349,9 +357,24 @@ impl Solver<'_> {
|
||||
/// Content-aware completion of `hole` (see module docs). Returns `None` when there is no fully-known
|
||||
/// patch to copy from (the caller can fall back to [`membrane_fill`]).
|
||||
pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &CompleteParams) -> Option<Vec<f32>> {
|
||||
// Never cancelled, so never `Err`.
|
||||
complete_with(w, h, ch, img, hole, p, &photocraft_raster::Interrupt::NONE).unwrap_or(None)
|
||||
}
|
||||
|
||||
/// [`complete`] that can be cancelled (checked per EM iteration, PatchMatch pass and row band)
|
||||
/// and reports progress (by pyramid level). `Err(Cancelled)` when `ctl` was cancelled.
|
||||
pub fn complete_with(
|
||||
w: usize,
|
||||
h: usize,
|
||||
ch: usize,
|
||||
img: &[f32],
|
||||
hole: &[bool],
|
||||
p: &CompleteParams,
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
) -> Result<Option<Vec<f32>>, photocraft_raster::Cancelled> {
|
||||
assert_eq!(img.len(), w * h * ch);
|
||||
assert_eq!(hole.len(), w * h);
|
||||
let Some((bx0, by0, bx1, by1)) = hole_bbox(w, h, hole) else { return Some(img.to_vec()) };
|
||||
let Some((bx0, by0, bx1, by1)) = hole_bbox(w, h, hole) else { return Ok(Some(img.to_vec())) };
|
||||
let r = p.patch_radius.max(1);
|
||||
let psz = 2 * r + 1;
|
||||
// Pyramid: shrink until the hole is a few patches across.
|
||||
@@ -359,6 +382,7 @@ pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &C
|
||||
let mut ext = (bx1 - bx0).max(by1 - by0);
|
||||
let mut exts = vec![ext];
|
||||
while let Some(l) = levels.last() {
|
||||
ctl.check()?;
|
||||
if ext <= 2 * psz || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
|
||||
break;
|
||||
}
|
||||
@@ -370,7 +394,10 @@ pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &C
|
||||
let nlev = levels.len();
|
||||
let mut nnf: Vec<(i32, i32)> = Vec::new();
|
||||
let mut prev: Option<(usize, usize, Vec<f32>)> = None;
|
||||
let total_px: usize = levels.iter().map(|l| l.w * l.h).sum();
|
||||
let mut done_px = 0usize;
|
||||
for li in (0..nlev).rev() {
|
||||
ctl.check()?;
|
||||
let Level { w: lw, h: lh, img: mut limg, hole: lhole } = levels[li].clone();
|
||||
// Initialise the hole: membrane at the coarsest level, upsampled result elsewhere.
|
||||
match &prev {
|
||||
@@ -393,6 +420,9 @@ pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &C
|
||||
let mut valid_list = Vec::new();
|
||||
let mut targets = vec![false; lw * lh];
|
||||
for y in 0..lh as i32 {
|
||||
if y % 32 == 0 {
|
||||
ctl.check()?;
|
||||
}
|
||||
for x in 0..lw as i32 {
|
||||
let cnt = window_count(&hs, lw, lh, x - ri, y - ri, x + ri + 1, y + ri + 1);
|
||||
let inside = x >= ri && y >= ri && x + ri < lw as i32 && y + ri < lh as i32;
|
||||
@@ -406,19 +436,22 @@ pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &C
|
||||
}
|
||||
if valid_list.is_empty() {
|
||||
if li == 0 {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
prev = Some((lw, lh, limg));
|
||||
nnf.clear();
|
||||
continue;
|
||||
}
|
||||
let mut solver = Solver { w: lw, h: lh, ch, r: ri, img: &mut limg, hole: &lhole, valid, valid_list, targets };
|
||||
let mut solver = Solver { w: lw, h: lh, ch, r: ri, img: &mut limg, hole: &lhole, valid, valid_list, targets, ctl: *ctl };
|
||||
// NNF init: upsampled from the coarser level where possible, random otherwise.
|
||||
let mut rng = Rng::new(p.seed ^ (li as u64) << 20);
|
||||
let pw_prev = prev.as_ref().map_or(0, |p| p.0);
|
||||
let old = std::mem::take(&mut nnf);
|
||||
let mut new_nnf = vec![(0i32, 0i32); lw * lh];
|
||||
for y in 0..lh {
|
||||
if y % 32 == 0 {
|
||||
ctl.check()?;
|
||||
}
|
||||
for x in 0..lw {
|
||||
if !solver.targets[y * lw + x] {
|
||||
continue;
|
||||
@@ -451,6 +484,9 @@ pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &C
|
||||
for it in 0..em {
|
||||
// Costs change as hole pixels are re-estimated: refresh before each PatchMatch.
|
||||
for (i, c) in cost.iter_mut().enumerate() {
|
||||
if i % (lw * 32).max(1) == 0 {
|
||||
ctl.check()?;
|
||||
}
|
||||
if solver.targets[i] {
|
||||
*c = solver.dist(((i % lw) as i32, (i / lw) as i32), nnf[i], f32::INFINITY);
|
||||
}
|
||||
@@ -464,13 +500,19 @@ pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &C
|
||||
(4 * psz as i32).min(full_search)
|
||||
};
|
||||
for pass in 0..pm_iters {
|
||||
ctl.check()?;
|
||||
solver.patchmatch(&mut nnf, &mut cost, pass + it * pm_iters, p.seed.wrapping_add((li * 1000 + it) as u64), radius);
|
||||
}
|
||||
ctl.check()?;
|
||||
solver.vote(&nnf, &cost);
|
||||
ctl.check()?;
|
||||
}
|
||||
// Finer levels cost ~4× the previous one: weight progress by pixel count.
|
||||
done_px += lw * lh;
|
||||
ctl.progress(done_px as f32 / total_px.max(1) as f32);
|
||||
prev = Some((lw, lh, limg));
|
||||
}
|
||||
prev.map(|(_, _, img)| img)
|
||||
Ok(prev.map(|(_, _, img)| img))
|
||||
}
|
||||
|
||||
/// Proximity match: the displacement `(dx, dy)` (within `max_radius`) whose surroundings best match the
|
||||
|
||||
+76
-2
@@ -33,6 +33,7 @@ pub mod features;
|
||||
mod fxutil;
|
||||
mod gallery;
|
||||
pub mod hdr;
|
||||
pub mod histogram;
|
||||
mod image;
|
||||
pub mod inpaint;
|
||||
pub mod lens;
|
||||
@@ -51,6 +52,7 @@ pub mod poisson;
|
||||
pub mod puppet;
|
||||
pub mod pyramid;
|
||||
pub mod quantize;
|
||||
mod relight;
|
||||
mod render;
|
||||
pub mod render2;
|
||||
pub mod resample;
|
||||
@@ -59,6 +61,7 @@ pub mod scancrop;
|
||||
pub mod seam;
|
||||
pub mod segment;
|
||||
pub mod selection;
|
||||
mod selection_blur;
|
||||
mod sharpen;
|
||||
pub mod stack;
|
||||
mod stylize;
|
||||
@@ -67,6 +70,7 @@ pub mod tone;
|
||||
pub mod transform;
|
||||
pub mod trap;
|
||||
pub mod vanishing;
|
||||
pub mod vectorscope;
|
||||
mod video;
|
||||
pub mod warp;
|
||||
pub mod wideangle;
|
||||
@@ -408,6 +412,16 @@ pub enum FilterParams {
|
||||
height: f32,
|
||||
white_is_high: bool,
|
||||
},
|
||||
/// Photographic relight: angle −180…180°, elevation 0…90°, intensity/ambient 0…100,
|
||||
/// warmth −100…100, softness 1…100. Intensity 0 is an identity copy.
|
||||
Relight {
|
||||
angle: f32,
|
||||
elevation: f32,
|
||||
intensity: f32,
|
||||
ambient: f32,
|
||||
warmth: f32,
|
||||
softness: f32,
|
||||
},
|
||||
|
||||
// ---- Noise ----
|
||||
/// Strength 0–10, the rest 0–100 %.
|
||||
@@ -584,6 +598,7 @@ impl FilterParams {
|
||||
FilterParams::Fibers { .. } => "Fibers",
|
||||
FilterParams::LensFlare { .. } => "Lens Flare",
|
||||
FilterParams::LightingEffects { .. } => "Lighting Effects",
|
||||
FilterParams::Relight { .. } => "Relight",
|
||||
FilterParams::ReduceNoise { .. } => "Reduce Noise",
|
||||
FilterParams::SmartBlur { .. } => "Smart Blur",
|
||||
FilterParams::LensBlur { .. } => "Lens Blur",
|
||||
@@ -625,6 +640,8 @@ fn halo_ext(p: &FilterParams) -> Halo {
|
||||
// Generators and per-pixel renders only need the bounds geometry (always in the context).
|
||||
FilterParams::Fibers { .. } | FilterParams::LensFlare { .. } => Halo::Radius(0),
|
||||
FilterParams::LightingEffects { .. } => r(1.0),
|
||||
FilterParams::Relight { intensity, .. } if *intensity == 0.0 => Halo::Radius(0),
|
||||
FilterParams::Relight { .. } => Halo::Radius(relight::HALO_RADIUS),
|
||||
FilterParams::ReduceNoise { .. } => r(denoise::reach()),
|
||||
FilterParams::SmartBlur { radius, .. } => r(*radius),
|
||||
FilterParams::LensBlur { radius, .. } => r(*radius),
|
||||
@@ -758,6 +775,12 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
|
||||
white_is_high: *white_is_high,
|
||||
},
|
||||
),
|
||||
FilterParams::Relight { angle, elevation, intensity, ambient, warmth, softness } => relight::relight(
|
||||
src,
|
||||
out,
|
||||
ctx,
|
||||
relight::Params { angle: *angle, elevation: *elevation, intensity: *intensity, ambient: *ambient, warmth: *warmth, softness: *softness },
|
||||
),
|
||||
FilterParams::ReduceNoise { strength, preserve_details, reduce_color_noise, sharpen_details, remove_jpeg_artifact } => denoise::reduce_noise(
|
||||
src,
|
||||
out,
|
||||
@@ -860,6 +883,20 @@ pub fn apply_in(surface: &Surface, params: &FilterParams, area: Rect, bounds: Re
|
||||
apply_tiled(surface, params, area.intersect(&extent), bounds, selection, auto_tile(params), Some(extent))
|
||||
}
|
||||
|
||||
/// [`apply_in`] that can be cancelled (checked before each tile) and reports progress per tile
|
||||
/// group. `None` when `ctl` was cancelled; the input surface is never modified.
|
||||
pub fn apply_in_with(
|
||||
surface: &Surface,
|
||||
params: &FilterParams,
|
||||
area: Rect,
|
||||
bounds: Rect,
|
||||
selection: Option<&Surface>,
|
||||
extent: Rect,
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
) -> Option<Surface> {
|
||||
apply_tiled_with(surface, params, area.intersect(&extent), bounds, selection, auto_tile(params), Some(extent), ctl)
|
||||
}
|
||||
|
||||
/// Results do not depend on the tiling, so wide-halo filters use bigger tiles to keep the
|
||||
/// re-read margin (and its cost) below ~2× the tile area.
|
||||
fn auto_tile(params: &FilterParams) -> i32 {
|
||||
@@ -879,9 +916,26 @@ pub fn apply_tiled(
|
||||
tile: i32,
|
||||
extent: Option<Rect>,
|
||||
) -> Surface {
|
||||
// Never cancelled, so always `Some`; the fallback (the input unchanged) is unreachable.
|
||||
apply_tiled_with(surface, params, area, bounds, selection, tile, extent, &photocraft_raster::Interrupt::NONE).unwrap_or_else(|| surface.clone())
|
||||
}
|
||||
|
||||
/// [`apply_tiled`] with cancellation (checked before each tile, so a cancel takes effect within
|
||||
/// one tile's work) and progress (after each group of tiles). `None` when cancelled.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn apply_tiled_with(
|
||||
surface: &Surface,
|
||||
params: &FilterParams,
|
||||
area: Rect,
|
||||
bounds: Rect,
|
||||
selection: Option<&Surface>,
|
||||
tile: i32,
|
||||
extent: Option<Rect>,
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
) -> Option<Surface> {
|
||||
let mut out = surface.clone();
|
||||
if area.is_empty() {
|
||||
return out;
|
||||
return Some(out);
|
||||
}
|
||||
let fmt = surface.format();
|
||||
let ctx = Ctx { bounds, mode: fmt.mode, alpha: fmt.alpha };
|
||||
@@ -897,7 +951,19 @@ pub fn apply_tiled(
|
||||
}
|
||||
y += tile;
|
||||
}
|
||||
// Tiles finished so far, for progress reported per tile (from any worker thread).
|
||||
let finished = std::sync::atomic::AtomicUsize::new(0);
|
||||
let total = tiles.len().max(1);
|
||||
let run = |t: &Rect| -> (Rect, Vec<f32>) {
|
||||
// Cancelled: skip the remaining tiles of the group (the result is discarded).
|
||||
if ctl.cancelled() {
|
||||
return (*t, Vec::new());
|
||||
}
|
||||
let tick = || {
|
||||
let n = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
// The last few percent are the writes after each group.
|
||||
ctl.progress(0.98 * n as f32 / total as f32);
|
||||
};
|
||||
let owned;
|
||||
let src = match (&shared, halo) {
|
||||
(Some(s), _) => s,
|
||||
@@ -930,6 +996,7 @@ pub fn apply_tiled(
|
||||
}
|
||||
}
|
||||
}
|
||||
tick();
|
||||
(*t, data)
|
||||
};
|
||||
// Tiles are filtered in groups of about RESULT_BUDGET bytes (at least one per core) and each
|
||||
@@ -942,6 +1009,9 @@ pub fn apply_tiled(
|
||||
let per_tile = (tile.max(1) as usize).pow(2) * fmt.channels() * std::mem::size_of::<f32>();
|
||||
let group = (RESULT_BUDGET / per_tile.max(1)).max(threads).max(1);
|
||||
for chunk in tiles.chunks(group) {
|
||||
if ctl.cancelled() {
|
||||
return None;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let results: Vec<(Rect, Vec<f32>)> = {
|
||||
use rayon::prelude::*;
|
||||
@@ -949,12 +1019,16 @@ pub fn apply_tiled(
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let results: Vec<(Rect, Vec<f32>)> = chunk.iter().map(run).collect();
|
||||
if ctl.cancelled() {
|
||||
return None;
|
||||
}
|
||||
for (t, data) in results {
|
||||
out.write_region(t, &data);
|
||||
}
|
||||
}
|
||||
out.prune();
|
||||
out
|
||||
ctl.progress(1.0);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Bytes of float filter results held at once by [`apply_tiled`].
|
||||
|
||||
+144
-3
@@ -38,6 +38,7 @@ pub enum LiquifyTool {
|
||||
PushLeft,
|
||||
Freeze,
|
||||
Thaw,
|
||||
LassoMask,
|
||||
/// The Reconstruct… button: scales the whole (unfrozen) field toward zero by `amount` %.
|
||||
ReconstructAll,
|
||||
/// Mask Options › Mask All / None / Invert All (whole-field freeze edits).
|
||||
@@ -47,7 +48,7 @@ pub enum LiquifyTool {
|
||||
}
|
||||
|
||||
impl LiquifyTool {
|
||||
pub const ALL: [LiquifyTool; 10] = [
|
||||
pub const ALL: [LiquifyTool; 11] = [
|
||||
LiquifyTool::ForwardWarp,
|
||||
LiquifyTool::Reconstruct,
|
||||
LiquifyTool::Smooth,
|
||||
@@ -58,6 +59,7 @@ impl LiquifyTool {
|
||||
LiquifyTool::PushLeft,
|
||||
LiquifyTool::Freeze,
|
||||
LiquifyTool::Thaw,
|
||||
LiquifyTool::LassoMask,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
@@ -72,6 +74,7 @@ impl LiquifyTool {
|
||||
LiquifyTool::PushLeft => "Push Left",
|
||||
LiquifyTool::Freeze => "Freeze Mask",
|
||||
LiquifyTool::Thaw => "Thaw Mask",
|
||||
LiquifyTool::LassoMask => "Freeze Lasso",
|
||||
LiquifyTool::ReconstructAll => "Reconstruct All",
|
||||
LiquifyTool::FreezeAll => "Mask All",
|
||||
LiquifyTool::ThawAll => "Mask None",
|
||||
@@ -86,7 +89,7 @@ impl LiquifyTool {
|
||||
|
||||
/// Tools that act while the brush is held still (each recorded point is a dab).
|
||||
pub fn is_stationary(self) -> bool {
|
||||
!matches!(self, LiquifyTool::ForwardWarp | LiquifyTool::PushLeft)
|
||||
!matches!(self, LiquifyTool::ForwardWarp | LiquifyTool::PushLeft | LiquifyTool::LassoMask)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +127,8 @@ pub struct LiquifyStroke {
|
||||
pub rate: f64,
|
||||
#[serde(default)]
|
||||
pub points: Vec<Vec<f64>>,
|
||||
/// `reconstructAll` only: how much to restore, 0..100.
|
||||
/// `reconstructAll` only: how much to restore, 0..100. `lassoMask`: 1.0/none freezes the
|
||||
/// polygon, 0.0 thaws it.
|
||||
#[serde(default)]
|
||||
pub amount: Option<f64>,
|
||||
}
|
||||
@@ -216,6 +220,7 @@ impl LiquifyField {
|
||||
/// Applies one stroke. Returns the document rectangle whose output changed.
|
||||
pub fn apply_stroke(&mut self, s: &LiquifyStroke) -> Rect {
|
||||
match s.tool {
|
||||
LiquifyTool::LassoMask => return self.apply_lasso(s),
|
||||
LiquifyTool::ReconstructAll => {
|
||||
let k = (s.amount.unwrap_or(100.0) / 100.0).clamp(0.0, 1.0) as f32;
|
||||
for (v, f) in self.d.iter_mut().zip(&self.freeze) {
|
||||
@@ -242,6 +247,60 @@ impl LiquifyField {
|
||||
dirty
|
||||
}
|
||||
|
||||
/// Lasso: sets the freeze mask to `amount` (1.0 when none; 0.0 thaws) at every field node
|
||||
/// inside the closed polygon in `s.points` (even-odd rule; non-finite points are skipped).
|
||||
/// A scanline fill: each node row costs one pass over the edges, then only the nodes inside
|
||||
/// are written. Returns the document rectangle the mask changed in.
|
||||
fn apply_lasso(&mut self, s: &LiquifyStroke) -> Rect {
|
||||
let poly: Vec<[f64; 2]> = s.points.iter().filter_map(|p| Some([*p.first()?, *p.get(1)?])).filter(|p| p[0].is_finite() && p[1].is_finite()).collect();
|
||||
if poly.len() < 3 || self.w == 0 || self.h == 0 {
|
||||
return Rect::EMPTY;
|
||||
}
|
||||
let (mut px0, mut py0, mut px1, mut py1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
for p in &poly {
|
||||
px0 = px0.min(p[0]);
|
||||
py0 = py0.min(p[1]);
|
||||
px1 = px1.max(p[0]);
|
||||
py1 = py1.max(p[1]);
|
||||
}
|
||||
let (_, gy0) = self.to_grid(px0, py0);
|
||||
let (_, gy1) = self.to_grid(px1, py1);
|
||||
let last_row = self.h.saturating_sub(1) as f64;
|
||||
let last_col = self.w.saturating_sub(1) as f64;
|
||||
let (j0, j1) = (gy0.ceil().max(0.0), gy1.floor().min(last_row));
|
||||
if j1 < j0 {
|
||||
return Rect::EMPTY;
|
||||
}
|
||||
let v = if s.amount.unwrap_or(1.0) > 0.5 { 1.0f32 } else { 0.0 };
|
||||
let mut xs: Vec<f64> = Vec::new();
|
||||
for j in (j0 as usize)..=(j1 as usize) {
|
||||
let y = self.node_pos(0, j)[1];
|
||||
xs.clear();
|
||||
let Some(mut prev) = poly.last().copied() else { return Rect::EMPTY };
|
||||
for &a in &poly {
|
||||
if (a[1] > y) != (prev[1] > y) {
|
||||
xs.push(a[0] + (y - a[1]) * (prev[0] - a[0]) / (prev[1] - a[1]));
|
||||
}
|
||||
prev = a;
|
||||
}
|
||||
xs.sort_by(f64::total_cmp);
|
||||
// Even-odd: nodes with xs[2k] <= x < xs[2k + 1] are inside.
|
||||
for &[xa, xb] in xs.as_chunks::<2>().0 {
|
||||
let (ga, _) = self.to_grid(xa, y);
|
||||
let (gb, _) = self.to_grid(xb, y);
|
||||
let (i0, i1) = (ga.ceil().max(0.0), (gb.ceil() - 1.0).min(last_col));
|
||||
if i1 < i0 {
|
||||
continue;
|
||||
}
|
||||
let row = j * self.w;
|
||||
if let Some(cells) = self.freeze.get_mut(row + i0 as usize..=row + i1 as usize) {
|
||||
cells.fill(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
Rect::new(px0.floor() as i32, py0.floor() as i32, px1.ceil() as i32, py1.ceil() as i32).intersect(&self.bounds)
|
||||
}
|
||||
|
||||
/// Starts a stroke at `p` (`[x, y, pressure]`): stationary tools dab once there. Interactive
|
||||
/// callers use this plus [`Self::stroke_segment`] per new point, which is exactly what
|
||||
/// [`Self::apply_stroke`] does with the whole point list.
|
||||
@@ -276,6 +335,9 @@ impl LiquifyField {
|
||||
|
||||
/// One dab at `c` with brush motion `delta`. Returns the document rect it affected.
|
||||
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64) -> Rect {
|
||||
if s.tool == LiquifyTool::LassoMask {
|
||||
return Rect::EMPTY; // the lasso is polygon-only; never a brush dab
|
||||
}
|
||||
let r = (s.size / 2.0).max(0.5);
|
||||
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure;
|
||||
let rate = (s.rate / 100.0).clamp(0.0, 1.0);
|
||||
@@ -699,6 +761,85 @@ mod tests {
|
||||
a.read_region(r).iter().zip(b.read_region(r)).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
fn lasso(points: &[[f64; 2]], amount: Option<f64>) -> LiquifyStroke {
|
||||
let mut s = LiquifyStroke::new(LiquifyTool::LassoMask, 1.0);
|
||||
s.points = points.iter().map(|p| p.to_vec()).collect();
|
||||
s.amount = amount;
|
||||
s
|
||||
}
|
||||
|
||||
/// Reference even-odd test (ray casting), to check the scanline fill against.
|
||||
fn inside(p: [f64; 2], poly: &[[f64; 2]]) -> bool {
|
||||
let mut inside = false;
|
||||
let mut j = poly.len() - 1;
|
||||
for (i, a) in poly.iter().enumerate() {
|
||||
let b = poly[j];
|
||||
if (a[1] > p[1]) != (b[1] > p[1]) && p[0] < (b[0] - a[0]) * (p[1] - a[1]) / (b[1] - a[1]) + a[0] {
|
||||
inside = !inside;
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
inside
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lasso_freezes_exactly_the_nodes_inside_a_concave_polygon() {
|
||||
// A concave "C" with a notch, at a non-integer cell size.
|
||||
let poly = [[10.0, 8.0], [70.0, 8.0], [70.0, 20.0], [30.0, 22.5], [33.0, 40.0], [80.0, 41.0], [60.0, 58.0], [9.0, 55.0]];
|
||||
for cell in [1.0, 2.0, 3.5] {
|
||||
let mut f = LiquifyField::new(bounds(), cell);
|
||||
let dirty = f.apply_stroke(&lasso(&poly, None));
|
||||
assert_eq!(dirty, Rect::new(9, 8, 80, 58));
|
||||
for j in 0..f.h {
|
||||
for i in 0..f.w {
|
||||
let want = if inside(f.node_pos(i, j), &poly) { 1.0 } else { 0.0 };
|
||||
assert_eq!(f.freeze[j * f.w + i], want, "cell {cell}: node ({i}, {j}) at {:?}", f.node_pos(i, j));
|
||||
}
|
||||
}
|
||||
assert!(f.freeze.contains(&1.0));
|
||||
// Strokes replay to the same field (undo rebuilds from the list).
|
||||
assert_eq!(LiquifyField::from_strokes(bounds(), cell, &[lasso(&poly, None)]), f);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lasso_thaws_with_amount_zero() {
|
||||
let mut f = LiquifyField::new(bounds(), 2.0);
|
||||
f.apply_stroke(&LiquifyStroke::new(LiquifyTool::FreezeAll, 1.0));
|
||||
assert!(f.freeze.iter().all(|&v| v == 1.0));
|
||||
let square = [[20.0, 20.0], [40.0, 20.0], [40.0, 40.0], [20.0, 40.0]];
|
||||
f.apply_stroke(&lasso(&square, Some(0.0)));
|
||||
assert_eq!(f.freeze_at(30.0, 30.0), 0.0, "thawed inside");
|
||||
assert_eq!(f.freeze_at(5.0, 5.0), 1.0, "still frozen outside");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lasso_ignores_degenerate_and_hostile_polygons() {
|
||||
let empty = LiquifyField::new(bounds(), 2.0);
|
||||
let cases: [&[[f64; 2]]; 6] = [
|
||||
&[],
|
||||
&[[10.0, 10.0], [50.0, 50.0]],
|
||||
&[[f64::NAN, 10.0], [50.0, f64::INFINITY], [20.0, 30.0]],
|
||||
// Entirely outside the canvas, on every side.
|
||||
&[[-50.0, -50.0], [-10.0, -50.0], [-10.0, -10.0]],
|
||||
&[[200.0, 10.0], [300.0, 10.0], [250.0, 50.0]],
|
||||
&[[10.0, 1e12], [50.0, 1e12], [30.0, 2e12]],
|
||||
];
|
||||
for poly in cases {
|
||||
let mut f = empty.clone();
|
||||
assert_eq!(f.apply_stroke(&lasso(poly, None)), Rect::EMPTY, "{poly:?}");
|
||||
assert_eq!(f, empty, "{poly:?}");
|
||||
}
|
||||
// Huge coordinates around the canvas clamp to it instead of overflowing.
|
||||
let mut f = empty.clone();
|
||||
f.apply_stroke(&lasso(&[[-1e15, -1e15], [1e15, -1e15], [1e15, 1e15], [-1e15, 1e15]], None));
|
||||
assert!(f.freeze.iter().all(|&v| v == 1.0));
|
||||
// A NaN point among good ones is skipped.
|
||||
let mut f = empty.clone();
|
||||
f.apply_stroke(&lasso(&[[10.0, 10.0], [f64::NAN, 0.0], [50.0, 10.0], [50.0, 50.0], [10.0, 50.0]], None));
|
||||
assert_eq!(f.freeze_at(30.0, 30.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_strokes_is_identity_at_every_depth() {
|
||||
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
|
||||
+71
-22
@@ -76,39 +76,41 @@ pub(crate) fn running_extreme(v: &[f32], k: usize, max: bool, out: &mut Vec<f32>
|
||||
out.extend((0..=n - k).map(|i| pick(suf[i], pre[i + k - 1])));
|
||||
}
|
||||
|
||||
/// Minimum (spreads dark/transparent areas) or Maximum over a square.
|
||||
/// Minimum (spreads dark/transparent areas) or Maximum over a square. Separable (rows, then
|
||||
/// columns), each pass a [`running_extreme`], so the cost per pixel does not grow with the radius.
|
||||
pub(crate) fn min_max(src: &Image, out: Rect, radius: f32, max: bool) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
let r = radius.max(0.0).round() as i32;
|
||||
if r == 0 {
|
||||
return src.crop(out);
|
||||
}
|
||||
// Separable: rows then columns.
|
||||
let k = (2 * r + 1) as usize;
|
||||
// Rows: the extreme over x - r..=x + r, for every row the column pass needs.
|
||||
let win = Rect::new(out.x0, out.y0 - r, out.x1, out.y1 + r);
|
||||
let ww = win.width() as usize;
|
||||
let mut tmp = vec![0.0f32; ww * win.height() as usize * n];
|
||||
let (ww, wh) = (win.width() as usize, win.height() as usize);
|
||||
let mut tmp = vec![0.0f32; ww * wh * n];
|
||||
let (mut line, mut ext) = (Vec::new(), Vec::new());
|
||||
for y in win.y0..win.y1 {
|
||||
for x in win.x0..win.x1 {
|
||||
for c in 0..n {
|
||||
let mut m = if max { f32::MIN } else { f32::MAX };
|
||||
for xx in x - r..=x + r {
|
||||
let v = src.get(xx, y, c);
|
||||
m = if max { m.max(v) } else { m.min(v) };
|
||||
}
|
||||
tmp[((y - win.y0) as usize * ww + (x - win.x0) as usize) * n + c] = m;
|
||||
let o = (y - win.y0) as usize * ww * n;
|
||||
for c in 0..n {
|
||||
line.clear();
|
||||
line.extend((win.x0 - r..win.x1 + r).map(|x| src.get(x, y, c)));
|
||||
running_extreme(&line, k, max, &mut ext);
|
||||
for (x, v) in ext.iter().enumerate() {
|
||||
tmp[o + x * n + c] = *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut res = Vec::with_capacity(out.width() as usize * out.height() as usize * n);
|
||||
for y in out.y0..out.y1 {
|
||||
for x in out.x0..out.x1 {
|
||||
for c in 0..n {
|
||||
let mut m = if max { f32::MIN } else { f32::MAX };
|
||||
for yy in y - r..=y + r {
|
||||
let v = tmp[((yy - win.y0) as usize * ww + (x - win.x0) as usize) * n + c];
|
||||
m = if max { m.max(v) } else { m.min(v) };
|
||||
}
|
||||
res.push(m);
|
||||
// Columns: the extreme over y - r..=y + r of the row results.
|
||||
let (ow, oh) = (out.width() as usize, out.height() as usize);
|
||||
let mut res = vec![0.0f32; ow * oh * n];
|
||||
for x in 0..ow {
|
||||
for c in 0..n {
|
||||
line.clear();
|
||||
line.extend((0..wh).map(|y| tmp[(y * ww + x) * n + c]));
|
||||
running_extreme(&line, k, max, &mut ext);
|
||||
for (y, v) in ext.iter().enumerate() {
|
||||
res[(y * ow + x) * n + c] = *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,3 +139,50 @@ pub(crate) fn offset(src: &Image, out: Rect, ctx: &Ctx, dx: i32, dy: i32, undefi
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The square window scanned directly, the reference for [`min_max`].
|
||||
fn min_max_direct(src: &Image, out: Rect, r: i32, max: bool) -> Vec<f32> {
|
||||
let mut res = Vec::new();
|
||||
for y in out.y0..out.y1 {
|
||||
for x in out.x0..out.x1 {
|
||||
for c in 0..src.ch {
|
||||
let mut m = if max { f32::MIN } else { f32::MAX };
|
||||
for yy in y - r..=y + r {
|
||||
for xx in x - r..=x + r {
|
||||
let v = src.get(xx, yy, c);
|
||||
m = if max { m.max(v) } else { m.min(v) };
|
||||
}
|
||||
}
|
||||
res.push(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square_min_max_matches_the_direct_window_exactly() {
|
||||
let src_rect = Rect::new(-12, -9, 37, 30);
|
||||
let mut img = Image::new(src_rect, 4);
|
||||
let mut s = 0x2545_f491u32;
|
||||
for v in img.data.iter_mut() {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
*v = (s % 1000) as f32 / 999.0;
|
||||
}
|
||||
// Includes windows wider than the output and output reaching past the source.
|
||||
for out in [Rect::new(0, 0, 25, 21), Rect::new(-14, -3, 3, 40)] {
|
||||
for r in [1, 2, 3, 7, 20] {
|
||||
for max in [false, true] {
|
||||
let fast = min_max(&img, out, r as f32, max);
|
||||
assert_eq!(fast, min_max_direct(&img, out, r, max), "r {r} max {max} out {out:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,16 +111,6 @@ fn composite_area(
|
||||
}
|
||||
|
||||
/// Gradient tool over `area` (the selection bounds or canvas).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// Per-pixel dither noise in [0, 1) from a position hash (decorrelated, no visible pattern).
|
||||
fn dither_noise(x: i32, y: i32) -> f32 {
|
||||
let mut h = (x as u32).wrapping_mul(0x9E37_79B1) ^ (y as u32).wrapping_mul(0x85EB_CA77);
|
||||
h ^= h >> 15;
|
||||
h = h.wrapping_mul(0x2C1B_3C6D);
|
||||
h ^= h >> 12;
|
||||
f32::from((h & 0xFFFF) as u16) / 65535.0
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn paint_gradient(
|
||||
s: &mut Surface,
|
||||
@@ -145,7 +135,7 @@ pub fn paint_gradient(
|
||||
let mut c = sample_stops(stops, if reverse { 1.0 - t } else { t });
|
||||
if dither {
|
||||
// One quantisation step of monochromatic noise breaks 8-bit banding without speckle.
|
||||
let n = (dither_noise(x, y) - 0.5) / 255.0;
|
||||
let n = (photocraft_color::dither_noise(x, y) - 0.5) / 255.0;
|
||||
for ch in c.iter_mut().take(3) {
|
||||
*ch = (*ch + n).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
@@ -31,10 +31,13 @@ const FINE_OMEGA: f32 = 1.7;
|
||||
pub fn solve_membrane(w: usize, h: usize, unknown: &[bool], v: &mut [f32]) {
|
||||
assert_eq!(unknown.len(), w * h);
|
||||
assert_eq!(v.len(), w * h);
|
||||
solve_rec(w, h, unknown, v, 0);
|
||||
solve_rec(w, h, unknown, v, 0, &|| false);
|
||||
}
|
||||
|
||||
fn solve_rec(w: usize, h: usize, unknown: &[bool], v: &mut [f32], depth: usize) {
|
||||
fn solve_rec(w: usize, h: usize, unknown: &[bool], v: &mut [f32], depth: usize, stop: &(dyn Fn() -> bool + Sync)) {
|
||||
if stop() {
|
||||
return;
|
||||
}
|
||||
let n_unknown = unknown.iter().filter(|u| **u).count();
|
||||
if n_unknown == 0 {
|
||||
return;
|
||||
@@ -64,7 +67,7 @@ fn solve_rec(w: usize, h: usize, unknown: &[bool], v: &mut [f32], depth: usize)
|
||||
}
|
||||
}
|
||||
}
|
||||
solve_rec(cw, chh, &cu, &mut cv, depth + 1);
|
||||
solve_rec(cw, chh, &cu, &mut cv, depth + 1, stop);
|
||||
// Prolong: bilinear sample of the coarse solution as the initial guess.
|
||||
for y in 0..h {
|
||||
let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (chh - 1) as f32);
|
||||
@@ -84,7 +87,7 @@ fn solve_rec(w: usize, h: usize, unknown: &[bool], v: &mut [f32], depth: usize)
|
||||
v[y * w + x] = a + (b - a) * ty;
|
||||
}
|
||||
}
|
||||
sor(w, h, unknown, v, FINE_ITERS, FINE_OMEGA);
|
||||
sor(w, h, unknown, v, FINE_ITERS, FINE_OMEGA, stop);
|
||||
} else {
|
||||
let mean = known.iter().sum::<f32>() / known.len() as f32;
|
||||
for (x, u) in v.iter_mut().zip(unknown) {
|
||||
@@ -92,12 +95,12 @@ fn solve_rec(w: usize, h: usize, unknown: &[bool], v: &mut [f32], depth: usize)
|
||||
*x = mean;
|
||||
}
|
||||
}
|
||||
sor(w, h, unknown, v, 2000, 1.85);
|
||||
sor(w, h, unknown, v, 2000, 1.85, stop);
|
||||
}
|
||||
}
|
||||
|
||||
/// SOR sweeps over the unknown pixels until the largest update drops below [`TOL`].
|
||||
fn sor(w: usize, h: usize, unknown: &[bool], v: &mut [f32], max_iter: usize, omega: f32) {
|
||||
fn sor(w: usize, h: usize, unknown: &[bool], v: &mut [f32], max_iter: usize, omega: f32, stop: &(dyn Fn() -> bool + Sync)) {
|
||||
// Precompute the unknown pixels and which neighbours exist.
|
||||
let cells: Vec<(u32, u8)> = (0..w * h)
|
||||
.filter(|&i| unknown[i])
|
||||
@@ -108,6 +111,9 @@ fn sor(w: usize, h: usize, unknown: &[bool], v: &mut [f32], max_iter: usize, ome
|
||||
})
|
||||
.collect();
|
||||
for _ in 0..max_iter {
|
||||
if stop() {
|
||||
return;
|
||||
}
|
||||
let mut max_d = 0.0f32;
|
||||
for &(i, m) in &cells {
|
||||
let i = i as usize;
|
||||
@@ -229,6 +235,28 @@ pub fn membrane_fill(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool])
|
||||
})
|
||||
}
|
||||
|
||||
/// [`membrane_fill`] that stops at the next SOR sweep once `ctl` is cancelled.
|
||||
pub fn membrane_fill_with(
|
||||
w: usize,
|
||||
h: usize,
|
||||
ch: usize,
|
||||
img: &[f32],
|
||||
hole: &[bool],
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
) -> Result<Vec<f32>, photocraft_raster::Cancelled> {
|
||||
ctl.check()?;
|
||||
let stop = || ctl.cancelled();
|
||||
let out = per_channel(w, h, ch, |c| {
|
||||
let mut v: Vec<f32> = (0..w * h).map(|i| if hole[i] { 0.0 } else { img[i * ch + c] }).collect();
|
||||
if hole.len() == w * h && v.len() == w * h {
|
||||
solve_rec(w, h, hole, &mut v, 0, &stop);
|
||||
}
|
||||
v
|
||||
});
|
||||
ctl.check()?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Photographic relight: recover shading with a blur (Retinex) and replace it with a
|
||||
//! Lambertian lamp whose direction comes from angle / elevation.
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use crate::Ctx;
|
||||
use crate::fxutil::{MAXC, rgba, set_rgba, xy};
|
||||
use crate::image::Image;
|
||||
|
||||
/// Neighbourhood the kernel reads: `3σ + 1` at `σ = 32` (the typical cap for parallel tiles).
|
||||
pub(crate) const HALO_RADIUS: i32 = 97;
|
||||
|
||||
/// Dialog units (degrees / 0–100), matching the command params.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Params {
|
||||
pub angle: f32,
|
||||
pub elevation: f32,
|
||||
pub intensity: f32,
|
||||
pub ambient: f32,
|
||||
pub warmth: f32,
|
||||
pub softness: f32,
|
||||
}
|
||||
|
||||
/// Blur σ from softness and the reference bounds, clamped to `[2, 96]`.
|
||||
pub(crate) fn sigma(softness: f32, bounds: Rect) -> f32 {
|
||||
let side = bounds.width().min(bounds.height()) as f32;
|
||||
let s = (softness.clamp(1.0, 100.0) / 100.0) * 0.08 * side.max(1.0);
|
||||
s.clamp(2.0, 96.0)
|
||||
}
|
||||
|
||||
/// Infinite-light direction: 0° from the right, 90° from above (image space, y down).
|
||||
fn light_dir(angle: f32, elevation: f32) -> [f32; 3] {
|
||||
let (s, co) = angle.to_radians().sin_cos();
|
||||
let e = elevation.clamp(0.0, 90.0).to_radians();
|
||||
norm3([co * e.cos(), -s * e.cos(), e.sin()])
|
||||
}
|
||||
|
||||
fn norm3(v: [f32; 3]) -> [f32; 3] {
|
||||
let m = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
||||
if !m.is_finite() || m < 1e-9 { [0.0, 0.0, 1.0] } else { [v[0] / m, v[1] / m, v[2] / m] }
|
||||
}
|
||||
|
||||
fn light_color(warmth: f32) -> [f32; 3] {
|
||||
let w = warmth.clamp(-100.0, 100.0) / 100.0;
|
||||
if w >= 0.0 {
|
||||
[1.0 + 0.15 * w, 1.0 - 0.05 * w, 1.0 - 0.25 * w]
|
||||
} else {
|
||||
let t = -w;
|
||||
[1.0 - 0.20 * t, 1.0 - 0.10 * t, 1.0 + 0.15 * t]
|
||||
}
|
||||
}
|
||||
|
||||
fn rec709_y(c: [f32; 4]) -> f32 {
|
||||
0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
|
||||
}
|
||||
|
||||
fn finite_all(v: &[f32]) -> bool {
|
||||
v.iter().all(|x| x.is_finite())
|
||||
}
|
||||
|
||||
fn sample_y(buf: &[f32], w: usize, h: usize, rect: Rect, x: i32, y: i32) -> f32 {
|
||||
if rect.is_empty() || w == 0 || h == 0 {
|
||||
return 0.04;
|
||||
}
|
||||
let xx = x.clamp(rect.x0, rect.x1.saturating_sub(1));
|
||||
let yy = y.clamp(rect.y0, rect.y1.saturating_sub(1));
|
||||
let ox = (xx - rect.x0) as usize;
|
||||
let oy = (yy - rect.y0) as usize;
|
||||
if ox >= w || oy >= h {
|
||||
return 0.04;
|
||||
}
|
||||
buf.get(oy.saturating_mul(w).saturating_add(ox)).copied().filter(|v| v.is_finite()).unwrap_or(0.04)
|
||||
}
|
||||
|
||||
/// Separable Gaussian of a single-channel field. Non-finite samples are skipped (kernel
|
||||
/// renormalised) so a bad pixel does not poison its neighbours.
|
||||
fn blur_channel(src: &[f32], w: usize, h: usize, sigma: f32) -> Vec<f32> {
|
||||
let n = w.saturating_mul(h);
|
||||
if n == 0 || src.len() < n {
|
||||
return vec![0.04; n];
|
||||
}
|
||||
let k = crate::blur::gaussian_kernel(sigma);
|
||||
let r = (k.len() / 2) as i32;
|
||||
let acc_at = |buf: &[f32], ww: usize, hh: usize, x: i32, y: i32, col: bool| -> f32 {
|
||||
let mut acc = 0.0f64;
|
||||
let mut wgt = 0.0f64;
|
||||
for (i, kv) in k.iter().enumerate() {
|
||||
let t = i as i32 - r;
|
||||
let (sx, sy) = if col { (x, y + t) } else { (x + t, y) };
|
||||
if sx < 0 || sy < 0 || (sx as usize) >= ww || (sy as usize) >= hh {
|
||||
continue;
|
||||
}
|
||||
let Some(v) = buf.get((sy as usize).saturating_mul(ww).saturating_add(sx as usize)) else {
|
||||
continue;
|
||||
};
|
||||
if !v.is_finite() {
|
||||
continue;
|
||||
}
|
||||
acc += *v as f64 * *kv as f64;
|
||||
wgt += *kv as f64;
|
||||
}
|
||||
if wgt > 1e-12 { (acc / wgt) as f32 } else { 0.5 }
|
||||
};
|
||||
let mut tmp = vec![0.0f32; n];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
if let Some(slot) = tmp.get_mut(y.saturating_mul(w).saturating_add(x)) {
|
||||
*slot = acc_at(src, w, h, x as i32, y as i32, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![0.0f32; n];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
if let Some(slot) = out.get_mut(y.saturating_mul(w).saturating_add(x)) {
|
||||
*slot = acc_at(&tmp, w, h, x as i32, y as i32, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn read_rgba(src: &Image, ctx: &Ctx, x: i32, y: i32) -> [f32; 4] {
|
||||
let n = src.ch.min(MAXC);
|
||||
let mut tmp = [0.0f32; MAXC];
|
||||
for c in 0..n {
|
||||
if let Some(slot) = tmp.get_mut(c) {
|
||||
*slot = src.get(x, y, c);
|
||||
}
|
||||
}
|
||||
rgba(ctx, &tmp[..n])
|
||||
}
|
||||
|
||||
/// Relight `out` of `src`. Identity when `intensity` is 0 (or not finite).
|
||||
pub(crate) fn relight(src: &Image, out: Rect, ctx: &Ctx, p: Params) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
if out.is_empty() || n == 0 {
|
||||
return src.crop(out);
|
||||
}
|
||||
let mut res = src.crop(out);
|
||||
if !p.intensity.is_finite() || p.intensity == 0.0 {
|
||||
return res;
|
||||
}
|
||||
let sig = sigma(p.softness, ctx.bounds);
|
||||
let radius = (sig * 3.0).ceil() as i32;
|
||||
// Finite differences at the shading scale (`σ`), not 1 px: a 1 px step vanishes on
|
||||
// large documents and the lamp collapses to a flat multiply.
|
||||
const SCALE: f32 = 2.0;
|
||||
let step = sig.round().clamp(1.0, 32.0) as i32;
|
||||
let s_rect = out.inflate(step.max(1));
|
||||
let y_rect = s_rect.inflate(radius.max(0));
|
||||
if y_rect.is_empty() {
|
||||
return res;
|
||||
}
|
||||
let yw = y_rect.width() as usize;
|
||||
let yh = y_rect.height() as usize;
|
||||
let n_px = yw.saturating_mul(yh);
|
||||
if n_px == 0 || n_px > 8_000_000 {
|
||||
return res;
|
||||
}
|
||||
let mut ybuf = vec![0.5f32; n_px];
|
||||
for y in y_rect.y0..y_rect.y1 {
|
||||
for x in y_rect.x0..y_rect.x1 {
|
||||
let ox = (x - y_rect.x0) as usize;
|
||||
let oy = (y - y_rect.y0) as usize;
|
||||
let Some(slot) = ybuf.get_mut(oy.saturating_mul(yw).saturating_add(ox)) else {
|
||||
continue;
|
||||
};
|
||||
let c = read_rgba(src, ctx, x, y);
|
||||
if !finite_all(&c) {
|
||||
*slot = f32::NAN;
|
||||
continue;
|
||||
}
|
||||
let a = if ctx.alpha { c[3] } else { 1.0 };
|
||||
*slot = if a > 0.0 && a.is_finite() { rec709_y(c) } else { f32::NAN };
|
||||
}
|
||||
}
|
||||
let sbuf = blur_channel(&ybuf, yw, yh, sig);
|
||||
let ldir = light_dir(p.angle, p.elevation);
|
||||
let lcol = light_color(p.warmth);
|
||||
let amb = (if p.ambient.is_finite() { p.ambient } else { 0.0 }).clamp(0.0, 100.0) / 100.0;
|
||||
let inten = p.intensity.clamp(0.0, 100.0) / 100.0;
|
||||
for (i, px) in res.chunks_exact_mut(n).enumerate() {
|
||||
let (x, y) = xy(out, i);
|
||||
if !finite_all(px) {
|
||||
continue;
|
||||
}
|
||||
let a = if ctx.alpha { px[n - 1] } else { 1.0 };
|
||||
if ctx.alpha && a <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let c = rgba(ctx, px);
|
||||
if !finite_all(&c) {
|
||||
continue;
|
||||
}
|
||||
let s0 = sample_y(&sbuf, yw, yh, y_rect, x, y).max(0.04);
|
||||
let nx = (sample_y(&sbuf, yw, yh, y_rect, x - step, y) - sample_y(&sbuf, yw, yh, y_rect, x + step, y)) * SCALE;
|
||||
let ny = (sample_y(&sbuf, yw, yh, y_rect, x, y - step) - sample_y(&sbuf, yw, yh, y_rect, x, y + step)) * SCALE;
|
||||
let nrm = norm3([nx, ny, 1.0]);
|
||||
let ndl = (nrm[0] * ldir[0] + nrm[1] * ldir[1] + nrm[2] * ldir[2]).max(0.0);
|
||||
let shade = amb + inten * ndl;
|
||||
let mut out_c = c;
|
||||
for (k, lc) in lcol.iter().enumerate() {
|
||||
let Some(ch) = c.get(k).copied() else { continue };
|
||||
let Some(slot) = out_c.get_mut(k) else { continue };
|
||||
*slot = (ch / s0) * shade * *lc;
|
||||
}
|
||||
if !finite_all(&out_c) {
|
||||
continue;
|
||||
}
|
||||
set_rgba(ctx, px, out_c);
|
||||
if ctx.alpha
|
||||
&& let Some(slot) = px.get_mut(n - 1)
|
||||
{
|
||||
*slot = a;
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{FilterParams, Halo, apply, apply_tiled, output_area};
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
fn fmt(s: SampleType) -> PixelFormat {
|
||||
PixelFormat::new(ColorMode::Rgb, s, true)
|
||||
}
|
||||
|
||||
fn gray_fmt(s: SampleType) -> PixelFormat {
|
||||
PixelFormat::new(ColorMode::Grayscale, s, true)
|
||||
}
|
||||
|
||||
fn params(intensity: f32, angle: f32, warmth: f32, softness: f32) -> FilterParams {
|
||||
FilterParams::Relight { angle, elevation: 40.0, intensity, ambient: 55.0, warmth, softness }
|
||||
}
|
||||
|
||||
fn relight_p(angle: f32, elevation: f32, intensity: f32, ambient: f32, warmth: f32, softness: f32) -> FilterParams {
|
||||
FilterParams::Relight { angle, elevation, intensity, ambient, warmth, softness }
|
||||
}
|
||||
|
||||
fn run(s: &Surface, p: &FilterParams, bounds: Rect) -> Surface {
|
||||
let area = output_area(p, s.content_bounds(), bounds, None);
|
||||
apply(s, p, area, bounds, None)
|
||||
}
|
||||
|
||||
fn max_diff(a: &[f32], b: &[f32]) -> f32 {
|
||||
a.iter().zip(b).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
fn luma(px: &[f32]) -> f32 {
|
||||
0.2126 * px[0] + 0.7152 * px[1] + 0.0722 * px[2]
|
||||
}
|
||||
|
||||
fn shaded_sphere(st: SampleType, size: i32) -> Surface {
|
||||
let r = Rect::new(0, 0, size, size);
|
||||
let mut surf = Surface::new(fmt(st));
|
||||
let cx = size as f32 / 2.0;
|
||||
let rad = size as f32 * 0.42;
|
||||
let mut v = Vec::new();
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let dx = x as f32 + 0.5 - cx;
|
||||
let dy = y as f32 + 0.5 - cx;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if d2 <= rad * rad {
|
||||
let nz = (rad * rad - d2).max(0.0).sqrt() / rad;
|
||||
let sh = 0.22 + 0.78 * nz;
|
||||
v.extend_from_slice(&[0.55 * sh, 0.55 * sh, 0.55 * sh, 1.0]);
|
||||
} else {
|
||||
v.extend_from_slice(&[0.18, 0.18, 0.18, 1.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
surf.write_region(r, &v);
|
||||
surf
|
||||
}
|
||||
|
||||
fn sphere_half_mean(s: &Surface, size: i32, left: bool) -> f32 {
|
||||
let cx = size as f32 / 2.0;
|
||||
let rad = size as f32 * 0.42;
|
||||
let mut sum = 0.0;
|
||||
let mut n: f32 = 0.0;
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let dx = x as f32 + 0.5 - cx;
|
||||
let dy = y as f32 + 0.5 - cx;
|
||||
if dx * dx + dy * dy > rad * rad {
|
||||
continue;
|
||||
}
|
||||
if left && (x as f32) >= cx - 4.0 {
|
||||
continue;
|
||||
}
|
||||
if !left && (x as f32) < cx + 4.0 {
|
||||
continue;
|
||||
}
|
||||
sum += luma(&s.pixel(x, y));
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
sum / n.max(1.0)
|
||||
}
|
||||
|
||||
fn pattern(st: SampleType, r: Rect) -> Surface {
|
||||
let mut surf = Surface::new(fmt(st));
|
||||
let mut v = Vec::new();
|
||||
for y in r.y0..r.y1 {
|
||||
for x in r.x0..r.x1 {
|
||||
v.extend_from_slice(&[((x * 7 + y * 3) % 64) as f32 / 63.0, ((x * x + y) % 50) as f32 / 49.0, (y % 9) as f32 / 8.0, 1.0]);
|
||||
}
|
||||
}
|
||||
surf.write_region(r, &v);
|
||||
surf
|
||||
}
|
||||
|
||||
fn flat(st: SampleType, r: Rect, px: [f32; 4]) -> Surface {
|
||||
let mut surf = Surface::new(fmt(st));
|
||||
surf.fill_rect(r, &px);
|
||||
surf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halo_is_zero_at_identity_and_97_otherwise() {
|
||||
assert_eq!(params(0.0, 45.0, 0.0, 25.0).halo(), Halo::Radius(0));
|
||||
assert_eq!(params(40.0, 45.0, 0.0, 25.0).halo(), Halo::Radius(HALO_RADIUS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_at_zero_intensity_all_depths() {
|
||||
let r = Rect::new(0, 0, 24, 16);
|
||||
let p = params(0.0, 120.0, 80.0, 90.0);
|
||||
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let s = pattern(st, r);
|
||||
let out = run(&s, &p, r);
|
||||
assert_eq!(out.read_region(r), s.read_region(r), "{st:?}");
|
||||
}
|
||||
let mut g = Surface::new(gray_fmt(SampleType::U8));
|
||||
g.fill_rect(r, &[0.4, 1.0]);
|
||||
let out = run(&g, &p, r);
|
||||
assert_eq!(out.read_region(r), g.read_region(r));
|
||||
let mut g32 = Surface::new(gray_fmt(SampleType::F32));
|
||||
g32.fill_rect(r, &[0.4, 1.0]);
|
||||
let out = run(&g32, &p, r);
|
||||
assert_eq!(out.read_region(r), g32.read_region(r));
|
||||
let mut g16 = Surface::new(gray_fmt(SampleType::U16));
|
||||
g16.fill_rect(r, &[0.4, 1.0]);
|
||||
let out = run(&g16, &p, r);
|
||||
assert_eq!(out.read_region(r), g16.read_region(r));
|
||||
}
|
||||
|
||||
fn assert_direction_flips(size: i32) {
|
||||
let bounds = Rect::new(0, 0, size, size);
|
||||
let s = shaded_sphere(SampleType::F32, size);
|
||||
let left_lit = run(&s, &relight_p(180.0, 30.0, 90.0, 12.0, 0.0, 15.0), bounds);
|
||||
let right_lit = run(&s, &relight_p(0.0, 30.0, 90.0, 12.0, 0.0, 15.0), bounds);
|
||||
let l_left = sphere_half_mean(&left_lit, size, true);
|
||||
let l_right = sphere_half_mean(&left_lit, size, false);
|
||||
let r_left = sphere_half_mean(&right_lit, size, true);
|
||||
let r_right = sphere_half_mean(&right_lit, size, false);
|
||||
assert!(l_left - l_right >= 0.05, "light from the left ({size}px): {l_left} vs {l_right}");
|
||||
assert!(r_right - r_left >= 0.05, "light from the right ({size}px): {r_left} vs {r_right}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direction_flips_on_a_shaded_sphere() {
|
||||
assert_direction_flips(64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direction_flips_on_a_large_shaded_sphere() {
|
||||
// A 1 px gradient vanishes at this size unless the kernel steps by σ.
|
||||
assert_direction_flips(256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmth_tints_a_neutral_field() {
|
||||
let r = Rect::new(0, 0, 32, 32);
|
||||
let s = flat(SampleType::F32, r, [0.5, 0.5, 0.5, 1.0]);
|
||||
let warm = run(&s, &relight_p(45.0, 40.0, 50.0, 55.0, 80.0, 25.0), r);
|
||||
let cool = run(&s, &relight_p(45.0, 40.0, 50.0, 55.0, -80.0, 25.0), r);
|
||||
let w = warm.pixel(16, 16);
|
||||
let c = cool.pixel(16, 16);
|
||||
assert!(w[0] > w[2], "warmth should raise R over B: {w:?}");
|
||||
assert!(c[2] > c[0], "cool light should raise B over R: {c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_ambient_keeps_white_finite() {
|
||||
let r = Rect::new(0, 0, 16, 16);
|
||||
let s = flat(SampleType::F32, r, [1.0, 1.0, 1.0, 1.0]);
|
||||
let out = run(&s, &relight_p(45.0, 40.0, 40.0, 100.0, 0.0, 25.0), r);
|
||||
let px = out.pixel(8, 8);
|
||||
assert!(finite_all(&px), "{px:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_is_preserved() {
|
||||
let r = Rect::new(0, 0, 16, 16);
|
||||
let mut s = Surface::new(fmt(SampleType::F32));
|
||||
s.fill_rect(r, &[0.4, 0.4, 0.5, 1.0]);
|
||||
s.write_pixel(3, 3, &[0.4, 0.4, 0.5, 0.0]);
|
||||
s.write_pixel(4, 4, &[0.2, 0.3, 0.4, 0.5]);
|
||||
let out = run(&s, ¶ms(50.0, 180.0, 20.0, 25.0), r);
|
||||
assert_eq!(out.pixel(3, 3)[3], 0.0);
|
||||
assert!((out.pixel(5, 5)[3] - 1.0).abs() < 1e-6);
|
||||
assert!((out.pixel(4, 4)[3] - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiles_match_a_full_apply() {
|
||||
let size = 64;
|
||||
let bounds = Rect::new(0, 0, size, size);
|
||||
let s = shaded_sphere(SampleType::F32, size);
|
||||
let p = relight_p(180.0, 40.0, 60.0, 40.0, 0.0, 25.0);
|
||||
let area = output_area(&p, s.content_bounds(), bounds, None);
|
||||
let full = apply_tiled(&s, &p, area, bounds, None, 256, None);
|
||||
let tiled = apply_tiled(&s, &p, area, bounds, None, 32, None);
|
||||
let inner = Rect::new(0, 0, size, size);
|
||||
let d = max_diff(&full.read_region(inner), &tiled.read_region(inner));
|
||||
assert!(d <= 1e-4, "tile seam {d}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_sizes_do_not_panic() {
|
||||
let p = params(40.0, 45.0, 0.0, 25.0);
|
||||
for (w, h) in [(1, 1), (2, 2), (1, 2), (0, 0)] {
|
||||
let r = Rect::new(0, 0, w, h);
|
||||
let mut s = Surface::new(fmt(SampleType::F32));
|
||||
if w > 0 && h > 0 {
|
||||
s.fill_rect(r, &[0.4, 0.5, 0.6, 1.0]);
|
||||
}
|
||||
let _ = run(&s, &p, if r.is_empty() { Rect::new(0, 0, 1, 1) } else { r });
|
||||
let _ = apply(&s, &p, Rect::EMPTY, Rect::new(0, 0, 4, 4), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nan_and_inf_pixels_pass_through() {
|
||||
let r = Rect::new(0, 0, 24, 24);
|
||||
let mut s = pattern(SampleType::F32, r);
|
||||
s.write_pixel(5, 5, &[f32::NAN, 0.4, 0.4, 1.0]);
|
||||
s.write_pixel(6, 8, &[f32::INFINITY, 0.2, 0.2, 1.0]);
|
||||
let before_n = s.pixel(7, 5);
|
||||
let out = run(&s, ¶ms(50.0, 180.0, 0.0, 20.0), r);
|
||||
assert!(out.pixel(5, 5)[0].is_nan());
|
||||
assert!(out.pixel(6, 8)[0].is_infinite());
|
||||
let n = out.pixel(7, 5);
|
||||
assert!(finite_all(&n));
|
||||
assert_ne!(n, before_n);
|
||||
}
|
||||
}
|
||||
@@ -71,15 +71,17 @@ struct Taps {
|
||||
}
|
||||
|
||||
/// Taps for mapping output coordinate `o` (in `[dst0, dst1)`) to the source
|
||||
/// via `u = (o + 0.5) / scale - 0.5`.
|
||||
fn taps(dst0: i32, dst1: i32, scale: f64, filter: Resample) -> Vec<Taps> {
|
||||
/// via `u = (o + 0.5) / scale - 0.5`. With `edge` (`[first, last]` source index), taps outside it
|
||||
/// read the nearest sample inside it: the image repeats its edge instead of fading out.
|
||||
fn taps(dst0: i32, dst1: i32, scale: f64, filter: Resample, edge: Option<(i32, i32)>) -> Vec<Taps> {
|
||||
let stretch = if scale < 1.0 { 1.0 / scale } else { 1.0 };
|
||||
let support = filter.support() * stretch;
|
||||
let clamp = |i: i32| edge.map_or(i, |(a, b)| i.clamp(a, b));
|
||||
(dst0..dst1)
|
||||
.map(|o| {
|
||||
let u = (o as f64 + 0.5) / scale - 0.5;
|
||||
if filter == Resample::Nearest && scale >= 1.0 {
|
||||
return Taps { start: (u + 0.5).floor() as i32, weights: vec![1.0] };
|
||||
return Taps { start: clamp((u + 0.5).floor() as i32), weights: vec![1.0] };
|
||||
}
|
||||
let lo = (u - support).ceil() as i32;
|
||||
let hi = (u + support).floor() as i32;
|
||||
@@ -90,7 +92,15 @@ fn taps(dst0: i32, dst1: i32, scale: f64, filter: Resample) -> Vec<Taps> {
|
||||
*v /= s;
|
||||
}
|
||||
}
|
||||
Taps { start: lo, weights: w.into_iter().map(|v| v as f32).collect() }
|
||||
// Fold the weights of taps beyond the edge onto the edge sample.
|
||||
let start = clamp(lo);
|
||||
let mut folded = vec![0.0f32; (clamp(hi) - start + 1).max(1) as usize];
|
||||
for (i, v) in (lo..=hi).zip(w) {
|
||||
if let Some(slot) = folded.get_mut((clamp(i) - start) as usize) {
|
||||
*slot += v as f32;
|
||||
}
|
||||
}
|
||||
Taps { start, weights: folded }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -106,6 +116,19 @@ pub fn scaled_rect(r: Rect, sx: f64, sy: f64) -> Rect {
|
||||
/// Resizes a surface by `(sx, sy)` about the document origin. The surface's
|
||||
/// default pixel is kept (masks keep their "reveal all" background).
|
||||
pub fn resize_surface(s: &Surface, sx: f64, sy: f64, filter: Resample) -> Surface {
|
||||
resize(s, sx, sy, filter, None)
|
||||
}
|
||||
|
||||
/// [`resize_surface`] for a layer, mask or channel of a document whose canvas is `canvas`
|
||||
/// (Image Size): at the canvas edges the pixels repeat outwards, so content that reaches an edge
|
||||
/// keeps it (an opaque layer stays opaque to the border) instead of fading into the default pixel.
|
||||
/// Pixels beyond the canvas count as the surface's own, and inside the canvas nothing changes.
|
||||
pub fn resize_surface_in_canvas(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Rect) -> Surface {
|
||||
let area = s.content_bounds().union(&canvas);
|
||||
resize(s, sx, sy, filter, (!area.is_empty()).then_some(area))
|
||||
}
|
||||
|
||||
fn resize(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Option<Rect>) -> Surface {
|
||||
let fmt = s.format();
|
||||
let mut out = Surface::with_default(fmt, &s.default_pixel());
|
||||
let src = s.content_bounds();
|
||||
@@ -115,8 +138,8 @@ pub fn resize_surface(s: &Surface, sx: f64, sy: f64, filter: Resample) -> Surfac
|
||||
}
|
||||
let n = fmt.channels();
|
||||
let alpha = fmt.alpha;
|
||||
let hx = taps(dst.x0, dst.x1, sx, filter);
|
||||
let vy = taps(dst.y0, dst.y1, sy, filter);
|
||||
let hx = taps(dst.x0, dst.x1, sx, filter, edge.map(|r| (r.x0, r.x1 - 1)));
|
||||
let vy = taps(dst.y0, dst.y1, sy, filter, edge.map(|r| (r.y0, r.y1 - 1)));
|
||||
let x_lo = hx.iter().map(|t| t.start).min().unwrap_or(0);
|
||||
let x_hi = hx.iter().map(|t| t.start + t.weights.len() as i32).max().unwrap_or(0);
|
||||
let dw = dst.width() as usize;
|
||||
@@ -268,6 +291,38 @@ mod tests {
|
||||
assert!(edge[3] > 0.0 && (edge[0] - 1.0).abs() < 0.05 && edge[1].abs() < 0.05, "{edge:?}");
|
||||
}
|
||||
|
||||
/// Image Size of a layer that fills the canvas: its edges stay opaque and keep their colour,
|
||||
/// up or down, at every depth; a layer away from the edges keeps its soft border.
|
||||
#[test]
|
||||
fn canvas_edges_repeat_instead_of_fading() {
|
||||
let canvas = Rect::new(0, 0, 30, 20);
|
||||
let colour = [0.2, 0.6, 1.0, 1.0];
|
||||
let filters = [Resample::Nearest, Resample::Bilinear, Resample::Bicubic, Resample::Lanczos, Resample::PreserveDetails];
|
||||
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let fmt = PixelFormat::new(ColorMode::Rgb, sample, true);
|
||||
let mut s = Surface::new(fmt);
|
||||
s.fill_rect(canvas, &colour);
|
||||
for f in filters {
|
||||
for k in [2.0, 1.37, 0.5] {
|
||||
let o = resize_surface_in_canvas(&s, k, k, f, canvas);
|
||||
let dst = scaled_rect(canvas, k, k);
|
||||
assert_eq!(o.content_bounds(), dst, "{sample:?} {f:?} {k}");
|
||||
for (x, y) in [(0, 0), (dst.x1 - 1, 0), (0, dst.y1 - 1), (dst.x1 - 1, dst.y1 - 1), (dst.x1 / 2, 0), (0, dst.y1 / 2)] {
|
||||
let p = o.pixel(x, y);
|
||||
let off = p.iter().zip(colour).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(off < 0.01, "{sample:?} {f:?} {k} ({x},{y}): {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Inside the canvas the default pixel is still what lies around a layer.
|
||||
let mut inner = Surface::new(fmt);
|
||||
inner.fill_rect(Rect::new(10, 5, 20, 15), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let r = Rect::new(0, 0, 60, 40);
|
||||
let a = resize_surface_in_canvas(&inner, 1.7, 1.7, Resample::Lanczos, canvas);
|
||||
assert_eq!(a.read_region(r), resize_surface(&inner, 1.7, 1.7, Resample::Lanczos).read_region(r), "{sample:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_keep_default_pixel() {
|
||||
let fmt = PixelFormat::GRAY8;
|
||||
|
||||
+516
-87
@@ -11,6 +11,8 @@
|
||||
//! seams avoid protected pixels. Buffers are interleaved `w × h × ch` normalised floats of any
|
||||
//! colour model and depth (alpha is carried like any other channel). Deterministic.
|
||||
|
||||
use photocraft_raster::{Cancelled, Interrupt};
|
||||
|
||||
/// Energy added per unit of protection: far above any gradient magnitude.
|
||||
const PROTECT_ENERGY: f32 = 1.0e4;
|
||||
|
||||
@@ -19,69 +21,206 @@ const PROTECT_ENERGY: f32 = 1.0e4;
|
||||
/// carry energy too.
|
||||
pub fn energy(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec<f32> {
|
||||
let mut e = vec![0.0f32; w * h];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (xl, xr) = (x.saturating_sub(1), (x + 1).min(w - 1));
|
||||
let (yu, yd) = (y.saturating_sub(1), (y + 1).min(h - 1));
|
||||
let at = |xx: usize, yy: usize, c: usize| img[(yy * w + xx) * ch + c];
|
||||
let mut s = 0.0;
|
||||
for c in 0..ch {
|
||||
let v = at(x, y, c);
|
||||
s += (at(xr, y, c) - v).abs() + (v - at(xl, y, c)).abs();
|
||||
s += (at(x, yd, c) - v).abs() + (v - at(x, yu, c)).abs();
|
||||
}
|
||||
e[y * w + x] = s * 0.5;
|
||||
}
|
||||
if w == 0 || h == 0 {
|
||||
return e;
|
||||
}
|
||||
let row = |(y, out): (usize, &mut [f32])| {
|
||||
for (x, v) in out.iter_mut().enumerate() {
|
||||
*v = energy_at(img, w, w, h, ch, x, y);
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
e.par_chunks_mut(w).enumerate().for_each(row);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
e.chunks_mut(w).enumerate().for_each(row);
|
||||
e
|
||||
}
|
||||
|
||||
/// [`energy`] of one pixel of a `cw`-wide image whose rows are `stride` pixels apart. Shared by
|
||||
/// the full map and the per-seam updates so both give bit-identical values.
|
||||
#[inline]
|
||||
fn energy_at(img: &[f32], stride: usize, cw: usize, h: usize, ch: usize, x: usize, y: usize) -> f32 {
|
||||
let (xl, xr) = (x.saturating_sub(1), (x + 1).min(cw - 1));
|
||||
let (yu, yd) = (y.saturating_sub(1), (y + 1).min(h - 1));
|
||||
let at = |xx: usize, yy: usize, c: usize| img[(yy * stride + xx) * ch + c];
|
||||
let mut s = 0.0;
|
||||
for c in 0..ch {
|
||||
let v = at(x, y, c);
|
||||
s += (at(xr, y, c) - v).abs() + (v - at(xl, y, c)).abs();
|
||||
s += (at(x, yd, c) - v).abs() + (v - at(x, yu, c)).abs();
|
||||
}
|
||||
s * 0.5
|
||||
}
|
||||
|
||||
/// The lowest-energy vertical seam: one x per row.
|
||||
pub fn find_seam(w: usize, h: usize, energy: &[f32]) -> Vec<usize> {
|
||||
let mut cost = energy[..w].to_vec();
|
||||
let mut from = vec![0u8; w * h]; // 0 = up-left, 1 = up, 2 = up-right
|
||||
let mut next = vec![0.0f32; w];
|
||||
for y in 1..h {
|
||||
for x in 0..w {
|
||||
let mut best = (cost[x], 1u8);
|
||||
if x > 0 && cost[x - 1] < best.0 {
|
||||
best = (cost[x - 1], 0);
|
||||
}
|
||||
if x + 1 < w && cost[x + 1] < best.0 {
|
||||
best = (cost[x + 1], 2);
|
||||
}
|
||||
next[x] = best.0 + energy[y * w + x];
|
||||
from[y * w + x] = best.1;
|
||||
}
|
||||
std::mem::swap(&mut cost, &mut next);
|
||||
if w == 0 || h == 0 || energy.len() < w * h {
|
||||
return vec![0; h];
|
||||
}
|
||||
let mut x = (0..w).min_by(|a, b| cost[*a].total_cmp(&cost[*b])).unwrap_or(0);
|
||||
let mut seam = vec![0usize; h];
|
||||
for y in (0..h).rev() {
|
||||
seam[y] = x;
|
||||
if y > 0 {
|
||||
x = match from[y * w + x] {
|
||||
0 => x - 1,
|
||||
2 => x + 1,
|
||||
_ => x,
|
||||
};
|
||||
}
|
||||
}
|
||||
seam
|
||||
Dp::default().seam(w, w, h, energy)
|
||||
}
|
||||
|
||||
fn remove_seam(w: usize, h: usize, ch: usize, img: &[f32], seam: &[usize]) -> Vec<f32> {
|
||||
let mut out = Vec::with_capacity((w - 1) * h * ch);
|
||||
for (y, &sx) in seam.iter().enumerate().take(h) {
|
||||
let row = &img[y * w * ch..(y + 1) * w * ch];
|
||||
out.extend_from_slice(&row[..sx * ch]);
|
||||
out.extend_from_slice(&row[(sx + 1) * ch..]);
|
||||
}
|
||||
out
|
||||
/// Scratch buffers for the seam search, reused across seams.
|
||||
#[derive(Default)]
|
||||
struct Dp {
|
||||
cost: Vec<f32>,
|
||||
next: Vec<f32>,
|
||||
/// Per pixel, where the cheapest path came from: 0 = up-left, 1 = up, 2 = up-right.
|
||||
from: Vec<u8>,
|
||||
}
|
||||
|
||||
fn remove_seam_1(w: usize, h: usize, v: &[f32], seam: &[usize]) -> Vec<f32> {
|
||||
remove_seam(w, h, 1, v, seam)
|
||||
impl Dp {
|
||||
/// The lowest-energy seam of a `cw`-wide, `h`-tall energy map with rows `stride` apart.
|
||||
/// Ties go to the pixel straight up, then up-left, then up-right; at the bottom, to the
|
||||
/// leftmost end.
|
||||
fn seam(&mut self, stride: usize, cw: usize, h: usize, energy: &[f32]) -> Vec<usize> {
|
||||
self.cost.clear();
|
||||
self.cost.extend_from_slice(&energy[..cw]);
|
||||
self.next.resize(cw, 0.0);
|
||||
self.from.resize(cw * h, 0);
|
||||
for y in 1..h {
|
||||
let (cost, next) = (&self.cost[..cw], &mut self.next[..cw]);
|
||||
let e = &energy[y * stride..y * stride + cw];
|
||||
let from = &mut self.from[y * cw..(y + 1) * cw];
|
||||
if cw == 1 {
|
||||
(next[0], from[0]) = (cost[0] + e[0], 1);
|
||||
} else {
|
||||
let (b, d) = if cost[1] < cost[0] { (cost[1], 2) } else { (cost[0], 1) };
|
||||
(next[0], from[0]) = (b + e[0], d);
|
||||
// The interior has all three parents: no edge tests in the hot loop.
|
||||
let inner = next[1..cw - 1].iter_mut().zip(&mut from[1..cw - 1]).zip(&e[1..cw - 1]).zip(cost.windows(3));
|
||||
for (((n, f), &ev), c) in inner {
|
||||
let (b, d) = if c[0] < c[1] { (c[0], 0) } else { (c[1], 1) };
|
||||
let (b, d) = if c[2] < b { (c[2], 2) } else { (b, d) };
|
||||
(*n, *f) = (b + ev, d);
|
||||
}
|
||||
let (b, d) = if cost[cw - 2] < cost[cw - 1] { (cost[cw - 2], 0) } else { (cost[cw - 1], 1) };
|
||||
(next[cw - 1], from[cw - 1]) = (b + e[cw - 1], d);
|
||||
}
|
||||
std::mem::swap(&mut self.cost, &mut self.next);
|
||||
}
|
||||
let cost = &self.cost[..cw];
|
||||
let mut x = (0..cw).min_by(|a, b| cost[*a].total_cmp(&cost[*b])).unwrap_or(0);
|
||||
let mut seam = vec![0usize; h];
|
||||
for y in (0..h).rev() {
|
||||
seam[y] = x;
|
||||
if y > 0 {
|
||||
x = match self.from[y * cw + x] {
|
||||
0 => x - 1,
|
||||
2 => x + 1,
|
||||
_ => x,
|
||||
};
|
||||
}
|
||||
}
|
||||
seam
|
||||
}
|
||||
}
|
||||
|
||||
/// Shrinks an image one seam at a time in place. Rows keep their original stride, so removing a
|
||||
/// seam shifts only the rest of each row (in parallel), and the energy map is updated only next
|
||||
/// to the removed seam instead of being recomputed: the gradient of a pixel changes only when a
|
||||
/// neighbour does, which happens within a pixel of the seam in its own row and the rows around it.
|
||||
struct Carver {
|
||||
stride: usize,
|
||||
cw: usize,
|
||||
h: usize,
|
||||
ch: usize,
|
||||
img: Vec<f32>,
|
||||
prot: Option<Vec<f32>>,
|
||||
/// Energy plus protection, as the seam search sees it.
|
||||
e: Vec<f32>,
|
||||
/// Original column of each pixel, when the caller needs to know which ones were removed.
|
||||
idx: Option<Vec<usize>>,
|
||||
dp: Dp,
|
||||
}
|
||||
|
||||
impl Carver {
|
||||
fn new(w: usize, h: usize, ch: usize, img: Vec<f32>, prot: Option<Vec<f32>>, track: bool) -> Self {
|
||||
let mut e = energy(w, h, ch, &img);
|
||||
if let Some(p) = &prot {
|
||||
for (e, p) in e.iter_mut().zip(p) {
|
||||
*e += p.clamp(0.0, 1.0) * PROTECT_ENERGY;
|
||||
}
|
||||
}
|
||||
let idx = track.then(|| (0..h).flat_map(|_| 0..w).collect());
|
||||
Carver { stride: w, cw: w, h, ch, img, prot, e, idx, dp: Dp::default() }
|
||||
}
|
||||
|
||||
/// Remove the lowest-energy seam. Returns, per row, the removed column (the original column
|
||||
/// when tracking).
|
||||
fn remove_seam(&mut self) -> Vec<usize> {
|
||||
let (stride, cw, h, ch) = (self.stride, self.cw, self.h, self.ch);
|
||||
let seam = self.dp.seam(stride, cw, h, &self.e);
|
||||
let removed = match &self.idx {
|
||||
Some(idx) => seam.iter().enumerate().map(|(y, &x)| idx[y * stride + x]).collect(),
|
||||
None => seam.clone(),
|
||||
};
|
||||
shift_rows(&mut self.img, stride * ch, &seam, ch, cw);
|
||||
shift_rows(&mut self.e, stride, &seam, 1, cw);
|
||||
if let Some(p) = &mut self.prot {
|
||||
shift_rows(p, stride, &seam, 1, cw);
|
||||
}
|
||||
if let Some(idx) = &mut self.idx {
|
||||
shift_rows(idx, stride, &seam, 1, cw);
|
||||
}
|
||||
self.cw -= 1;
|
||||
self.update_energy(&seam);
|
||||
removed
|
||||
}
|
||||
|
||||
/// Recompute the energy of the pixels whose neighbours changed when `seam` was removed: in
|
||||
/// row y, from one left of the leftmost of the seam's x in rows y-1..=y+1 to the rightmost.
|
||||
fn update_energy(&mut self, seam: &[usize]) {
|
||||
let (stride, cw, h, ch) = (self.stride, self.cw, self.h, self.ch);
|
||||
if cw == 0 {
|
||||
return;
|
||||
}
|
||||
let (img, prot) = (&self.img, self.prot.as_deref());
|
||||
let row = |(y, e): (usize, &mut [f32])| {
|
||||
let near = &seam[y.saturating_sub(1)..(y + 2).min(h)];
|
||||
let lo = near.iter().min().map_or(0, |&s| s.saturating_sub(1));
|
||||
let hi = near.iter().max().map_or(0, |&s| s.min(cw - 1));
|
||||
for x in lo..=hi {
|
||||
let mut v = energy_at(img, stride, cw, h, ch, x, y);
|
||||
if let Some(p) = prot {
|
||||
v += p[y * stride + x].clamp(0.0, 1.0) * PROTECT_ENERGY;
|
||||
}
|
||||
e[x] = v;
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
self.e.par_chunks_mut(stride).enumerate().for_each(row);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.e.chunks_mut(stride).enumerate().for_each(row);
|
||||
}
|
||||
|
||||
/// The carved pixels, `cw × h × ch`, rows packed.
|
||||
fn into_pixels(self) -> Vec<f32> {
|
||||
let (row, used) = (self.stride * self.ch, self.cw * self.ch);
|
||||
if row == used {
|
||||
return self.img;
|
||||
}
|
||||
self.img.chunks(row).flat_map(|r| &r[..used]).copied().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove pixel `seam[y]` (of `n` values) from each `row_len`-long row whose first `cw` pixels
|
||||
/// are in use, by shifting the rest of the row left.
|
||||
fn shift_rows<T: Copy + Send>(buf: &mut [T], row_len: usize, seam: &[usize], n: usize, cw: usize) {
|
||||
let shift = |(r, &x): (&mut [T], &usize)| r.copy_within((x + 1) * n..cw * n, x * n);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
buf.par_chunks_mut(row_len).zip(seam.par_iter()).for_each(shift);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
buf.chunks_mut(row_len).zip(seam).for_each(shift);
|
||||
}
|
||||
|
||||
fn transpose(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec<f32> {
|
||||
@@ -97,29 +236,25 @@ fn transpose(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec<f32> {
|
||||
|
||||
/// Change the width of `img` to `new_w` by removing or duplicating vertical seams.
|
||||
pub fn carve_width(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize) -> Vec<f32> {
|
||||
// Never cancelled; the fallback is unreachable.
|
||||
carve_width_with(w, h, ch, img, protect, new_w, &Interrupt::NONE).unwrap_or_else(|_| img.to_vec())
|
||||
}
|
||||
|
||||
/// [`carve_width`] that checks `ctl` before every seam and reports progress per seam.
|
||||
pub fn carve_width_with(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, ctl: &Interrupt) -> Result<Vec<f32>, Cancelled> {
|
||||
let new_w = new_w.max(1);
|
||||
if new_w == w || w == 0 || h == 0 {
|
||||
return img.to_vec();
|
||||
return Ok(img.to_vec());
|
||||
}
|
||||
let protect_e = |e: &mut [f32], p: Option<&Vec<f32>>| {
|
||||
if let Some(p) = p {
|
||||
for (e, p) in e.iter_mut().zip(p) {
|
||||
*e += p.clamp(0.0, 1.0) * PROTECT_ENERGY;
|
||||
}
|
||||
}
|
||||
};
|
||||
let seams = new_w.abs_diff(w).max(1) as f32;
|
||||
if new_w < w {
|
||||
let (mut cur, mut cw) = (img.to_vec(), w);
|
||||
let mut prot = protect.map(<[f32]>::to_vec);
|
||||
while cw > new_w {
|
||||
let mut e = energy(cw, h, ch, &cur);
|
||||
protect_e(&mut e, prot.as_ref());
|
||||
let seam = find_seam(cw, h, &e);
|
||||
cur = remove_seam(cw, h, ch, &cur, &seam);
|
||||
prot = prot.map(|p| remove_seam_1(cw, h, &p, &seam));
|
||||
cw -= 1;
|
||||
let mut carver = Carver::new(w, h, ch, img.to_vec(), protect.map(<[f32]>::to_vec), false);
|
||||
while carver.cw > new_w {
|
||||
ctl.check()?;
|
||||
ctl.progress((w - carver.cw) as f32 / seams);
|
||||
carver.remove_seam();
|
||||
}
|
||||
return cur;
|
||||
return Ok(carver.into_pixels());
|
||||
}
|
||||
// Enlarge in rounds of at most half the current width, each duplicating the k lowest seams.
|
||||
let (mut cur, mut cw) = (img.to_vec(), w);
|
||||
@@ -127,23 +262,18 @@ pub fn carve_width(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&
|
||||
while cw < new_w {
|
||||
let k = (new_w - cw).min((cw / 2).max(1));
|
||||
// Find k seams on a shrinking copy, mapping each back to original columns.
|
||||
let mut idx: Vec<Vec<usize>> = (0..h).map(|_| (0..cw).collect()).collect();
|
||||
let (mut tmp, mut tw) = (cur.clone(), cw);
|
||||
let mut tprot = prot.clone();
|
||||
let mut carver = Carver::new(cw, h, ch, cur.clone(), prot.clone(), true);
|
||||
let mut dup: Vec<Vec<usize>> = vec![Vec::with_capacity(k); h];
|
||||
for _ in 0..k {
|
||||
if tw <= 1 {
|
||||
// A 1 px wide image's only seam is its one column: take it, so it is duplicated.
|
||||
if carver.cw == 0 {
|
||||
break;
|
||||
}
|
||||
let mut e = energy(tw, h, ch, &tmp);
|
||||
protect_e(&mut e, tprot.as_ref());
|
||||
let seam = find_seam(tw, h, &e);
|
||||
for (y, &sx) in seam.iter().enumerate() {
|
||||
dup[y].push(idx[y].remove(sx));
|
||||
ctl.check()?;
|
||||
ctl.progress((cw + (cw - carver.cw)).saturating_sub(w) as f32 / seams);
|
||||
for (d, x) in dup.iter_mut().zip(carver.remove_seam()) {
|
||||
d.push(x);
|
||||
}
|
||||
tmp = remove_seam(tw, h, ch, &tmp, &seam);
|
||||
tprot = tprot.map(|p| remove_seam_1(tw, h, &p, &seam));
|
||||
tw -= 1;
|
||||
}
|
||||
let added = dup[0].len();
|
||||
if added == 0 {
|
||||
@@ -177,21 +307,44 @@ pub fn carve_width(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&
|
||||
prot = nprot;
|
||||
cw = nw;
|
||||
}
|
||||
cur
|
||||
Ok(cur)
|
||||
}
|
||||
|
||||
/// Content-aware resize of `img` to `new_w × new_h` (width first, then height by transposing).
|
||||
pub fn carve(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, new_h: usize) -> Vec<f32> {
|
||||
// Never cancelled; the fallback is unreachable.
|
||||
carve_with(w, h, ch, img, protect, new_w, new_h, &Interrupt::NONE).unwrap_or_else(|_| img.to_vec())
|
||||
}
|
||||
|
||||
/// [`carve`] that checks `ctl` before every seam (a cancel takes effect within one seam) and
|
||||
/// reports progress over both passes.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn carve_with(
|
||||
w: usize,
|
||||
h: usize,
|
||||
ch: usize,
|
||||
img: &[f32],
|
||||
protect: Option<&[f32]>,
|
||||
new_w: usize,
|
||||
new_h: usize,
|
||||
ctl: &Interrupt,
|
||||
) -> Result<Vec<f32>, Cancelled> {
|
||||
let (new_w, new_h) = (new_w.max(1), new_h.max(1));
|
||||
let wide = carve_width(w, h, ch, img, protect, new_w);
|
||||
// Split progress between the passes by their seam counts.
|
||||
let (sw, sh) = (new_w.abs_diff(w) as f32, new_h.abs_diff(h) as f32);
|
||||
let split = if sw + sh > 0.0 { sw / (sw + sh) } else { 1.0 };
|
||||
let cancel = || ctl.cancelled();
|
||||
let first = |f: f32| ctl.progress(f * split);
|
||||
let wide = carve_width_with(w, h, ch, img, protect, new_w, &Interrupt::new(&cancel, &first))?;
|
||||
if new_h == h {
|
||||
return wide;
|
||||
return Ok(wide);
|
||||
}
|
||||
// Protection follows the width change by plain resampling (it only steers seams).
|
||||
let prot_t = protect.map(|p| transpose(new_w, h, 1, &resize_bilinear(w, h, 1, p, new_w, h)));
|
||||
let t = transpose(new_w, h, ch, &wide);
|
||||
let tall = carve_width(h, new_w, ch, &t, prot_t.as_deref(), new_h);
|
||||
transpose(new_h, new_w, ch, &tall)
|
||||
let second = |f: f32| ctl.progress(split + f * (1.0 - split));
|
||||
let tall = carve_width_with(h, new_w, ch, &t, prot_t.as_deref(), new_h, &Interrupt::new(&cancel, &second))?;
|
||||
Ok(transpose(new_h, new_w, ch, &tall))
|
||||
}
|
||||
|
||||
/// Plain bilinear resize (the non-content-aware part of the Amount blend).
|
||||
@@ -241,6 +394,211 @@ pub fn skin_mask(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec<f32> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The straightforward implementation the per-seam carver replaced (full energy map, fresh
|
||||
/// seam search and a new buffer per seam), kept as the oracle for `carver_matches_reference`.
|
||||
mod reference {
|
||||
use super::super::{PROTECT_ENERGY, resize_bilinear, transpose};
|
||||
|
||||
pub(super) fn energy(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec<f32> {
|
||||
let mut e = vec![0.0f32; w * h];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (xl, xr) = (x.saturating_sub(1), (x + 1).min(w - 1));
|
||||
let (yu, yd) = (y.saturating_sub(1), (y + 1).min(h - 1));
|
||||
let at = |xx: usize, yy: usize, c: usize| img[(yy * w + xx) * ch + c];
|
||||
let mut s = 0.0;
|
||||
for c in 0..ch {
|
||||
let v = at(x, y, c);
|
||||
s += (at(xr, y, c) - v).abs() + (v - at(xl, y, c)).abs();
|
||||
s += (at(x, yd, c) - v).abs() + (v - at(x, yu, c)).abs();
|
||||
}
|
||||
e[y * w + x] = s * 0.5;
|
||||
}
|
||||
}
|
||||
e
|
||||
}
|
||||
pub(super) fn find_seam(w: usize, h: usize, energy: &[f32]) -> Vec<usize> {
|
||||
let mut cost = energy[..w].to_vec();
|
||||
let mut from = vec![0u8; w * h]; // 0 = up-left, 1 = up, 2 = up-right
|
||||
let mut next = vec![0.0f32; w];
|
||||
for y in 1..h {
|
||||
for x in 0..w {
|
||||
let mut best = (cost[x], 1u8);
|
||||
if x > 0 && cost[x - 1] < best.0 {
|
||||
best = (cost[x - 1], 0);
|
||||
}
|
||||
if x + 1 < w && cost[x + 1] < best.0 {
|
||||
best = (cost[x + 1], 2);
|
||||
}
|
||||
next[x] = best.0 + energy[y * w + x];
|
||||
from[y * w + x] = best.1;
|
||||
}
|
||||
std::mem::swap(&mut cost, &mut next);
|
||||
}
|
||||
let mut x = (0..w).min_by(|a, b| cost[*a].total_cmp(&cost[*b])).unwrap_or(0);
|
||||
let mut seam = vec![0usize; h];
|
||||
for y in (0..h).rev() {
|
||||
seam[y] = x;
|
||||
if y > 0 {
|
||||
x = match from[y * w + x] {
|
||||
0 => x - 1,
|
||||
2 => x + 1,
|
||||
_ => x,
|
||||
};
|
||||
}
|
||||
}
|
||||
seam
|
||||
}
|
||||
fn remove_seam(w: usize, h: usize, ch: usize, img: &[f32], seam: &[usize]) -> Vec<f32> {
|
||||
let mut out = Vec::with_capacity((w - 1) * h * ch);
|
||||
for (y, &sx) in seam.iter().enumerate().take(h) {
|
||||
let row = &img[y * w * ch..(y + 1) * w * ch];
|
||||
out.extend_from_slice(&row[..sx * ch]);
|
||||
out.extend_from_slice(&row[(sx + 1) * ch..]);
|
||||
}
|
||||
out
|
||||
}
|
||||
pub(super) fn carve_width(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize) -> Vec<f32> {
|
||||
let new_w = new_w.max(1);
|
||||
if new_w == w || w == 0 || h == 0 {
|
||||
return img.to_vec();
|
||||
}
|
||||
let protect_e = |e: &mut [f32], p: Option<&Vec<f32>>| {
|
||||
if let Some(p) = p {
|
||||
for (e, p) in e.iter_mut().zip(p) {
|
||||
*e += p.clamp(0.0, 1.0) * PROTECT_ENERGY;
|
||||
}
|
||||
}
|
||||
};
|
||||
if new_w < w {
|
||||
let (mut cur, mut cw) = (img.to_vec(), w);
|
||||
let mut prot = protect.map(<[f32]>::to_vec);
|
||||
while cw > new_w {
|
||||
let mut e = energy(cw, h, ch, &cur);
|
||||
protect_e(&mut e, prot.as_ref());
|
||||
let seam = find_seam(cw, h, &e);
|
||||
cur = remove_seam(cw, h, ch, &cur, &seam);
|
||||
prot = prot.map(|p| remove_seam(cw, h, 1, &p, &seam));
|
||||
cw -= 1;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
// Enlarge in rounds of at most half the current width, each duplicating the k lowest seams.
|
||||
let (mut cur, mut cw) = (img.to_vec(), w);
|
||||
let mut prot = protect.map(<[f32]>::to_vec);
|
||||
while cw < new_w {
|
||||
let k = (new_w - cw).min((cw / 2).max(1));
|
||||
// Find k seams on a shrinking copy, mapping each back to original columns.
|
||||
let mut idx: Vec<Vec<usize>> = (0..h).map(|_| (0..cw).collect()).collect();
|
||||
let (mut tmp, mut tw) = (cur.clone(), cw);
|
||||
let mut tprot = prot.clone();
|
||||
let mut dup: Vec<Vec<usize>> = vec![Vec::with_capacity(k); h];
|
||||
for _ in 0..k {
|
||||
// A 1 px wide image's only seam is its one column: take it, so it is duplicated.
|
||||
if tw == 0 {
|
||||
break;
|
||||
}
|
||||
let mut e = energy(tw, h, ch, &tmp);
|
||||
protect_e(&mut e, tprot.as_ref());
|
||||
let seam = find_seam(tw, h, &e);
|
||||
for (y, &sx) in seam.iter().enumerate() {
|
||||
dup[y].push(idx[y].remove(sx));
|
||||
}
|
||||
tmp = remove_seam(tw, h, ch, &tmp, &seam);
|
||||
tprot = tprot.map(|p| remove_seam(tw, h, 1, &p, &seam));
|
||||
tw -= 1;
|
||||
}
|
||||
let added = dup[0].len();
|
||||
if added == 0 {
|
||||
break;
|
||||
}
|
||||
let nw = cw + added;
|
||||
let mut out = Vec::with_capacity(nw * h * ch);
|
||||
let mut nprot = prot.as_ref().map(|_| Vec::with_capacity(nw * h));
|
||||
for (y, d) in dup.iter_mut().enumerate() {
|
||||
d.sort_unstable();
|
||||
let row = &cur[y * cw * ch..(y + 1) * cw * ch];
|
||||
let mut di = 0;
|
||||
for x in 0..cw {
|
||||
out.extend_from_slice(&row[x * ch..(x + 1) * ch]);
|
||||
if let (Some(np), Some(p)) = (nprot.as_mut(), prot.as_ref()) {
|
||||
np.push(p[y * cw + x]);
|
||||
}
|
||||
while di < d.len() && d[di] == x {
|
||||
let xr = (x + 1).min(cw - 1);
|
||||
for c in 0..ch {
|
||||
out.push((row[x * ch + c] + row[xr * ch + c]) * 0.5);
|
||||
}
|
||||
if let (Some(np), Some(p)) = (nprot.as_mut(), prot.as_ref()) {
|
||||
np.push(p[y * cw + x]);
|
||||
}
|
||||
di += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
cur = out;
|
||||
prot = nprot;
|
||||
cw = nw;
|
||||
}
|
||||
cur
|
||||
}
|
||||
|
||||
pub(super) fn carve(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, new_h: usize) -> Vec<f32> {
|
||||
let (new_w, new_h) = (new_w.max(1), new_h.max(1));
|
||||
let wide = carve_width(w, h, ch, img, protect, new_w);
|
||||
if new_h == h {
|
||||
return wide;
|
||||
}
|
||||
let prot_t = protect.map(|p| transpose(new_w, h, 1, &resize_bilinear(w, h, 1, p, new_w, h)));
|
||||
let t = transpose(new_w, h, ch, &wide);
|
||||
let tall = carve_width(h, new_w, ch, &t, prot_t.as_deref(), new_h);
|
||||
transpose(new_h, new_w, ch, &tall)
|
||||
}
|
||||
}
|
||||
|
||||
/// The carver gives bit-identical output to the reference implementation it replaced, over
|
||||
/// random shapes (including 1 px wide and tall), channel counts, tie-heavy quantised noise,
|
||||
/// protection, shrinking, enlarging past half the width and both axes at once.
|
||||
#[test]
|
||||
fn carver_matches_reference() {
|
||||
let mut seed = 1u32;
|
||||
let mut next = |n: usize| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
seed as usize % n
|
||||
};
|
||||
for case in 0..400u32 {
|
||||
let (w, h, ch) = (1 + next(24), 1 + next(18), [1, 3, 4][next(3)]);
|
||||
let levels = [2, 5, 255, 65_536][next(4)];
|
||||
let img = noise(w * h * ch, levels, 11 + case);
|
||||
let protect = (next(3) == 0).then(|| noise(w * h, 3, 101 + case));
|
||||
let (nw, nh) = (1 + next(2 * w + 2), 1 + next(2 * h + 2));
|
||||
let got = carve(w, h, ch, &img, protect.as_deref(), nw, nh);
|
||||
let want = reference::carve(w, h, ch, &img, protect.as_deref(), nw, nh);
|
||||
let bits = |v: &[f32]| v.iter().map(|f| f.to_bits()).collect::<Vec<_>>();
|
||||
assert_eq!(bits(&got), bits(&want), "case {case}: {w}x{h}x{ch} -> {nw}x{nh}, levels {levels}, protect {}", protect.is_some());
|
||||
}
|
||||
// The seam search alone, on raw energy maps full of ties.
|
||||
for case in 0..200u32 {
|
||||
let (w, h) = (1 + next(40), 1 + next(30));
|
||||
let e = noise(w * h, [2, 3, 1000][next(3)], 7 + case);
|
||||
assert_eq!(find_seam(w, h, &e), reference::find_seam(w, h, &e), "seam case {case}: {w}x{h}");
|
||||
let img = noise(w * h * 3, 7, 3 + case);
|
||||
let bits = |v: Vec<f32>| v.into_iter().map(f32::to_bits).collect::<Vec<_>>();
|
||||
assert_eq!(bits(energy(w, h, 3, &img)), bits(reference::energy(w, h, 3, &img)), "energy case {case}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_carve_stops() {
|
||||
let (w, h) = (40, 20);
|
||||
let img = vec![0.5f32; w * h * 3];
|
||||
let yes = || true;
|
||||
assert_eq!(carve_with(w, h, 3, &img, None, 30, 15, &Interrupt::cancel_only(&yes)), Err(Cancelled));
|
||||
assert_eq!(carve_with(w, h, 3, &img, None, 30, 15, &Interrupt::NONE).unwrap(), carve(w, h, 3, &img, None, 30, 15));
|
||||
}
|
||||
|
||||
/// A flat grey image with a salient vertical bar at x = 10..14 (red/blue columns).
|
||||
fn bar(w: usize, h: usize) -> Vec<f32> {
|
||||
let mut v = vec![0.5f32; w * h * 3];
|
||||
@@ -315,6 +673,77 @@ mod tests {
|
||||
assert!((0..4).all(|y| out[y * 8..y * 8 + 8].iter().any(|v| (*v - 0.51).abs() < 1e-6)));
|
||||
}
|
||||
|
||||
/// Noise quantised to `levels` values, so the energy map is full of exact ties.
|
||||
fn noise(len: usize, levels: u32, seed: u32) -> Vec<f32> {
|
||||
let mut s = seed | 1;
|
||||
(0..len)
|
||||
.map(|_| {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
(s % levels) as f32 / (levels - 1) as f32
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fnv(v: &[f32]) -> u64 {
|
||||
v.iter().flat_map(|f| f.to_bits().to_le_bytes()).fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(b)).wrapping_mul(0x100_0000_01b3))
|
||||
}
|
||||
|
||||
/// The exact output bits of a spread of carves (shrink, enlarge past half the width, both
|
||||
/// axes, protection, 1/3/4 channels, tie-heavy and smooth images), pinned so that speed work
|
||||
/// can't change which seams are chosen or how pixels are blended.
|
||||
#[test]
|
||||
fn output_bits_are_pinned() {
|
||||
let cases: [(usize, usize, usize, u32, bool, usize, usize); 8] = [
|
||||
(61, 47, 3, 2, false, 40, 47),
|
||||
(61, 47, 3, 255, false, 61, 30),
|
||||
(61, 47, 4, 4, true, 35, 29),
|
||||
(33, 20, 1, 3, false, 80, 20),
|
||||
(33, 20, 3, 1000, true, 50, 45),
|
||||
(48, 36, 4, 7, false, 70, 18),
|
||||
(5, 3, 1, 2, false, 1, 1),
|
||||
(40, 40, 3, 2, true, 39, 41),
|
||||
];
|
||||
let got: Vec<u64> = cases
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &(w, h, ch, levels, prot, nw, nh))| {
|
||||
let img = noise(w * h * ch, levels, 7 + i as u32);
|
||||
let protect = prot.then(|| noise(w * h, 3, 99 + i as u32));
|
||||
let out = carve(w, h, ch, &img, protect.as_deref(), nw, nh);
|
||||
assert_eq!(out.len(), nw * nh * ch);
|
||||
fnv(&out)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(got, PINNED);
|
||||
}
|
||||
|
||||
const PINNED: [u64; 8] = [
|
||||
0x24ea_9353_02c2_7c58,
|
||||
0x5a6e_8554_a0cb_ebb1,
|
||||
0x8b18_d63f_396e_21c9,
|
||||
0x1db2_23ad_3e6a_a7c5,
|
||||
0x721d_6cc5_34e0_3fdf,
|
||||
0x961a_ea6d_e512_ec7d,
|
||||
0x4d25_767f_9dce_13f5,
|
||||
0x2157_9f86_27d6_b835,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn one_pixel_lines_enlarge_by_repeating() {
|
||||
// Width 1 has no seam to spare: its one column is repeated. Height 1 likewise (through
|
||||
// the transposed pass), alone or with the other axis.
|
||||
let col = [0.1f32, 0.2, 0.3, 0.4, 0.5];
|
||||
assert_eq!(carve(1, 5, 1, &col, None, 4, 5), col.iter().flat_map(|v| [*v; 4]).collect::<Vec<_>>());
|
||||
assert_eq!(carve(5, 1, 1, &col, None, 5, 3), col.repeat(3));
|
||||
assert_eq!(carve(1, 1, 3, &[0.7, 0.8, 0.9], None, 3, 2), [0.7, 0.8, 0.9].repeat(6));
|
||||
for (w, h, nw, nh) in [(1, 5, 6, 10), (1, 17, 6, 34), (16, 1, 21, 2), (7, 1, 3, 8), (2, 1, 1, 8)] {
|
||||
let img = vec![0.5f32; w * h * 4];
|
||||
assert_eq!(carve(w, h, 4, &img, Some(&vec![1.0; w * h]), nw, nh).len(), nw * nh * 4, "{w}x{h} -> {nw}x{nh}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skin_rule() {
|
||||
let px = [0.86f32, 0.62, 0.5, 0.2, 0.4, 0.8];
|
||||
|
||||
+174
-87
@@ -4,10 +4,12 @@
|
||||
//! and combining with an existing selection.
|
||||
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
|
||||
use photocraft_raster::Surface;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::photo_util::par_rows;
|
||||
|
||||
/// How a new selection combines with the current one.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -36,6 +38,32 @@ pub fn mask_from_surface(s: Option<&Surface>, area: Rect) -> Vec<f32> {
|
||||
let n = area.width() as usize * area.height() as usize;
|
||||
match s {
|
||||
None => vec![0.0; n],
|
||||
// Selections are GRAY8: copy tile bytes through a table, tile rows in parallel.
|
||||
Some(s) if s.format() == PixelFormat::GRAY8 && !area.is_empty() => {
|
||||
let w = area.width() as usize;
|
||||
let lut: [f32; 256] = std::array::from_fn(|i| i as f32 / 255.0);
|
||||
let dp = s.default_pixel().first().copied().unwrap_or(0.0);
|
||||
let mut out = vec![0.0f32; n];
|
||||
par_rows(&mut out, w, 1, |yy, row| {
|
||||
let y = area.y0 + yy as i32;
|
||||
let mut x = area.x0;
|
||||
while x < area.x1 {
|
||||
let tc = TileCoord::containing(x, y);
|
||||
let x1 = (tc.rect().x1).min(area.x1);
|
||||
let dst = row.get_mut((x - area.x0) as usize..(x1 - area.x0) as usize).unwrap_or_default();
|
||||
let src = s.tile(tc).and_then(|t| {
|
||||
let base = (y - tc.ty * TILE_SIZE) as usize * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize;
|
||||
t.bytes().get(base..base + dst.len())
|
||||
});
|
||||
match src {
|
||||
Some(src) => dst.iter_mut().zip(src).for_each(|(d, b)| *d = lut[*b as usize]),
|
||||
None => dst.fill(dp),
|
||||
}
|
||||
x = x1;
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
Some(s) => {
|
||||
let mut v = Vec::new();
|
||||
s.read_region_into(area, &mut v);
|
||||
@@ -45,35 +73,81 @@ pub fn mask_from_surface(s: Option<&Surface>, area: Rect) -> Vec<f32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 8-bit selection value of a coverage (clamped and rounded, as GRAY8 surfaces store it; NaN → 0).
|
||||
#[inline]
|
||||
fn coverage8(v: f32) -> u8 {
|
||||
(v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8
|
||||
}
|
||||
|
||||
/// Builds a GRAY8 selection surface from 8-bit coverage over `area`, allocating only the tiles
|
||||
/// that hold something (as [`Surface::prune`] would leave them).
|
||||
fn surface_from_coverage8(bytes: &[u8], area: Rect) -> Surface {
|
||||
let mut s = Surface::new(PixelFormat::GRAY8);
|
||||
let w = area.width() as usize;
|
||||
let ts = TILE_SIZE as usize;
|
||||
for tc in area.tiles() {
|
||||
let tr = tc.rect().intersect(&area);
|
||||
let (lx, span) = ((tr.x0 - area.x0) as usize, tr.width() as usize);
|
||||
let row = |y: i32| bytes.get((y - area.y0) as usize * w + lx..(y - area.y0) as usize * w + lx + span);
|
||||
if !(tr.y0..tr.y1).any(|y| row(y).is_some_and(|r| r.iter().any(|b| *b != 0))) {
|
||||
continue;
|
||||
}
|
||||
let data = s.tile_mut(tc).bytes_mut();
|
||||
for y in tr.y0..tr.y1 {
|
||||
let o = (y - tc.ty * TILE_SIZE) as usize * ts + (tr.x0 - tc.tx * TILE_SIZE) as usize;
|
||||
if let (Some(src), Some(dst)) = (row(y), data.get_mut(o..o + span)) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// 8-bit coverage of `m` (missing samples are 0), computed in parallel.
|
||||
fn to_coverage8(m: &[f32], area: Rect, f: impl Fn(usize, f32) -> f32 + Sync + Send) -> Vec<u8> {
|
||||
let w = area.width() as usize;
|
||||
let mut bytes = vec![0u8; w * area.height() as usize];
|
||||
par_rows(&mut bytes, w, 1, |y, row| {
|
||||
for (x, b) in row.iter_mut().enumerate() {
|
||||
let i = y * w + x;
|
||||
*b = coverage8(f(i, m.get(i).copied().unwrap_or(0.0)));
|
||||
}
|
||||
});
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Builds a GRAY8 selection surface from a mask over `area`.
|
||||
pub fn mask_to_surface(m: &[f32], area: Rect) -> Surface {
|
||||
let mut s = Surface::new(PixelFormat::GRAY8);
|
||||
if !area.is_empty() {
|
||||
s.write_region(area, m);
|
||||
if area.is_empty() {
|
||||
return Surface::new(PixelFormat::GRAY8);
|
||||
}
|
||||
s.prune();
|
||||
s
|
||||
surface_from_coverage8(&to_coverage8(m, area, |_, v| v), area)
|
||||
}
|
||||
|
||||
/// Combines `new` with `old` by `mode`. Returns `None` when nothing is selected.
|
||||
pub fn combine(old: Option<&Surface>, new: &[f32], area: Rect, mode: SelectionMode) -> Option<Surface> {
|
||||
let prev = mask_from_surface(old, area);
|
||||
// Quantize to the 8-bit grid selections are stored on, so combining a
|
||||
// mask with itself is exact.
|
||||
let q: Vec<f32> = new.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() / 255.0).collect();
|
||||
let new = &q[..];
|
||||
let out: Vec<f32> = match mode {
|
||||
SelectionMode::Replace => new.to_vec(),
|
||||
SelectionMode::Add => prev.iter().zip(new).map(|(a, b)| a.max(*b)).collect(),
|
||||
SelectionMode::Subtract => prev.iter().zip(new).map(|(a, b)| (a - b).max(0.0)).collect(),
|
||||
SelectionMode::Intersect => prev.iter().zip(new).map(|(a, b)| a.min(*b)).collect(),
|
||||
};
|
||||
// Below half an 8-bit step is nothing (selections are stored as 8-bit).
|
||||
let out: Vec<f32> = out.into_iter().map(|v| if v < 0.5 / 255.0 { 0.0 } else { v }).collect();
|
||||
if out.iter().all(|v| *v <= 0.0) {
|
||||
if area.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(mask_to_surface(&out, area))
|
||||
// Quantize to the 8-bit grid selections are stored on, so combining a mask with itself is
|
||||
// exact. Below half an 8-bit step is nothing.
|
||||
let q = |v: f32| f32::from(coverage8(v)) / 255.0;
|
||||
let bytes = match mode {
|
||||
SelectionMode::Replace => to_coverage8(new, area, |_, v| v),
|
||||
_ => {
|
||||
let prev = mask_from_surface(old, area);
|
||||
let at = |i: usize| prev.get(i).copied().unwrap_or(0.0);
|
||||
match mode {
|
||||
SelectionMode::Add => to_coverage8(new, area, |i, v| at(i).max(q(v))),
|
||||
SelectionMode::Subtract => to_coverage8(new, area, |i, v| (at(i) - q(v)).max(0.0)),
|
||||
_ => to_coverage8(new, area, |i, v| at(i).min(q(v))),
|
||||
}
|
||||
}
|
||||
};
|
||||
if bytes.iter().all(|b| *b == 0) {
|
||||
return None;
|
||||
}
|
||||
Some(surface_from_coverage8(&bytes, area))
|
||||
}
|
||||
|
||||
fn close(a: [f32; 4], b: [f32; 4], tol: f32) -> bool {
|
||||
@@ -548,76 +622,17 @@ pub fn border(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
|
||||
outer.iter().zip(inner).map(|(o, i)| (o - i).max(0.0)).collect()
|
||||
}
|
||||
|
||||
fn box_blur(m: &[f32], w: usize, h: usize, r: usize) -> Vec<f32> {
|
||||
if r == 0 {
|
||||
return m.to_vec();
|
||||
}
|
||||
let mut tmp = vec![0.0f32; m.len()];
|
||||
let win = (2 * r + 1) as f32;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut s = 0.0;
|
||||
for k in x as i64 - r as i64..=x as i64 + r as i64 {
|
||||
s += m[y * w + k.clamp(0, w as i64 - 1) as usize];
|
||||
}
|
||||
tmp[y * w + x] = s / win;
|
||||
}
|
||||
}
|
||||
let mut out = vec![0.0f32; m.len()];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut s = 0.0;
|
||||
for k in y as i64 - r as i64..=y as i64 + r as i64 {
|
||||
s += tmp[k.clamp(0, h as i64 - 1) as usize * w + x];
|
||||
}
|
||||
out[y * w + x] = s / win;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Smooth: removes specks and rounds corners (box average, then re-threshold).
|
||||
/// Smooth: removes specks and rounds corners (box average of radius `round(r)`, edges
|
||||
/// replicated, then re-thresholded). Parallel and independent of the radius (#211).
|
||||
pub fn smooth(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
|
||||
box_blur(m, w, h, r.max(0.0).round() as usize).into_iter().map(|v| ((v - 0.5) * 4.0 + 0.5).clamp(0.0, 1.0)).collect()
|
||||
crate::selection_blur::smooth(m, w, h, r)
|
||||
}
|
||||
|
||||
/// Feather: Gaussian blur of the mask with sigma = radius / 2.
|
||||
/// Feather: Gaussian blur of the mask with sigma = radius / 2 (kernel truncated at 3σ, zero
|
||||
/// beyond the canvas). Parallel, confined to the selection's bounds, and independent of the
|
||||
/// radius (#211).
|
||||
pub fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
|
||||
let sigma = radius.max(0.0) / 2.0;
|
||||
if sigma < 0.1 {
|
||||
return m.to_vec();
|
||||
}
|
||||
let r = (sigma * 3.0).ceil() as i64;
|
||||
let k: Vec<f32> = (-r..=r).map(|i| (-(i * i) as f32 / (2.0 * sigma * sigma)).exp()).collect();
|
||||
let s: f32 = k.iter().sum();
|
||||
let k: Vec<f32> = k.iter().map(|v| v / s).collect();
|
||||
let mut tmp = vec![0.0f32; m.len()];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut a = 0.0;
|
||||
for (i, kv) in k.iter().enumerate() {
|
||||
let xx = x as i64 + i as i64 - r;
|
||||
if xx >= 0 && xx < w as i64 {
|
||||
a += m[y * w + xx as usize] * kv;
|
||||
}
|
||||
}
|
||||
tmp[y * w + x] = a;
|
||||
}
|
||||
}
|
||||
let mut out = vec![0.0f32; m.len()];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut a = 0.0;
|
||||
for (i, kv) in k.iter().enumerate() {
|
||||
let yy = y as i64 + i as i64 - r;
|
||||
if yy >= 0 && yy < h as i64 {
|
||||
a += tmp[yy as usize * w + x] * kv;
|
||||
}
|
||||
}
|
||||
out[y * w + x] = a;
|
||||
}
|
||||
}
|
||||
out
|
||||
crate::selection_blur::feather(m, w, h, radius)
|
||||
}
|
||||
|
||||
/// Anti-aliased polygon (even-odd) coverage over `area`: exact horizontal
|
||||
@@ -828,6 +843,78 @@ mod tests {
|
||||
assert_eq!(SelectionMode::parse("add"), SelectionMode::Add);
|
||||
}
|
||||
|
||||
/// The previous (per-sample) conversions, kept as the oracle for the tiled fast paths.
|
||||
fn old_mask_to_surface(m: &[f32], area: Rect) -> Surface {
|
||||
let mut s = Surface::new(PixelFormat::GRAY8);
|
||||
if !area.is_empty() {
|
||||
s.write_region(area, m);
|
||||
}
|
||||
s.prune();
|
||||
s
|
||||
}
|
||||
|
||||
fn old_combine(old: Option<&Surface>, new: &[f32], area: Rect, mode: SelectionMode) -> Option<Surface> {
|
||||
let mut prev = Vec::new();
|
||||
match old {
|
||||
Some(s) => s.read_region_into(area, &mut prev),
|
||||
None => prev = vec![0.0; new.len()],
|
||||
}
|
||||
let q: Vec<f32> = new.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() / 255.0).collect();
|
||||
let out: Vec<f32> = match mode {
|
||||
SelectionMode::Replace => q,
|
||||
SelectionMode::Add => prev.iter().zip(&q).map(|(a, b)| a.max(*b)).collect(),
|
||||
SelectionMode::Subtract => prev.iter().zip(&q).map(|(a, b)| (a - b).max(0.0)).collect(),
|
||||
SelectionMode::Intersect => prev.iter().zip(&q).map(|(a, b)| a.min(*b)).collect(),
|
||||
};
|
||||
let out: Vec<f32> = out.into_iter().map(|v| if v < 0.5 / 255.0 { 0.0 } else { v }).collect();
|
||||
if out.iter().all(|v| *v <= 0.0) {
|
||||
return None;
|
||||
}
|
||||
Some(old_mask_to_surface(&out, area))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiled_conversions_match_per_sample_ones() {
|
||||
let mut rng = crate::photo_util::Rng(7);
|
||||
let mut u = move || (rng.next() >> 11) as f32 / (1u64 << 53) as f32;
|
||||
// Off-grid areas spanning several tiles, including negative origins.
|
||||
for area in [Rect::new(0, 0, 300, 20), Rect::new(-37, 250, 530, 263), Rect::new(255, -3, 258, 600), Rect::new(5, 5, 6, 6)] {
|
||||
let n = area.width() as usize * area.height() as usize;
|
||||
let old_m: Vec<f32> = (0..n).map(|i| if i % 97 < 40 { u() } else { 0.0 }).collect();
|
||||
let old = old_mask_to_surface(&old_m, area);
|
||||
let fast = mask_to_surface(&old_m, area);
|
||||
assert_eq!(fast, old, "{area:?}");
|
||||
let mut slow_read = Vec::new();
|
||||
old.read_region_into(area, &mut slow_read);
|
||||
assert_eq!(mask_from_surface(Some(&old), area), slow_read);
|
||||
for kind in 0..3 {
|
||||
let new: Vec<f32> = (0..n)
|
||||
.map(|i| match kind {
|
||||
0 => u() * 1.2 - 0.1,
|
||||
1 => {
|
||||
if i % 131 < 50 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
_ => 0.001,
|
||||
})
|
||||
.collect();
|
||||
for mode in [SelectionMode::Replace, SelectionMode::Add, SelectionMode::Subtract, SelectionMode::Intersect] {
|
||||
for prev in [None, Some(&old)] {
|
||||
assert_eq!(combine(prev, &new, area, mode), old_combine(prev, &new, area, mode), "{area:?} {kind} {mode:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A short mask buffer reads as zeros instead of panicking.
|
||||
let a = Rect::new(0, 0, 10, 10);
|
||||
assert!(combine(None, &[1.0; 5], a, SelectionMode::Replace).is_some());
|
||||
assert_eq!(mask_to_surface(&[], a).tile_count(), 0);
|
||||
assert!(combine(None, &[], Rect::EMPTY, SelectionMode::Replace).is_none());
|
||||
}
|
||||
|
||||
fn blobs(w: i32, h: i32) -> Vec<[u8; 4]> {
|
||||
// Deterministic blotchy image with gradients and hard edges.
|
||||
(0..w * h)
|
||||
|
||||
@@ -0,0 +1,788 @@
|
||||
//! Fast mask blurs behind Select › Modify › Smooth and Feather (#211).
|
||||
//!
|
||||
//! Both are separable and give the same numbers as the direct definitions (see the oracle tests):
|
||||
//!
|
||||
//! * **Smooth** is a box average with edge-replicated borders: a running sum per line, `O(1)` per
|
||||
//! pixel whatever the radius.
|
||||
//! * **Feather** is a Gaussian (`σ = radius / 2`, kernel truncated at `⌈3σ⌉` and normalized, zero
|
||||
//! outside the canvas). Small kernels are convolved directly. Wide ones are written as a short
|
||||
//! cosine series over the kernel window, `k(j) ≈ a₀ + Σ aₖ cos(ωₖ j)`, and every term is a
|
||||
//! sliding windowed sum `Σ m[x+j]·e^{iωₖj}` updated in `O(1)` per pixel, so the cost doesn't grow
|
||||
//! with the radius. The series is fitted per call by least squares and its error is checked
|
||||
//! (≤ 0.3/255 per pass); a fit that misses falls back to the direct convolution.
|
||||
//!
|
||||
//! Work is confined to the mask's non-zero bounding box grown by the kernel radius (pixels
|
||||
//! farther away stay exactly 0). Rows run in parallel (`par_rows`/`par_map`, serial on wasm);
|
||||
//! the vertical pass walks strips of 128 columns row by row (vectorizable, no transpose). Windows
|
||||
//! lying entirely in a constant run (inside or outside the selection) are written without
|
||||
//! arithmetic.
|
||||
|
||||
use crate::photo_util::{par_map, par_rows};
|
||||
|
||||
/// Cosine terms in the wide-kernel series (5 gives ≈ 0.1/255 per pass; see the module docs).
|
||||
const TERMS: usize = 5;
|
||||
/// Kernels up to this radius are convolved directly.
|
||||
const DIRECT_MAX_R: usize = 12;
|
||||
/// Largest error (in mask units, per pass) a cosine fit may have before falling back.
|
||||
const FIT_TOL: f64 = 0.3 / 255.0;
|
||||
/// σ cap: far beyond any canvas, keeps the kernel arithmetic finite for absurd radii.
|
||||
const MAX_SIGMA: f32 = 100_000.0;
|
||||
/// Box radius cap for the same reason.
|
||||
const MAX_BOX_R: usize = 1 << 24;
|
||||
/// Columns per task in the vertical pass.
|
||||
const STRIP: usize = 64;
|
||||
|
||||
/// A window of the mask: columns `x0..x1`, rows `y0..y1`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Win {
|
||||
x0: usize,
|
||||
x1: usize,
|
||||
y0: usize,
|
||||
y1: usize,
|
||||
}
|
||||
|
||||
impl Win {
|
||||
fn w(&self) -> usize {
|
||||
self.x1 - self.x0
|
||||
}
|
||||
fn h(&self) -> usize {
|
||||
self.y1 - self.y0
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounding box of the non-zero samples (NaN counts as non-zero), or `None` when all are 0.
|
||||
fn nonzero_bounds(m: &[f32], w: usize, h: usize) -> Option<Win> {
|
||||
let rows: Vec<Option<(usize, usize)>> = par_map(h, |y| {
|
||||
let row = m.get(y * w..(y + 1) * w)?;
|
||||
let first = row.iter().position(|v| *v != 0.0)?;
|
||||
let last = row.iter().rposition(|v| *v != 0.0)?;
|
||||
Some((first, last + 1))
|
||||
});
|
||||
let mut b: Option<Win> = None;
|
||||
for (y, r) in rows.into_iter().enumerate() {
|
||||
if let Some((a, z)) = r {
|
||||
b = Some(match b {
|
||||
None => Win { x0: a, x1: z, y0: y, y1: y + 1 },
|
||||
Some(b) => Win { x0: b.x0.min(a), x1: b.x1.max(z), y0: b.y0, y1: y + 1 },
|
||||
});
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// `b` grown by `g` on every side, clamped to `w × h`.
|
||||
fn grow(b: Win, g: usize, w: usize, h: usize) -> Win {
|
||||
Win { x0: b.x0.saturating_sub(g), x1: b.x1.saturating_add(g).min(w), y0: b.y0.saturating_sub(g), y1: b.y1.saturating_add(g).min(h) }
|
||||
}
|
||||
|
||||
/// The horizontal pass's output as the vertical pass reads it: `bh` rows of `gw` samples placed
|
||||
/// at window rows `off..off + bh` of a window `gh` rows tall (other rows are zero).
|
||||
struct Rows<'a> {
|
||||
tmp: &'a [f32],
|
||||
gw: usize,
|
||||
bh: usize,
|
||||
off: usize,
|
||||
gh: usize,
|
||||
}
|
||||
|
||||
impl Rows<'_> {
|
||||
/// Columns `c0..c0 + sw` of window row `yy`, or `None` for a zero row.
|
||||
fn get(&self, yy: usize, c0: usize, sw: usize) -> Option<&[f32]> {
|
||||
let r = yy.checked_sub(self.off).filter(|r| *r < self.bh)?;
|
||||
self.tmp.get(r * self.gw + c0..r * self.gw + c0 + sw)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the vertical pass `f(c0, sw)` on strips of `STRIP` columns in parallel; each returns its
|
||||
/// `gh × sw` result, row-major.
|
||||
fn strips(gw: usize, f: impl Fn(usize, usize) -> Vec<f32> + Sync + Send) -> Vec<Vec<f32>> {
|
||||
par_map(gw.div_ceil(STRIP), |s| {
|
||||
let c0 = s * STRIP;
|
||||
f(c0, STRIP.min(gw - c0))
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes the strips into a fresh `w × h` mask through `f`; outside the window it is `outside`.
|
||||
fn assemble(strips: &[Vec<f32>], win: Win, w: usize, h: usize, outside: f32, f: impl Fn(f32) -> f32 + Sync + Send) -> Vec<f32> {
|
||||
let mut out = vec![outside; w * h];
|
||||
par_rows(&mut out, w, 1, |y, row| {
|
||||
let Some(yy) = y.checked_sub(win.y0).filter(|_| y < win.y1) else { return };
|
||||
for (s, strip) in strips.iter().enumerate() {
|
||||
let c0 = s * STRIP;
|
||||
let sw = STRIP.min(win.w().saturating_sub(c0));
|
||||
if let (Some(src), Some(dst)) = (strip.get(yy * sw..(yy + 1) * sw), row.get_mut(win.x0 + c0..win.x0 + c0 + sw)) {
|
||||
for (d, v) in dst.iter_mut().zip(src) {
|
||||
*d = f(*v);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Adds `k ×` window row `yy` (columns `c0..c0 + acc.len()`) into `acc`.
|
||||
fn add_row(acc: &mut [f64], rows: &Rows, yy: usize, c0: usize, k: f64) {
|
||||
if k == 0.0 {
|
||||
return;
|
||||
}
|
||||
if let Some(row) = rows.get(yy, c0, acc.len()) {
|
||||
for (a, v) in acc.iter_mut().zip(row) {
|
||||
*a += k * f64::from(*v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Smooth: box average, edges replicated -------------------------------------------------
|
||||
|
||||
/// Running-sum box average of one line (radius `r`, edges replicated) into `dst`.
|
||||
fn box_line(src: &[f32], dst: &mut [f32], r: usize) {
|
||||
let n = src.len().min(dst.len());
|
||||
let (Some(&first), Some(&last)) = (src.first(), src.get(n.wrapping_sub(1))) else { return };
|
||||
if r == 0 {
|
||||
dst[..n].copy_from_slice(&src[..n]);
|
||||
return;
|
||||
}
|
||||
let at = |i: i64| -> f64 { f64::from(src.get(i.clamp(0, n as i64 - 1) as usize).copied().unwrap_or(0.0)) };
|
||||
// Window at x = 0: r copies of the first sample, then src[0..=r], then copies of the last.
|
||||
let inside = r.min(n - 1);
|
||||
let mut acc = r as f64 * f64::from(first) + src[..=inside].iter().map(|v| f64::from(*v)).sum::<f64>() + (r - inside) as f64 * f64::from(last);
|
||||
let norm = 1.0 / (2 * r + 1) as f64;
|
||||
let ri = r as i64;
|
||||
for (x, d) in dst[..n].iter_mut().enumerate() {
|
||||
*d = (acc * norm) as f32;
|
||||
let x = x as i64;
|
||||
acc += at(x + ri + 1) - at(x - ri);
|
||||
}
|
||||
}
|
||||
|
||||
/// Select › Modify › Smooth: box average of radius `round(r)` (edges replicated), then the
|
||||
/// contrast curve `(v − ½)·4 + ½` that re-hardens the edge and drops specks.
|
||||
pub(crate) fn smooth(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
|
||||
let curve = |v: f32| ((v - 0.5) * 4.0 + 0.5).clamp(0.0, 1.0);
|
||||
let n = w.saturating_mul(h);
|
||||
if m.len() != n || n == 0 {
|
||||
return m.iter().map(|v| curve(*v)).collect();
|
||||
}
|
||||
let r = if r.is_finite() {
|
||||
(r.max(0.0).round() as usize).min(MAX_BOX_R)
|
||||
} else if r > 0.0 {
|
||||
MAX_BOX_R
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let Some(b) = nonzero_bounds(m, w, h) else {
|
||||
return vec![curve(0.0); n];
|
||||
};
|
||||
// Grown one past the radius, so a window edge inside the canvas sits on zeros and edge
|
||||
// replication there equals the zeros beyond it.
|
||||
let win = grow(b, r.saturating_add(1), w, h);
|
||||
let (gw, gh) = (win.w(), win.h());
|
||||
// Rows: only the bounding box's rows are non-zero.
|
||||
let mut tmp = vec![0.0f32; b.h() * gw];
|
||||
par_rows(&mut tmp, gw, 1, |y, row| {
|
||||
let o = (b.y0 + y) * w + win.x0;
|
||||
if let Some(s) = m.get(o..o + gw) {
|
||||
box_line(s, row, r);
|
||||
}
|
||||
});
|
||||
// Columns: a running sum of rows per strip of columns.
|
||||
let rows = Rows { tmp: &tmp, gw, bh: b.h(), off: b.y0 - win.y0, gh };
|
||||
let res = strips(gw, |c0, sw| box_strip(&rows, c0, sw, r));
|
||||
drop(tmp);
|
||||
assemble(&res, win, w, h, curve(0.0), curve)
|
||||
}
|
||||
|
||||
/// The vertical box pass over columns `c0..c0 + sw` (edges replicated).
|
||||
fn box_strip(rows: &Rows, c0: usize, sw: usize, r: usize) -> Vec<f32> {
|
||||
let gh = rows.gh;
|
||||
let mut out = vec![0.0f32; gh * sw];
|
||||
if gh == 0 || sw == 0 {
|
||||
return out;
|
||||
}
|
||||
let mut acc = vec![0.0f64; sw];
|
||||
// Window at row 0: r copies of row 0, rows 0..=r, then copies of the last row.
|
||||
let inside = r.min(gh - 1);
|
||||
add_row(&mut acc, rows, 0, c0, r as f64);
|
||||
for j in 0..=inside {
|
||||
add_row(&mut acc, rows, j, c0, 1.0);
|
||||
}
|
||||
add_row(&mut acc, rows, gh - 1, c0, (r - inside) as f64);
|
||||
let norm = 1.0 / (2 * r + 1) as f64;
|
||||
for (yy, dst) in out.chunks_exact_mut(sw).enumerate() {
|
||||
for (d, a) in dst.iter_mut().zip(&acc) {
|
||||
*d = (a * norm) as f32;
|
||||
}
|
||||
add_row(&mut acc, rows, (yy + r + 1).min(gh - 1), c0, 1.0);
|
||||
add_row(&mut acc, rows, yy.saturating_sub(r), c0, -1.0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---- Feather: truncated Gaussian, zero outside -----------------------------------------------
|
||||
|
||||
/// Sliding cosine-series form of a symmetric kernel over `-r..=r`.
|
||||
struct CosKernel {
|
||||
r: usize,
|
||||
a0: f64,
|
||||
a: [f64; TERMS],
|
||||
/// `e^{-iω}`: shifts the window by one sample.
|
||||
rot: [(f64, f64); TERMS],
|
||||
/// `e^{-iωr}` (leaving sample) and `e^{iωr}` (entering sample).
|
||||
out_w: [(f64, f64); TERMS],
|
||||
in_w: [(f64, f64); TERMS],
|
||||
/// `Σ_{|j|≤r} cos(ωj)`: a window full of ones.
|
||||
full: [f64; TERMS],
|
||||
omega: [f64; TERMS],
|
||||
}
|
||||
|
||||
enum Kernel {
|
||||
/// Taps for `-r..=r`.
|
||||
Direct(Vec<f32>),
|
||||
Cos(Box<CosKernel>),
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_range_loop)] // textbook elimination reads clearer with indices
|
||||
/// Solves the small dense system `a·x = b` (partial pivoting); `None` when singular.
|
||||
fn solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
|
||||
let n = b.len();
|
||||
for c in 0..n {
|
||||
let p = (c..n).max_by(|i, j| a[*i][c].abs().total_cmp(&a[*j][c].abs()))?;
|
||||
if a[p][c].abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
a.swap(c, p);
|
||||
b.swap(c, p);
|
||||
for i in c + 1..n {
|
||||
let f = a[i][c] / a[c][c];
|
||||
for j in c..n {
|
||||
a[i][j] -= f * a[c][j];
|
||||
}
|
||||
b[i] -= f * b[c];
|
||||
}
|
||||
}
|
||||
let mut x = vec![0.0; n];
|
||||
for i in (0..n).rev() {
|
||||
let s: f64 = (i + 1..n).map(|j| a[i][j] * x[j]).sum();
|
||||
x[i] = (b[i] - s) / a[i][i];
|
||||
}
|
||||
x.iter().all(|v| v.is_finite()).then_some(x)
|
||||
}
|
||||
|
||||
impl Kernel {
|
||||
/// The feather kernel (σ, truncation radius `r`) as applied along lines of length `n`.
|
||||
fn new(sigma: f64, r: usize, n: usize) -> Kernel {
|
||||
// Normalized over the full truncated window, as the direct definition does.
|
||||
let g = |j: f64| (-(j * j) / (2.0 * sigma * sigma)).exp();
|
||||
let total: f64 = 1.0 + 2.0 * (1..=r).map(|j| g(j as f64)).sum::<f64>();
|
||||
// Only offsets inside the line matter.
|
||||
let jr = r.min(n.saturating_sub(1));
|
||||
if jr == 0 {
|
||||
// One-sample line: the centre tap alone.
|
||||
return Kernel::Direct(vec![(1.0 / total) as f32]);
|
||||
}
|
||||
let taps: Vec<f64> = (0..=jr).map(|j| g(j as f64) / total).collect();
|
||||
let direct = || Kernel::Direct((0..=2 * jr).map(|i| taps[i.abs_diff(jr)] as f32).collect());
|
||||
if jr <= DIRECT_MAX_R {
|
||||
return direct();
|
||||
}
|
||||
// Least squares over j = -jr..=jr (symmetric: weight 2 for j > 0).
|
||||
let l = (jr + 1) as f64;
|
||||
let omega: [f64; TERMS] = std::array::from_fn(|k| std::f64::consts::PI * (k + 1) as f64 / l);
|
||||
let basis = |q: usize, j: usize| if q == 0 { 1.0 } else { (omega[q - 1] * j as f64).cos() };
|
||||
let wt = |j: usize| if j == 0 { 1.0 } else { 2.0 };
|
||||
let mut a = vec![vec![0.0; TERMS + 1]; TERMS + 1];
|
||||
let mut b = vec![0.0; TERMS + 1];
|
||||
for (j, t) in taps.iter().enumerate() {
|
||||
let phi: Vec<f64> = (0..=TERMS).map(|q| basis(q, j)).collect();
|
||||
for p in 0..=TERMS {
|
||||
b[p] += wt(j) * phi[p] * t;
|
||||
for q in 0..=TERMS {
|
||||
a[p][q] += wt(j) * phi[p] * phi[q];
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(c) = solve(a, b) else { return direct() };
|
||||
// Error bound per pass: half the L1 distance between the series and the kernel.
|
||||
let err: f64 = taps.iter().enumerate().map(|(j, t)| wt(j) * ((0..=TERMS).map(|q| c[q] * basis(q, j)).sum::<f64>() - t).abs()).sum::<f64>() / 2.0;
|
||||
if !err.is_finite() || err > FIT_TOL {
|
||||
return direct();
|
||||
}
|
||||
let jf = jr as f64;
|
||||
let full: [f64; TERMS] = std::array::from_fn(|k| 1.0 + 2.0 * (1..=jr).map(|j| (omega[k] * j as f64).cos()).sum::<f64>());
|
||||
Kernel::Cos(Box::new(CosKernel {
|
||||
r: jr,
|
||||
a0: c[0],
|
||||
a: std::array::from_fn(|k| c[k + 1]),
|
||||
rot: std::array::from_fn(|k| (omega[k].cos(), -omega[k].sin())),
|
||||
out_w: std::array::from_fn(|k| ((omega[k] * jf).cos(), -(omega[k] * jf).sin())),
|
||||
in_w: std::array::from_fn(|k| ((omega[k] * jf).cos(), (omega[k] * jf).sin())),
|
||||
full,
|
||||
omega,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Convolves one line (zero outside it) into `dst`.
|
||||
fn line(&self, src: &[f32], dst: &mut [f32], runs: &mut Vec<usize>) {
|
||||
let n = src.len().min(dst.len());
|
||||
match self {
|
||||
Kernel::Direct(taps) => {
|
||||
let r = taps.len() / 2;
|
||||
dst[..n].fill(0.0);
|
||||
for (i, k) in taps.iter().enumerate() {
|
||||
// Output x reads src[x + i - r].
|
||||
let (d0, s0) = if i >= r { (0, i - r) } else { (r - i, 0) };
|
||||
if d0 >= n || s0 >= n {
|
||||
continue;
|
||||
}
|
||||
let len = (n - d0).min(n - s0);
|
||||
for (d, s) in dst[d0..d0 + len].iter_mut().zip(&src[s0..s0 + len]) {
|
||||
*d += k * s;
|
||||
}
|
||||
}
|
||||
}
|
||||
Kernel::Cos(k) => k.line(&src[..n], &mut dst[..n], runs),
|
||||
}
|
||||
}
|
||||
|
||||
/// The vertical pass over columns `c0..c0 + sw` (zero outside the window).
|
||||
fn strip(&self, rows: &Rows, c0: usize, sw: usize) -> Vec<f32> {
|
||||
match self {
|
||||
Kernel::Direct(taps) => {
|
||||
let r = taps.len() / 2;
|
||||
let mut out = vec![0.0f32; rows.gh * sw];
|
||||
if sw == 0 {
|
||||
return out;
|
||||
}
|
||||
for (yy, dst) in out.chunks_exact_mut(sw).enumerate() {
|
||||
for (i, k) in taps.iter().enumerate() {
|
||||
let Some(src) = (yy + i).checked_sub(r).and_then(|y| rows.get(y, c0, sw)) else { continue };
|
||||
for (d, s) in dst.iter_mut().zip(src) {
|
||||
*d += k * s;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
Kernel::Cos(k) => k.strip(rows, c0, sw),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CosKernel {
|
||||
#[allow(clippy::needless_range_loop)] // `x` and `k` index several arrays in lockstep
|
||||
fn line(&self, src: &[f32], dst: &mut [f32], runs: &mut Vec<usize>) {
|
||||
let n = src.len();
|
||||
let r = self.r;
|
||||
// runs[i]: end (exclusive) of the constant run containing i.
|
||||
runs.clear();
|
||||
runs.resize(n, n);
|
||||
for i in (0..n.saturating_sub(1)).rev() {
|
||||
runs[i] = if src[i] == src[i + 1] { runs[i + 1] } else { i + 1 };
|
||||
}
|
||||
let at = |i: usize| f64::from(src.get(i).copied().unwrap_or(0.0));
|
||||
// Window sums for the last position handled (valid when `have`).
|
||||
let mut have = false;
|
||||
let mut sb = 0.0f64;
|
||||
let mut s = [(0.0f64, 0.0f64); TERMS];
|
||||
for x in 0..n {
|
||||
let lo = x.saturating_sub(r);
|
||||
let hi = (x + r + 1).min(n);
|
||||
let c = src[lo];
|
||||
if runs[lo] >= hi && (c == 0.0 || (x >= r && x + r < n)) {
|
||||
// The whole window is one value (zeros may run off the line; ones may not).
|
||||
dst[x] = c;
|
||||
let cf = f64::from(c);
|
||||
sb = cf * (2 * r + 1) as f64;
|
||||
for (sk, fk) in s.iter_mut().zip(&self.full) {
|
||||
*sk = (cf * fk, 0.0);
|
||||
}
|
||||
have = true;
|
||||
continue;
|
||||
}
|
||||
if have {
|
||||
// Slide from x - 1: drop src[x - 1 - r], take src[x + r].
|
||||
let gone = if x > r { at(x - 1 - r) } else { 0.0 };
|
||||
let come = at(x + r);
|
||||
sb += come - gone;
|
||||
for k in 0..TERMS {
|
||||
let (re, im) = (s[k].0 - gone * self.out_w[k].0, s[k].1 - gone * self.out_w[k].1);
|
||||
let (cr, ci) = self.rot[k];
|
||||
s[k] = (re * cr - im * ci + come * self.in_w[k].0, re * ci + im * cr + come * self.in_w[k].1);
|
||||
}
|
||||
} else {
|
||||
// Direct sums over the in-line part of the window.
|
||||
sb = 0.0;
|
||||
s = [(0.0, 0.0); TERMS];
|
||||
for (i, v) in src.iter().enumerate().take(hi).skip(lo) {
|
||||
let v = f64::from(*v);
|
||||
if v == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let j = i as f64 - x as f64;
|
||||
sb += v;
|
||||
for (sk, w) in s.iter_mut().zip(&self.omega) {
|
||||
sk.0 += v * (w * j).cos();
|
||||
sk.1 += v * (w * j).sin();
|
||||
}
|
||||
}
|
||||
have = true;
|
||||
}
|
||||
let v = self.a0 * sb + s.iter().zip(&self.a).map(|(sk, a)| a * sk.0).sum::<f64>();
|
||||
dst[x] = v as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CosKernel {
|
||||
/// [`CosKernel::line`] down columns `c0..c0 + sw`, all columns of a row at once.
|
||||
#[allow(clippy::needless_range_loop)] // `k` indexes several term arrays in lockstep
|
||||
fn strip(&self, rows: &Rows, c0: usize, sw: usize) -> Vec<f32> {
|
||||
let gh = rows.gh;
|
||||
let r = self.r;
|
||||
let mut out = vec![0.0f32; gh * sw];
|
||||
if sw == 0 {
|
||||
return out;
|
||||
}
|
||||
// Rows whose strip segment is one value, and the runs of equal such rows.
|
||||
let flag: Vec<Option<f32>> = (0..gh)
|
||||
.map(|yy| match rows.get(yy, c0, sw) {
|
||||
None => Some(0.0),
|
||||
Some(s) => {
|
||||
let c = s.first().copied().unwrap_or(0.0);
|
||||
s.iter().all(|v| *v == c).then_some(c)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut runs = vec![gh; gh];
|
||||
for i in (0..gh.saturating_sub(1)).rev() {
|
||||
runs[i] = match (flag[i], flag[i + 1]) {
|
||||
(Some(a), Some(b)) if a == b => runs[i + 1],
|
||||
_ => i + 1,
|
||||
};
|
||||
}
|
||||
let zeros = vec![0.0f32; sw];
|
||||
let mut sb = vec![0.0f64; sw];
|
||||
let mut re = vec![vec![0.0f64; sw]; TERMS];
|
||||
let mut im = vec![vec![0.0f64; sw]; TERMS];
|
||||
let mut acc = vec![0.0f64; sw];
|
||||
let mut have = false;
|
||||
for (yy, dst) in out.chunks_exact_mut(sw).enumerate() {
|
||||
let lo = yy.saturating_sub(r);
|
||||
let hi = (yy + r + 1).min(gh);
|
||||
// The whole window is one value (zero rows may run off the window; others may not).
|
||||
if let Some(c) = flag[lo]
|
||||
&& runs[lo] >= hi
|
||||
&& (c == 0.0 || (yy >= r && yy + r < gh))
|
||||
{
|
||||
dst.fill(c);
|
||||
let cf = f64::from(c);
|
||||
sb.fill(cf * (2 * r + 1) as f64);
|
||||
for k in 0..TERMS {
|
||||
re[k].fill(cf * self.full[k]);
|
||||
im[k].fill(0.0);
|
||||
}
|
||||
have = true;
|
||||
continue;
|
||||
}
|
||||
if have {
|
||||
// Slide from yy - 1: drop row yy - 1 - r, take row yy + r.
|
||||
let gone = if yy > r { rows.get(yy - 1 - r, c0, sw) } else { None }.unwrap_or(&zeros);
|
||||
let come = rows.get(yy + r, c0, sw).unwrap_or(&zeros);
|
||||
for ((s, g), c) in sb.iter_mut().zip(gone).zip(come) {
|
||||
*s += f64::from(*c) - f64::from(*g);
|
||||
}
|
||||
for k in 0..TERMS {
|
||||
let (cr, ci) = self.rot[k];
|
||||
let (o0, o1) = self.out_w[k];
|
||||
let (i0, i1) = self.in_w[k];
|
||||
for (((sr, si), g), c) in re[k].iter_mut().zip(im[k].iter_mut()).zip(gone).zip(come) {
|
||||
let (g, c) = (f64::from(*g), f64::from(*c));
|
||||
let (a, b) = (*sr - g * o0, *si - g * o1);
|
||||
*sr = a * cr - b * ci + c * i0;
|
||||
*si = a * ci + b * cr + c * i1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Direct sums over the in-window rows.
|
||||
sb.fill(0.0);
|
||||
for k in 0..TERMS {
|
||||
re[k].fill(0.0);
|
||||
im[k].fill(0.0);
|
||||
}
|
||||
for i in lo..hi {
|
||||
let Some(row) = rows.get(i, c0, sw) else { continue };
|
||||
let j = i as f64 - yy as f64;
|
||||
add_row(&mut sb, rows, i, c0, 1.0);
|
||||
for k in 0..TERMS {
|
||||
let (cs, sn) = ((self.omega[k] * j).cos(), (self.omega[k] * j).sin());
|
||||
for ((sr, si), v) in re[k].iter_mut().zip(im[k].iter_mut()).zip(row) {
|
||||
*sr += f64::from(*v) * cs;
|
||||
*si += f64::from(*v) * sn;
|
||||
}
|
||||
}
|
||||
}
|
||||
have = true;
|
||||
}
|
||||
for (a, s) in acc.iter_mut().zip(&sb) {
|
||||
*a = self.a0 * s;
|
||||
}
|
||||
for k in 0..TERMS {
|
||||
for (a, s) in acc.iter_mut().zip(&re[k]) {
|
||||
*a += self.a[k] * s;
|
||||
}
|
||||
}
|
||||
for (d, a) in dst.iter_mut().zip(&acc) {
|
||||
*d = *a as f32;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Select › Modify › Feather: Gaussian blur of the mask with `σ = radius / 2`, the kernel
|
||||
/// truncated at `⌈3σ⌉` and normalized, zero beyond the canvas.
|
||||
pub(crate) fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
|
||||
let n = w.saturating_mul(h);
|
||||
let sigma = if radius.is_finite() {
|
||||
radius.max(0.0) / 2.0
|
||||
} else if radius > 0.0 {
|
||||
MAX_SIGMA
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if sigma < 0.1 || m.len() != n || n == 0 {
|
||||
return m.to_vec();
|
||||
}
|
||||
let sigma = sigma.min(MAX_SIGMA);
|
||||
let r = (sigma * 3.0).ceil() as usize;
|
||||
let Some(b) = nonzero_bounds(m, w, h) else {
|
||||
return m.to_vec();
|
||||
};
|
||||
let win = grow(b, r, w, h);
|
||||
let (gw, gh) = (win.w(), win.h());
|
||||
let s = f64::from(sigma);
|
||||
let (kh, kv) = (Kernel::new(s, r, gw), Kernel::new(s, r, gh));
|
||||
// Rows: only the bounding box's rows are non-zero.
|
||||
let mut tmp = vec![0.0f32; b.h() * gw];
|
||||
par_rows(&mut tmp, gw, 1, |y, row| {
|
||||
let o = (b.y0 + y) * w + win.x0;
|
||||
if let Some(s) = m.get(o..o + gw) {
|
||||
kh.line(s, row, &mut Vec::new());
|
||||
}
|
||||
});
|
||||
// Columns, a strip of columns at a time.
|
||||
let rows = Rows { tmp: &tmp, gw, bh: b.h(), off: b.y0 - win.y0, gh };
|
||||
let res = strips(gw, |c0, sw| kv.strip(&rows, c0, sw));
|
||||
drop(tmp);
|
||||
// Rounding residue of the sliding sums is not coverage.
|
||||
assemble(&res, win, w, h, 0.0, |v| if v.abs() < 1e-7 { 0.0 } else { v })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::photo_util::Rng;
|
||||
|
||||
/// The previous direct implementations, kept as the oracle.
|
||||
mod oracle {
|
||||
pub fn box_blur(m: &[f32], w: usize, h: usize, r: usize) -> Vec<f32> {
|
||||
if r == 0 {
|
||||
return m.to_vec();
|
||||
}
|
||||
let mut tmp = vec![0.0f32; m.len()];
|
||||
let win = (2 * r + 1) as f32;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut s = 0.0;
|
||||
for k in x as i64 - r as i64..=x as i64 + r as i64 {
|
||||
s += m[y * w + k.clamp(0, w as i64 - 1) as usize];
|
||||
}
|
||||
tmp[y * w + x] = s / win;
|
||||
}
|
||||
}
|
||||
let mut out = vec![0.0f32; m.len()];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut s = 0.0;
|
||||
for k in y as i64 - r as i64..=y as i64 + r as i64 {
|
||||
s += tmp[k.clamp(0, h as i64 - 1) as usize * w + x];
|
||||
}
|
||||
out[y * w + x] = s / win;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn smooth(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
|
||||
box_blur(m, w, h, r.max(0.0).round() as usize).into_iter().map(|v| ((v - 0.5) * 4.0 + 0.5).clamp(0.0, 1.0)).collect()
|
||||
}
|
||||
|
||||
pub fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
|
||||
let sigma = radius.max(0.0) / 2.0;
|
||||
if sigma < 0.1 {
|
||||
return m.to_vec();
|
||||
}
|
||||
let r = (sigma * 3.0).ceil() as i64;
|
||||
let k: Vec<f32> = (-r..=r).map(|i| (-(i * i) as f32 / (2.0 * sigma * sigma)).exp()).collect();
|
||||
let s: f32 = k.iter().sum();
|
||||
let k: Vec<f32> = k.iter().map(|v| v / s).collect();
|
||||
let mut tmp = vec![0.0f32; m.len()];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut a = 0.0;
|
||||
for (i, kv) in k.iter().enumerate() {
|
||||
let xx = x as i64 + i as i64 - r;
|
||||
if xx >= 0 && xx < w as i64 {
|
||||
a += m[y * w + xx as usize] * kv;
|
||||
}
|
||||
}
|
||||
tmp[y * w + x] = a;
|
||||
}
|
||||
}
|
||||
let mut out = vec![0.0f32; m.len()];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut a = 0.0;
|
||||
for (i, kv) in k.iter().enumerate() {
|
||||
let yy = y as i64 + i as i64 - r;
|
||||
if yy >= 0 && yy < h as i64 {
|
||||
a += tmp[yy as usize * w + x] * kv;
|
||||
}
|
||||
}
|
||||
out[y * w + x] = a;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn max_diff(a: &[f32], b: &[f32]) -> f32 {
|
||||
assert_eq!(a.len(), b.len());
|
||||
a.iter().zip(b).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// Random masks of several kinds: binary noise, soft noise, blobs with soft edges, a sparse
|
||||
/// scatter and an off-centre rectangle.
|
||||
fn masks(w: usize, h: usize, seed: u64) -> Vec<(&'static str, Vec<f32>)> {
|
||||
let mut rng = Rng(seed);
|
||||
let mut u = move || (rng.next() >> 11) as f32 / (1u64 << 53) as f32;
|
||||
let binary: Vec<f32> = (0..w * h).map(|_| if u() < 0.5 { 1.0 } else { 0.0 }).collect();
|
||||
let soft: Vec<f32> = (0..w * h).map(|_| u()).collect();
|
||||
let (cx, cy, rad) = (w as f32 * u(), h as f32 * u(), (w.min(h) as f32 * 0.4).max(1.0));
|
||||
let blob: Vec<f32> = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as f32 + 0.5, (i / w) as f32 + 0.5);
|
||||
(rad - ((x - cx).powi(2) + (y - cy).powi(2)).sqrt()).clamp(0.0, 1.0)
|
||||
})
|
||||
.collect();
|
||||
let sparse: Vec<f32> = (0..w * h).map(|_| if u() < 0.02 { 1.0 } else { 0.0 }).collect();
|
||||
let rect: Vec<f32> = (0..w * h).map(|i| if (i % w) * 3 >= w && (i / w) * 4 < h * 3 { 1.0 } else { 0.0 }).collect();
|
||||
vec![("binary", binary), ("soft", soft), ("blob", blob), ("sparse", sparse), ("rect", rect)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feather_matches_direct_gaussian() {
|
||||
for (w, h, seed) in [(97, 61, 1), (40, 150, 2), (13, 7, 3)] {
|
||||
for (kind, m) in masks(w, h, seed) {
|
||||
for radius in [0.1, 0.3, 1.0, 2.5, 8.0, 9.0, 17.0, 25.0, 50.0, 120.0, 1000.0] {
|
||||
let d = max_diff(&feather(&m, w, h, radius), &oracle::feather(&m, w, h, radius));
|
||||
assert!(d <= 1.0 / 255.0, "{kind} {w}x{h} r {radius}: {d}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_matches_direct_box() {
|
||||
for (w, h, seed) in [(97, 61, 4), (40, 150, 5), (13, 7, 6)] {
|
||||
for (kind, m) in masks(w, h, seed) {
|
||||
for radius in [0.0, 0.4, 1.0, 2.0, 5.0, 16.0, 50.0, 500.0] {
|
||||
let d = max_diff(&smooth(&m, w, h, radius), &oracle::smooth(&m, w, h, radius));
|
||||
assert!(d <= 1.0 / 255.0, "{kind} {w}x{h} r {radius}: {d}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_cases() {
|
||||
// Empty selection stays empty; nothing selected far from a selection stays exactly 0.
|
||||
let z = vec![0.0; 50 * 40];
|
||||
assert_eq!(feather(&z, 50, 40, 30.0), z);
|
||||
assert_eq!(smooth(&z, 50, 40, 10.0), z);
|
||||
let mut dot = z.clone();
|
||||
dot[20 * 50 + 25] = 1.0;
|
||||
let f = feather(&dot, 50, 40, 4.0);
|
||||
assert_eq!(f[0], 0.0);
|
||||
assert!(f[20 * 50 + 25] > 0.0);
|
||||
// A full canvas: smooth keeps it; feather fades only at the canvas edge, as before.
|
||||
let one = vec![1.0; 50 * 40];
|
||||
assert_eq!(smooth(&one, 50, 40, 25.0), one);
|
||||
let f = feather(&one, 50, 40, 6.0);
|
||||
assert!((f[20 * 50 + 25] - 1.0).abs() < 1e-5);
|
||||
let f30 = feather(&one, 50, 40, 30.0);
|
||||
assert!(max_diff(&f30, &oracle::feather(&one, 50, 40, 30.0)) <= 1.0 / 255.0);
|
||||
assert!(f[0] < 0.5);
|
||||
assert!(max_diff(&f, &oracle::feather(&one, 50, 40, 6.0)) <= 1.0 / 255.0);
|
||||
// Radius 0 is identity (feather) / the bare contrast curve (smooth).
|
||||
let m: Vec<f32> = (0..50 * 40).map(|i| (i % 7) as f32 / 6.0).collect();
|
||||
assert_eq!(feather(&m, 50, 40, 0.0), m);
|
||||
assert_eq!(smooth(&m, 50, 40, 0.0), oracle::smooth(&m, 50, 40, 0.0));
|
||||
// 1×1 canvas and empty canvases.
|
||||
for r in [0.0, 1.0, 50.0, 1000.0] {
|
||||
assert!((feather(&[1.0], 1, 1, r)[0] - oracle::feather(&[1.0], 1, 1, r)[0]).abs() <= 1.0 / 255.0);
|
||||
assert_eq!(smooth(&[1.0], 1, 1, r), vec![1.0]);
|
||||
}
|
||||
assert!(feather(&[], 0, 0, 5.0).is_empty());
|
||||
assert!(smooth(&[], 0, 5, 5.0).is_empty());
|
||||
// Non-finite or absurd radii and a mismatched buffer return without panicking.
|
||||
for r in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -5.0, 1e30] {
|
||||
let f = feather(&dot, 50, 40, r);
|
||||
assert_eq!(f.len(), dot.len());
|
||||
assert!(f.iter().all(|v| v.is_finite()), "{r}");
|
||||
let s = smooth(&dot, 50, 40, r);
|
||||
assert_eq!(s.len(), dot.len());
|
||||
}
|
||||
assert_eq!(feather(&dot, 50, 41, 5.0).len(), dot.len());
|
||||
assert_eq!(smooth(&dot, 49, 40, 5.0).len(), dot.len());
|
||||
}
|
||||
|
||||
/// Timings on a 24 MP ellipse (run with `--release -- --ignored --nocapture`).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_24mp() {
|
||||
let (w, h) = (6000usize, 4000usize);
|
||||
let m: Vec<f32> = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as f32 / w as f32 - 0.5, (i / w) as f32 / h as f32 - 0.5);
|
||||
if x * x + y * y < 0.11 { 1.0 } else { 0.0 }
|
||||
})
|
||||
.collect();
|
||||
// `SEL_BENCH=feather:250` runs one case (e.g. under `time` for its CPU cost).
|
||||
let only = std::env::var("SEL_BENCH").ok();
|
||||
for (name, r) in [("smooth", 50.0), ("feather", 50.0), ("feather", 250.0), ("feather", 1000.0)] {
|
||||
if only.as_ref().is_some_and(|o| *o != format!("{name}:{r}")) {
|
||||
continue;
|
||||
}
|
||||
let mut best = f64::MAX;
|
||||
for _ in 0..5 {
|
||||
let t = std::time::Instant::now();
|
||||
let out = if name == "smooth" { smooth(&m, w, h, r) } else { feather(&m, w, h, r) };
|
||||
best = best.min(t.elapsed().as_secs_f64() * 1000.0);
|
||||
assert_eq!(out.len(), m.len());
|
||||
}
|
||||
eprintln!("{name} r {r}: {best:.1} ms (best of 5)");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cosine_fit_is_used_and_accurate() {
|
||||
for sigma in [7.0, 25.0, 125.0] {
|
||||
let r = (sigma * 3.0f64).ceil() as usize;
|
||||
match Kernel::new(sigma, r, 4000) {
|
||||
Kernel::Cos(_) => {}
|
||||
_ => panic!("σ {sigma}: expected the sliding cosine kernel"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,7 @@ fn new_filters() -> Vec<FilterParams> {
|
||||
height: 50.0,
|
||||
white_is_high: true,
|
||||
},
|
||||
FilterParams::Relight { angle: 45.0, elevation: 40.0, intensity: 40.0, ambient: 55.0, warmth: 0.0, softness: 25.0 },
|
||||
FilterParams::ReduceNoise { strength: 6.0, preserve_details: 60.0, reduce_color_noise: 45.0, sharpen_details: 25.0, remove_jpeg_artifact: true },
|
||||
FilterParams::SmartBlur { radius: 3.0, threshold: 40.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal },
|
||||
FilterParams::SmartBlur { radius: 3.0, threshold: 25.0, quality: BlurQuality::Low, mode: SmartBlurMode::OverlayEdge },
|
||||
@@ -666,3 +667,28 @@ fn oil_paint_smooths_along_strokes() {
|
||||
let inner = Rect::new(8, 8, 32, 22);
|
||||
assert!(variance(&out, inner, 0) < variance(&s, inner, 0) * 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_filters_return_none_and_progress_never_regresses() {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
let s = pattern(SampleType::U8, R);
|
||||
let p = FilterParams::GaussianBlur { radius: 3.0 };
|
||||
let area = output_area(&p, s.content_bounds(), R, None);
|
||||
let yes = || true;
|
||||
assert!(apply_tiled_with(&s, &p, area, R, None, 16, Some(R), &photocraft_raster::Interrupt::cancel_only(&yes)).is_none());
|
||||
// Tile workers report progress from their own threads, so values can arrive out of order;
|
||||
// consumers keep the high-water mark (as `JobCtx::progress` does with `fetch_max`).
|
||||
// Non-negative f32s order like their bits, so `fetch_max` on the bits needs no lock.
|
||||
let high = AtomicU32::new(0);
|
||||
let calls = AtomicU32::new(0);
|
||||
let no = || false;
|
||||
let progress = |f: f32| {
|
||||
assert!((0.0..=1.0).contains(&f), "progress in range: {f}");
|
||||
high.fetch_max(f.to_bits(), Ordering::Relaxed);
|
||||
calls.fetch_add(1, Ordering::Relaxed);
|
||||
};
|
||||
let out = apply_tiled_with(&s, &p, area, R, None, 16, Some(R), &photocraft_raster::Interrupt::new(&no, &progress)).unwrap();
|
||||
assert_eq!(f32::from_bits(high.load(Ordering::Relaxed)), 1.0);
|
||||
assert!(calls.load(Ordering::Relaxed) >= 1);
|
||||
assert_eq!(out.read_region(R), apply_tiled(&s, &p, area, R, None, 16, Some(R)).read_region(R));
|
||||
}
|
||||
@@ -343,7 +343,9 @@ pub fn hdr_toning(px: &mut [[f32; 4]], w: usize, h: usize, p: &HdrToning) {
|
||||
let c = [q[0], q[1], q[2]];
|
||||
let l0 = luma(c).max(0.0) + EPS;
|
||||
let new_log = mean + (base[i] - mean) * compress + (logl[i] - base[i]) * detail;
|
||||
let mut l1 = (new_log.exp() * exposure).max(0.0).powf(gamma);
|
||||
// The contract is "lower = more contrast", i.e. gamma is the divisor of
|
||||
// the exponent, matching the crate's other gamma controls (hdr.rs, proof_sim.rs).
|
||||
let mut l1 = (new_log.exp() * exposure).max(0.0).powf(1.0 / gamma);
|
||||
l1 += p.shadow / 100.0 * (1.0 - l1).powi(3) * 0.5 - p.highlight / 100.0 * l1.powi(3) * 0.5;
|
||||
l1 = curve_eval(&sorted, l1.clamp(0.0, 1.0));
|
||||
let ratio = l1 / l0;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Bounded hue/saturation distribution. Input colours must already be in the chosen RGB space.
|
||||
//! Display orientation and colour management belong to callers, not to this analysis.
|
||||
pub const HUES: usize = 128;
|
||||
pub const SATURATIONS: usize = 64;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Vectorscope {
|
||||
pub counts: Vec<u32>,
|
||||
pub samples: u64,
|
||||
}
|
||||
|
||||
impl Default for Vectorscope {
|
||||
fn default() -> Self {
|
||||
Self { counts: vec![0; HUES * SATURATIONS], samples: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Vectorscope {
|
||||
pub fn from_rgba(pixels: &[[f32; 4]]) -> Self {
|
||||
let mut out = Self::default();
|
||||
for p in pixels {
|
||||
if p[3] <= 0.0 || !p.iter().all(|v| v.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
let rgb = [p[0].clamp(0.0, 1.0), p[1].clamp(0.0, 1.0), p[2].clamp(0.0, 1.0)];
|
||||
let (h, s) = hue_saturation(rgb);
|
||||
let hue = ((h * HUES as f32) as usize).min(HUES - 1);
|
||||
let saturation = ((s * (SATURATIONS - 1) as f32).round() as usize).min(SATURATIONS - 1);
|
||||
if let Some(n) = out.counts.get_mut(hue * SATURATIONS + saturation) {
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
out.samples = out.samples.saturating_add(1);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Hue as turns and saturation in 0..1; achromatic pixels have zero saturation.
|
||||
pub fn hue_saturation([r, g, b]: [f32; 3]) -> (f32, f32) {
|
||||
let high = r.max(g).max(b);
|
||||
let low = r.min(g).min(b);
|
||||
let delta = high - low;
|
||||
if delta <= f32::EPSILON || high <= 0.0 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let h = if high == r {
|
||||
(g - b) / delta
|
||||
} else if high == g {
|
||||
2.0 + (b - r) / delta
|
||||
} else {
|
||||
4.0 + (r - g) / delta
|
||||
};
|
||||
((h / 6.0).rem_euclid(1.0), delta / high)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn rgb_primary_positions_neutrals_and_transparency() {
|
||||
for (rgb, expected) in [([1.0, 0.0, 0.0], 0.0), ([1.0, 1.0, 0.0], 1.0 / 6.0), ([0.0, 1.0, 0.0], 1.0 / 3.0), ([0.0, 0.0, 1.0], 2.0 / 3.0)] {
|
||||
let (h, s) = hue_saturation(rgb);
|
||||
assert!((h - expected).abs() < 1e-6);
|
||||
assert_eq!(s, 1.0);
|
||||
}
|
||||
let v = Vectorscope::from_rgba(&[[0.5, 0.5, 0.5, 1.0], [1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], [f32::NAN, 0.0, 0.0, 1.0]]);
|
||||
assert_eq!(v.samples, 2);
|
||||
assert_eq!(v.counts[0], 1);
|
||||
assert_eq!(v.counts[SATURATIONS - 1], 1);
|
||||
assert_eq!(v.counts.iter().map(|&c| u64::from(c)).sum::<u64>(), v.samples);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,39 @@ const CELL: f64 = 4.0;
|
||||
/// Most cells along one axis (bounds memory and time on huge layers).
|
||||
const MAX_CELLS: usize = 512;
|
||||
|
||||
/// Places a smart object's source image (`src`, its pixels in `src_rect`) in the document:
|
||||
/// through `warp` (source space) and then the affine `t` (source → document) in one resampling
|
||||
/// pass, an exact shift for whole-pixel translations (so conversions and re-renders are
|
||||
/// lossless), bicubic otherwise. Shared by the engine's re-render and PSD export's filter cache.
|
||||
pub fn place_source(src: &Surface, src_rect: Rect, t: &photocraft_geom::Affine, warp: Option<&photocraft_geom::warp::Warp>) -> Surface {
|
||||
let [a, b, c, d, e, f] = t.m;
|
||||
if let Some(w) = warp.filter(|w| !w.is_identity()) {
|
||||
let map = |x: f64, y: f64| {
|
||||
let (u, v) = w.map(x, y);
|
||||
(a * u + c * v + e, b * u + d * v + f)
|
||||
};
|
||||
return warp_mesh_surface(src, src_rect, &map, Interp::Bicubic);
|
||||
}
|
||||
let near = |x: f64, y: f64| (x - y).abs() < 1e-9;
|
||||
if near(a, 1.0) && near(b, 0.0) && near(c, 0.0) && near(d, 1.0) && near(e, e.round()) && near(f, f.round()) {
|
||||
return crate::resample::translate_surface(src, e.round() as i32, f.round() as i32);
|
||||
}
|
||||
crate::transform::warp_surface(src, src_rect, &crate::transform::Homography([a, c, e, b, d, f, 0.0, 0.0, 1.0]), Interp::Bicubic)
|
||||
}
|
||||
|
||||
/// [`place_source`] through a projective map `h` (source → document; Distort, Perspective): the
|
||||
/// warp first (in source space), then `h`.
|
||||
pub fn place_source_projective(src: &Surface, src_rect: Rect, h: &crate::transform::Homography, warp: Option<&photocraft_geom::warp::Warp>) -> Surface {
|
||||
if let Some(w) = warp.filter(|w| !w.is_identity()) {
|
||||
let map = |x: f64, y: f64| {
|
||||
let (u, v) = w.map(x, y);
|
||||
h.apply(u, v)
|
||||
};
|
||||
return warp_mesh_surface(src, src_rect, &map, Interp::Bicubic);
|
||||
}
|
||||
crate::transform::warp_surface(src, src_rect, h, Interp::Bicubic)
|
||||
}
|
||||
|
||||
/// Warps the content of `src` inside `src_rect` through the forward map `f` (source document
|
||||
/// coordinates → destination document coordinates). Output has `src`'s format with alpha
|
||||
/// added; pixels outside the warped area are transparent.
|
||||
|
||||
@@ -103,19 +103,34 @@ pub fn write_reply(out: &mut impl Write, reply: &Value) -> io::Result<()> {
|
||||
/// many individually valid replies cannot accumulate into an enormous batch.
|
||||
pub struct BatchReplyBudget {
|
||||
remaining: usize,
|
||||
/// Charge each result as it costs inside a JSON string (MCP text content).
|
||||
escaped: bool,
|
||||
}
|
||||
|
||||
impl Default for BatchReplyBudget {
|
||||
fn default() -> Self {
|
||||
Self { remaining: MAX_RESPONSE_BYTES - MAX_REQUEST_BYTES - 4096 }
|
||||
Self { remaining: MAX_RESPONSE_BYTES - MAX_REQUEST_BYTES - 4096, escaped: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl BatchReplyBudget {
|
||||
/// For a batch reply sent as MCP text content: the encoded reply is embedded in a JSON
|
||||
/// string, which escapes every quote and backslash again, so a batch the plain budget accepts
|
||||
/// could exceed the tool-result ceiling and lose every step result.
|
||||
pub fn escaped() -> Self {
|
||||
Self { escaped: true, ..Self::default() }
|
||||
}
|
||||
|
||||
/// Must be called before retaining the result or dispatching the next step.
|
||||
pub fn charge(&mut self, result: &Value) -> Result<(), AutomationError> {
|
||||
let bytes = encode_with_limit(result, self.remaining.saturating_sub(1))?;
|
||||
self.remaining = self.remaining.saturating_sub(bytes.len() + 1);
|
||||
let limit = self.remaining.saturating_sub(1);
|
||||
let bytes = encode_with_limit(result, limit)?;
|
||||
// Compact JSON has no raw control characters, so only `"` and `\` grow when escaped.
|
||||
let size = if self.escaped { bytes.len() + bytes.iter().filter(|&&b| b == b'"' || b == b'\\').count() } else { bytes.len() };
|
||||
if size > limit {
|
||||
return Err(bad(format!("response exceeds {limit} bytes once escaped")));
|
||||
}
|
||||
self.remaining = self.remaining.saturating_sub(size + 1);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -161,9 +176,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn batch_cannot_retain_more_than_the_aggregate_reply_budget() {
|
||||
let mut budget = BatchReplyBudget { remaining: 10 };
|
||||
let mut budget = BatchReplyBudget { remaining: 10, escaped: false };
|
||||
budget.charge(&json!("abc")).unwrap();
|
||||
assert!(budget.charge(&json!("abc")).is_err());
|
||||
assert_eq!(budget.remaining, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_budget_charges_the_size_inside_a_json_string() {
|
||||
// `"a\"b"` is 6 bytes plain and 10 once embedded in a string (`\"a\\\"b\"`).
|
||||
let value = json!("a\"b");
|
||||
let escaped_len = serde_json::to_string(&Value::String(value.to_string())).unwrap().len() - 2;
|
||||
assert_eq!(escaped_len, 10);
|
||||
let mut budget = BatchReplyBudget { remaining: 11, escaped: true };
|
||||
budget.charge(&value).unwrap();
|
||||
assert_eq!(budget.remaining, 0);
|
||||
let mut budget = BatchReplyBudget { remaining: 10, escaped: true };
|
||||
assert!(budget.charge(&value).is_err());
|
||||
assert!(BatchReplyBudget { remaining: 10, escaped: false }.charge(&value).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ pub fn open(path: &Path) -> Result<Opened, AutomationError> {
|
||||
if path.is_dir() {
|
||||
return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new() });
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
|
||||
let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
open_bytes(&name, &bytes)
|
||||
}
|
||||
@@ -61,10 +61,8 @@ pub fn save(
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let r = photocraft_io::export(doc, &ext, opts)?;
|
||||
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(parent).map_err(|e| AutomationError::Io(e.to_string()))?;
|
||||
}
|
||||
std::fs::write(path, &r.bytes).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
|
||||
// Crash-safe: a failed write never destroys the previous file.
|
||||
photocraft_format::atomic_write(path, &r.bytes).map_err(|e| AutomationError::Io(e.to_string()))?;
|
||||
Ok(r.warnings)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,20 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use photocraft_engine::{Session, command_specs};
|
||||
use photocraft_engine::{Session, command_specs, file_cmds};
|
||||
use photocraft_format::PcraftWriter;
|
||||
use photocraft_io::ExportOptions;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::workspace::authorize_engine_command;
|
||||
use crate::workspace::{authorize_engine_command, authorize_engine_step};
|
||||
use crate::{AuthorizedWorkspace, AutomationError, files};
|
||||
|
||||
fn untrusted(filesystem: Filesystem) -> Headless {
|
||||
let mut session = Session::new();
|
||||
session.authorize = Some(authorize_engine_step);
|
||||
Headless { session, writers: HashMap::new(), filesystem }
|
||||
}
|
||||
|
||||
enum Filesystem {
|
||||
Denied,
|
||||
TrustedLocal,
|
||||
@@ -28,7 +34,7 @@ pub struct Headless {
|
||||
|
||||
impl Default for Headless {
|
||||
fn default() -> Self {
|
||||
Self { session: Session::new(), writers: HashMap::new(), filesystem: Filesystem::Denied }
|
||||
untrusted(Filesystem::Denied)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,14 +45,23 @@ impl Headless {
|
||||
}
|
||||
|
||||
/// Create a session for an explicit local CLI invocation. The CLI caller,
|
||||
/// not a remote automation client, supplies these host paths.
|
||||
/// not a remote automation client, supplies these host paths. Nested
|
||||
/// `actions.play` steps are not re-checked: this caller is already trusted.
|
||||
pub fn trusted_local() -> Self {
|
||||
Self { session: Session::new(), writers: HashMap::new(), filesystem: Filesystem::TrustedLocal }
|
||||
}
|
||||
|
||||
/// Create an automation session with capability-scoped file access.
|
||||
/// Each step of `actions.play` is checked with [`authorize_engine_step`].
|
||||
pub fn with_workspace(workspace: AuthorizedWorkspace) -> Self {
|
||||
Self { session: Session::new(), writers: HashMap::new(), filesystem: Filesystem::Workspace(workspace) }
|
||||
untrusted(Filesystem::Workspace(workspace))
|
||||
}
|
||||
|
||||
/// Apply background jobs that finished since the last request, so every request (save,
|
||||
/// export, inspect, preview, `session.list`, commands) sees their result. Cheap when no job
|
||||
/// runs. The JSON-lines server and the MCP server call it before each request.
|
||||
pub fn sync_jobs(&mut self) {
|
||||
self.session.poll_jobs();
|
||||
}
|
||||
|
||||
fn doc_index(&self, index: Option<usize>) -> Result<usize, AutomationError> {
|
||||
@@ -60,7 +75,7 @@ impl Headless {
|
||||
/// Open a file and make it the active document.
|
||||
pub fn open(&mut self, path: &Path) -> Result<Value, AutomationError> {
|
||||
let requested = path.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?;
|
||||
let o = match &self.filesystem {
|
||||
let mut o = match &self.filesystem {
|
||||
Filesystem::Denied => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())),
|
||||
Filesystem::TrustedLocal => files::open(path)?,
|
||||
Filesystem::Workspace(workspace) => {
|
||||
@@ -69,7 +84,14 @@ impl Headless {
|
||||
files::open_bytes(name, &bytes)?
|
||||
}
|
||||
};
|
||||
let index = self.session.add_document(o.document, Some(requested.to_string()));
|
||||
let path = match file_cmds::template_name(&self.session, requested) {
|
||||
Some(untitled) => {
|
||||
o.document.name = untitled;
|
||||
None
|
||||
}
|
||||
None => Some(requested.to_string()),
|
||||
};
|
||||
let index = self.session.add_document(o.document, path);
|
||||
let d = &self.session.documents()[index];
|
||||
Ok(json!({
|
||||
"index": index,
|
||||
@@ -91,7 +113,18 @@ impl Headless {
|
||||
};
|
||||
let target: PathBuf = match (path, stored_path.as_deref()) {
|
||||
(Some(p), _) => p.to_path_buf(),
|
||||
(None, Some(p)) => PathBuf::from(p),
|
||||
// Like the desktop's File › Save: without a new path, only a layered file is written
|
||||
// back, in its own format; a flattened or converted copy never replaces it (#416).
|
||||
(None, Some(p)) => {
|
||||
let own = file_cmds::extension(p);
|
||||
let written = format.map(|f| f.trim_start_matches('.').to_ascii_lowercase()).or_else(|| own.clone());
|
||||
if !file_cmds::saves_in_place(p) || written != own {
|
||||
return Err(AutomationError::BadRequest(format!(
|
||||
"pass `path`: without one, only a PSD, PSB or .pcraft file is written back, in its own format, so `{p}` was left unchanged"
|
||||
)));
|
||||
}
|
||||
PathBuf::from(p)
|
||||
}
|
||||
(None, None) => {
|
||||
return Err(AutomationError::BadRequest("document has no path; pass `path`".into()));
|
||||
}
|
||||
@@ -127,7 +160,7 @@ impl Headless {
|
||||
let requested = path.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?;
|
||||
match &self.filesystem {
|
||||
Filesystem::Denied => Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
|
||||
Filesystem::TrustedLocal => std::fs::write(path, bytes).map_err(|error| AutomationError::Io(format!("{}: {error}", path.display()))),
|
||||
Filesystem::TrustedLocal => photocraft_format::atomic_write(path, bytes).map_err(|error| AutomationError::Io(error.to_string())),
|
||||
Filesystem::Workspace(workspace) => workspace.write(requested, bytes),
|
||||
}
|
||||
}
|
||||
@@ -185,10 +218,25 @@ impl Headless {
|
||||
}
|
||||
|
||||
pub fn command_run(&mut self, id: &str, params: Value) -> Result<Value, AutomationError> {
|
||||
self.command_start(id, params, true)
|
||||
}
|
||||
|
||||
/// Run a command; with `wait` false, a job-capable command (filters, Content-Aware Fill,
|
||||
/// Photomerge, …) runs in the background and this returns `{"job": id}` at once (poll with
|
||||
/// `jobs.list`, stop with `jobs.cancel`). Other commands finish before returning either way.
|
||||
pub fn command_start(&mut self, id: &str, params: Value, wait: bool) -> Result<Value, AutomationError> {
|
||||
let params = if params.is_null() { json!({}) } else { params };
|
||||
if !matches!(&self.filesystem, Filesystem::TrustedLocal) {
|
||||
authorize_engine_command(id, ¶ms)?;
|
||||
}
|
||||
Ok(self.session.execute(id, params)?)
|
||||
// Background jobs that finished since the last request (or batch step) are applied first.
|
||||
self.sync_jobs();
|
||||
if wait {
|
||||
return Ok(self.session.execute(id, params)?);
|
||||
}
|
||||
Ok(match self.session.start(id, params)? {
|
||||
photocraft_engine::jobs::Started::Done(v) => v,
|
||||
photocraft_engine::jobs::Started::Job(job) => json!({"job": job.0, "pending": true}),
|
||||
})
|
||||
}
|
||||
}
|
||||
+166
-22
@@ -5,12 +5,13 @@
|
||||
//! loopback TCP port.
|
||||
//!
|
||||
//! Methods (camelCase params):
|
||||
//! - `engine.execute {command, params?}` / `engine.commands {filter?}`
|
||||
//! - `engine.execute {command, params?, wait?}` / `engine.commands {filter?}`
|
||||
//! - `jobs.list` / `jobs.cancel {job?}`: background jobs (#210)
|
||||
//! - `session.list`, `doc.open {path}`, `doc.new {…file.new params}`,
|
||||
//! `doc.save {path?, format?, quality?, index?}`, `doc.inspect {index?}`,
|
||||
//! `doc.render {index?, maxSide?, path?}` (writes a PNG to `path`, else
|
||||
//! returns it base64-encoded), `doc.select {index}`, `doc.close {index?}`
|
||||
//! - `batch {steps: [{command, params?} | {method, params?}], stopOnError?}`
|
||||
//! - `batch {steps: [{command, params?, wait?} | {method, params?}], stopOnError?}`
|
||||
//! - `methods`: this list.
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
@@ -23,13 +24,16 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::budgets::{BatchReplyBudget, write_reply};
|
||||
use crate::security::{
|
||||
ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line,
|
||||
ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, discard_rest_of_line,
|
||||
read_bounded_line,
|
||||
};
|
||||
use crate::{AutomationError, Headless};
|
||||
|
||||
/// Method names served by [`Headless::handle`].
|
||||
pub const METHODS: &[&str] = &[
|
||||
"engine.execute",
|
||||
"jobs.list",
|
||||
"jobs.cancel",
|
||||
"engine.commands",
|
||||
"session.list",
|
||||
"doc.open",
|
||||
@@ -58,12 +62,17 @@ fn str_of<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
|
||||
impl Headless {
|
||||
/// Dispatch one request. Unknown methods and bad params are errors, never panics.
|
||||
pub fn handle(&mut self, method: &str, params: Value) -> Result<Value, AutomationError> {
|
||||
self.sync_jobs();
|
||||
let p = if params.is_null() { json!({}) } else { params };
|
||||
match method {
|
||||
"engine.execute" => {
|
||||
let id = str_of(&p, "command").ok_or_else(|| bad("engine.execute needs `command`"))?;
|
||||
self.command_run(id, p.get("params").cloned().unwrap_or(Value::Null))
|
||||
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(true);
|
||||
self.command_start(id, p.get("params").cloned().unwrap_or(Value::Null), wait)
|
||||
}
|
||||
// Background jobs (#210): list (applying any that finished) and cancel.
|
||||
"jobs.list" => self.command_start("jobs.list", json!({}), true),
|
||||
"jobs.cancel" => self.command_start("jobs.cancel", p, true),
|
||||
"engine.commands" => {
|
||||
let all = self.command_list();
|
||||
Ok(match str_of(&p, "filter").map(str::to_lowercase) {
|
||||
@@ -88,7 +97,8 @@ impl Headless {
|
||||
}
|
||||
"doc.new" => self.command_run("file.new", p),
|
||||
"doc.save" => {
|
||||
let mut opts = photocraft_io::ExportOptions::default();
|
||||
let mut opts =
|
||||
photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), ..Default::default() };
|
||||
if let Some(q) = p.get("quality").and_then(Value::as_u64) {
|
||||
opts.encode.jpeg_quality = q.clamp(1, 100) as u8;
|
||||
}
|
||||
@@ -126,10 +136,16 @@ impl Headless {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `steps` in order. Each step is `{command, params?}` (an engine command) or
|
||||
/// Run `steps` in order. Each step is `{command, params?, wait?}` (an engine command) or
|
||||
/// `{method, params?}` (any [`METHODS`] entry). Stops at the first error unless
|
||||
/// `stopOnError` is false; the reply lists every step's result.
|
||||
pub fn batch(&mut self, p: &Value) -> Result<Value, AutomationError> {
|
||||
self.batch_with_budget(p, BatchReplyBudget::default())
|
||||
}
|
||||
|
||||
/// [`Headless::batch`] with a caller's reply budget (MCP charges escaped sizes, see
|
||||
/// [`BatchReplyBudget::escaped`]). Steps stop once the budget runs out.
|
||||
pub fn batch_with_budget(&mut self, p: &Value, mut reply_budget: BatchReplyBudget) -> Result<Value, AutomationError> {
|
||||
let steps = p.get("steps").and_then(Value::as_array).ok_or_else(|| bad("batch needs `steps`"))?;
|
||||
if steps.len() > MAX_BATCH_STEPS {
|
||||
return Err(bad(format!("batch contains {} steps; maximum is {MAX_BATCH_STEPS}", steps.len())));
|
||||
@@ -137,11 +153,12 @@ impl Headless {
|
||||
let stop = p.get("stopOnError").and_then(Value::as_bool).unwrap_or(true);
|
||||
let mut results = Vec::with_capacity(steps.len());
|
||||
let mut failed = 0usize;
|
||||
let mut reply_budget = BatchReplyBudget::default();
|
||||
for (i, s) in steps.iter().enumerate() {
|
||||
let params = s.get("params").cloned().unwrap_or(Value::Null);
|
||||
let r = if let Some(c) = str_of(s, "command") {
|
||||
self.command_run(c, params)
|
||||
// Like `engine.execute`: `wait: false` starts a long command as a background job.
|
||||
let wait = s.get("wait").and_then(Value::as_bool).unwrap_or(true);
|
||||
self.command_start(c, params, wait)
|
||||
} else if let Some(m) = str_of(s, "method") {
|
||||
if m == "batch" { Err(bad("nested batch")) } else { self.handle(m, params) }
|
||||
} else {
|
||||
@@ -192,25 +209,24 @@ pub fn respond(h: &Mutex<Headless>, line: &str) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve JSON lines from `r` to `w` until EOF. Blank lines are ignored.
|
||||
/// Serve JSON lines from `r` to `w` until EOF. Blank lines are ignored. An over-long or non-UTF-8
|
||||
/// line gets an error reply and is skipped; the session (and its open documents) keeps serving.
|
||||
pub fn serve_lines(h: &Mutex<Headless>, mut r: impl BufRead, mut w: impl Write) -> std::io::Result<()> {
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
match read_bounded_line(&mut r, &mut line)? {
|
||||
LineRead::Eof => return Ok(()),
|
||||
LineRead::TooLong => {
|
||||
write_reply(&mut w, &json!({"id": null, "ok": false, "error": format!("request exceeds {MAX_REQUEST_BYTES} bytes")}))?;
|
||||
w.flush()?;
|
||||
return Ok(());
|
||||
}
|
||||
LineRead::Line => {}
|
||||
}
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = respond(h, &line);
|
||||
let (reply, unread_rest) = match read_bounded_line(&mut r, &mut line) {
|
||||
Ok(LineRead::Eof) => return Ok(()),
|
||||
Ok(LineRead::TooLong) => (json!({"id": null, "ok": false, "error": format!("request exceeds {MAX_REQUEST_BYTES} bytes")}), !line.ends_with('\n')),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => (json!({"id": null, "ok": false, "error": "request is not valid UTF-8"}), false),
|
||||
Err(e) => return Err(e),
|
||||
Ok(LineRead::Line) if line.trim().is_empty() => continue,
|
||||
Ok(LineRead::Line) => (respond(h, &line), false),
|
||||
};
|
||||
write_reply(&mut w, &reply)?;
|
||||
w.flush()?;
|
||||
if unread_rest {
|
||||
discard_rest_of_line(&mut r)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,6 +394,96 @@ mod tests {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_jobs_list_wait_and_cancel() {
|
||||
let mut h = Headless::new();
|
||||
h.handle("doc.new", json!({"width": 600, "height": 400})).unwrap();
|
||||
h.handle("engine.execute", json!({"command": "layer.new.layer"})).unwrap();
|
||||
h.handle("engine.execute", json!({"command": "edit.fill", "params": {"color": "#808080"}})).unwrap();
|
||||
// Default: waits, the result is the command's.
|
||||
let r = h.handle("engine.execute", json!({"command": "filter.noise.addNoise", "params": {"amount": 20}})).unwrap();
|
||||
assert!(r["filter"].is_object(), "{r}");
|
||||
// wait: false returns a job id at once.
|
||||
let r = h.handle("engine.execute", json!({"command": "filter.blur.gaussianBlur", "params": {"radius": 40}, "wait": false})).unwrap();
|
||||
let job = r["job"].as_u64().unwrap();
|
||||
let listed = h.handle("jobs.list", json!({})).unwrap();
|
||||
assert!(listed["jobs"].as_array().unwrap().iter().any(|j| j["id"] == job), "{listed}");
|
||||
// Wait for it via jobs.list (which applies finished jobs).
|
||||
let t = std::time::Instant::now();
|
||||
loop {
|
||||
let l = h.handle("jobs.list", json!({})).unwrap();
|
||||
let state = l["jobs"].as_array().unwrap().iter().find(|j| j["id"] == job).map(|j| j["state"].clone());
|
||||
if state == Some(json!("done")) {
|
||||
break;
|
||||
}
|
||||
assert!(t.elapsed().as_secs() < 60, "{l}");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
// Cancel: a second job, cancelled at once, leaves the document unchanged.
|
||||
let before = h.session.active().unwrap().doc.clone();
|
||||
let r = h.handle("engine.execute", json!({"command": "filter.blur.gaussianBlur", "params": {"radius": 40}, "wait": false})).unwrap();
|
||||
let job = r["job"].as_u64().unwrap();
|
||||
h.handle("jobs.cancel", json!({"job": job})).unwrap();
|
||||
assert!(h.handle("jobs.cancel", json!({"job": "nope"})).is_err());
|
||||
let l = h.handle("jobs.list", json!({})).unwrap();
|
||||
assert_eq!(l["jobs"].as_array().unwrap().iter().find(|j| j["id"] == job).unwrap()["state"], "cancelled");
|
||||
assert!(std::sync::Arc::ptr_eq(&h.session.active().unwrap().doc, &before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_steps_can_start_background_jobs() {
|
||||
let mut h = Headless::new();
|
||||
h.handle("doc.new", json!({"width": 600, "height": 400})).unwrap();
|
||||
let steps = json!([
|
||||
{"command": "layer.new.layer"},
|
||||
{"command": "edit.fill", "params": {"color": "#808080"}},
|
||||
{"command": "filter.blur.gaussianBlur", "params": {"radius": 40}, "wait": false}
|
||||
]);
|
||||
let r = h.handle("batch", json!({"steps": steps})).unwrap();
|
||||
assert_eq!(r["completed"], 3, "{r}");
|
||||
let job = r["results"][2]["result"]["job"].as_u64().unwrap_or_else(|| panic!("no job id: {r}"));
|
||||
assert_eq!(r["results"][2]["result"]["pending"], true);
|
||||
let listed = h.handle("jobs.list", json!({})).unwrap();
|
||||
assert!(listed["jobs"].as_array().unwrap().iter().any(|j| j["id"] == job), "{listed}");
|
||||
let t = std::time::Instant::now();
|
||||
while h.session.jobs().iter().any(|j| j.id.0 == job) {
|
||||
assert!(t.elapsed().as_secs() < 60);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
h.handle("jobs.list", json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression (#503): a finished `wait: false` job was only applied by a later command or
|
||||
/// `jobs.list`, so save, inspect, render and `session.list` still saw the document without it.
|
||||
#[test]
|
||||
fn every_request_sees_a_finished_background_job() {
|
||||
let dir = std::env::temp_dir().join(format!("pc-rpc-job-sync-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (before, after) = (dir.join("before.png"), dir.join("after.png"));
|
||||
let mut h = Headless::trusted_local();
|
||||
h.handle("doc.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
h.handle("engine.execute", json!({"command": "filter.noise.addNoise", "params": {"amount": 50}})).unwrap();
|
||||
h.handle("doc.save", json!({"path": before.to_string_lossy()})).unwrap();
|
||||
let render = |h: &mut Headless| h.handle("doc.render", json!({"maxSide": 0})).unwrap()["base64"].clone();
|
||||
let unblurred = render(&mut h);
|
||||
let revision = |h: &mut Headless| h.handle("session.list", json!({})).unwrap()["documents"][0]["revision"].clone();
|
||||
let start = revision(&mut h);
|
||||
h.handle("engine.execute", json!({"command": "filter.blur.gaussianBlur", "params": {"radius": 4}, "wait": false})).unwrap();
|
||||
// Only reading requests from here on: no command and no `jobs.list`.
|
||||
let t = std::time::Instant::now();
|
||||
while revision(&mut h) == start {
|
||||
assert!(t.elapsed().as_secs() < 60, "session.list never saw the finished job");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
let history = h.handle("doc.inspect", json!({})).unwrap()["history"].clone();
|
||||
assert_eq!(history.as_array().unwrap().last().unwrap(), "Gaussian Blur", "{history}");
|
||||
h.handle("doc.save", json!({"path": after.to_string_lossy()})).unwrap();
|
||||
let decode = |p: &std::path::Path| photocraft_codecs::decode(&std::fs::read(p).unwrap()).unwrap().to_rgba8();
|
||||
assert_ne!(decode(&before), decode(&after), "the export holds the blur");
|
||||
assert_ne!(render(&mut h), unblurred, "the preview holds the blur");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_preview_rejects_oversized_full_size_and_numeric_wraparound() {
|
||||
let mut h = Headless::new();
|
||||
@@ -404,6 +510,28 @@ mod tests {
|
||||
assert!(h.lock().unwrap().session.documents().is_empty());
|
||||
}
|
||||
|
||||
/// Regression (#505): an over-long or non-UTF-8 line ended the stdio session, losing every
|
||||
/// open document. Now it gets one error reply, the rest of the line is skipped (never
|
||||
/// dispatched), and the next request is served.
|
||||
#[test]
|
||||
fn stdio_skips_a_rejected_line_and_keeps_the_session() {
|
||||
let h = Mutex::new(Headless::new());
|
||||
let mut input = b"{\"id\":1,\"method\":\"doc.new\",\"params\":{\"width\":8,\"height\":8}}\n".to_vec();
|
||||
input.extend(" ".repeat(MAX_REQUEST_BYTES + 1).into_bytes());
|
||||
input.extend(b"{\"id\":9,\"method\":\"doc.close\"}\n");
|
||||
input.extend(b"\xff\xfe{\"id\":8,\"method\":\"doc.close\"}\n");
|
||||
input.extend(b"{\"id\":2,\"method\":\"session.list\"}\n");
|
||||
let mut out = Vec::new();
|
||||
serve_lines(&h, input.as_slice(), &mut out).unwrap();
|
||||
let replies: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(replies.len(), 4, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], true);
|
||||
assert!(replies[1]["error"].as_str().unwrap().contains("request exceeds"), "{}", replies[1]);
|
||||
assert!(replies[2]["error"].as_str().unwrap().contains("not valid UTF-8"), "{}", replies[2]);
|
||||
assert_eq!(replies[3]["id"], 2);
|
||||
assert_eq!(replies[3]["result"]["documents"].as_array().unwrap().len(), 1, "the document stays open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_rejection_precedes_output_file_creation() {
|
||||
let root = std::env::temp_dir().join(format!("pc-preview-budget-{}", std::process::id()));
|
||||
@@ -431,6 +559,22 @@ mod tests {
|
||||
assert_eq!(photocraft_codecs::decode(&png).unwrap().dimensions(), (2049, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn methods_lists_the_job_methods_and_every_listed_name_is_served() {
|
||||
let mut h = Headless::new();
|
||||
let listed = h.handle("methods", Value::Null).unwrap();
|
||||
let listed: Vec<&str> = listed.as_array().unwrap().iter().map(|m| m.as_str().unwrap()).collect();
|
||||
assert_eq!(listed, METHODS);
|
||||
// The job methods `engine.execute {"wait": false}` depends on are discoverable (#414).
|
||||
assert!(listed.contains(&"jobs.list") && listed.contains(&"jobs.cancel"));
|
||||
for m in METHODS {
|
||||
if let Err(e) = h.handle(m, Value::Null) {
|
||||
assert!(!e.to_string().contains("unknown method"), "{m} is listed but not served: {e}");
|
||||
}
|
||||
}
|
||||
assert!(h.handle("jobs.nope", Value::Null).unwrap_err().to_string().contains("unknown method"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_response_budget_stops_later_steps_even_when_stop_on_error_is_false() {
|
||||
let mut h = Headless::new();
|
||||
|
||||
@@ -34,19 +34,43 @@ pub enum LineRead {
|
||||
}
|
||||
|
||||
/// Read one line without ever buffering more than [`MAX_REQUEST_BYTES`] plus one byte.
|
||||
///
|
||||
/// `TooLong` leaves the bytes read so far in `line` (lossily decoded); the rest of that line is
|
||||
/// still unread unless `line` ends with a newline (see [`discard_rest_of_line`]). A line within
|
||||
/// the limit that is not UTF-8 is an `InvalidData` error, and the whole line has been consumed.
|
||||
pub fn read_bounded_line(reader: &mut impl BufRead, line: &mut String) -> std::io::Result<LineRead> {
|
||||
line.clear();
|
||||
let mut limited = std::io::Read::take(reader, (MAX_REQUEST_BYTES + 1) as u64);
|
||||
let n = limited.read_line(line)?;
|
||||
let mut bytes = Vec::new();
|
||||
let n = std::io::Read::take(reader, (MAX_REQUEST_BYTES + 1) as u64).read_until(b'\n', &mut bytes)?;
|
||||
if n == 0 {
|
||||
Ok(LineRead::Eof)
|
||||
} else if n > MAX_REQUEST_BYTES {
|
||||
// A cut can split a multi-byte character; the length, not the text, decides.
|
||||
line.push_str(&String::from_utf8_lossy(&bytes));
|
||||
Ok(LineRead::TooLong)
|
||||
} else {
|
||||
*line = String::from_utf8(bytes).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
Ok(LineRead::Line)
|
||||
}
|
||||
}
|
||||
|
||||
/// Skip the rest of the current line (through its newline, or to EOF) without buffering it, so
|
||||
/// a stream transport can answer an over-long request and keep reading the next one.
|
||||
pub fn discard_rest_of_line(reader: &mut impl BufRead) -> std::io::Result<()> {
|
||||
loop {
|
||||
let buf = reader.fill_buf()?;
|
||||
if buf.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(end) = buf.iter().position(|&b| b == b'\n') {
|
||||
reader.consume(end + 1);
|
||||
return Ok(());
|
||||
}
|
||||
let n = buf.len();
|
||||
reader.consume(n);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a 256-bit bearer token with the operating system CSPRNG.
|
||||
pub fn generate_token() -> Result<String, AutomationError> {
|
||||
let mut bytes = [0u8; TOKEN_BYTES];
|
||||
@@ -235,6 +259,25 @@ mod tests {
|
||||
let mut line = String::new();
|
||||
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::TooLong);
|
||||
assert_eq!(line.len(), MAX_REQUEST_BYTES + 1);
|
||||
discard_rest_of_line(&mut reader).unwrap();
|
||||
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Eof);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_reader_reports_a_cut_multibyte_line_as_too_long_and_bad_utf8_as_invalid_data() {
|
||||
// The cut at the limit falls inside a two-byte character.
|
||||
let input = format!("xx{}\nnext\n", "é".repeat(MAX_REQUEST_BYTES / 2));
|
||||
let mut reader = std::io::Cursor::new(input);
|
||||
let mut line = String::new();
|
||||
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::TooLong);
|
||||
discard_rest_of_line(&mut reader).unwrap();
|
||||
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
|
||||
assert_eq!(line, "next\n");
|
||||
// Invalid UTF-8 consumes its whole line, so the next read starts on the next line.
|
||||
let mut reader = std::io::Cursor::new(b"\xff\xfe\nnext\n".to_vec());
|
||||
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap_err().kind(), std::io::ErrorKind::InvalidData);
|
||||
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
|
||||
assert_eq!(line, "next\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+141
-25
@@ -72,7 +72,9 @@ pub struct NewParams {
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct SaveParams {
|
||||
/// Forward-slash relative target beneath the configured automation write root.
|
||||
/// The extension selects the format. Omit to save to the document's own relative path.
|
||||
/// The extension selects the format. Omit to write back to the document's own file, which
|
||||
/// works only for a PSD, PSB or .pcraft file kept in its own format; any other save needs
|
||||
/// `path`, so a flattened or converted copy never replaces the opened file.
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
/// Format override as an extension (pcraft, psd, png, jpg, tif, webp, exr, …).
|
||||
@@ -81,6 +83,9 @@ pub struct SaveParams {
|
||||
/// JPEG quality 1..100 for lossy exports.
|
||||
#[serde(default)]
|
||||
pub quality: Option<u8>,
|
||||
/// TIFF: keep the layers (Photoshop layer data). Off by default: a flat TIFF.
|
||||
#[serde(default, rename = "tiffLayers")]
|
||||
pub tiff_layers: bool,
|
||||
#[serde(default)]
|
||||
pub index: Option<usize>,
|
||||
}
|
||||
@@ -112,29 +117,51 @@ pub struct ListParams {
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct RunParams {
|
||||
/// Command id, e.g. `layer.new.layer`, `filter.blur.gaussian`.
|
||||
/// Command id, e.g. `layer.new.layer`, `filter.blur.gaussianBlur`.
|
||||
pub id: String,
|
||||
/// Command parameters as a JSON object (see the `params` doc in `command_list`).
|
||||
#[serde(default)]
|
||||
pub params: Option<Value>,
|
||||
/// Wait for a long command (filters, Content-Aware Fill, Photomerge, …) to finish (default
|
||||
/// true). With false it runs as a background job and the result is `{"job": id}`: poll it
|
||||
/// with `jobs_list`, stop it with `jobs_cancel`.
|
||||
#[serde(default)]
|
||||
pub wait: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct JobCancelParams {
|
||||
/// The job id from `command_run` / `jobs_list` (omit to cancel every running job).
|
||||
#[serde(default)]
|
||||
pub job: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct BatchParams {
|
||||
/// Commands to run in order: `[{"id": "layer.new.layer", "params": {"name": "Ink"}}, …]`.
|
||||
/// Commands to run in order: `[{"id": "layer.new.layer", "params": {"name": "Ink"}}, …]`. A
|
||||
/// step with `"wait": false` starts a long command as a background job (its result is
|
||||
/// `{"job": id}`, as in `command_run`); later steps that edit the same document fail until
|
||||
/// the job ends.
|
||||
pub steps: Vec<RunParams>,
|
||||
/// Stop at the first failing step (default true).
|
||||
#[serde(default)]
|
||||
pub stop_on_error: Option<bool>,
|
||||
}
|
||||
|
||||
/// Strict: an argument the tool doesn't forward is an error, not silently dropped.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PointerParams {
|
||||
/// Events in document coordinates: `[{"kind":"down|move|up","x":..,"y":..,"pressure":..}]`.
|
||||
pub events: Vec<Value>,
|
||||
/// Modifier keys, e.g. `{"shift":true}`.
|
||||
/// Modifier keys, e.g. `{"shift":true}` (also `alt`, `command`, `ctrl`, `space`).
|
||||
#[serde(default)]
|
||||
pub modifiers: Option<Value>,
|
||||
/// Mouse button: `left` (default), or `right` / `secondary`: with the Move tool or
|
||||
/// `{"command":true}` it opens the canvas layer menu, with a painting tool the Brush Preset
|
||||
/// picker.
|
||||
#[serde(default)]
|
||||
pub button: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
@@ -145,7 +172,10 @@ pub struct MenuParams {
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct UiSetParams {
|
||||
/// Fields accepted by the control method `ui.set` (tool, panels, zoom, center, dark).
|
||||
/// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, maskTarget,
|
||||
/// vectorMaskTarget, selectionMode, zoom, center, fit, theme (pro, proMedium, studio,
|
||||
/// studioLight, classic), brushSection, brushTab, brushesView, brushSize. Other fields are an
|
||||
/// error.
|
||||
pub fields: Value,
|
||||
}
|
||||
|
||||
@@ -252,6 +282,7 @@ impl PhotocraftMcp {
|
||||
// of the document (`Session::edit`), so the session is still
|
||||
// consistent: keep serving instead of failing every later call.
|
||||
let mut g = h.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
g.sync_jobs();
|
||||
f(&mut g)
|
||||
})
|
||||
.await
|
||||
@@ -281,22 +312,24 @@ impl PhotocraftMcp {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => return Ok(fail(format!("app screenshot data: {error}"))),
|
||||
};
|
||||
if let Err(error) = check_png(bytes.len()) {
|
||||
return Ok(fail(error));
|
||||
}
|
||||
let bytes = match downscale_png(&bytes, max_side.unwrap_or(0)) {
|
||||
Ok(Some(small)) => small,
|
||||
Ok(None) => bytes,
|
||||
Err(error) => return Ok(fail(error)),
|
||||
};
|
||||
if let Err(error) = check_png(bytes.len()) {
|
||||
return Ok(fail(error));
|
||||
}
|
||||
Ok(png_result(&bytes, "screenshot of the live app window".into()))
|
||||
}
|
||||
}
|
||||
|
||||
fn downscale_png(png: &[u8], max_side: u32) -> Result<Option<Vec<u8>>, AutomationError> {
|
||||
// A compressed reply's byte limit does not bound its decoded pixel allocation.
|
||||
let opts =
|
||||
photocraft_codecs::DecodeOptions { limits: photocraft_codecs::Limits { max_width: 8192, max_height: 8192, max_pixels: 16 << 20, max_alloc: 64 << 20 } };
|
||||
let opts = photocraft_codecs::DecodeOptions {
|
||||
limits: photocraft_codecs::Limits { max_width: 8192, max_height: 8192, max_pixels: 16 << 20, max_alloc: 64 << 20 },
|
||||
..Default::default()
|
||||
};
|
||||
let img = photocraft_codecs::decode_as_with(photocraft_codecs::Format::Png, png, &opts).map_err(|error| AutomationError::Other(error.to_string()))?;
|
||||
let (w, h) = img.dimensions();
|
||||
if max_side == 0 || w.max(h) <= max_side {
|
||||
@@ -372,11 +405,11 @@ impl PhotocraftMcp {
|
||||
if let Some(v) = p.name {
|
||||
params.insert("name".into(), v.into());
|
||||
}
|
||||
self.run_command("file.new".into(), Value::Object(params)).await
|
||||
self.run_command("file.new".into(), Value::Object(params), true).await
|
||||
}
|
||||
|
||||
#[tool(description = "Save the document. `.pcraft` is the lossless native format (incremental); other extensions \
|
||||
(psd, png, jpg, tif, webp, exr, …) export. Returns warnings about anything the format cannot hold.")]
|
||||
(psd, png, jpg, tif, webp, exr, …) export. Without `path` only a PSD, PSB or .pcraft document is written back to its own file. Returns warnings about anything the format cannot hold.")]
|
||||
async fn doc_save(&self, Parameters(p): Parameters<SaveParams>) -> Result<CallToolResult, McpError> {
|
||||
self.save_impl(p).await
|
||||
}
|
||||
@@ -397,7 +430,8 @@ impl PhotocraftMcp {
|
||||
let Some(b) = self.bridge_client() else {
|
||||
return Ok(no_backend());
|
||||
};
|
||||
to_result(b.call("engine.execute", json!({"command": "document.inspect", "params": {}})).await)
|
||||
let params = p.index.map(|index| json!({"document": index})).unwrap_or_else(|| json!({}));
|
||||
to_result(b.call("engine.execute", json!({"command": "document.inspect", "params": params})).await)
|
||||
}
|
||||
|
||||
#[tool(description = "Render the flattened document and return it as a PNG image (bridge mode: window screenshot).")]
|
||||
@@ -461,9 +495,23 @@ impl PhotocraftMcp {
|
||||
Ok(ok_json(&Value::Array(items)))
|
||||
}
|
||||
|
||||
#[tool(description = "Run an engine command by id with JSON params (see command_list). Returns the command's JSON result.")]
|
||||
#[tool(description = "Run an engine command by id with JSON params (see command_list). Returns the command's JSON result. \
|
||||
Long commands (filters, Content-Aware Fill/Scale, Photomerge, brush import) wait to finish unless `wait` is false: \
|
||||
then they run as a background job and the result is {job: id} (see jobs_list, jobs_cancel).")]
|
||||
async fn command_run(&self, Parameters(p): Parameters<RunParams>) -> Result<CallToolResult, McpError> {
|
||||
self.run_command(p.id, p.params.unwrap_or_else(|| json!({}))).await
|
||||
self.run_command(p.id, p.params.unwrap_or_else(|| json!({})), p.wait.unwrap_or(true)).await
|
||||
}
|
||||
|
||||
#[tool(description = "List background jobs: running ones with progress (0-1), message and elapsed time, then the last few \
|
||||
that ended (state done|failed|cancelled with their result or error). Finished jobs are applied first.")]
|
||||
async fn jobs_list(&self) -> Result<CallToolResult, McpError> {
|
||||
self.jobs_call("jobs.list", json!({})).await
|
||||
}
|
||||
|
||||
#[tool(description = "Cancel a background job by id (or every running job when `job` is omitted). The document is left \
|
||||
unchanged.")]
|
||||
async fn jobs_cancel(&self, Parameters(p): Parameters<JobCancelParams>) -> Result<CallToolResult, McpError> {
|
||||
self.jobs_call("jobs.cancel", p.job.map_or_else(|| json!({}), |j| json!({"job": j}))).await
|
||||
}
|
||||
|
||||
#[tool(description = "Run several engine commands in one call (fewer round trips). Returns {completed, failed, \
|
||||
@@ -473,9 +521,11 @@ impl PhotocraftMcp {
|
||||
return Ok(fail(format!("batch contains {} steps; maximum is {MAX_BATCH_STEPS}", p.steps.len())));
|
||||
}
|
||||
let stop = p.stop_on_error.unwrap_or(true);
|
||||
let steps: Vec<Value> = p.steps.into_iter().map(|s| json!({"command": s.id, "params": s.params.unwrap_or_else(|| json!({}))})).collect();
|
||||
let steps: Vec<Value> =
|
||||
p.steps.into_iter().map(|s| json!({"command": s.id, "params": s.params.unwrap_or_else(|| json!({})), "wait": s.wait.unwrap_or(true)})).collect();
|
||||
let args = json!({"steps": steps, "stopOnError": stop});
|
||||
if let Some(r) = self.headless_op(move |h| h.batch(&args)).await {
|
||||
// The reply travels as escaped JSON text, so steps are charged their escaped size.
|
||||
if let Some(r) = self.headless_op(move |h| h.batch_with_budget(&args, BatchReplyBudget::escaped())).await {
|
||||
return to_result(r);
|
||||
}
|
||||
let Some(b) = self.bridge_client() else {
|
||||
@@ -483,7 +533,7 @@ impl PhotocraftMcp {
|
||||
};
|
||||
let mut results = Vec::new();
|
||||
let mut failed = 0;
|
||||
let mut reply_budget = BatchReplyBudget::default();
|
||||
let mut reply_budget = BatchReplyBudget::escaped();
|
||||
for s in &steps {
|
||||
let response = b.call("engine.execute", s.clone()).await;
|
||||
let was_error = response.is_err();
|
||||
@@ -509,7 +559,9 @@ impl PhotocraftMcp {
|
||||
|
||||
// ----- live-GUI tools (bridge mode) -----
|
||||
|
||||
#[tool(description = "Bridge mode: full UI state of the live app (tool, panels, views, dialogs, menu tree).")]
|
||||
#[tool(
|
||||
description = "Bridge mode: full UI state of the live app (tool, panels, views, dialogs, windows). For the menu tree, call `control_call` with method `ui.menu.list`."
|
||||
)]
|
||||
async fn ui_inspect(&self) -> Result<CallToolResult, McpError> {
|
||||
match self.bridge_client() {
|
||||
Some(b) => to_result(b.call("ui.inspect", json!({})).await),
|
||||
@@ -525,7 +577,9 @@ impl PhotocraftMcp {
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(description = "Bridge mode: send pointer events (document coordinates) to the active tool.")]
|
||||
#[tool(
|
||||
description = "Bridge mode: send pointer events (document coordinates) to the active tool; with a Color Picker open they sample the image into it instead."
|
||||
)]
|
||||
async fn ui_pointer(&self, Parameters(p): Parameters<PointerParams>) -> Result<CallToolResult, McpError> {
|
||||
match self.bridge_client() {
|
||||
Some(b) => {
|
||||
@@ -533,6 +587,9 @@ impl PhotocraftMcp {
|
||||
if let Some(m) = p.modifiers {
|
||||
params["modifiers"] = m;
|
||||
}
|
||||
if let Some(button) = p.button {
|
||||
params["button"] = button.into();
|
||||
}
|
||||
to_result(b.call("ui.pointer", params).await)
|
||||
}
|
||||
None => bridge_only("ui_pointer"),
|
||||
@@ -547,7 +604,9 @@ impl PhotocraftMcp {
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(description = "Bridge mode: change UI state (tool, panels, zoom, center, dark).")]
|
||||
#[tool(
|
||||
description = "Bridge mode: change UI state (tool, panels, dock, zoom, center, fit, theme, brush settings; see `fields`). Unknown fields are an error."
|
||||
)]
|
||||
async fn ui_set(&self, Parameters(p): Parameters<UiSetParams>) -> Result<CallToolResult, McpError> {
|
||||
match self.bridge_client() {
|
||||
Some(b) => to_result(b.call("ui.set", p.fields).await),
|
||||
@@ -569,15 +628,27 @@ fn bridge_only_headless(name: &str) -> Result<CallToolResult, McpError> {
|
||||
}
|
||||
|
||||
impl PhotocraftMcp {
|
||||
async fn run_command(&self, id: String, params: Value) -> Result<CallToolResult, McpError> {
|
||||
async fn run_command(&self, id: String, params: Value, wait: bool) -> Result<CallToolResult, McpError> {
|
||||
let (id2, params2) = (id.clone(), params.clone());
|
||||
if let Some(r) = self.headless_op(move |h| h.command_run(&id2, params2)).await {
|
||||
if let Some(r) = self.headless_op(move |h| h.command_start(&id2, params2, wait)).await {
|
||||
return to_result(r);
|
||||
}
|
||||
let Some(b) = self.bridge_client() else {
|
||||
return Ok(no_backend());
|
||||
};
|
||||
to_result(b.call("engine.execute", json!({"command": id, "params": params})).await)
|
||||
to_result(b.call("engine.execute", json!({"command": id, "params": params, "wait": wait})).await)
|
||||
}
|
||||
|
||||
/// `jobs.list` / `jobs.cancel` in either backend.
|
||||
async fn jobs_call(&self, method: &'static str, params: Value) -> Result<CallToolResult, McpError> {
|
||||
let p2 = params.clone();
|
||||
if let Some(r) = self.headless_op(move |h| h.command_start(method, p2, true)).await {
|
||||
return to_result(r);
|
||||
}
|
||||
let Some(b) = self.bridge_client() else {
|
||||
return Ok(no_backend());
|
||||
};
|
||||
to_result(b.call(method, params).await)
|
||||
}
|
||||
|
||||
async fn save_impl(&self, p: SaveParams) -> Result<CallToolResult, McpError> {
|
||||
@@ -589,7 +660,7 @@ impl PhotocraftMcp {
|
||||
}
|
||||
let Some(r) = self
|
||||
.headless_op(move |h| {
|
||||
let mut opts = photocraft_io::ExportOptions::default();
|
||||
let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, ..Default::default() };
|
||||
if let Some(q) = p.quality {
|
||||
opts.encode.jpeg_quality = q.clamp(1, 100);
|
||||
}
|
||||
@@ -631,6 +702,7 @@ mod tests {
|
||||
fn bridge_screenshot_decode_limits_apply_even_without_downscaling() {
|
||||
let image = photocraft_codecs::Image::from_u8(8193, 1, photocraft_codecs::ChannelLayout::Rgba, vec![0; 8193 * 4]).unwrap();
|
||||
let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
assert!(downscale_png(b"not a png", 32).is_err());
|
||||
assert!(downscale_png(&png, 0).is_err());
|
||||
assert!(downscale_png(&png, 1024).is_err());
|
||||
}
|
||||
@@ -643,4 +715,48 @@ mod tests {
|
||||
let r = mcp.headless_op(|h| h.command_run("file.new", json!({"width": 8, "height": 8}))).await.unwrap();
|
||||
assert!(r.is_ok(), "session still usable after a panic: {r:?}");
|
||||
}
|
||||
|
||||
/// Regression (#504): the batch budget counted plain JSON, but MCP sends the reply as escaped
|
||||
/// text, so a batch the budget stopped could still exceed the tool-result ceiling and the
|
||||
/// client lost every step result.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn mcp_command_batch_stopped_by_the_budget_keeps_its_step_results() {
|
||||
let mcp = PhotocraftMcp::headless();
|
||||
let step = |id: &str, params: Value| RunParams { id: id.into(), params: Some(params), wait: None };
|
||||
let mut steps: Vec<RunParams> = (0..MAX_BATCH_STEPS - 1).map(|_| step("command.list", json!({}))).collect();
|
||||
steps.push(step("file.new", json!({"width": 8, "height": 8})));
|
||||
let result = mcp.command_batch(Parameters(BatchParams { steps, stop_on_error: Some(false) })).await.unwrap();
|
||||
assert_ne!(result.is_error, Some(true), "{:?}", result.content.first().and_then(|c| c.as_text()).map(|t| &t.text[..200.min(t.text.len())]));
|
||||
let text = &result.content.first().and_then(|c| c.as_text()).unwrap().text;
|
||||
let reply: Value = serde_json::from_str(text).unwrap();
|
||||
let results = reply["results"].as_array().unwrap();
|
||||
assert!(results.last().unwrap()["error"].as_str().unwrap().contains("batch response budget exceeded"), "{}", results.last().unwrap());
|
||||
assert_eq!(reply["completed"].as_u64().unwrap() as usize, results.len() - 1);
|
||||
assert_eq!(reply["failed"], 1);
|
||||
let docs = mcp.headless_op(|h| Ok(h.session.documents().len())).await.unwrap().unwrap();
|
||||
assert_eq!(docs, 0, "the step after the budget ran out did not run");
|
||||
}
|
||||
|
||||
/// The MCP server applies finished jobs before every tool call too (#503).
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn mcp_tools_see_a_finished_background_job() {
|
||||
let mcp = PhotocraftMcp::headless();
|
||||
mcp.headless_op(|h| {
|
||||
h.command_run("file.new", json!({"width": 64, "height": 48}))?;
|
||||
h.command_run("filter.noise.addNoise", json!({"amount": 50}))?;
|
||||
h.command_start("filter.blur.gaussianBlur", json!({"radius": 4}), false)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let t = std::time::Instant::now();
|
||||
loop {
|
||||
let inspected = mcp.headless_op(|h| h.inspect(None)).await.unwrap().unwrap();
|
||||
if inspected["history"].as_array().unwrap().last().unwrap() == "Gaussian Blur" {
|
||||
break;
|
||||
}
|
||||
assert!(t.elapsed().as_secs() < 60, "doc_inspect never saw the finished job: {inspected}");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Capability-based filesystem policy for untrusted automation paths.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cap_std::ambient_authority;
|
||||
use cap_std::fs::{Dir, OpenOptions};
|
||||
use photocraft_format::atomic::RenameRetry;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::AutomationError;
|
||||
@@ -45,12 +46,14 @@ impl AuthorizedWorkspace {
|
||||
if !metadata.is_file() {
|
||||
return Err(AutomationError::BadRequest(format!("automation read path is not a regular file: `{path}`")));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes).map_err(|e| file_error("read", path, e))?;
|
||||
Ok(bytes)
|
||||
// Bounded reads, and a clear error for a file larger than memory (#375).
|
||||
photocraft_format::read::read_all(&mut file, metadata.len()).map_err(|e| file_error("read", path, e))
|
||||
}
|
||||
|
||||
/// Create or replace one file below the configured write root.
|
||||
/// Create or replace one file below the configured write root, crash-safely: the bytes go to
|
||||
/// a temporary file beside the target, which is synced and renamed over it (the same steps
|
||||
/// as [`photocraft_format::atomic_write`], through the directory capability). On failure
|
||||
/// the previous file is untouched and the temporary file is removed.
|
||||
///
|
||||
/// The parent directory must already exist. `cap-std` performs path
|
||||
/// resolution and file creation relative to the held directory handle, so
|
||||
@@ -58,11 +61,29 @@ impl AuthorizedWorkspace {
|
||||
pub fn write(&self, path: &str, bytes: &[u8]) -> Result<(), AutomationError> {
|
||||
let relative = relative_path(path)?;
|
||||
let root = self.write.as_ref().ok_or_else(|| AutomationError::BadRequest(format!("{DENIED}: write authority is absent")))?;
|
||||
let leaf = relative.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let parent = relative.parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let tmp = parent.join(photocraft_format::atomic::temp_name(&leaf));
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true);
|
||||
let mut file = root.dir.open_with(&relative, &options).map_err(|e| file_error("write", path, e))?;
|
||||
file.write_all(bytes).map_err(|e| file_error("write", path, e))?;
|
||||
file.flush().map_err(|e| file_error("write", path, e))
|
||||
options.write(true).create_new(true);
|
||||
let written = root.dir.open_with(&tmp, &options).and_then(|mut file| {
|
||||
file.write_all(bytes)?;
|
||||
file.sync_all()
|
||||
});
|
||||
let renamed = written.and_then(|()| photocraft_format::atomic::retry_rename(RenameRetry::platform(), || root.dir.rename(&tmp, &root.dir, &relative)));
|
||||
if let Err(e) = renamed {
|
||||
let _ = root.dir.remove_file(&tmp);
|
||||
return Err(file_error("write", path, e));
|
||||
}
|
||||
// Flush the directory entry (Unix); best effort, the new bytes are already in place.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let dir = if parent.as_os_str().is_empty() { PathBuf::from(".") } else { parent };
|
||||
if let Ok(d) = root.dir.open(&dir) {
|
||||
let _ = d.sync_all();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +153,7 @@ pub fn authorize_engine_command(id: &str, params: &Value) -> Result<(), Automati
|
||||
let safe_file_command = matches!(
|
||||
id,
|
||||
"file.new"
|
||||
| "file.newFromClipboard"
|
||||
| "file.close"
|
||||
| "file.closeAll"
|
||||
| "file.closeOthers"
|
||||
@@ -158,6 +180,7 @@ pub fn authorize_engine_command(id: &str, params: &Value) -> Result<(), Automati
|
||||
| "layer.videoLayers.reloadFrame"
|
||||
| "image.applyDataSet"
|
||||
)
|
||||
|| command_uses_ambient_path(id, params)
|
||||
|| profile_command_may_read_ambient(id, params)
|
||||
|| preferences_may_grant_ambient_paths(id, params)
|
||||
|| params_contain_ambient_path(id, params)
|
||||
@@ -179,11 +202,23 @@ pub fn authorize_desktop_engine_command(id: &str, params: &Value) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`authorize_engine_command`] as the function pointer [`photocraft_engine::Session::authorize`]
|
||||
/// stores. `actions.play` calls it for every nested step.
|
||||
pub fn authorize_engine_step(id: &str, params: &Value) -> photocraft_engine::Result<()> {
|
||||
authorize_engine_command(id, params).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// [`authorize_desktop_engine_command`] as a [`photocraft_engine::Session::authorize`] hook.
|
||||
pub fn authorize_desktop_engine_step(id: &str, params: &Value) -> photocraft_engine::Result<()> {
|
||||
authorize_desktop_engine_command(id, params).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
fn params_contain_ambient_path(id: &str, params: &Value) -> bool {
|
||||
let keys: &[&str] = match id {
|
||||
"image.adjustments.colorLookup" | "layer.newAdjustmentLayer.colorLookup" | "layer.setAdjustment" => &["file"],
|
||||
"filter.distort.displace" => &["mapPath"],
|
||||
"layer.quickExportAsPng" | "layer.exportAs" => &["path"],
|
||||
"image.mode.rgb" | "image.mode.grayscale" | "image.mode.cmyk" | "image.mode.lab" => &["profile"],
|
||||
"edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo" | "view.proofSetup" | "view.gamutWarning" => &["profile"],
|
||||
"edit.colorSettings" => &["workingRgb", "workingCmyk", "workingGray"],
|
||||
_ => &[],
|
||||
@@ -210,16 +245,29 @@ fn preferences_may_grant_ambient_paths(id: &str, params: &Value) -> bool {
|
||||
if id != "prefs.set" {
|
||||
return false;
|
||||
}
|
||||
let direct = params
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|path| path == "colorSettings" || path.starts_with("colorSettings.") || path == "scriptEvents" || path.starts_with("scriptEvents."));
|
||||
let batch = params.get("values").and_then(Value::as_object).is_some_and(|values| {
|
||||
values.keys().any(|path| path == "colorSettings" || path.starts_with("colorSettings.") || path == "scriptEvents" || path.starts_with("scriptEvents."))
|
||||
});
|
||||
let direct = params.get("path").and_then(Value::as_str).is_some_and(preference_uses_ambient_filesystem);
|
||||
let batch = params.get("values").and_then(Value::as_object).is_some_and(|values| values.keys().any(|path| preference_uses_ambient_filesystem(path)));
|
||||
direct || batch
|
||||
}
|
||||
|
||||
fn preference_uses_ambient_filesystem(path: &str) -> bool {
|
||||
// The engine skips empty segments (`.scriptEvents.enabled` sets `scriptEvents.enabled`), so
|
||||
// judge the first non-empty one; no segment at all is the whole-preferences update.
|
||||
let Some(section) = path.split('.').find(|segment| !segment.is_empty()) else { return true };
|
||||
matches!(section, "colorSettings" | "scriptEvents" | "historyLog" | "plugIns" | "scratchDisks")
|
||||
}
|
||||
|
||||
fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
|
||||
match id {
|
||||
"brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" => {
|
||||
// `data` wins over `path` in these commands; any `path` without it reads the filesystem.
|
||||
params.get("data").is_none() && params.get("path").is_some()
|
||||
}
|
||||
"plugin.reload" => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_path(value: &str) -> bool {
|
||||
value.contains('/')
|
||||
|| value.contains('\\')
|
||||
@@ -256,6 +304,43 @@ mod tests {
|
||||
assert_eq!(std::fs::read(inside.join("new-output.txt")).unwrap(), b"created");
|
||||
}
|
||||
|
||||
fn temp_files(dir: &Path) -> Vec<String> {
|
||||
std::fs::read_dir(dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".tmp")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_replaces_atomically_and_leaves_no_temp_file() {
|
||||
let (inside, _, workspace) = roots("atomic");
|
||||
std::fs::create_dir_all(inside.join("sub")).unwrap();
|
||||
workspace.write("sub/out.psd", b"original").unwrap();
|
||||
workspace.write("sub/out.psd", b"replacement").unwrap();
|
||||
assert_eq!(std::fs::read(inside.join("sub/out.psd")).unwrap(), b"replacement");
|
||||
assert!(temp_files(&inside.join("sub")).is_empty());
|
||||
// Renaming over a directory fails: nothing is left behind and the directory survives.
|
||||
std::fs::create_dir_all(inside.join("adir")).unwrap();
|
||||
assert!(workspace.write("adir", b"x").is_err());
|
||||
assert!(inside.join("adir").is_dir());
|
||||
assert!(temp_files(&inside).is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn write_into_read_only_folder_keeps_the_original() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let (inside, _, workspace) = roots("readonly");
|
||||
let ro = inside.join("ro");
|
||||
std::fs::create_dir_all(&ro).unwrap();
|
||||
std::fs::write(ro.join("doc.psd"), b"original").unwrap();
|
||||
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o555)).unwrap();
|
||||
let root_user = std::fs::File::create(ro.join("probe")).is_ok();
|
||||
if !root_user {
|
||||
assert!(workspace.write("ro/doc.psd", b"new").is_err());
|
||||
assert_eq!(std::fs::read(ro.join("doc.psd")).unwrap(), b"original");
|
||||
assert!(temp_files(&ro).is_empty());
|
||||
}
|
||||
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_absolute_traversal_mixed_prefix_and_malformed_paths_without_panicking() {
|
||||
let (_, _, workspace) = roots("reject");
|
||||
@@ -348,7 +433,11 @@ mod tests {
|
||||
#[test]
|
||||
fn filesystem_commands_fail_closed() {
|
||||
for id in [
|
||||
"file.open",
|
||||
"file.openAs",
|
||||
"file.save",
|
||||
"file.saveAs",
|
||||
"file.saveACopy",
|
||||
"file.export.saveForWebLegacy",
|
||||
"pattern.import",
|
||||
"layer.smartObjects.exportContents",
|
||||
@@ -360,10 +449,159 @@ mod tests {
|
||||
assert!(authorize_engine_command(id, &serde_json::json!({})).is_err());
|
||||
}
|
||||
assert!(authorize_engine_command("file.new", &serde_json::json!({})).is_ok());
|
||||
// The UI-level examples in docs/control-protocol.md.
|
||||
for id in ["view.zoomIn", "window.theme.pro", "edit.search"] {
|
||||
assert!(authorize_desktop_engine_command(id, &serde_json::json!({})).is_ok(), "{id}");
|
||||
}
|
||||
assert!(authorize_engine_command("image.mode.cmyk", &serde_json::json!({})).is_ok());
|
||||
assert!(authorize_desktop_engine_command("image.mode.cmyk", &serde_json::json!({})).is_err());
|
||||
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err());
|
||||
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "srgb"})).is_ok());
|
||||
assert!(authorize_engine_command("filter.distort.displace", &serde_json::json!({"mapPath": "outside.png"})).is_err());
|
||||
assert!(authorize_engine_command("layer.setAdjustment", &serde_json::json!({"file": "outside.cube"})).is_err());
|
||||
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "colorSettings.workingRgb", "value": "outside.icc"})).is_err());
|
||||
assert!(authorize_engine_step("file.open", &serde_json::json!({})).is_err());
|
||||
assert!(authorize_engine_step("actions.play", &serde_json::json!({})).is_ok());
|
||||
assert!(authorize_desktop_engine_step("file.saveACopy", &serde_json::json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_rejects_ambient_path_commands_and_preferences() {
|
||||
for (id, params) in [
|
||||
("brush.presets.importAbr", serde_json::json!({"path": "/outside/set.abr"})),
|
||||
("gradient.presets.importGrd", serde_json::json!({"path": "/outside/set.grd"})),
|
||||
("plugin.install", serde_json::json!({"path": "/outside/plugin.wasm"})),
|
||||
("plugin.reload", serde_json::json!({"path": "/outside/plugins"})),
|
||||
("plugin.reload", serde_json::json!({})),
|
||||
("plugin.install", serde_json::json!({"path": " "})),
|
||||
] {
|
||||
assert!(authorize_engine_command(id, ¶ms).is_err(), "{id}: {params}");
|
||||
}
|
||||
for (id, params) in [
|
||||
("brush.presets.importAbr", serde_json::json!({"data": "QUJD"})),
|
||||
("gradient.presets.importGrd", serde_json::json!({"data": "QUJD"})),
|
||||
("plugin.install", serde_json::json!({"data": "QUJD"})),
|
||||
] {
|
||||
assert!(authorize_engine_command(id, ¶ms).is_ok(), "{id}: {params}");
|
||||
}
|
||||
for path in [
|
||||
"",
|
||||
"colorSettings",
|
||||
"colorSettings.workingRgb",
|
||||
"scriptEvents",
|
||||
"scriptEvents.enabled",
|
||||
".scriptEvents",
|
||||
".scriptEvents.enabled",
|
||||
"historyLog.filePath",
|
||||
".historyLog.filePath",
|
||||
"plugIns.additionalPluginsFolder",
|
||||
"scratchDisks.disks",
|
||||
".",
|
||||
"..historyLog.filePath",
|
||||
] {
|
||||
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": path, "value": {}})).is_err(), "{path}");
|
||||
}
|
||||
assert!(
|
||||
authorize_engine_command("prefs.set", &serde_json::json!({"values": {"interface.language": "fr", "historyLog.filePath": "/outside/log"}})).is_err()
|
||||
);
|
||||
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "interface.language", "value": "fr"})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_filesystem_path_params_are_classified() {
|
||||
let probe = serde_json::json!({
|
||||
"path": "/outside/photocraft-probe",
|
||||
"file": "/outside/photocraft-probe.icc",
|
||||
"mapPath": "/outside/photocraft-probe.png",
|
||||
"profile": "/outside/photocraft-probe.icc",
|
||||
"workingRgb": "/outside/photocraft-probe.icc",
|
||||
"workingCmyk": "/outside/photocraft-probe.icc",
|
||||
"workingGray": "/outside/photocraft-probe.icc",
|
||||
"input": "/outside/photocraft-input",
|
||||
"output": "/outside/photocraft-output",
|
||||
"paths": ["/outside/photocraft-probe.psd"],
|
||||
});
|
||||
let preference_probe = serde_json::json!({"path": ".scriptEvents", "value": {}});
|
||||
let unclassified: Vec<_> = photocraft_engine::command_specs()
|
||||
.iter()
|
||||
.filter(|spec| documents_filesystem_path_params(spec.id, spec.params))
|
||||
.filter(|spec| {
|
||||
let params = if spec.id == "prefs.set" { &preference_probe } else { &probe };
|
||||
authorize_engine_command(spec.id, params).is_ok()
|
||||
})
|
||||
.map(|spec| spec.id)
|
||||
.collect();
|
||||
assert!(unclassified.is_empty(), "filesystem path parameters in the command registry need an automation policy: {unclassified:?}");
|
||||
}
|
||||
|
||||
fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
|
||||
if matches!(id, "prefs.get" | "prefs.reset") {
|
||||
return false;
|
||||
}
|
||||
// These registry descriptions refer to document vector paths, not host files.
|
||||
if matches!(
|
||||
id,
|
||||
"filter.blurGallery.pathBlur"
|
||||
| "filter.render.flame"
|
||||
| "shape.create"
|
||||
| "shape.edit"
|
||||
| "shape.info"
|
||||
| "shape.presets.list"
|
||||
| "shape.presets.new"
|
||||
| "path.list"
|
||||
| "path.info"
|
||||
| "path.set"
|
||||
| "path.transform"
|
||||
| "path.clippingPath.set"
|
||||
| "path.rename"
|
||||
| "select.toWorkPath"
|
||||
| "layer.vectorMask.add"
|
||||
| "layer.vectorMask.edit"
|
||||
| "layer.vectorMask.info"
|
||||
| "paint.symmetryFromPath"
|
||||
| "edit.defineCustomShape"
|
||||
| "layer.combineShapes.unite"
|
||||
| "layer.combineShapes.subtractFrontShape"
|
||||
| "layer.combineShapes.intersectShapeAreas"
|
||||
| "layer.combineShapes.excludeOverlappingShapes"
|
||||
| "layer.combineShapes.mergeShapeComponents"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
params.to_ascii_lowercase().contains("path")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_preferences_update_cannot_enable_automation_script_events() {
|
||||
let mut headless = crate::headless::Headless::new();
|
||||
let result = headless.command_run(
|
||||
"prefs.set",
|
||||
serde_json::json!({
|
||||
"path": "",
|
||||
"value": {
|
||||
"scriptEvents": {
|
||||
"enabled": true,
|
||||
"bindings": [{
|
||||
"event": "newDocument",
|
||||
"steps": [["file.saveACopy", {"path": "/outside/canary.psd"}]]
|
||||
}]
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
|
||||
for (id, params) in [
|
||||
("brush.presets.importAbr", serde_json::json!({"path": "/outside/set.abr"})),
|
||||
("gradient.presets.importGrd", serde_json::json!({"path": "/outside/set.grd"})),
|
||||
("plugin.install", serde_json::json!({"path": "/outside/plugin.wasm"})),
|
||||
("plugin.reload", serde_json::json!({"path": "/outside/plugins"})),
|
||||
("prefs.set", serde_json::json!({"path": "historyLog.filePath", "value": "/outside/log"})),
|
||||
("prefs.set", serde_json::json!({"values": {"interface.language": "fr", "historyLog.filePath": "/outside/log"}})),
|
||||
] {
|
||||
assert!(headless.command_run(id, params).is_err(), "{id}");
|
||||
}
|
||||
headless.command_run("file.new", serde_json::json!({"width": 5, "height": 5})).unwrap();
|
||||
assert!(headless.session.file_menu.event_log.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -214,3 +214,119 @@ async fn agent_completes_ten_scripted_tasks() {
|
||||
c.cancel().await.unwrap();
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// Click just after "Big", insert a word, then bold through the end of the line. Geometry comes
|
||||
/// from `type.hitTest` / `type.caret` / `type.navigate`; the edit is the existing `type.edit`.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn agent_edits_text_by_position() {
|
||||
let dir = tmp("type-caret");
|
||||
let c = connect(&dir).await;
|
||||
tool(&c, "doc_new", json!({"width": 480, "height": 200, "background": "white"})).await;
|
||||
let listed = tool(&c, "command_list", json!({"filter": "type."})).await;
|
||||
let ids: Vec<&str> = listed.as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect();
|
||||
for id in ["type.hitTest", "type.caret", "type.navigate"] {
|
||||
assert!(ids.contains(&id), "command_list missing {id}: {ids:?}");
|
||||
}
|
||||
|
||||
run(&c, "type.create", json!({"x": 40, "y": 90, "text": "Big Sale", "size": 48})).await;
|
||||
let before = inspect(&c).await["history"].as_array().unwrap().len();
|
||||
// The caret after "Big" and the caret after the following space. A quarter of the way is
|
||||
// the left half of that space, which hit-tests as the caret after "Big".
|
||||
let after_big = run(&c, "type.caret", json!({"index": 3})).await;
|
||||
let after_space = run(&c, "type.caret", json!({"index": 4})).await;
|
||||
let seg = after_big["segment"].as_array().unwrap();
|
||||
let (x3, y0, y1) = (seg[0][0].as_f64().unwrap(), seg[0][1].as_f64().unwrap(), seg[1][1].as_f64().unwrap());
|
||||
let x4 = after_space["segment"][0][0].as_f64().unwrap();
|
||||
assert!(x4 > x3 + 0.5, "space should have width, caret x {x3} -> {x4}");
|
||||
let hit = run(&c, "type.hitTest", json!({"x": x3 + 0.25 * (x4 - x3), "y": (y0 + y1) / 2.0})).await;
|
||||
assert_eq!(hit["index"].as_u64(), Some(3), "{hit}");
|
||||
assert_eq!(hit["inside"], true, "{hit}");
|
||||
let quiet = inspect(&c).await["history"].as_array().unwrap().len();
|
||||
assert_eq!(quiet, before, "caret queries are not history steps");
|
||||
|
||||
run(&c, "type.edit", json!({"replace": {"start": hit["index"], "end": hit["index"], "text": " Summer"}})).await;
|
||||
let info = run(&c, "type.info", json!({})).await;
|
||||
assert_eq!(info["text"], "Big Summer Sale", "{info}");
|
||||
|
||||
let end = run(&c, "type.navigate", json!({"index": 0, "move": "lineEnd"})).await;
|
||||
let n = info["text"].as_str().unwrap().chars().count() as u64;
|
||||
assert_eq!(end["index"].as_u64(), Some(n), "{end}");
|
||||
// "Summer" starts one character after the insertion caret (the space in " Summer").
|
||||
let summer = hit["index"].as_u64().unwrap() + 1;
|
||||
run(&c, "type.setStyle", json!({"range": [summer, end["index"]], "fauxBold": true})).await;
|
||||
let info = run(&c, "type.info", json!({})).await;
|
||||
assert_eq!(info["text"], "Big Summer Sale");
|
||||
let bold = info["runs"].as_array().unwrap().iter().find(|r| r["style"]["faux_bold"] == true);
|
||||
let bold = bold.unwrap_or_else(|| panic!("no bold run in {info}"));
|
||||
assert_eq!(bold["start"].as_u64(), Some(summer), "{bold}");
|
||||
assert_eq!(bold["end"].as_u64(), end["index"].as_u64(), "{bold}");
|
||||
|
||||
tool(&c, "doc_close", json!({})).await;
|
||||
c.cancel().await.unwrap();
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
async fn connect_headless(headless: photocraft_automation::Headless) -> Conn {
|
||||
let (s, c) = tokio::io::duplex(1 << 20);
|
||||
tokio::spawn(async move {
|
||||
let backend = photocraft_automation::Backend::Headless(std::sync::Arc::new(std::sync::Mutex::new(headless)));
|
||||
if let Ok(running) = PhotocraftMcp::with_backend(backend).serve(s).await {
|
||||
let _ = running.waiting().await;
|
||||
}
|
||||
});
|
||||
Client.serve(c).await.expect("client init")
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn agent_records_and_replays_an_action() {
|
||||
let dir = tmp("actions");
|
||||
let c = connect(&dir).await;
|
||||
let listed = tool(&c, "command_list", json!({"filter": "actions."})).await;
|
||||
let ids: Vec<&str> = listed.as_array().unwrap().iter().filter_map(|item| item.get("id").and_then(Value::as_str)).collect();
|
||||
for id in ["actions.list", "actions.get", "actions.record", "actions.stop", "actions.play", "actions.delete"] {
|
||||
assert!(ids.contains(&id), "{id} missing from {ids:?}");
|
||||
}
|
||||
|
||||
run(&c, "file.new", json!({"width": 32, "height": 32, "background": "white"})).await;
|
||||
run(&c, "actions.record", json!({"name": "Red"})).await;
|
||||
run(&c, "layer.new.layer", json!({})).await;
|
||||
run(&c, "select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 8})).await;
|
||||
run(&c, "edit.fill", json!({"color": "#ff0000"})).await;
|
||||
let stopped = run(&c, "actions.stop", json!({})).await;
|
||||
assert_eq!(stopped["steps"], 3, "{stopped}");
|
||||
let got = run(&c, "actions.get", json!({"action": "Red"})).await;
|
||||
assert_eq!(got["steps"][0][0], "layer.new.layer");
|
||||
assert_eq!(got["steps"][1][0], "select.rect");
|
||||
assert_eq!(got["steps"][2][0], "edit.fill");
|
||||
|
||||
run(&c, "file.new", json!({"width": 32, "height": 32, "background": "white"})).await;
|
||||
let played = run(&c, "actions.play", json!({"action": "Red"})).await;
|
||||
assert_eq!(played["ran"], 3, "{played}");
|
||||
assert!(played.get("failed").is_none(), "{played}");
|
||||
let px = pixel(&c, 2, 2).await;
|
||||
assert!(close(&px, &[1.0, 0.0, 0.0, 1.0], 1e-4), "{px:?}");
|
||||
|
||||
c.cancel().await.unwrap();
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn agent_play_refuses_a_recorded_file_open() {
|
||||
let dir = tmp("actions-auth");
|
||||
let workspace = AuthorizedWorkspace::new(Some(&dir), Some(&dir)).expect("test workspace");
|
||||
let mut headless = photocraft_automation::Headless::with_workspace(workspace);
|
||||
headless.session.actions.list.push(photocraft_engine::actions_cmds::Action {
|
||||
name: "Open".into(),
|
||||
steps: vec![("file.open".into(), json!({"path": "/etc/passwd"})), ("layer.new.layer".into(), json!({}))],
|
||||
});
|
||||
let c = connect_headless(headless).await;
|
||||
let played = run(&c, "actions.play", json!({"action": "Open"})).await;
|
||||
assert_eq!(played["ran"], 0, "{played}");
|
||||
assert_eq!(played["failed"]["step"], 0, "{played}");
|
||||
assert_eq!(played["failed"]["id"], "file.open", "{played}");
|
||||
assert!(played["failed"]["error"].as_str().unwrap_or("").contains("disabled"), "{played}");
|
||||
let session = tool(&c, "session_list", json!({})).await;
|
||||
assert!(session["documents"].as_array().is_some_and(|d| d.is_empty()), "{session}");
|
||||
c.cancel().await.unwrap();
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -169,6 +169,39 @@ async fn command_list_filters() {
|
||||
client.cancel().await.unwrap();
|
||||
}
|
||||
|
||||
/// The backticked examples in every `id` property description of a schema.
|
||||
fn id_examples(schema: &Value, out: &mut Vec<String>) {
|
||||
match schema {
|
||||
Value::Object(m) => {
|
||||
if let Some(d) = m.get("properties").and_then(|p| p["id"]["description"].as_str()) {
|
||||
out.extend(d.split('`').skip(1).step_by(2).map(str::to_owned));
|
||||
}
|
||||
m.values().for_each(|v| id_examples(v, out));
|
||||
}
|
||||
Value::Array(a) => a.iter().for_each(|v| id_examples(v, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn command_ids_in_tool_schemas_exist() {
|
||||
// Agents copy these examples verbatim (#378).
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
let all = json_of(&call(&client, "command_list", json!({})).await);
|
||||
let ids: Vec<&str> = all.as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect();
|
||||
let tools = client.list_all_tools().await.unwrap();
|
||||
for name in ["command_run", "command_batch"] {
|
||||
let tool = tools.iter().find(|t| t.name == name).unwrap();
|
||||
let mut examples = Vec::new();
|
||||
id_examples(&Value::Object((*tool.input_schema).clone()), &mut examples);
|
||||
assert!(!examples.is_empty(), "{name}: no example ids");
|
||||
for id in examples {
|
||||
assert!(ids.contains(&id.as_str()), "{name}: `{id}` is not a registered command");
|
||||
}
|
||||
}
|
||||
client.cancel().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn errors_are_tool_errors_not_crashes() {
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
@@ -254,6 +287,86 @@ async fn open_png_and_inspect() {
|
||||
cleanup(&dir);
|
||||
}
|
||||
|
||||
/// #518, #523: `doc_open` returns why an opened image is incomplete.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn open_reports_decode_warnings() {
|
||||
let dir = tmp("open-warnings");
|
||||
let jpeg = write_image(&dir, "whole.jpg", photocraft_codecs::Format::Jpeg);
|
||||
std::fs::write(dir.join("cut.jpg"), &jpeg[..jpeg.len() - 4]).unwrap();
|
||||
// Two 1x1 frames.
|
||||
let mut gif = b"GIF89a\x01\0\x01\0\x80\0\0\0\0\0\xFF\xFF\xFF".to_vec();
|
||||
for _ in 0..2 {
|
||||
gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0");
|
||||
}
|
||||
gif.push(0x3B);
|
||||
std::fs::write(dir.join("anim.gif"), gif).unwrap();
|
||||
let client = connect(headless_in(&dir)).await;
|
||||
let warnings = async |path: &str| json_of(&call(&client, "doc_open", json!({"path": path})).await)["warnings"].clone();
|
||||
assert_eq!(warnings("whole.jpg").await, json!([]));
|
||||
assert_eq!(warnings("cut.jpg").await, json!(["JPEG data ends early (the file is truncated or damaged); part of the image is missing"]));
|
||||
assert_eq!(warnings("anim.gif").await, json!(["only the first of 2 frames was imported"]));
|
||||
client.cancel().await.unwrap();
|
||||
cleanup(&dir);
|
||||
}
|
||||
|
||||
fn write_image(dir: &std::path::Path, name: &str, format: photocraft_codecs::Format) -> Vec<u8> {
|
||||
let img = photocraft_codecs::Image::from_u8(16, 8, photocraft_codecs::ChannelLayout::Rgb, (0..384).map(|i| (i * 7 % 251) as u8).collect()).unwrap();
|
||||
let bytes = photocraft_codecs::encode(&img, format, &Default::default()).unwrap();
|
||||
std::fs::write(dir.join(name), &bytes).unwrap();
|
||||
bytes
|
||||
}
|
||||
|
||||
/// A save without `path` writes back only to a layered file in its own format (#416).
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn save_without_path_never_flattens_over_the_opened_file() {
|
||||
let dir = tmp("save-in-place");
|
||||
let png = write_image(&dir, "seed.png", photocraft_codecs::Format::Png);
|
||||
let jpg = write_image(&dir, "seed.jpg", photocraft_codecs::Format::Jpeg);
|
||||
let client = connect(headless_in(&dir)).await;
|
||||
let refused = |r: &CallToolResult| r.is_error == Some(true) && text(r).contains("pass `path`");
|
||||
|
||||
// A flat file, edited or not, is left unchanged.
|
||||
json_of(&call(&client, "doc_open", json!({"path": "seed.png"})).await);
|
||||
json_of(&call(&client, "command_run", json!({"id": "layer.newAdjustmentLayer.curves", "params": {"points": [[0, 0], [128, 170], [255, 255]]}})).await);
|
||||
let r = call(&client, "doc_save", json!({})).await;
|
||||
assert!(refused(&r), "{}", text(&r));
|
||||
assert_eq!(std::fs::read(dir.join("seed.png")).unwrap(), png);
|
||||
json_of(&call(&client, "doc_open", json!({"path": "seed.jpg"})).await);
|
||||
let r = call(&client, "doc_save", json!({})).await;
|
||||
assert!(refused(&r), "{}", text(&r));
|
||||
assert_eq!(std::fs::read(dir.join("seed.jpg")).unwrap(), jpg);
|
||||
|
||||
// An explicit path, even the opened file's own, still writes and reports what was lost.
|
||||
let r = json_of(&call(&client, "doc_save", json!({"path": "seed.jpg"})).await);
|
||||
assert!(r["warnings"].to_string().contains("lossy"), "{r}");
|
||||
|
||||
// A layered file saves in place in its own format, but not converted over itself.
|
||||
json_of(&call(&client, "doc_select", json!({"index": 0})).await);
|
||||
json_of(&call(&client, "doc_save", json!({"path": "layered.psd"})).await);
|
||||
json_of(&call(&client, "doc_open", json!({"path": "layered.psd"})).await);
|
||||
let psd = std::fs::read(dir.join("layered.psd")).unwrap();
|
||||
let r = call(&client, "doc_save", json!({"format": "png"})).await;
|
||||
assert!(refused(&r), "{}", text(&r));
|
||||
assert_eq!(std::fs::read(dir.join("layered.psd")).unwrap(), psd);
|
||||
assert_eq!(json_of(&call(&client, "doc_save", json!({})).await)["path"], "layered.psd");
|
||||
assert!(photocraft_io::is_psd(&std::fs::read(dir.join("layered.psd")).unwrap()));
|
||||
|
||||
// Once saved as .pcraft, a flat-born document saves in place there.
|
||||
json_of(&call(&client, "doc_select", json!({"index": 0})).await);
|
||||
json_of(&call(&client, "doc_save", json!({"path": "work.pcraft"})).await);
|
||||
assert_eq!(json_of(&call(&client, "doc_save", json!({})).await)["path"], "work.pcraft");
|
||||
assert_eq!(std::fs::read(dir.join("seed.png")).unwrap(), png);
|
||||
|
||||
// A template opens untitled, without its path.
|
||||
std::fs::write(dir.join("card.psdt"), &psd).unwrap();
|
||||
assert_eq!(json_of(&call(&client, "doc_open", json!({"path": "card.psdt"})).await)["name"], "Untitled-1");
|
||||
let r = call(&client, "doc_save", json!({})).await;
|
||||
assert!(refused(&r), "{}", text(&r));
|
||||
assert_eq!(std::fs::read(dir.join("card.psdt")).unwrap(), psd);
|
||||
client.cancel().await.unwrap();
|
||||
cleanup(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn filesystem_policy_rejects_absolute_and_escaping_paths_before_effects() {
|
||||
let base = tmp("filesystem-policy");
|
||||
@@ -293,6 +406,10 @@ async fn filesystem_policy_rejects_absolute_and_escaping_paths_before_effects()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn fake_app() -> (String, tokio::task::JoinHandle<Vec<Value>>) {
|
||||
fake_app_with_screenshot(None).await
|
||||
}
|
||||
|
||||
async fn fake_app_with_screenshot(screenshot_png: Option<Vec<u8>>) -> (String, tokio::task::JoinHandle<Vec<Value>>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let h = tokio::spawn(async move {
|
||||
@@ -317,14 +434,17 @@ async fn fake_app() -> (String, tokio::task::JoinHandle<Vec<Value>>) {
|
||||
json!({"id": id, "ok": true, "result": {"tool": "brush", "panels": ["layers"]}})
|
||||
}
|
||||
"engine.execute" => {
|
||||
json!({"id": id, "ok": true, "result": {"ran": req["params"]["command"]}})
|
||||
json!({"id": id, "ok": true, "result": {"ran": req["params"]["command"], "params": req["params"]["params"], "wait": req["params"]["wait"]}})
|
||||
}
|
||||
"engine.commands" => {
|
||||
json!({"id": id, "ok": true, "result": [{"id": "file.new", "label": "New…", "enabled": true}]})
|
||||
}
|
||||
"ui.pointer" => json!({"id": id, "ok": true, "result": req["params"]}),
|
||||
"ui.screenshot" => {
|
||||
let img = photocraft_codecs::Image::from_u8(40, 20, photocraft_codecs::ChannelLayout::Rgba, vec![9; 3200]).unwrap();
|
||||
let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
let bytes = screenshot_png.clone().unwrap_or_else(|| {
|
||||
let img = photocraft_codecs::Image::from_u8(40, 20, photocraft_codecs::ChannelLayout::Rgba, vec![9; 3200]).unwrap();
|
||||
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()
|
||||
});
|
||||
let png = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
json!({"id": id, "ok": true, "result": {"mimeType": "image/png", "base64": png}})
|
||||
}
|
||||
@@ -349,6 +469,9 @@ async fn bridge_forwards_to_control_protocol() {
|
||||
assert_eq!(ui["tool"], "brush");
|
||||
let r = json_of(&call(&client, "command_run", json!({"id": "layer.new.layer", "params": {"name": "X"}})).await);
|
||||
assert_eq!(r["ran"], "layer.new.layer");
|
||||
let inspected = json_of(&call(&client, "doc_inspect", json!({"index": 1})).await);
|
||||
assert_eq!(inspected["ran"], "document.inspect");
|
||||
assert_eq!(inspected["params"], json!({"document": 1}));
|
||||
let l = json_of(&call(&client, "command_list", json!({})).await);
|
||||
assert_eq!(l[0]["id"], "file.new");
|
||||
let shot = call(&client, "ui_screenshot", json!({"max_side": 20})).await;
|
||||
@@ -365,6 +488,44 @@ async fn bridge_forwards_to_control_protocol() {
|
||||
app.abort();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_previews_downscale_before_enforcing_the_png_budget() {
|
||||
use photocraft_automation::budgets::MAX_PNG_BYTES;
|
||||
|
||||
let (width, height) = (1400, 1000);
|
||||
let mut pixels = vec![0; width * height * 4];
|
||||
let mut state = 0x6d2b_79f5_u32;
|
||||
for pixel in &mut pixels {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 17;
|
||||
state ^= state << 5;
|
||||
*pixel = state as u8;
|
||||
}
|
||||
let image = photocraft_codecs::Image::from_u8(width as u32, height as u32, photocraft_codecs::ChannelLayout::Rgba, pixels).unwrap();
|
||||
let source_png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
assert!(source_png.len() > MAX_PNG_BYTES, "fixture must exceed the PNG budget");
|
||||
|
||||
let (addr, app) = fake_app_with_screenshot(Some(source_png)).await;
|
||||
let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await;
|
||||
|
||||
for (tool, args) in [("ui_screenshot", json!({"max_side": 32})), ("doc_render_preview", json!({"max_side": 32}))] {
|
||||
let reply = call(&client, tool, args).await;
|
||||
assert_ne!(reply.is_error, Some(true), "{tool}: {}", text(&reply));
|
||||
let img = reply.content.iter().find_map(|content| content.as_image()).expect("preview image");
|
||||
let png = base64::engine::general_purpose::STANDARD.decode(&img.data).unwrap();
|
||||
assert!(png.len() <= MAX_PNG_BYTES, "{tool} returned {} bytes", png.len());
|
||||
let decoded = photocraft_codecs::decode(&png).unwrap();
|
||||
assert_eq!(decoded.dimensions(), (32, 22), "{tool}");
|
||||
}
|
||||
|
||||
let full_size = call(&client, "ui_screenshot", json!({})).await;
|
||||
assert_eq!(full_size.is_error, Some(true));
|
||||
assert!(text(&full_size).contains("automation PNG exceeds"), "{}", text(&full_size));
|
||||
|
||||
client.cancel().await.unwrap();
|
||||
app.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_rejects_non_loopback() {
|
||||
assert!(PhotocraftMcp::bridge("10.0.0.5:7878", CONTROL_TOKEN).is_err());
|
||||
@@ -418,6 +579,97 @@ async fn command_batch_runs_steps_in_order() {
|
||||
client.cancel().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn command_batch_step_without_waiting_starts_a_background_job() {
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
json_of(&call(&client, "doc_new", json!({"width": 600, "height": 400})).await);
|
||||
let blur = json!({"id": "filter.blur.gaussianBlur", "params": {"radius": 40}, "wait": false});
|
||||
let r = json_of(
|
||||
&call(&client, "command_batch", json!({"steps": [{"id": "layer.new.layer"}, {"id": "edit.fill", "params": {"color": "#808080"}}, blur]})).await,
|
||||
);
|
||||
assert_eq!(r["completed"], 3, "{r}");
|
||||
let started = &r["results"][2]["result"];
|
||||
assert_eq!(started["pending"], true, "the step returned without waiting: {r}");
|
||||
let job = started["job"].as_u64().expect("a job id");
|
||||
// The job is listed and finishes like one started by command_run.
|
||||
let t = std::time::Instant::now();
|
||||
loop {
|
||||
let l = json_of(&call(&client, "jobs_list", json!({})).await);
|
||||
let state = l["jobs"].as_array().unwrap().iter().find(|j| j["id"] == job).map(|j| j["state"].clone());
|
||||
assert!(state.is_some(), "job {job} not listed: {l}");
|
||||
if state == Some(json!("done")) {
|
||||
break;
|
||||
}
|
||||
assert!(t.elapsed().as_secs() < 60, "{l}");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
// Without `wait` a step still returns the command's own result.
|
||||
let r = json_of(&call(&client, "command_batch", json!({"steps": [{"id": "filter.blur.gaussianBlur", "params": {"radius": 2}}]})).await);
|
||||
assert!(r["results"][0]["result"]["filter"].is_object(), "{r}");
|
||||
client.cancel().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_command_batch_forwards_each_steps_wait() {
|
||||
let (addr, app) = fake_app().await;
|
||||
let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await;
|
||||
let r = json_of(&call(&client, "command_batch", json!({"steps": [{"id": "filter.blur.gaussianBlur", "wait": false}, {"id": "layer.new.layer"}]})).await);
|
||||
assert_eq!(r["completed"], 2, "{r}");
|
||||
assert_eq!(r["results"][0]["result"]["wait"], false, "{r}");
|
||||
assert_eq!(r["results"][1]["result"]["wait"], true, "{r}");
|
||||
client.cancel().await.unwrap();
|
||||
app.abort();
|
||||
}
|
||||
|
||||
/// #514: `ui_pointer` forwards `button` (a right-click opens the layer menu or the Brush Preset
|
||||
/// picker) and rejects arguments it doesn't forward instead of dropping them.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_ui_pointer_forwards_the_button_and_rejects_unknown_arguments() {
|
||||
let (addr, app) = fake_app().await;
|
||||
let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await;
|
||||
let events = json!([{"kind": "down", "x": 5, "y": 6}, {"kind": "up", "x": 5, "y": 6}]);
|
||||
let sent = json_of(&call(&client, "ui_pointer", json!({"events": events, "button": "right", "modifiers": {"command": true}})).await);
|
||||
assert_eq!(sent, json!({"events": events, "button": "right", "modifiers": {"command": true}}));
|
||||
let sent = json_of(&call(&client, "ui_pointer", json!({"events": events})).await);
|
||||
assert_eq!(sent, json!({"events": events}), "unset fields are not sent");
|
||||
for bad in [json!({"events": events, "buton": "right"}), json!({"events": events, "space": true})] {
|
||||
let Value::Object(args) = bad.clone() else { unreachable!() };
|
||||
let r = client.call_tool(CallToolRequestParams::new("ui_pointer").with_arguments(args)).await;
|
||||
assert!(!r.as_ref().is_ok_and(|r| r.is_error != Some(true)), "{bad} accepted: {r:?}");
|
||||
}
|
||||
let tools = client.list_all_tools().await.unwrap();
|
||||
let schema = &tools.iter().find(|t| t.name == "ui_pointer").unwrap().input_schema;
|
||||
assert_eq!(schema.get("additionalProperties"), Some(&json!(false)), "{schema:?}");
|
||||
assert!(schema.get("properties").and_then(|p| p.get("button")).is_some(), "{schema:?}");
|
||||
client.cancel().await.unwrap();
|
||||
app.abort();
|
||||
}
|
||||
|
||||
/// #368: agents can open the clipboard as a document of its own.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn new_from_clipboard_opens_the_copy_as_a_document() {
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
json_of(&call(&client, "doc_new", json!({"width": 40, "height": 30})).await);
|
||||
let r = json_of(
|
||||
&call(
|
||||
&client,
|
||||
"command_batch",
|
||||
json!({"steps": [
|
||||
{"id": "select.rect", "params": {"x": 5, "y": 5, "width": 12, "height": 7}},
|
||||
{"id": "edit.copy"},
|
||||
{"id": "file.newFromClipboard"}
|
||||
]}),
|
||||
)
|
||||
.await,
|
||||
);
|
||||
assert_eq!(r["completed"], 3, "{r}");
|
||||
let doc = json_of(&call(&client, "doc_inspect", json!({})).await);
|
||||
assert_eq!((doc["width"].as_u64(), doc["height"].as_u64()), (Some(12), Some(7)), "{doc}");
|
||||
let sess = json_of(&call(&client, "session_list", json!({})).await);
|
||||
assert_eq!(sess["documents"].as_array().unwrap().len(), 2);
|
||||
client.cancel().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn command_batch_rejects_too_many_steps() {
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
|
||||
@@ -22,6 +22,9 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou
|
||||
* `Lut3d::from_transform(&t, 33).to_rgba16f_bytes()`: display transform as a 3D texture.
|
||||
* `transform::cached(src, dst, opts)`: process-wide transform cache.
|
||||
* `Profile::to_bytes()`: original bytes of parsed profiles (byte-exact), or an ICC v4.3 encoding.
|
||||
* `Profile::same_colors(&other)`: the same colour space whatever the encoding (every sample of
|
||||
a device grid within ΔE 0.5 through both), e.g. Photoshop's sRGB IEC61966-2.1 and the built-in
|
||||
sRGB. `content_hash()` compares bytes and suits cache keys only.
|
||||
|
||||
### Evaluation
|
||||
|
||||
|
||||
@@ -34,6 +34,15 @@ fn err<T>(msg: impl Into<String>) -> Result<T, LutError> {
|
||||
Err(LutError(msg.into()))
|
||||
}
|
||||
|
||||
fn checked_data_len(size: usize) -> Result<(usize, usize), LutError> {
|
||||
if !(2..=MAX_SIZE).contains(&size) {
|
||||
return err(format!("unsupported LUT size {size}"));
|
||||
}
|
||||
let entries = size.checked_pow(3).ok_or_else(|| LutError(format!("unsupported LUT size {size}")))?;
|
||||
let data_len = entries.checked_mul(3).ok_or_else(|| LutError(format!("unsupported LUT size {size}")))?;
|
||||
Ok((entries, data_len))
|
||||
}
|
||||
|
||||
impl LutFile {
|
||||
/// The identity table of edge `size`.
|
||||
pub fn identity(size: usize) -> Self {
|
||||
@@ -56,11 +65,9 @@ impl LutFile {
|
||||
}
|
||||
|
||||
fn check(self) -> Result<Self, LutError> {
|
||||
if self.size < 2 || self.size > MAX_SIZE {
|
||||
return err(format!("unsupported LUT size {}", self.size));
|
||||
}
|
||||
if self.data.len() != self.size.pow(3) * 3 {
|
||||
return err(format!("expected {} entries, found {}", self.size.pow(3), self.data.len() / 3));
|
||||
let (entries, data_len) = checked_data_len(self.size)?;
|
||||
if self.data.len() != data_len {
|
||||
return err(format!("expected {entries} entries, found {}", self.data.len() / 3));
|
||||
}
|
||||
if self.data.iter().any(|v| !v.is_finite()) {
|
||||
return err("non-finite LUT value");
|
||||
@@ -128,7 +135,11 @@ pub fn parse_cube(text: &str) -> Result<LutFile, LutError> {
|
||||
if size3 > 0 {
|
||||
return LutFile { title, size: size3, data: rows }.check();
|
||||
}
|
||||
if size1 >= 2 && rows.len() == size1 * 3 {
|
||||
if size1 >= 2 {
|
||||
let expected_rows = size1.checked_mul(3).ok_or_else(|| LutError(format!("unsupported 1D LUT size {size1}")))?;
|
||||
if rows.len() != expected_rows {
|
||||
return err("no LUT_3D_SIZE in .cube file");
|
||||
}
|
||||
let curve = |ch: usize, v: f32| {
|
||||
let x = v.clamp(0.0, 1.0) * (size1 - 1) as f32;
|
||||
let i = (x.floor() as usize).min(size1 - 2);
|
||||
@@ -184,12 +195,13 @@ pub fn parse_look(text: &str) -> Result<LutFile, LutError> {
|
||||
Some(text[start..end].trim().trim_matches('"').trim().to_string())
|
||||
};
|
||||
let size: usize = tag("size").and_then(|s| s.parse().ok()).ok_or_else(|| LutError("no <size> in .look".into()))?;
|
||||
let (entries, _) = checked_data_len(size)?;
|
||||
let hex: Vec<u8> = tag("data").ok_or_else(|| LutError("no <data> in .look".into()))?.bytes().filter(u8::is_ascii_hexdigit).collect();
|
||||
let nib = |c: u8| (c as char).to_digit(16).unwrap_or(0) as u8;
|
||||
let bytes: Vec<u8> = hex.as_chunks::<2>().0.iter().map(|p| nib(p[0]) << 4 | nib(p[1])).collect();
|
||||
let floats: Vec<f32> = bytes.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
|
||||
let n3 = size.pow(3);
|
||||
let data = if floats.len() == n3 * 4 { floats.as_chunks::<4>().0.iter().flat_map(|c| [c[0], c[1], c[2]]).collect() } else { floats };
|
||||
let rgba_entries = entries.checked_mul(4).ok_or_else(|| LutError(format!("unsupported LUT size {size}")))?;
|
||||
let data = if floats.len() == rgba_entries { floats.as_chunks::<4>().0.iter().flat_map(|c| [c[0], c[1], c[2]]).collect() } else { floats };
|
||||
LutFile { title: tag("title").unwrap_or_default(), size, data }.check()
|
||||
}
|
||||
|
||||
@@ -292,6 +304,19 @@ mod tests {
|
||||
assert_eq!(l.size, 33);
|
||||
let last = &l.data[l.data.len() - 3..];
|
||||
assert!((last[0] - 0.5).abs() < 1e-6 && (last[1] - 1.0).abs() < 1e-6);
|
||||
|
||||
let mut larger = String::from("LUT_1D_SIZE 256\n");
|
||||
for i in 0..256 {
|
||||
let value = i as f32 / 255.0;
|
||||
larger.push_str(&format!("{value} {value} {value}\n"));
|
||||
}
|
||||
assert_eq!(parse_cube(&larger).unwrap().size, 33);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cube_1d_size_multiplication_overflow_is_rejected() {
|
||||
let text = format!("LUT_1D_SIZE {}\n", usize::MAX);
|
||||
assert!(parse_cube(&text).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -317,6 +342,12 @@ mod tests {
|
||||
assert_eq!(parse("a.look", xml.as_bytes()).unwrap().data, id.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn look_3d_size_exponent_overflow_is_rejected() {
|
||||
let text = format!("<look><size>{}</size><data>00</data></look>", usize::MAX);
|
||||
assert!(parse_look(&text).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_rejected() {
|
||||
assert!(parse_cube("LUT_3D_SIZE 2\n0 0 0\n").is_err());
|
||||
|
||||
@@ -147,6 +147,11 @@ pub struct Lut {
|
||||
pub stages: Vec<Stage>,
|
||||
}
|
||||
|
||||
/// Largest colour difference (CIE ΔE*ab) for which [`Profile::same_colors`] treats two profiles
|
||||
/// as one colour space: invisible, yet 20× what re-encoding a profile costs (sRGB as v2 tables
|
||||
/// against v4 parametric curves differs by 0.025). Moving sRGB's red primary 2% costs 1.0.
|
||||
pub const SAME_COLORS_MAX_DELTA_E: f64 = 0.5;
|
||||
|
||||
/// A parsed ICC profile.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Profile {
|
||||
@@ -216,6 +221,43 @@ impl Profile {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `self` and `other` describe the same colours: same colour space, and every sample
|
||||
/// of a device-value grid has the same colour through both (relative colorimetric, within
|
||||
/// [`SAME_COLORS_MAX_DELTA_E`]). Different encodings of one space are the same colours: the
|
||||
/// sRGB IEC61966-2.1 Photoshop embeds (v2, 1024-entry tables) and the built-in sRGB (v4,
|
||||
/// parametric curves) differ in every byte. Colours are compared rather than round-tripped
|
||||
/// because CMYK → PCS → CMYK is not an identity even through a single profile.
|
||||
pub fn same_colors(&self, other: &Profile) -> bool {
|
||||
if self.content_hash() == other.content_hash() {
|
||||
return true;
|
||||
}
|
||||
let n = self.channels();
|
||||
if self.color_space != other.color_space || n == 0 || n > 4 {
|
||||
return false;
|
||||
}
|
||||
let lab = crate::builtin::Builtin::LabD50.profile();
|
||||
let to_lab = |p: &Profile| crate::transform::Transform::new(p, lab, Intent::RelativeColorimetric, false);
|
||||
let (Ok(a), Ok(b)) = (to_lab(self), to_lab(other)) else {
|
||||
return false;
|
||||
};
|
||||
// 17 steps per channel catch a TRC that differs only in its toe; 9 keep CMYK at 6561 samples.
|
||||
let steps: usize = if n <= 3 { 17 } else { 9 };
|
||||
// Lab D50 is the PCS, normalised as in ICC v4.
|
||||
let decode = |o: [f32; 3]| [f64::from(o[0]) * 100.0, f64::from(o[1]) * 255.0 - 128.0, f64::from(o[2]) * 255.0 - 128.0];
|
||||
let mut input = [0f32; 4];
|
||||
(0..steps.pow(n as u32)).all(|i| {
|
||||
let mut k = i;
|
||||
for v in input.iter_mut().take(n) {
|
||||
*v = (k % steps) as f32 / (steps - 1) as f32;
|
||||
k /= steps;
|
||||
}
|
||||
let (mut la, mut lb) = ([0f32; 3], [0f32; 3]);
|
||||
a.eval(&input[..n], &mut la);
|
||||
b.eval(&input[..n], &mut lb);
|
||||
math::delta_e76(decode(la), decode(lb)) <= SAME_COLORS_MAX_DELTA_E
|
||||
})
|
||||
}
|
||||
|
||||
/// Media white point used for ICC-absolute colorimetry: `wtpt`, except for v2 display
|
||||
/// profiles whose `wtpt` records the (unadapted) display white; those are treated as D50.
|
||||
pub fn media_white(&self) -> [f64; 3] {
|
||||
|
||||
@@ -590,3 +590,52 @@ fn gray_profile_with_reversed_lut_type_reencodes() {
|
||||
odd.description = "re-encoded".into();
|
||||
let _ = Profile::parse(&odd.with_encoded_bytes().to_bytes()).unwrap();
|
||||
}
|
||||
|
||||
/// sRGB the way Photoshop embeds it: a v2 profile whose TRCs are 1024-entry 16-bit tables.
|
||||
fn srgb_as_v2_tables() -> Profile {
|
||||
let mut p = srgb().clone();
|
||||
let table = |c: &photocraft_cms::Curve| {
|
||||
photocraft_cms::Curve::Table((0..1024).map(|i| ((c.eval64(i as f64 / 1023.0) * 65535.0).round() / 65535.0) as f32).collect())
|
||||
};
|
||||
let trc = p.trc.clone().unwrap();
|
||||
p.trc = Some([table(&trc[0]), table(&trc[1]), table(&trc[2])]);
|
||||
p.version = (2, 0x10);
|
||||
p.with_encoded_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_colors_ignores_the_encoding() {
|
||||
let v2 = Profile::parse(&srgb_as_v2_tables().to_bytes()).unwrap();
|
||||
assert_ne!(v2.content_hash(), srgb().content_hash(), "different bytes");
|
||||
assert!(v2.same_colors(srgb()) && srgb().same_colors(&v2));
|
||||
for b in Builtin::ALL {
|
||||
assert!(b.profile().same_colors(b.profile()), "{b:?}");
|
||||
// Re-encoded (fresh bytes, same model): CMYK too, where a round trip is not an identity.
|
||||
let again = Profile::parse(&b.profile().clone().with_encoded_bytes().to_bytes()).unwrap();
|
||||
assert!(again.same_colors(b.profile()), "{b:?} re-encoded");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_colors_tells_different_spaces_apart() {
|
||||
for other in [Builtin::DisplayP3, Builtin::LinearSrgb, Builtin::AdobeRgbCompat, Builtin::Rec2020, Builtin::CoatedCmyk, Builtin::GrayGamma22] {
|
||||
assert!(!srgb().same_colors(other.profile()), "{other:?}");
|
||||
}
|
||||
assert!(!Builtin::GrayGamma22.profile().same_colors(Builtin::SGray.profile()));
|
||||
// Scaling the red primary's XYZ by 2% makes a different space too.
|
||||
let mut moved = srgb().clone();
|
||||
let m = moved.matrix.as_mut().unwrap();
|
||||
for row in m.iter_mut() {
|
||||
row[0] *= 1.02;
|
||||
}
|
||||
assert!(!moved.with_encoded_bytes().same_colors(srgb()));
|
||||
}
|
||||
|
||||
/// The sRGB IEC61966-2.1 profile Windows ships (the one Photoshop embeds), when present.
|
||||
#[test]
|
||||
fn windows_srgb_is_the_builtin_srgb() {
|
||||
let Ok(bytes) = std::fs::read("C:/Windows/System32/spool/drivers/color/sRGB Color Space Profile.icm") else { return };
|
||||
let p = Profile::parse(&bytes).unwrap();
|
||||
assert_ne!(p.content_hash(), srgb().content_hash());
|
||||
assert!(p.same_colors(srgb()));
|
||||
}
|
||||
@@ -7,10 +7,16 @@ license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[features]
|
||||
# Real-file corpus tests (opt-in; `cargo xtask test-corpus`). Fail when the corpus is missing.
|
||||
corpus = []
|
||||
default = []
|
||||
# AVIF *encode* via ravif (pure Rust). Decoding AVIF needs dav1d (C), so AVIF
|
||||
# stays read-unsupported and therefore asymmetric; it is off by default.
|
||||
avif = ["image/avif"]
|
||||
# HEIC/HEIF decode via the optional photocraft-heif crate (heic-rs, pure Rust). Off by default so
|
||||
# distributors choose it at build time; official builds and CI enable it (`--features heif` on the
|
||||
# apps). Without it HEIF files are still recognised and opening one is a clear "unsupported" error.
|
||||
heif = ["dep:photocraft-heif"]
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
@@ -24,6 +30,11 @@ image-webp = "0.2"
|
||||
zune-jpeg = "0.5"
|
||||
zune-core = "0.5"
|
||||
jpeg-encoder = "0.6"
|
||||
photocraft-heif = { path = "../heif", optional = true }
|
||||
|
||||
# Orientation is applied a band of rows per task on native; wasm stays single-threaded.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rayon.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
|
||||
+32
-5
@@ -15,7 +15,8 @@ It follows the "avoid GIMP's hole" rules in `plan/architecture.md` §1.1:
|
||||
* **Honest.** `caps(format)` says what each format holds, and
|
||||
`fidelity_warnings(&image, format)` lists what an export will lose before you write it. Both
|
||||
functions use the same encode plan as `encode`, so a warning appears exactly when the file
|
||||
changes.
|
||||
changes. On the way in, `Image::warnings` lists what the decoded image doesn't show: frames or
|
||||
pages left out, or data that ended early.
|
||||
|
||||
```rust
|
||||
use photocraft_codecs::*;
|
||||
@@ -44,6 +45,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
|
||||
| OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` |
|
||||
| Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` |
|
||||
| AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` |
|
||||
| HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` |
|
||||
|
||||
"Native" means the data is stored and read back without conversion. Anything else is converted by
|
||||
the encode plan, and `fidelity_warnings` reports the conversion when it loses information:
|
||||
@@ -64,7 +66,17 @@ the same.
|
||||
with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes
|
||||
and develops them, and `photocraft-io` routes them there.
|
||||
|
||||
* **AVIF (the only asymmetric format).** Encoding uses `ravif`, which is pure Rust. Decoding
|
||||
* **HEIF/HEIC (read-only, feature `heif`).** Decoding lives in the optional `photocraft-heif` crate
|
||||
behind the non-default `heif` feature (official PhotoCraft builds enable it; distributors choose).
|
||||
Without it, HEIF is still detected and decoding returns `CodecError::Unsupported` ("HEIC/HEIF
|
||||
support isn't included in this build"). iPhone and Mac photos decode with `heic-rs` (pure Rust, no `unsafe`):
|
||||
single pictures and grid-tiled photos, 8 to 12 bits, auxiliary alpha, ICC, EXIF and XMP. The
|
||||
container's `irot`/`imir`/`clap` are applied and EXIF Orientation is rewritten to 1 (HEIF's EXIF
|
||||
tag only repeats the container's, and applying both would turn the photo twice). Grayscale
|
||||
decodes to RGB. Image sequences, overlays and identity derivations, multilayer HEVC and some
|
||||
4:2:2/4:4:4 streams return `CodecError::Unsupported` or `Malformed`, never wrong pixels. Writing
|
||||
needs an HEVC encoder and every mature one is C, so HEIF is listed in `ASYMMETRIC_EXCEPTIONS`.
|
||||
* **AVIF.** Encoding uses `ravif`, which is pure Rust. Decoding
|
||||
needs `dav1d`, which is C. AVIF is therefore read-unsupported, and write support is gated
|
||||
behind the non-default `avif` feature. In a default build it is neither readable nor writable,
|
||||
so the symmetric guarantee holds. It is listed in `ASYMMETRIC_EXCEPTIONS`.
|
||||
@@ -73,12 +85,27 @@ the same.
|
||||
files.
|
||||
* **Animation and multi-page files** (APNG, animated GIF/WebP, multi-page TIFF): only the first
|
||||
frame or page is decoded, and a single frame is written. `FormatCaps::animation` marks
|
||||
containers that can hold more frames.
|
||||
containers that can hold more frames. The decoded image then carries a
|
||||
`DecodeWarning::MoreFrames` / `MorePages` in `Image::warnings`, with the total when the file
|
||||
states it (frames and TIFF directories are counted without decoding them; reduced-resolution
|
||||
TIFF directories and transparency masks are not pages).
|
||||
* **EXIF in TIFF** is stored as a sub-IFD rather than a blob, so it is not preserved yet.
|
||||
`caps.exif = false` for TIFF, and a warning is raised.
|
||||
* **EXIF orientation** is preserved as data but not applied to the pixels.
|
||||
* **Orientation** (EXIF tag 274 in JPEG, PNG `eXIf` and WebP; the IFD0 tag in TIFF) is applied on
|
||||
decode, like Photoshop: the pixels come back upright and the EXIF/XMP orientation is rewritten
|
||||
to 1 (`DecodeOptions::keep_orientation` opts out). Encoders always write Orientation = 1
|
||||
(`upright_exif`, `upright_xmp`, which change only the tag's value, kept in its own type), so upright pixels
|
||||
are never rotated twice. Parsing is strict: an IFD0 inside the 8-byte header or cut off before
|
||||
its next-IFD pointer, an Orientation that is not exactly one SHORT or LONG, or an out-of-range value
|
||||
reads as 1; with duplicate entries the first wins. `Limits` are checked again on the upright
|
||||
size (5–8 swap width and height), and the rotated buffer is allocated fallibly.
|
||||
`Image::oriented` turns any layout and depth in parallel bands (about 10 ms for 24 MP RGB8 in
|
||||
release).
|
||||
* **JPEG**
|
||||
* 8-bit only. Neither decoder backend supports 12-bit.
|
||||
* A file cut off inside its image data decodes leniently (a baseline JPEG's missing rows come
|
||||
out grey) but never silently: the image carries `DecodeWarning::Truncated`. A file that ends
|
||||
before any scan data is an error.
|
||||
* CMYK is always written 4:4:4 (subsampled CMYK is not portable) as Adobe-inverted CMYK with an
|
||||
APP14 marker.
|
||||
* Text is not written because there is no COM-segment support.
|
||||
@@ -102,7 +129,7 @@ the same.
|
||||
|
||||
## Limits (decompression bombs)
|
||||
|
||||
`DecodeOptions { limits: Limits { max_width, max_height, max_pixels, max_alloc } }` guards
|
||||
`DecodeOptions { limits: Limits { max_width, max_height, max_pixels, max_alloc }, .. }` guards
|
||||
decoding. Header dimensions are checked before the pixel buffer is allocated, and the budget is
|
||||
also passed to the underlying decoders. A violation returns `CodecError::LimitExceeded`. The
|
||||
defaults are 262144 px per side, 2^30 pixels and 8 GiB (2 GiB on 32-bit targets such as wasm).
|
||||
|
||||
@@ -14,7 +14,9 @@ cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
photocraft-codecs = { path = ".." }
|
||||
photocraft-codecs = { path = "..", features = ["heif"] }
|
||||
# Called directly too: the codec turns its panics into errors (see decode_heif.rs).
|
||||
heic-rs = "=0.1.1"
|
||||
|
||||
[profile.release]
|
||||
debug = 1
|
||||
@@ -53,3 +55,10 @@ path = "fuzz_targets/decode_exr.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "decode_heif"
|
||||
path = "fuzz_targets/decode_heif.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
@@ -8,6 +8,7 @@ use photocraft_codecs::{decode_with, encode, DecodeOptions, EncodeOptions, Limit
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let opts = DecodeOptions {
|
||||
limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 },
|
||||
..Default::default()
|
||||
};
|
||||
if let Ok(img) = decode_with(data, &opts)
|
||||
&& let Some(f) = photocraft_codecs::detect(data)
|
||||
|
||||
@@ -6,6 +6,7 @@ use photocraft_codecs::{decode_as_with, DecodeOptions, Format, Limits};
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let opts = DecodeOptions {
|
||||
limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 },
|
||||
..Default::default()
|
||||
};
|
||||
let _ = decode_as_with(Format::OpenExr, data, &opts);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_codecs::{decode_as_with, DecodeOptions, Format, Limits};
|
||||
|
||||
// heic-rs is young and parses untrusted HEIF/HEVC: run with `-timeout=10` so a hang is reported,
|
||||
// not just a panic. The codec turns a heic-rs panic into an error, so heic-rs is also called
|
||||
// directly, where its panics still reach the fuzzer. Both orientation paths are covered.
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let keep_orientation = data.first().is_some_and(|b| b & 1 == 1);
|
||||
let raw = heic_rs::DecodeOptions::default().with_max_pixels(Some(1 << 22)).with_transforms(!keep_orientation).with_alpha(true);
|
||||
let _ = heic_rs::decode(data, &raw);
|
||||
let limits = Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 };
|
||||
let _ = decode_as_with(Format::Heif, data, &DecodeOptions { limits, keep_orientation });
|
||||
});
|
||||
@@ -6,6 +6,7 @@ use photocraft_codecs::{decode_as_with, DecodeOptions, Format, Limits};
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let opts = DecodeOptions {
|
||||
limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 },
|
||||
..Default::default()
|
||||
};
|
||||
let _ = decode_as_with(Format::Jpeg, data, &opts);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ use photocraft_codecs::{decode_as_with, DecodeOptions, Format, Limits};
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let opts = DecodeOptions {
|
||||
limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 },
|
||||
..Default::default()
|
||||
};
|
||||
let _ = decode_as_with(Format::Png, data, &opts);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ use photocraft_codecs::{decode_as_with, DecodeOptions, Format, Limits};
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let opts = DecodeOptions {
|
||||
limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 },
|
||||
..Default::default()
|
||||
};
|
||||
let _ = decode_as_with(Format::Tiff, data, &opts);
|
||||
});
|
||||
@@ -0,0 +1,568 @@
|
||||
//! Deep OpenEXR (`deepscanline` / `deeptile`): a variable-length list of samples per pixel,
|
||||
//! each with its own colour, alpha and depth. The `exr` crate rejects deep headers, so the
|
||||
//! chunk stream is walked directly from the bytes, per the OpenEXR file-layout specification:
|
||||
//! a deep chunk carries its coordinates, the packed size of the sample-count table, the
|
||||
//! packed and unpacked sizes of the sample data, then the compressed count table (cumulative
|
||||
//! within each row of the block) and the compressed sample data (row by row, each row channel
|
||||
//! by channel). The layout was checked against files written by the OpenEXR library. Deep data permits only NONE, RLE, ZIPS and ZIP
|
||||
//! compression; we support the first three (one scan line per block, which keeps the count
|
||||
//! table unambiguous) and decline ZIP with `unsupported`.
|
||||
//!
|
||||
//! [`decode_deep`] returns the structured samples ([`DeepImage`]); [`flatten`] composites
|
||||
//! them into a flat `Image` — samples sorted by Z, front-to-back `over` with premultiplied
|
||||
//! colour, un-premultiplied into straight alpha (HDR values are kept, not clamped) — because the flat document model has no
|
||||
//! per-pixel depth.
|
||||
|
||||
use std::io::{Cursor, Read, Seek, SeekFrom};
|
||||
|
||||
use exr::meta::attribute::{LevelMode, SampleType as ExrSample};
|
||||
use exr::meta::{BlockDescription, MetaData};
|
||||
use exr::prelude::Compression;
|
||||
use half::f16;
|
||||
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::image::{ChannelLayout, DecodeWarning, DeepChannel, DeepImage, Image, SampleType};
|
||||
use crate::options::Limits;
|
||||
|
||||
const F: Format = Format::OpenExr;
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> CodecError {
|
||||
CodecError::malformed(F, e)
|
||||
}
|
||||
|
||||
fn unsup(reason: impl Into<String>) -> CodecError {
|
||||
CodecError::unsupported(F, reason)
|
||||
}
|
||||
|
||||
// --- checked little-endian reads -------------------------------------------------------------
|
||||
// Every length and offset comes from the file, so every read is bounds-checked and a short
|
||||
// read is a malformed file, never a panic.
|
||||
|
||||
fn read_exact(cur: &mut Cursor<&[u8]>, n: usize, what: &str) -> Result<Vec<u8>, CodecError> {
|
||||
let mut v = vec![0u8; n];
|
||||
cur.read_exact(&mut v).map_err(|_| err(format!("truncated file while reading {what}")))?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn i32(cur: &mut Cursor<&[u8]>) -> Result<i32, CodecError> {
|
||||
let b = read_exact(cur, 4, "an i32")?;
|
||||
Ok(i32::from_le_bytes([b.first().copied().unwrap_or(0), b.get(1).copied().unwrap_or(0), b.get(2).copied().unwrap_or(0), b.get(3).copied().unwrap_or(0)]))
|
||||
}
|
||||
|
||||
fn u64(cur: &mut Cursor<&[u8]>) -> Result<u64, CodecError> {
|
||||
let b = read_exact(cur, 8, "a u64")?;
|
||||
let a: [u8; 8] = b.try_into().unwrap_or([0; 8]);
|
||||
Ok(u64::from_le_bytes(a))
|
||||
}
|
||||
|
||||
fn seek_to(cur: &mut Cursor<&[u8]>, pos: u64) -> Result<(), CodecError> {
|
||||
if pos > cur.get_ref().len() as u64 {
|
||||
return Err(err(format!("chunk offset {pos} points past the end of the file")));
|
||||
}
|
||||
cur.seek(SeekFrom::Start(pos)).map_err(err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- decompression ----------------------------------------------------------------------------
|
||||
|
||||
/// Undo the ZIP predictor: each byte was stored as its difference to the previous byte plus
|
||||
/// 128 (so a zero difference is 0x80), modulo 256. Applies to the sample-count table and the
|
||||
/// sample data alike.
|
||||
fn differences_to_samples(buf: &mut [u8]) {
|
||||
if let Some(&first) = buf.first() {
|
||||
let mut prev = i16::from(first);
|
||||
for b in buf.iter_mut().skip(1) {
|
||||
let s = (prev + i16::from(*b) - 128) as u8;
|
||||
prev = i16::from(s);
|
||||
*b = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo the ZIP half-split: the stream is `[first half][second half]` with the second half
|
||||
/// holding every second byte of the original; weave them back together.
|
||||
fn interleave_byte_blocks(buf: &mut [u8]) {
|
||||
let half = buf.len().div_ceil(2);
|
||||
let mut out = vec![0u8; buf.len()];
|
||||
let mut k = 0usize;
|
||||
for i in 0..buf.len() / 2 {
|
||||
let (a, b) = (buf.get(i).copied(), buf.get(half + i).copied());
|
||||
if let (Some(a), Some(b), Some(pair)) = (a, b, out.get_mut(k..k + 2)) {
|
||||
if let Some(byte) = pair.first_mut() {
|
||||
*byte = a;
|
||||
}
|
||||
if let Some(byte) = pair.get_mut(1) {
|
||||
*byte = b;
|
||||
}
|
||||
}
|
||||
k += 2;
|
||||
}
|
||||
if buf.len() % 2 == 1 && half > 0 {
|
||||
let last = buf.get(half - 1).copied();
|
||||
if let (Some(last), Some(slot)) = (last, out.get_mut(k)) {
|
||||
*slot = last;
|
||||
}
|
||||
}
|
||||
buf.copy_from_slice(&out);
|
||||
}
|
||||
|
||||
/// OpenEXR RLE tokens: a negative byte `n` means `-n` literal bytes follow, a non-negative
|
||||
/// byte `n` means the next byte repeats `n + 1` times (as `ImfRle.cpp` and the `exr` crate).
|
||||
fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecError> {
|
||||
let mut out = Vec::with_capacity(expected.min(1 << 20));
|
||||
let mut i = 0;
|
||||
while i < src.len() {
|
||||
let count = src[i] as i8;
|
||||
i += 1;
|
||||
let n = if count < 0 { (-(count as i32)) as usize } else { count as usize + 1 };
|
||||
if out.len().saturating_add(n) > expected {
|
||||
return Err(err(format!("{what}: RLE data expands past {expected} bytes")));
|
||||
}
|
||||
if count < 0 {
|
||||
let end = i.saturating_add(n);
|
||||
let run = src.get(i..end.min(src.len())).ok_or_else(|| err(format!("{what}: RLE run points past the compressed data")))?;
|
||||
if run.len() != n {
|
||||
return Err(err(format!("{what}: RLE run points past the compressed data")));
|
||||
}
|
||||
out.extend_from_slice(run);
|
||||
i = end;
|
||||
} else {
|
||||
let b = src.get(i).copied().ok_or_else(|| err(format!("{what}: RLE run points past the compressed data")))?;
|
||||
i += 1;
|
||||
out.resize(out.len() + n, b);
|
||||
}
|
||||
}
|
||||
if out.len() != expected {
|
||||
return Err(err(format!("{what}: RLE data expands to {} bytes, expected {expected}", out.len())));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decompress `what` to exactly `expected` bytes. Like flat blocks, a payload that already
|
||||
/// has the uncompressed length is stored raw whatever the declared compression.
|
||||
fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecError> {
|
||||
if packed.len() == expected {
|
||||
return Ok(packed.to_vec());
|
||||
}
|
||||
match compression {
|
||||
Compression::Uncompressed => Err(err(format!("{what}: expected {expected} uncompressed bytes, found {}", packed.len()))),
|
||||
Compression::RLE => {
|
||||
// Like ZIPS, RLE runs over the predicted, byte-split stream.
|
||||
let mut out = rle_decode(packed, expected, what)?;
|
||||
differences_to_samples(&mut out);
|
||||
interleave_byte_blocks(&mut out);
|
||||
Ok(out)
|
||||
}
|
||||
Compression::ZIP1 => {
|
||||
let mut out = Vec::with_capacity(expected.min(1 << 20));
|
||||
flate2::read::ZlibDecoder::new(packed)
|
||||
.take(expected as u64 + 1)
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|_| err(format!("{what}: zlib data is malformed")))?;
|
||||
if out.len() != expected {
|
||||
return Err(err(format!("{what}: zlib data expands to {} bytes, expected {expected}", out.len())));
|
||||
}
|
||||
differences_to_samples(&mut out);
|
||||
interleave_byte_blocks(&mut out);
|
||||
Ok(out)
|
||||
}
|
||||
other => Err(unsup(format!("deep data with {other:?} compression (NONE, RLE and ZIPS are supported)"))),
|
||||
}
|
||||
}
|
||||
|
||||
// --- the deep chunk stream ---------------------------------------------------------------------
|
||||
|
||||
/// One parsed chunk: where its pixels go and its cumulative sample counts, plus where to
|
||||
/// find the compressed sample data in the second pass.
|
||||
struct DeepChunk {
|
||||
/// Global pixel indices of the block's pixels, in block order (row-major within the block).
|
||||
pixels: Vec<usize>,
|
||||
/// Pixels per row of the block (the data window width for a scan line, the clipped tile
|
||||
/// width for a tile).
|
||||
row_w: usize,
|
||||
/// Cumulative sample count table of the block: `len == pixels.len() + 1`.
|
||||
prefix: Vec<u32>,
|
||||
/// File offset of the chunk start.
|
||||
pos: u64,
|
||||
/// Size of the compressed sample data in the file.
|
||||
packed_data: u64,
|
||||
/// The decompressed sample-data size the file declares (validated against the table).
|
||||
unpacked_data: u64,
|
||||
}
|
||||
|
||||
fn sample_bytes(t: ExrSample) -> usize {
|
||||
match t {
|
||||
ExrSample::U32 | ExrSample::F32 => 4,
|
||||
ExrSample::F16 => 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn our_sample_type(t: ExrSample) -> SampleType {
|
||||
match t {
|
||||
ExrSample::F16 => SampleType::F16,
|
||||
ExrSample::U32 | ExrSample::F32 => SampleType::F32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the deep samples of the first deep part. `meta` must come from an unvalidated
|
||||
/// [`MetaData::read_from_buffered`] of the same bytes (deep headers fail validation).
|
||||
pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Result<DeepImage, CodecError> {
|
||||
let header_index = meta.headers.iter().position(|h| h.deep).ok_or_else(|| unsup("the file has no deep part"))?;
|
||||
let header = meta.headers.get(header_index).ok_or_else(|| unsup("the file has no deep part"))?;
|
||||
let compression = header.compression;
|
||||
if !matches!(compression, Compression::Uncompressed | Compression::RLE | Compression::ZIP1) {
|
||||
return Err(unsup(format!("deep data with {compression:?} compression (NONE, RLE and ZIPS are supported)")));
|
||||
}
|
||||
|
||||
let dw = header.data_window();
|
||||
let (w, h) = (dw.size.0, dw.size.1);
|
||||
if w == 0 || h == 0 {
|
||||
return Err(err("the data window is empty"));
|
||||
}
|
||||
if w > u32::MAX as usize || h > u32::MAX as usize {
|
||||
return Err(err("the data window is too large"));
|
||||
}
|
||||
let y0 = dw.position.1;
|
||||
let npixels = w.checked_mul(h).ok_or_else(|| err("the data window is too large"))?;
|
||||
limits.check_bytes(w as u32, h as u32, (header.channels.list.len().max(1) as u64) * 4)?;
|
||||
|
||||
let exr_types: Vec<ExrSample> = header.channels.list.iter().map(|c| c.sample_type).collect();
|
||||
let bytes_per_sample: usize = exr_types.iter().map(|t| sample_bytes(*t)).sum();
|
||||
let multipart = meta.requirements.has_multiple_layers;
|
||||
|
||||
// The offset tables follow the headers: `chunk_count` u64 per header, in header order.
|
||||
// Our part's table starts after the tables of all earlier parts.
|
||||
let mut table_pos = header_end(bytes)?;
|
||||
for h_ in meta.headers.iter().take(header_index) {
|
||||
table_pos = table_pos.saturating_add((h_.chunk_count.min(1 << 24) as u64).saturating_mul(8));
|
||||
}
|
||||
let mut cur = Cursor::new(bytes);
|
||||
seek_to(&mut cur, table_pos)?;
|
||||
let mut offsets = Vec::with_capacity(header.chunk_count.min(1 << 20));
|
||||
for _ in 0..header.chunk_count {
|
||||
offsets.push(u64(&mut cur)?);
|
||||
}
|
||||
let tables_end = cur.position();
|
||||
|
||||
// Block geometry.
|
||||
let (tiles_x, tile_w, tile_h, slots) = match &header.blocks {
|
||||
BlockDescription::ScanLines => (0usize, w, 1usize, h), // one line per block for NONE/RLE/ZIPS
|
||||
BlockDescription::Tiles(t) => {
|
||||
if t.level_mode != LevelMode::Singular {
|
||||
return Err(unsup("deep mip-map or rip-map tiles (single-level tiles are supported)"));
|
||||
}
|
||||
let (tw, th) = (t.tile_size.0.max(1), t.tile_size.1.max(1));
|
||||
let tx = w.saturating_add(tw - 1) / tw;
|
||||
let ty = h.saturating_add(th - 1) / th;
|
||||
(tx, tw, th, tx.checked_mul(ty).ok_or_else(|| err("tile count overflows"))?)
|
||||
}
|
||||
};
|
||||
|
||||
let mut counts = vec![0u32; npixels];
|
||||
let mut seen = vec![false; slots];
|
||||
let mut chunks: Vec<DeepChunk> = Vec::with_capacity(offsets.len());
|
||||
|
||||
for &off in &offsets {
|
||||
if off < tables_end || off >= bytes.len() as u64 {
|
||||
return Err(err(format!("chunk offset {off} is outside the chunk area of the file")));
|
||||
}
|
||||
seek_to(&mut cur, off)?;
|
||||
if multipart {
|
||||
let part = i32(&mut cur)?;
|
||||
if part < 0 || part as usize != header_index {
|
||||
return Err(err(format!("chunk declares part {part}, expected {header_index}")));
|
||||
}
|
||||
}
|
||||
// Block coordinates → the block's global pixel range and its slot.
|
||||
let (block_pixels, slot, row_w): (Vec<usize>, usize, usize) = match &header.blocks {
|
||||
BlockDescription::ScanLines => {
|
||||
let y = i32(&mut cur)?;
|
||||
let row = match y.checked_sub(y0) {
|
||||
Some(d) if d >= 0 && (d as usize) < h => d as usize,
|
||||
_ => return Err(err(format!("scan line block y={y} is outside the data window"))),
|
||||
};
|
||||
((0..w).map(|x| row * w + x).collect(), row, w)
|
||||
}
|
||||
BlockDescription::Tiles(_) => {
|
||||
let (tx, ty, lx, ly) = (i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?);
|
||||
if (lx, ly) != (0, 0) {
|
||||
return Err(err(format!("deep tile at level ({lx},{ly}); only level 0 exists in a single-level file")));
|
||||
}
|
||||
if tx < 0 || ty < 0 || (tx as usize) >= tiles_x || (ty as usize) * tile_h >= h {
|
||||
return Err(err(format!("tile ({tx},{ty}) is outside the data window")));
|
||||
}
|
||||
let (bx, by) = ((tx as usize) * tile_w, (ty as usize) * tile_h);
|
||||
let rows = (by + tile_h).min(h);
|
||||
let cols = (bx + tile_w).min(w);
|
||||
let mut px = Vec::with_capacity((rows - by) * (cols - bx));
|
||||
for yy in by..rows {
|
||||
for xx in bx..cols {
|
||||
px.push(yy.saturating_mul(w).saturating_add(xx));
|
||||
}
|
||||
}
|
||||
let slot = (ty as usize).saturating_mul(tiles_x) + tx as usize;
|
||||
(px, slot, cols - bx)
|
||||
}
|
||||
};
|
||||
if seen.get(slot).copied().unwrap_or(true) {
|
||||
return Err(err(format!("two chunks cover block {slot}")));
|
||||
}
|
||||
if let Some(s) = seen.get_mut(slot) {
|
||||
*s = true;
|
||||
}
|
||||
|
||||
let packed_table = u64(&mut cur)?;
|
||||
let packed_data = u64(&mut cur)?;
|
||||
let unpacked_data = u64(&mut cur)?;
|
||||
let remaining = (bytes.len() as u64).saturating_sub(cur.position());
|
||||
if packed_table > remaining || packed_data > remaining.saturating_sub(packed_table) {
|
||||
return Err(err("deep chunk sizes point past the end of the file"));
|
||||
}
|
||||
let table_bytes = read_exact(&mut cur, packed_table as usize, "the sample-count table")?;
|
||||
let expected_table = block_pixels.len().checked_mul(4).ok_or_else(|| err("count table overflows"))?;
|
||||
// An edge tile's table may still be stored at the full tile size (its pixels first, in
|
||||
// rows of the clipped width, then unused entries), as the OpenEXR library writes it.
|
||||
let full_table = tile_w.saturating_mul(tile_h).saturating_mul(4).max(expected_table);
|
||||
let raw = decompress(compression, &table_bytes, full_table, "the sample-count table")
|
||||
.or_else(|_| decompress(compression, &table_bytes, expected_table, "the sample-count table"))?;
|
||||
|
||||
// The table is cumulative within each row of the block (a tile restarts at every row);
|
||||
// `prefix` is cumulative over the whole block, and each pixel's own count is the difference.
|
||||
let mut prefix = Vec::with_capacity(block_pixels.len() + 1);
|
||||
prefix.push(0u32);
|
||||
let (mut row_base, mut prev) = (0u32, 0u32);
|
||||
for (i, p) in block_pixels.iter().enumerate() {
|
||||
let b: [u8; 4] = raw.get(i * 4..i * 4 + 4).and_then(|s| s.try_into().ok()).ok_or_else(|| err("the count table is short"))?;
|
||||
let v = u32::from_le_bytes(b);
|
||||
if row_w > 0 && i % row_w == 0 {
|
||||
row_base = row_base.checked_add(prev).ok_or_else(|| err("the block's sample count overflows"))?;
|
||||
prev = 0;
|
||||
}
|
||||
if v < prev {
|
||||
return Err(err("the sample-count table decreases (malformed cumulative counts)"));
|
||||
}
|
||||
if let Some(c) = counts.get_mut(*p) {
|
||||
*c = v - prev;
|
||||
}
|
||||
prev = v;
|
||||
prefix.push(row_base.checked_add(v).ok_or_else(|| err("the block's sample count overflows"))?);
|
||||
}
|
||||
let total = prefix.last().copied().unwrap_or(0) as u64;
|
||||
let expected_data = total.checked_mul(bytes_per_sample as u64).ok_or_else(|| err("the sample data size overflows"))?;
|
||||
if expected_data != unpacked_data {
|
||||
return Err(err(format!("the chunk declares {unpacked_data} bytes of sample data, the count table implies {expected_data}")));
|
||||
}
|
||||
chunks.push(DeepChunk { pixels: block_pixels, row_w, prefix, pos: off, packed_data, unpacked_data });
|
||||
}
|
||||
if seen.iter().any(|s| !s) {
|
||||
let missing = seen.iter().filter(|s| !**s).count();
|
||||
return Err(err(format!("{missing} of {slots} chunks are missing from the offset table")));
|
||||
}
|
||||
|
||||
// Global cumulative counts in raster order.
|
||||
let mut deep_counts = Vec::with_capacity(npixels + 1);
|
||||
deep_counts.push(0u64);
|
||||
let mut acc = 0u64;
|
||||
for &c in &counts {
|
||||
acc = acc.checked_add(c as u64).ok_or_else(|| err("the total sample count overflows"))?;
|
||||
deep_counts.push(acc);
|
||||
}
|
||||
let total_samples = acc;
|
||||
let sample_bytes_total = total_samples.checked_mul(exr_types.len() as u64 * 4).ok_or_else(|| err("the sample buffer size overflows"))?;
|
||||
if sample_bytes_total > limits.max_alloc {
|
||||
return Err(CodecError::LimitExceeded(format!("deep samples need {sample_bytes_total} bytes, exceeding max_alloc {}", limits.max_alloc)));
|
||||
}
|
||||
|
||||
let mut channels: Vec<DeepChannel> = header
|
||||
.channels
|
||||
.list
|
||||
.iter()
|
||||
.map(|c| DeepChannel { name: c.name.to_string(), sample: our_sample_type(c.sample_type), samples: vec![0f32; total_samples as usize] })
|
||||
.collect();
|
||||
|
||||
// Second pass: the compressed sample data, scattered per pixel (a block's channel data
|
||||
// is contiguous in the file but the pixels' samples interleave with other blocks').
|
||||
for chunk in &chunks {
|
||||
seek_to(&mut cur, chunk.pos)?;
|
||||
if multipart {
|
||||
let _part = i32(&mut cur)?;
|
||||
}
|
||||
match &header.blocks {
|
||||
BlockDescription::ScanLines => {
|
||||
let _y = i32(&mut cur)?;
|
||||
}
|
||||
BlockDescription::Tiles(_) => {
|
||||
let _coords = (i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?);
|
||||
}
|
||||
}
|
||||
let packed_table = u64(&mut cur)?;
|
||||
let _packed_data = u64(&mut cur)?;
|
||||
let _unpacked_data = u64(&mut cur)?;
|
||||
// The count table sits between the sizes and the sample data; skip its bytes.
|
||||
read_exact(&mut cur, packed_table as usize, "the sample-count table")?;
|
||||
let data_bytes = read_exact(&mut cur, chunk.packed_data as usize, "the deep sample data")?;
|
||||
let raw = decompress(compression, &data_bytes, chunk.unpacked_data as usize, "the deep sample data")?;
|
||||
// Row by row (a scan line block is one row), and within a row channel by channel, as
|
||||
// flat tiles are laid out: each pixel's samples are contiguous within its channel.
|
||||
let mut in_off = 0usize;
|
||||
let row_w = chunk.row_w.max(1);
|
||||
for (r, row) in chunk.pixels.chunks(row_w).enumerate() {
|
||||
let i0 = r * row_w;
|
||||
let row_from = chunk.prefix.get(i0).copied().unwrap_or(0) as usize;
|
||||
let row_to = chunk.prefix.get(i0 + row.len()).copied().unwrap_or(0) as usize;
|
||||
let row_total = row_to.saturating_sub(row_from);
|
||||
for (ci, t) in exr_types.iter().enumerate() {
|
||||
let sb = sample_bytes(*t);
|
||||
let ch_bytes = row_total.checked_mul(sb).ok_or_else(|| err("channel size overflows"))?;
|
||||
let end = in_off.checked_add(ch_bytes).ok_or_else(|| err("channel size overflows"))?;
|
||||
let src = raw.get(in_off..end).ok_or_else(|| err("the sample data is short"))?;
|
||||
in_off = end;
|
||||
let out = channels.get_mut(ci).ok_or_else(|| err("channel index out of range"))?;
|
||||
for (j, p) in row.iter().enumerate() {
|
||||
let from = (chunk.prefix.get(i0 + j).copied().unwrap_or(0) as usize).saturating_sub(row_from);
|
||||
let n = (chunk.prefix.get(i0 + j + 1).copied().unwrap_or(0) as usize).saturating_sub(row_from).saturating_sub(from);
|
||||
let to = deep_counts.get(*p).copied().unwrap_or(0) as usize;
|
||||
for k in 0..n {
|
||||
let base = (from + k) * sb;
|
||||
let v = match t {
|
||||
ExrSample::F16 => src.get(base..base + 2).map(|b| f16::from_le_bytes([b[0], b[1]]).to_f32()),
|
||||
ExrSample::F32 => src.get(base..base + 4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])),
|
||||
ExrSample::U32 => src.get(base..base + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f32),
|
||||
};
|
||||
let (Some(v), Some(slot)) = (v, out.samples.get_mut(to + k)) else {
|
||||
return Err(err("the sample data is short"));
|
||||
};
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DeepImage { width: w as u32, height: h as u32, channels, counts: deep_counts })
|
||||
}
|
||||
|
||||
/// Where the headers end and the offset tables begin. [`MetaData::read_from_buffered`]
|
||||
/// consumes exactly the version field and the headers, so re-reading with a fresh cursor
|
||||
/// and taking its end position yields it.
|
||||
fn header_end(bytes: &[u8]) -> Result<u64, CodecError> {
|
||||
let mut cur = Cursor::new(bytes);
|
||||
MetaData::read_from_buffered(&mut cur, false).map_err(err)?;
|
||||
Ok(cur.position())
|
||||
}
|
||||
|
||||
/// Composite the deep samples into a flat image: per pixel, samples sorted by Z (stable; the
|
||||
/// file is assumed messy), front-to-back `over` with premultiplied colour, un-premultiplied
|
||||
/// into straight alpha at the end. Without any alpha channel the front-most sample wins,
|
||||
/// opaque. Z, ZBack and other deep channels influence the flat result only through the
|
||||
/// ordering; volume samples (with ZBack) are approximated as point samples.
|
||||
pub(crate) fn flatten(deep: &DeepImage) -> Result<Image, CodecError> {
|
||||
let npx = deep.width as usize * deep.height as usize;
|
||||
let has = |n: &str| deep.channel(n).is_some();
|
||||
let rgb = has("R") && has("G") && has("B");
|
||||
let gray = !rgb && (has("Y") || deep.channels.len() == 1);
|
||||
let layout = if rgb || gray {
|
||||
if has("A") || has("AR") || has("AG") || has("AB") {
|
||||
if rgb { ChannelLayout::Rgba } else { ChannelLayout::GrayA }
|
||||
} else if rgb {
|
||||
ChannelLayout::Rgb
|
||||
} else {
|
||||
ChannelLayout::Gray
|
||||
}
|
||||
} else {
|
||||
return Err(CodecError::unsupported(F, "no R/G/B or Y channels in the deep part"));
|
||||
};
|
||||
let nc = layout.channels();
|
||||
let samples = |n: &str| -> &[f32] { deep.channel(n).map(|c| c.samples.as_slice()).unwrap_or(&[]) };
|
||||
let (r, g, b) = (samples("R"), samples("G"), samples("B"));
|
||||
let (ar, ag, ab) = (samples("AR"), samples("AG"), samples("AB"));
|
||||
let (a, z) = (samples("A"), samples("Z"));
|
||||
let y = samples("Y");
|
||||
let lum = if y.is_empty() { deep.channels.first().map(|c| c.samples.as_slice()).unwrap_or(&[]) } else { y };
|
||||
|
||||
let mut out = vec![0f32; npx * nc];
|
||||
let mut order: Vec<u32> = Vec::new();
|
||||
let mut max_samples = 0u32;
|
||||
for p in 0..npx {
|
||||
let range = deep.sample_range(p);
|
||||
let (s0, n) = (range.start as usize, (range.end - range.start) as usize);
|
||||
max_samples = max_samples.max(u32::try_from(n).unwrap_or(u32::MAX));
|
||||
if n == 0 {
|
||||
continue; // no samples: transparent black
|
||||
}
|
||||
order.clear();
|
||||
order.extend(0u32..n as u32);
|
||||
if !z.is_empty() {
|
||||
// Stable sort by Z; a NaN depth (invalid) sorts last, deterministically.
|
||||
order.sort_by(|&i, &j| {
|
||||
let zi = z.get(s0 + i as usize).copied().unwrap_or(f32::NAN);
|
||||
let zj = z.get(s0 + j as usize).copied().unwrap_or(f32::NAN);
|
||||
zi.total_cmp(&zj)
|
||||
});
|
||||
}
|
||||
let at = |ch: &[f32], i: u32| ch.get(s0 + i as usize).copied().unwrap_or(0.0);
|
||||
// Per-colour-channel alpha: AR/AG/AB when present, else A, else opaque.
|
||||
let alpha = |i: u32| -> [f32; 3] {
|
||||
let s = at(a, i);
|
||||
[
|
||||
if !a.is_empty() && ar.is_empty() {
|
||||
s
|
||||
} else if !ar.is_empty() {
|
||||
at(ar, i)
|
||||
} else {
|
||||
1.0
|
||||
},
|
||||
if !a.is_empty() && ag.is_empty() {
|
||||
s
|
||||
} else if !ag.is_empty() {
|
||||
at(ag, i)
|
||||
} else {
|
||||
1.0
|
||||
},
|
||||
if !a.is_empty() && ab.is_empty() {
|
||||
s
|
||||
} else if !ab.is_empty() {
|
||||
at(ab, i)
|
||||
} else {
|
||||
1.0
|
||||
},
|
||||
]
|
||||
};
|
||||
let mut cacc = [0f32; 3]; // premultiplied colour
|
||||
let mut aacc = [0f32; 3]; // accumulated alpha
|
||||
for &i in &order {
|
||||
let ai = alpha(i);
|
||||
let src = if rgb { [at(r, i), at(g, i), at(b, i)] } else { [at(lum, i); 3] };
|
||||
let mut covered = true;
|
||||
for k in 0..3 {
|
||||
let t = (1.0 - aacc[k]).max(0.0);
|
||||
cacc[k] += src[k] * t;
|
||||
aacc[k] += t * ai[k];
|
||||
covered &= aacc[k] >= 1.0;
|
||||
}
|
||||
if covered {
|
||||
break; // everything behind is hidden
|
||||
}
|
||||
}
|
||||
let a_out = if a.is_empty() && ar.is_empty() && ag.is_empty() && ab.is_empty() { 1.0 } else { (aacc[0] + aacc[1] + aacc[2]) / 3.0 };
|
||||
let a_clamped = a_out.clamp(0.0, 1.0);
|
||||
// Un-premultiply into straight alpha (the deep colours are premultiplied).
|
||||
let straight = |c: f32| if a_clamped > 1e-6 { c / a_clamped } else { 0.0 };
|
||||
let base = p * nc;
|
||||
for (k, v) in [straight(cacc[0]), straight(cacc[1]), straight(cacc[2]), a_clamped].into_iter().enumerate() {
|
||||
if let Some(slot) = out.get_mut(base + k) {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
if !rgb {
|
||||
// Luminance: keep only channel 0, and the alpha behind it.
|
||||
if let Some(slot) = out.get_mut(base) {
|
||||
*slot = straight(cacc[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut img = Image::from_f32(deep.width, deep.height, layout, &out)?;
|
||||
img.warnings.push(DecodeWarning::DeepFlattened { format: F, max_samples_per_pixel: max_samples });
|
||||
Ok(img)
|
||||
}
|
||||
@@ -21,6 +21,15 @@ fn err(e: impl std::fmt::Display) -> CodecError {
|
||||
CodecError::malformed(F, e)
|
||||
}
|
||||
|
||||
/// An `exr`-crate error, with its `NotSupported` kind kept distinct from malformed data
|
||||
/// (the same split the TIFF codec makes), so unsupported features say so.
|
||||
fn map_exr(e: exr::error::Error) -> CodecError {
|
||||
match e {
|
||||
exr::error::Error::NotSupported(message) => CodecError::unsupported(F, format!("not supported: {message}")),
|
||||
e => err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip an optional `layer.` prefix from a channel name.
|
||||
fn base_name(name: &str) -> &str {
|
||||
name.rsplit('.').next().unwrap_or(name)
|
||||
@@ -36,8 +45,21 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
limits.check_bytes(w, h, bpp)?;
|
||||
}
|
||||
|
||||
let image =
|
||||
read().no_deep_data().largest_resolution_level().all_channels().first_valid_layer().all_attributes().from_buffered(Cursor::new(bytes)).map_err(err)?;
|
||||
// Deep parts are rejected by the `exr` crate's reader; walk their chunks ourselves and
|
||||
// composite the samples into the flat image (a warning says what was lost).
|
||||
if meta.requirements.has_deep_data {
|
||||
let deep = super::deep_exr::decode_deep(&meta, bytes, limits)?;
|
||||
return super::deep_exr::flatten(&deep);
|
||||
}
|
||||
|
||||
let image = read()
|
||||
.no_deep_data()
|
||||
.largest_resolution_level()
|
||||
.all_channels()
|
||||
.first_valid_layer()
|
||||
.all_attributes()
|
||||
.from_buffered(Cursor::new(bytes))
|
||||
.map_err(map_exr)?;
|
||||
let layer = &image.layer_data;
|
||||
let (w, h) = (layer.size.0, layer.size.1);
|
||||
let channels = &layer.channel_data.list;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! HEIF / HEIC (the iPhone and Mac photo format), read-only: see [`crate::ASYMMETRIC_EXCEPTIONS`].
|
||||
//!
|
||||
//! Decoding lives in the optional `photocraft-heif` crate (heic-rs, a pure-Rust HEVC
|
||||
//! still-picture decoder), enabled by this crate's `heif` feature. Without it, HEIF files are
|
||||
//! still detected and opening one is an [`CodecError::Unsupported`] error, never a panic.
|
||||
//!
|
||||
//! HEIF records orientation in the container (`irot`/`imir`, plus a `clap` crop), not in EXIF,
|
||||
//! so those are applied by the decoder and the EXIF Orientation is rewritten to 1. Its EXIF tag
|
||||
//! only mirrors what the container already says, and applying it as well would turn the photo
|
||||
//! twice.
|
||||
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::image::Image;
|
||||
use crate::options::Limits;
|
||||
|
||||
const F: Format = Format::Heif;
|
||||
|
||||
/// The reason given when this build has no HEIF decoder.
|
||||
#[cfg(not(feature = "heif"))]
|
||||
pub(crate) const NOT_IN_BUILD: &str = "HEIC/HEIF support isn't included in this build of PhotoCraft";
|
||||
|
||||
#[cfg(not(feature = "heif"))]
|
||||
pub(crate) fn decode(_bytes: &[u8], _limits: &Limits, _keep_orientation: bool) -> Result<Image, CodecError> {
|
||||
Err(CodecError::unsupported(F, NOT_IN_BUILD))
|
||||
}
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
fn err(e: photocraft_heif::Error) -> CodecError {
|
||||
match e {
|
||||
photocraft_heif::Error::Unsupported(what) => CodecError::unsupported(F, what),
|
||||
photocraft_heif::Error::Limit(what) => CodecError::LimitExceeded(what),
|
||||
photocraft_heif::Error::Malformed(what) => CodecError::malformed(F, what),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
pub(crate) fn decode(bytes: &[u8], limits: &Limits, keep_orientation: bool) -> Result<Image, CodecError> {
|
||||
use crate::image::{ChannelLayout, Metadata, SampleType};
|
||||
|
||||
// The container alone: the declared size is checked before any pixel is decoded.
|
||||
let info = photocraft_heif::probe(bytes).map_err(err)?;
|
||||
let layout = if info.has_alpha { ChannelLayout::Rgba } else { ChannelLayout::Rgb };
|
||||
let sample = if info.sixteen_bit { SampleType::U16 } else { SampleType::U8 };
|
||||
let (w, h) = if keep_orientation { (info.coded_width, info.coded_height) } else { (info.width, info.height) };
|
||||
limits.check(w, h, layout, sample)?;
|
||||
let options = photocraft_heif::Options { max_pixels: limits.max_pixels, apply_transforms: !keep_orientation };
|
||||
let decoded = photocraft_heif::decode(bytes, &options).map_err(err)?;
|
||||
// The decoder re-reads the container; trust its output's shape, not the probe's.
|
||||
let layout = if decoded.has_alpha { ChannelLayout::Rgba } else { ChannelLayout::Rgb };
|
||||
let sample = if decoded.sixteen_bit { SampleType::U16 } else { SampleType::U8 };
|
||||
let mut img = Image::from_raw(decoded.width, decoded.height, layout, sample, decoded.data)?;
|
||||
img.icc = decoded.icc;
|
||||
img.meta = Metadata {
|
||||
exif: decoded.exif.map(|e| if keep_orientation { e } else { crate::orientation::upright_exif(&e).into_owned() }),
|
||||
xmp: decoded.xmp.map(|x| if keep_orientation { x } else { crate::orientation::upright_xmp(&x).into_owned() }),
|
||||
..Default::default()
|
||||
};
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(not(feature = "heif"))]
|
||||
#[test]
|
||||
fn without_the_feature_heif_is_a_clear_unsupported_error() {
|
||||
let header = b"\0\0\0\x18ftypheic\0\0\0\0mif1heic";
|
||||
assert_eq!(crate::detect(header), Some(F), "still recognised");
|
||||
let r = crate::decode(header);
|
||||
assert!(matches!(&r, Err(CodecError::Unsupported { format: Format::Heif, reason }) if reason.contains("isn't included in this build")), "{r:?}");
|
||||
assert!(!crate::caps(F).read);
|
||||
}
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
#[test]
|
||||
fn with_the_feature_heif_is_readable_and_errors_map() {
|
||||
assert!(crate::caps(F).read);
|
||||
let r = crate::decode(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc");
|
||||
assert!(matches!(&r, Err(CodecError::Unsupported { format: Format::Heif, reason }) if reason.contains("sequence")), "{r:?}");
|
||||
assert!(matches!(crate::decode_as(F, b""), Err(CodecError::Malformed { .. } | CodecError::Unsupported { .. })));
|
||||
}
|
||||
|
||||
/// Found by the `decode_heif` fuzz target: a malformed box makes heic-rs 0.1.1 slice out of range
|
||||
/// (`boxes.rs:130`, "slice index starts at 24 but ends at 16"). It must be an error, not a crash.
|
||||
#[cfg(feature = "heif")]
|
||||
const HEIC_RS_BOX_PANIC: [u8; 72] = [
|
||||
0x00, 0x00, 0x00, 0x24, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x69, 0x66, 0x31, 0x4d, 0x69, 0x50, 0x72, 0x6d,
|
||||
0x69, 0x61, 0x66, 0x4d, 0x69, 0x48, 0x42, 0x72, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x65, 0x74, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x10, 0x75, 0x75, 0x69, 0x64, 0x00, 0x00, 0x00, 0x00, 0x03, 0x08, 0x08, 0x08, 0x00, 0x03, 0x01, 0x03, 0x70, 0x00, 0xa8, 0x00,
|
||||
];
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
#[test]
|
||||
fn a_heic_rs_panic_is_a_malformed_file_error() {
|
||||
assert_eq!(crate::detect(&HEIC_RS_BOX_PANIC), Some(Format::Heif));
|
||||
for opts in [crate::DecodeOptions::default(), crate::DecodeOptions { keep_orientation: true, ..Default::default() }] {
|
||||
let r = crate::decode_with(&HEIC_RS_BOX_PANIC, &opts);
|
||||
assert!(matches!(r, Err(CodecError::Malformed { format: Format::Heif, .. })), "{r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,9 @@ use zune_core::options::DecoderOptions;
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, Image, Metadata, SampleType};
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits};
|
||||
use crate::orientation::{upright_exif, upright_xmp};
|
||||
|
||||
const F: Format = Format::Jpeg;
|
||||
const EXIF_HEADER: &[u8] = b"Exif\0\0";
|
||||
@@ -100,11 +101,70 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta {
|
||||
m
|
||||
}
|
||||
|
||||
/// How a JPEG's data ends, seen from its marker structure.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum DataEnd {
|
||||
/// An end-of-image marker follows the scans (or the structure is too odd to tell).
|
||||
Complete,
|
||||
/// The file ends inside the image data, before the end-of-image marker.
|
||||
Truncated,
|
||||
/// The file ends before the first scan holds any data: nothing can be decoded.
|
||||
Empty,
|
||||
}
|
||||
|
||||
/// Walks the marker segments (skipped by their length) and the entropy-coded scan data
|
||||
/// (skipped up to the next marker that isn't a stuffed `FF 00`, a restart or a fill byte),
|
||||
/// so an embedded thumbnail's end marker or data after the end of the image don't count.
|
||||
fn data_end(b: &[u8]) -> DataEnd {
|
||||
// Where the bytes run out: inside the image data once a scan has held some.
|
||||
let cut = |scan_data: bool| if scan_data { DataEnd::Truncated } else { DataEnd::Empty };
|
||||
let mut i = 2; // past SOI
|
||||
let mut scan_data = false;
|
||||
loop {
|
||||
let Some(&byte) = b.get(i) else { return cut(scan_data) };
|
||||
if byte != 0xFF {
|
||||
// Not where a marker should be: too odd to judge.
|
||||
return DataEnd::Complete;
|
||||
}
|
||||
let Some(&code) = b.get(i + 1) else { return cut(scan_data) };
|
||||
match code {
|
||||
0xD9 => return DataEnd::Complete,
|
||||
0xFF => i += 1,
|
||||
0x01 | 0xD0..=0xD8 => i += 2,
|
||||
_ => {
|
||||
let Some(len) = b.get(i + 2..i + 4).and_then(|s| <[u8; 2]>::try_from(s).ok()).map(u16::from_be_bytes) else {
|
||||
return cut(scan_data);
|
||||
};
|
||||
i = i.saturating_add(2 + usize::from(len));
|
||||
if code == 0xDA {
|
||||
let start = i;
|
||||
loop {
|
||||
let Some(p) = b.get(i..).and_then(|r| r.iter().position(|&v| v == 0xFF)) else {
|
||||
return cut(scan_data || b.len() > start);
|
||||
};
|
||||
i += p;
|
||||
match b.get(i + 1) {
|
||||
Some(0x00 | 0xD0..=0xD7) => i += 2,
|
||||
Some(0xFF) => i += 1,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
scan_data |= i > start;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> CodecError {
|
||||
CodecError::malformed(F, e)
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
let end = data_end(bytes);
|
||||
if end == DataEnd::Empty {
|
||||
return Err(err("the file ends before any image data"));
|
||||
}
|
||||
let meta = scan_metadata(bytes);
|
||||
if let Some((w, h, nc)) = meta.frame {
|
||||
limits.check_bytes(w, h, u64::from(nc.max(1)))?;
|
||||
@@ -148,7 +208,10 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
}
|
||||
let mut img = Image::from_raw(w, h, layout, SampleType::U8, px)?;
|
||||
img.icc = meta.icc;
|
||||
img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, text: Vec::new() };
|
||||
img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, ..Default::default() };
|
||||
if end == DataEnd::Truncated {
|
||||
img.warnings.push(DecodeWarning::Truncated { format: F });
|
||||
}
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
@@ -185,14 +248,12 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
{
|
||||
enc.set_density(jpeg_encoder::Density::Inch { x: x.round().min(65535.0) as u16, y: y.round().min(65535.0) as u16 });
|
||||
}
|
||||
if let Some(exif) = &img.meta.exif {
|
||||
let mut seg = EXIF_HEADER.to_vec();
|
||||
seg.extend_from_slice(exif);
|
||||
// Metadata too large for its one segment is left out (and reported by the fidelity
|
||||
// warnings) rather than failing the whole export.
|
||||
if let Some(seg) = img.meta.exif.as_deref().and_then(exif_segment) {
|
||||
enc.add_app_segment(1, &seg).map_err(e)?;
|
||||
}
|
||||
if let Some(xmp) = &img.meta.xmp {
|
||||
let mut seg = XMP_HEADER.to_vec();
|
||||
seg.extend_from_slice(xmp.as_bytes());
|
||||
if let Some(seg) = img.meta.xmp.as_deref().and_then(xmp_segment) {
|
||||
enc.add_app_segment(1, &seg).map_err(e)?;
|
||||
}
|
||||
}
|
||||
@@ -204,3 +265,63 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
enc.encode(img.data(), w16, h16, ct).map_err(e)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The most data one APP segment holds (its 16-bit length counts its own two bytes).
|
||||
const MAX_SEGMENT: usize = 65533;
|
||||
|
||||
/// XMP properties (local names, any namespace prefix) that only describe the layered document:
|
||||
/// every document ever placed in it, the text of every type layer. A flat image doesn't need them,
|
||||
/// and they alone can outgrow a segment.
|
||||
const LAYERED_ONLY_XMP: [&str; 2] = ["DocumentAncestors", "TextLayers"];
|
||||
|
||||
/// The EXIF APP1 segment, or `None` when it doesn't fit in one segment.
|
||||
pub(crate) fn exif_segment(exif: &[u8]) -> Option<Vec<u8>> {
|
||||
// The pixels are written as they are shown: never let a viewer rotate them again.
|
||||
let seg = [EXIF_HEADER, upright_exif(exif).as_ref()].concat();
|
||||
(seg.len() <= MAX_SEGMENT).then_some(seg)
|
||||
}
|
||||
|
||||
/// The XMP APP1 segment. When the packet doesn't fit in one segment, the layered-document
|
||||
/// properties are left out; `None` when it still doesn't fit.
|
||||
pub(crate) fn xmp_segment(xmp: &str) -> Option<Vec<u8>> {
|
||||
let xmp = upright_xmp(xmp);
|
||||
let seg = |x: &str| [XMP_HEADER, x.as_bytes()].concat();
|
||||
if XMP_HEADER.len() + xmp.len() <= MAX_SEGMENT {
|
||||
return Some(seg(&xmp));
|
||||
}
|
||||
let trimmed = LAYERED_ONLY_XMP.iter().fold(xmp.into_owned(), |x, name| remove_element(&x, name));
|
||||
(XMP_HEADER.len() + trimmed.len() <= MAX_SEGMENT).then(|| seg(&trimmed))
|
||||
}
|
||||
|
||||
/// `xmp` without its `<prefix:name …>…</prefix:name>` and `<prefix:name …/>` elements. Unbalanced
|
||||
/// markup is kept as is.
|
||||
fn remove_element(xmp: &str, name: &str) -> String {
|
||||
let local = format!(":{name}");
|
||||
let mut out = String::with_capacity(xmp.len());
|
||||
let mut rest = xmp;
|
||||
while let Some(j) = rest.find(&local) {
|
||||
let head = rest.get(..j).unwrap_or_default();
|
||||
let prefix_len = head.bytes().rev().take_while(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.')).count();
|
||||
let lt = j - prefix_len;
|
||||
let after = rest.get(j + local.len()..).unwrap_or_default();
|
||||
// An opening tag of exactly this name (not a closing tag, an attribute or a longer name).
|
||||
let opening = prefix_len > 0 && lt > 0 && rest.get(lt - 1..lt) == Some("<");
|
||||
if !opening || !after.starts_with(['>', '/', ' ', '\t', '\r', '\n']) {
|
||||
out.push_str(rest.get(..j + local.len()).unwrap_or_default());
|
||||
rest = after;
|
||||
continue;
|
||||
}
|
||||
let Some(gt) = after.find('>') else { break };
|
||||
let end = if after.get(..gt).is_some_and(|s| s.ends_with('/')) {
|
||||
gt + 1
|
||||
} else {
|
||||
let close = format!("</{}{local}>", rest.get(lt..j).unwrap_or_default());
|
||||
let Some(c) = after.find(&close) else { break };
|
||||
c + close.len()
|
||||
};
|
||||
out.push_str(rest.get(..lt - 1).unwrap_or_default());
|
||||
rest = after.get(end..).unwrap_or_default();
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
pub(crate) mod deep_exr;
|
||||
pub(crate) mod exr;
|
||||
pub(crate) mod heif;
|
||||
pub(crate) mod jpeg;
|
||||
pub(crate) mod png;
|
||||
pub(crate) mod pnm;
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::io::{Cursor, Write};
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, Image, Metadata, SampleType};
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits, PngCompression};
|
||||
|
||||
const F: Format = Format::Png;
|
||||
@@ -27,6 +27,8 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
limits.check_bytes(w, h, 8)?;
|
||||
}
|
||||
let mut reader = decoder.read_info().map_err(map_png_err)?;
|
||||
// APNG: the IDAT image is the first frame, or an extra default image when no fcTL precedes it.
|
||||
let images = reader.info().animation_control.map_or(1, |a| u64::from(a.num_frames) + u64::from(reader.info().frame_control.is_none()));
|
||||
let (color, depth) = reader.output_color_type();
|
||||
let layout = match color {
|
||||
png::ColorType::Grayscale => ChannelLayout::Gray,
|
||||
@@ -82,6 +84,9 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
}
|
||||
}
|
||||
img.meta = meta;
|
||||
if images > 1 {
|
||||
img.warnings.push(DecodeWarning::MoreFrames { total: u32::try_from(images).ok() });
|
||||
}
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
@@ -131,7 +136,8 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
}
|
||||
if opts.embed_metadata {
|
||||
if let Some(exif) = &img.meta.exif {
|
||||
info.exif_metadata = Some(exif.clone().into());
|
||||
// The pixels are written as they are shown: never let a viewer rotate them again.
|
||||
info.exif_metadata = Some(crate::orientation::upright_exif(exif).into_owned().into());
|
||||
}
|
||||
if let Some((x, y)) = img.meta.dpi
|
||||
&& x > 0.0
|
||||
@@ -164,7 +170,7 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
res.map_err(|e| CodecError::encode(F, e))?;
|
||||
}
|
||||
if let Some(xmp) = &img.meta.xmp {
|
||||
encoder.add_itxt_chunk(XMP_KEYWORD.into(), xmp.clone()).map_err(|e| CodecError::encode(F, e))?;
|
||||
encoder.add_itxt_chunk(XMP_KEYWORD.into(), crate::orientation::upright_xmp(xmp).into_owned()).map_err(|e| CodecError::encode(F, e))?;
|
||||
}
|
||||
}
|
||||
let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
//! TIFF via the `tiff` crate: 8/16-bit integer and 32-bit float, gray/RGB/
|
||||
//! CMYK with optional (unassociated) alpha, ICC (34675), XMP (700), DPI and
|
||||
//! a few ASCII text tags. Only the first IFD is read.
|
||||
//! a few ASCII text tags. Only the first IFD is read; further pages are counted and reported
|
||||
//! as a [`DecodeWarning::MorePages`].
|
||||
//!
|
||||
//! Photoshop's private tags are carried as opaque bytes in [`Metadata`]: 34377 (image
|
||||
//! resources) and 37724 (`ImageSourceData`, the layers of a layered TIFF). They are read with
|
||||
//! a bounds-checked walk of the first directory ([`photoshop_tags`]) rather than through the
|
||||
//! decoder, which would materialize a multi-megabyte layer block as one `Value` per byte, and
|
||||
//! written back as BYTE / UNDEFINED tags. The encoder writes files in the native byte order
|
||||
//! (see [`writes_little_endian`]), which the layer data must match.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::io::Cursor;
|
||||
@@ -13,11 +21,92 @@ use tiff::tags::{PhotometricInterpretation, ResolutionUnit, SampleFormat, Tag, T
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, Image, Metadata, SampleType};
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits, TiffCompression};
|
||||
|
||||
const F: Format = Format::Tiff;
|
||||
const TAG_XMP: u16 = 700;
|
||||
/// Photoshop image resources (`8BIM` blocks).
|
||||
const TAG_PHOTOSHOP: u16 = 34377;
|
||||
/// Photoshop `ImageSourceData`: the layers of a layered TIFF.
|
||||
const TAG_IMAGE_SOURCE_DATA: u16 = 37724;
|
||||
|
||||
/// `true` when [`encode`] writes little-endian (`II`) files: the `tiff` encoder writes the
|
||||
/// running target's byte order, and every target PhotoCraft builds for is little-endian.
|
||||
/// Byte-order-sensitive payloads (Photoshop layer data) must be produced to match.
|
||||
pub fn writes_little_endian() -> bool {
|
||||
cfg!(target_endian = "little")
|
||||
}
|
||||
|
||||
/// `(34377 image resources, 37724 image source data)` payloads, each absent when the tag is.
|
||||
pub type PhotoshopTags<'a> = (Option<&'a [u8]>, Option<&'a [u8]>);
|
||||
|
||||
/// The raw payloads of the Photoshop private tags in the first directory: `(34377 image
|
||||
/// resources, 37724 image source data)`. Classic and BigTIFF, either byte order; only
|
||||
/// header bytes and the two payloads are touched, and nothing is allocated. Anything malformed
|
||||
/// reads as "absent".
|
||||
pub fn photoshop_tags(b: &[u8]) -> PhotoshopTags<'_> {
|
||||
fn walk(b: &[u8]) -> Option<PhotoshopTags<'_>> {
|
||||
let le = match b.get(0..2)? {
|
||||
b"II" => true,
|
||||
b"MM" => false,
|
||||
_ => return None,
|
||||
};
|
||||
let u16_at = |o: u64| -> Option<u16> {
|
||||
let o = usize::try_from(o).ok()?;
|
||||
let s = b.get(o..o.checked_add(2)?)?;
|
||||
Some(if le { u16::from_le_bytes([s[0], s[1]]) } else { u16::from_be_bytes([s[0], s[1]]) })
|
||||
};
|
||||
let u32_at = |o: u64| -> Option<u32> {
|
||||
let o = usize::try_from(o).ok()?;
|
||||
let s: [u8; 4] = b.get(o..o.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(if le { u32::from_le_bytes(s) } else { u32::from_be_bytes(s) })
|
||||
};
|
||||
let u64_at = |o: u64| -> Option<u64> {
|
||||
let o = usize::try_from(o).ok()?;
|
||||
let s: [u8; 8] = b.get(o..o.checked_add(8)?)?.try_into().ok()?;
|
||||
Some(if le { u64::from_le_bytes(s) } else { u64::from_be_bytes(s) })
|
||||
};
|
||||
let (big, ifd) = match u16_at(2)? {
|
||||
42 => (false, u64::from(u32_at(4)?)),
|
||||
43 => (true, u64_at(8)?),
|
||||
_ => return None,
|
||||
};
|
||||
let n = if big { u64_at(ifd)? } else { u64::from(u16_at(ifd)?) }.min(4096);
|
||||
let (entry, first, inline) = if big { (20u64, ifd.checked_add(8)?, 8u64) } else { (12, ifd.checked_add(2)?, 4) };
|
||||
let mut resources = None;
|
||||
let mut layers = None;
|
||||
for i in 0..n {
|
||||
let e = first.checked_add(i.checked_mul(entry)?)?;
|
||||
let tag = u16_at(e)?;
|
||||
if tag != TAG_PHOTOSHOP && tag != TAG_IMAGE_SOURCE_DATA {
|
||||
continue;
|
||||
}
|
||||
// BYTE, ASCII, SBYTE or UNDEFINED: one byte per element.
|
||||
if !matches!(u16_at(e + 2)?, 1 | 2 | 6 | 7) {
|
||||
continue;
|
||||
}
|
||||
let count = if big { u64_at(e + 4)? } else { u64::from(u32_at(e + 4)?) };
|
||||
let value_at = e + if big { 12 } else { 8 };
|
||||
let start = if count <= inline {
|
||||
value_at
|
||||
} else if big {
|
||||
u64_at(value_at)?
|
||||
} else {
|
||||
u64::from(u32_at(value_at)?)
|
||||
};
|
||||
let start = usize::try_from(start).ok()?;
|
||||
let data = b.get(start..start.checked_add(usize::try_from(count).ok()?)?)?;
|
||||
if tag == TAG_PHOTOSHOP {
|
||||
resources = Some(data);
|
||||
} else {
|
||||
layers = Some(data);
|
||||
}
|
||||
}
|
||||
Some((resources, layers))
|
||||
}
|
||||
walk(b).unwrap_or((None, None))
|
||||
}
|
||||
|
||||
/// ASCII tags mapped to `Metadata::text` keys.
|
||||
const TEXT_TAGS: &[(Tag, &str)] = &[
|
||||
@@ -159,8 +248,6 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
};
|
||||
let bytes_per_sample = u64::from(bits.max(8) / 8);
|
||||
limits.check_bytes(w, h, layout.channels() as u64 * bytes_per_sample.max(1))?;
|
||||
let white_is_zero = dec.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation).ok().flatten() == Some(PhotometricInterpretation::WhiteIsZero.to_u16());
|
||||
|
||||
let result = dec.read_image().map_err(map_err)?;
|
||||
let n = w as usize * h as usize * layout.channels();
|
||||
let mut img = match result {
|
||||
@@ -184,19 +271,6 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
if img.sample_count() != n {
|
||||
return Err(err("sample count mismatch"));
|
||||
}
|
||||
if white_is_zero && layout.is_gray() {
|
||||
let v: Vec<f32> = img
|
||||
.to_normalized()
|
||||
.chunks(layout.channels())
|
||||
.flat_map(|p| {
|
||||
let mut p = p.to_vec();
|
||||
p[0] = 1.0 - p[0];
|
||||
p
|
||||
})
|
||||
.collect();
|
||||
img = Image::from_normalized(w, h, layout, img.sample_type(), &v)?;
|
||||
}
|
||||
|
||||
img.icc = dec.find_tag(Tag::IccProfile).ok().flatten().and_then(value_bytes);
|
||||
let mut meta = Metadata {
|
||||
xmp: dec
|
||||
@@ -229,10 +303,40 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
}
|
||||
}
|
||||
}
|
||||
let (resources, layers) = photoshop_tags(bytes);
|
||||
meta.photoshop_resources = resources.map(<[u8]>::to_vec);
|
||||
meta.photoshop_layers = layers.map(<[u8]>::to_vec);
|
||||
img.meta = meta;
|
||||
img.warnings.extend(more_pages(&mut dec));
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// Counts the pages after the first IFD without decoding their pixels, skipping
|
||||
/// reduced-resolution copies (thumbnails) and transparency masks. The total is unknown when
|
||||
/// a directory can't be read or there are more than `MAX_IFDS`.
|
||||
fn more_pages(dec: &mut Decoder<Cursor<&[u8]>>) -> Option<DecodeWarning> {
|
||||
const MAX_IFDS: u32 = 10_000;
|
||||
let mut pages = 1u32;
|
||||
let mut walked = 0;
|
||||
let mut total = None;
|
||||
loop {
|
||||
if !dec.more_images() {
|
||||
total = Some(pages);
|
||||
break;
|
||||
}
|
||||
walked += 1;
|
||||
if walked > MAX_IFDS || dec.next_image().is_err() {
|
||||
break;
|
||||
}
|
||||
// NewSubfileType bit 0: a reduced-resolution image; bit 2: a transparency mask.
|
||||
let kind = dec.find_tag_unsigned::<u32>(Tag::NewSubfileType).ok().flatten().unwrap_or(0);
|
||||
if kind & 0b101 == 0 {
|
||||
pages += 1;
|
||||
}
|
||||
}
|
||||
(pages > 1).then_some(DecodeWarning::MorePages { total })
|
||||
}
|
||||
|
||||
fn rational_f64(v: tiff::decoder::ifd::Value) -> Option<f64> {
|
||||
use tiff::decoder::ifd::Value;
|
||||
match v {
|
||||
@@ -329,6 +433,8 @@ struct TagSet<'a> {
|
||||
dpi: Option<(f32, f32)>,
|
||||
text: Vec<(Tag, &'a str)>,
|
||||
alpha: bool,
|
||||
photoshop_resources: Option<&'a [u8]>,
|
||||
photoshop_layers: Option<&'a [u8]>,
|
||||
}
|
||||
|
||||
fn write_one<C>(enc: &mut TiffEncoder<&mut Cursor<Vec<u8>>>, w: u32, h: u32, data: &[C::Inner], tags: &TagSet<'_>) -> tiff::TiffResult<()>
|
||||
@@ -356,6 +462,12 @@ where
|
||||
for (tag, s) in &tags.text {
|
||||
d.write_tag(*tag, *s)?;
|
||||
}
|
||||
if let Some(r) = tags.photoshop_resources {
|
||||
d.write_tag(Tag::Unknown(TAG_PHOTOSHOP), r)?;
|
||||
}
|
||||
if let Some(l) = tags.photoshop_layers {
|
||||
d.write_tag(Tag::Unknown(TAG_IMAGE_SOURCE_DATA), Undefined(l))?;
|
||||
}
|
||||
im.write_data(data)
|
||||
}
|
||||
|
||||
@@ -383,12 +495,17 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
// TIFF writes no Orientation tag (= 1): keep the XMP from contradicting it.
|
||||
let xmp = if opts.embed_metadata { img.meta.xmp.as_deref().map(crate::orientation::upright_xmp) } else { None };
|
||||
let tags = TagSet {
|
||||
icc: if opts.embed_icc { img.icc.as_deref() } else { None },
|
||||
xmp: if opts.embed_metadata { img.meta.xmp.as_deref().map(str::as_bytes) } else { None },
|
||||
xmp: xmp.as_deref().map(str::as_bytes),
|
||||
dpi: if opts.embed_metadata { img.meta.dpi.filter(|d| d.0 > 0.0 && d.1 > 0.0) } else { None },
|
||||
text,
|
||||
alpha: img.layout().has_alpha(),
|
||||
// Document content, not metadata: written whenever present.
|
||||
photoshop_resources: img.meta.photoshop_resources.as_deref().filter(|r| !r.is_empty()),
|
||||
photoshop_layers: img.meta.photoshop_layers.as_deref().filter(|l| !l.is_empty()),
|
||||
};
|
||||
|
||||
let mut cursor = Cursor::new(Vec::new());
|
||||
|
||||
@@ -8,7 +8,7 @@ use image::{DynamicImage, ImageDecoder, ImageFormat};
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, Image, SampleType};
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits};
|
||||
|
||||
fn image_format(f: Format) -> Option<ImageFormat> {
|
||||
@@ -49,9 +49,49 @@ pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image,
|
||||
let mut img = from_dynamic(f, dynimg)?;
|
||||
img.icc = icc;
|
||||
img.meta.exif = exif;
|
||||
if f == Format::Gif {
|
||||
img.warnings.extend(gif_more_frames(bytes));
|
||||
}
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// Counts a GIF's frames (image descriptors) by walking its blocks, without decompressing any.
|
||||
/// The total is unknown when the blocks end before the trailer or break off.
|
||||
fn gif_more_frames(b: &[u8]) -> Option<DecodeWarning> {
|
||||
// Size of the colour table a packed-fields byte announces.
|
||||
let table = |flags: u8| if flags & 0x80 != 0 { 3usize << ((flags & 7) + 1) } else { 0 };
|
||||
// Skips the data sub-blocks starting at `i` (each length-prefixed, ended by a zero length).
|
||||
let skip = |mut i: usize| -> Option<usize> {
|
||||
loop {
|
||||
let n = usize::from(*b.get(i)?);
|
||||
i = i.checked_add(1 + n)?;
|
||||
if n == 0 {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Header (6 bytes), logical screen descriptor (7) and the global colour table.
|
||||
let mut i = 13 + table(*b.get(10)?);
|
||||
let mut frames = 0u32;
|
||||
let total = loop {
|
||||
let next = match b.get(i) {
|
||||
Some(0x3B) => break Some(frames),
|
||||
Some(0x21) => skip(i + 2),
|
||||
Some(0x2C) => {
|
||||
frames = frames.saturating_add(1);
|
||||
// Descriptor (10 bytes, packed fields last), local colour table, LZW code size.
|
||||
b.get(i + 9).and_then(|&flags| skip(i + 11 + table(flags)))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match next {
|
||||
Some(n) => i = n,
|
||||
None => break None,
|
||||
}
|
||||
};
|
||||
(frames > 1).then_some(DecodeWarning::MoreFrames { total })
|
||||
}
|
||||
|
||||
pub(crate) fn from_dynamic(f: Format, d: DynamicImage) -> Result<Image, CodecError> {
|
||||
let (w, h) = (d.width(), d.height());
|
||||
Ok(match d {
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::io::Cursor;
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, Image, Metadata, SampleType};
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits};
|
||||
|
||||
const F: Format = Format::WebP;
|
||||
@@ -30,6 +30,9 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
let mut img = Image::from_u8(w, h, layout, buf)?;
|
||||
img.icc = icc;
|
||||
img.meta = Metadata { exif, xmp, ..Default::default() };
|
||||
if dec.is_animated() && dec.num_frames() > 1 {
|
||||
img.warnings.push(DecodeWarning::MoreFrames { total: Some(dec.num_frames()) });
|
||||
}
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
@@ -54,10 +57,11 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
}
|
||||
if opts.embed_metadata {
|
||||
if let Some(exif) = &img.meta.exif {
|
||||
enc.set_exif_metadata(exif.clone());
|
||||
// The pixels are written as they are shown: never let a viewer rotate them again.
|
||||
enc.set_exif_metadata(crate::orientation::upright_exif(exif).into_owned());
|
||||
}
|
||||
if let Some(xmp) = &img.meta.xmp {
|
||||
enc.set_xmp_metadata(xmp.as_bytes().to_vec());
|
||||
enc.set_xmp_metadata(crate::orientation::upright_xmp(xmp).as_bytes().to_vec());
|
||||
}
|
||||
}
|
||||
enc.encode(img.data(), img.width(), img.height(), ct).map_err(|e| CodecError::encode(F, e))?;
|
||||
|
||||
@@ -41,6 +41,12 @@ pub enum FidelityWarning {
|
||||
XmpDropped,
|
||||
DpiDropped,
|
||||
TextDropped,
|
||||
/// Metadata larger than the format can hold in one piece (`what`: "EXIF", "XMP") will be
|
||||
/// dropped.
|
||||
MetadataTooLarge {
|
||||
what: &'static str,
|
||||
bytes: usize,
|
||||
},
|
||||
/// Image exceeds the format's maximum size; encoding will fail.
|
||||
DimensionsExceeded {
|
||||
max_width: u32,
|
||||
@@ -86,6 +92,7 @@ impl fmt::Display for FidelityWarning {
|
||||
XmpDropped => write!(f, "XMP not supported; it will be dropped"),
|
||||
DpiDropped => write!(f, "resolution (DPI) not supported; it will be dropped"),
|
||||
TextDropped => write!(f, "text metadata not supported; it will be dropped"),
|
||||
MetadataTooLarge { what, bytes } => write!(f, "{what} ({bytes} bytes) is too large for the format; it will be dropped"),
|
||||
DimensionsExceeded { max_width, max_height } => {
|
||||
write!(f, "image exceeds the format maximum of {max_width}x{max_height}")
|
||||
}
|
||||
@@ -219,6 +226,14 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
|
||||
if m.xmp.is_some() && !c.xmp {
|
||||
w.push(W::XmpDropped);
|
||||
}
|
||||
if format == Format::Jpeg {
|
||||
if let Some(exif) = m.exif.as_deref().filter(|e| crate::codecs::jpeg::exif_segment(e).is_none()) {
|
||||
w.push(W::MetadataTooLarge { what: "EXIF", bytes: exif.len() });
|
||||
}
|
||||
if let Some(xmp) = m.xmp.as_deref().filter(|x| crate::codecs::jpeg::xmp_segment(x).is_none()) {
|
||||
w.push(W::MetadataTooLarge { what: "XMP", bytes: xmp.len() });
|
||||
}
|
||||
}
|
||||
if m.dpi.is_some() && !c.dpi {
|
||||
w.push(W::DpiDropped);
|
||||
}
|
||||
|
||||
+45
-11
@@ -21,6 +21,8 @@ pub enum Format {
|
||||
Hdr,
|
||||
/// Encode-only behind the `avif` feature; see [`ASYMMETRIC_EXCEPTIONS`].
|
||||
Avif,
|
||||
/// HEIF/HEIC (HEVC-coded): read-only; see [`ASYMMETRIC_EXCEPTIONS`].
|
||||
Heif,
|
||||
}
|
||||
|
||||
/// What a format can hold **and** what this crate reads/writes for it.
|
||||
@@ -51,11 +53,20 @@ pub struct FormatCaps {
|
||||
|
||||
/// Formats that are enabled but intentionally not symmetric, with the reason.
|
||||
/// The test-suite asserts that every other enabled format is read+write.
|
||||
pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[(
|
||||
Format::Avif,
|
||||
"AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
|
||||
until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.",
|
||||
)];
|
||||
pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
|
||||
(
|
||||
Format::Avif,
|
||||
"AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
|
||||
until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.",
|
||||
),
|
||||
(
|
||||
Format::Heif,
|
||||
"HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \
|
||||
non-default `heif` feature, which official builds enable), so iPhone and Mac photos open; \
|
||||
writing needs an HEVC encoder, and the mature ones (x265, libheif) are C, so write stays \
|
||||
unsupported. Without the feature HEIF is detected but neither read nor written.",
|
||||
),
|
||||
];
|
||||
|
||||
use ChannelLayout as L;
|
||||
use SampleType as S;
|
||||
@@ -65,7 +76,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C
|
||||
|
||||
impl Format {
|
||||
/// Every format known to the crate (enabled or not).
|
||||
pub const ALL: [Format; 13] = [
|
||||
pub const ALL: [Format; 14] = [
|
||||
Format::Png,
|
||||
Format::Jpeg,
|
||||
Format::Tiff,
|
||||
@@ -79,6 +90,7 @@ impl Format {
|
||||
Format::OpenExr,
|
||||
Format::Hdr,
|
||||
Format::Avif,
|
||||
Format::Heif,
|
||||
];
|
||||
|
||||
pub fn caps(self) -> FormatCaps {
|
||||
@@ -106,6 +118,7 @@ impl Format {
|
||||
Format::OpenExr => "OpenEXR",
|
||||
Format::Hdr => "Radiance HDR",
|
||||
Format::Avif => "AVIF",
|
||||
Format::Heif => "HEIF",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +139,7 @@ impl Format {
|
||||
Format::OpenExr => &["exr"],
|
||||
Format::Hdr => &["hdr"],
|
||||
Format::Avif => &["avif"],
|
||||
Format::Heif => &["heic", "heif", "hif"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +158,7 @@ impl Format {
|
||||
Format::OpenExr => "image/x-exr",
|
||||
Format::Hdr => "image/vnd.radiance",
|
||||
Format::Avif => "image/avif",
|
||||
Format::Heif => "image/heif",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +205,9 @@ pub fn caps(format: Format) -> FormatCaps {
|
||||
Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base },
|
||||
Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base },
|
||||
Format::Avif => FormatCaps { read: false, write: cfg!(feature = "avif"), lossy: true, ..base },
|
||||
Format::Heif => {
|
||||
FormatCaps { read: cfg!(feature = "heif"), write: false, depths: &[S::U8, S::U16], icc: true, exif: true, xmp: true, lossy: true, ..base }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,8 +239,13 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
|
||||
if b.starts_with(b"#?RADIANCE") || b.starts_with(b"#?RGBE") {
|
||||
return Some(Format::Hdr);
|
||||
}
|
||||
if b.len() >= 12 && &b[4..8] == b"ftyp" && is_avif_ftyp(b) {
|
||||
return Some(Format::Avif);
|
||||
if b.len() >= 12 && &b[4..8] == b"ftyp" {
|
||||
if is_avif_ftyp(b) {
|
||||
return Some(Format::Avif);
|
||||
}
|
||||
if is_heif_ftyp(b) {
|
||||
return Some(Format::Heif);
|
||||
}
|
||||
}
|
||||
if b.len() >= 14 && b.starts_with(b"BM") {
|
||||
return Some(Format::Bmp);
|
||||
@@ -239,11 +262,22 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
|
||||
None
|
||||
}
|
||||
|
||||
fn is_avif_ftyp(b: &[u8]) -> bool {
|
||||
/// The `ftyp` brands: the major brand, then the compatible ones (the minor version between them is skipped).
|
||||
fn ftyp_brands(b: &[u8]) -> impl Iterator<Item = &[u8; 4]> {
|
||||
let size = u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize;
|
||||
let end = size.clamp(12, b.len().min(64));
|
||||
let brands = &b[8..end];
|
||||
brands.as_chunks::<4>().0.iter().enumerate().any(|(i, c)| i != 1 && (c == b"avif" || c == b"avis"))
|
||||
b[8..end].as_chunks::<4>().0.iter().enumerate().filter(|(i, _)| *i != 1).map(|(_, c)| c)
|
||||
}
|
||||
|
||||
fn is_avif_ftyp(b: &[u8]) -> bool {
|
||||
ftyp_brands(b).any(|c| c == b"avif" || c == b"avis")
|
||||
}
|
||||
|
||||
/// HEVC-coded HEIF (`heic`, `heix`, ...) or a generic HEIF file (`mif1`, `msf1`, `miaf`) that is
|
||||
/// not AVIF (checked first). Sequence-only files (`hevc`, `msf1`) are recognised so that opening
|
||||
/// one says what is unsupported instead of "unrecognized format".
|
||||
fn is_heif_ftyp(b: &[u8]) -> bool {
|
||||
ftyp_brands(b).any(|c| matches!(c, b"heic" | b"heix" | b"heim" | b"heis" | b"hevc" | b"hevx" | b"mif1" | b"msf1" | b"miaf"))
|
||||
}
|
||||
|
||||
fn looks_like_tga(b: &[u8]) -> bool {
|
||||
|
||||
+102
-3
@@ -4,7 +4,10 @@
|
||||
//! in **native endianness**. Integer samples are unsigned and normalized to
|
||||
//! `[0, max]`; float samples are nominally `[0, 1]` but may exceed it (HDR).
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::error::CodecError;
|
||||
use crate::format::Format;
|
||||
use half::f16;
|
||||
|
||||
/// Channel layout of an [`Image`]. Alpha, when present, is always the last
|
||||
@@ -118,11 +121,103 @@ pub struct Metadata {
|
||||
pub dpi: Option<(f32, f32)>,
|
||||
/// Free-form key/value text (PNG tEXt/iTXt, TIFF ASCII tags).
|
||||
pub text: Vec<(String, String)>,
|
||||
/// Photoshop image resources (`8BIM` resource blocks): TIFF tag 34377. Kept opaque here;
|
||||
/// `photocraft-io` reads and writes them through `photocraft-psd`.
|
||||
pub photoshop_resources: Option<Vec<u8>>,
|
||||
/// Photoshop layer data (TIFF tag 37724 `ImageSourceData`): a layered TIFF's layers. Kept
|
||||
/// opaque here; `photocraft-io` reads and writes it through `photocraft-psd`.
|
||||
pub photoshop_layers: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Metadata {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.exif.is_none() && self.xmp.is_none() && self.dpi.is_none() && self.text.is_empty()
|
||||
self.exif.is_none()
|
||||
&& self.xmp.is_none()
|
||||
&& self.dpi.is_none()
|
||||
&& self.text.is_empty()
|
||||
&& self.photoshop_resources.is_none()
|
||||
&& self.photoshop_layers.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Something a decoder noticed about the file that the decoded pixels don't show: the file
|
||||
/// held more than was decoded, or less than it should.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DecodeWarning {
|
||||
/// Only the first frame of an animation was decoded; `total` counts every frame when known.
|
||||
MoreFrames { total: Option<u32> },
|
||||
/// Only the first page of a multi-page file was decoded; `total` counts every page when known.
|
||||
MorePages { total: Option<u32> },
|
||||
/// The image data ends early (truncated or damaged file); the decoder filled in the rest
|
||||
/// (a baseline JPEG's missing rows come out grey).
|
||||
Truncated { format: Format },
|
||||
/// Deep image data (per-pixel sample lists) was composited into the flat image; the samples
|
||||
/// themselves (Z and other deep channels) are not part of the flat result.
|
||||
DeepFlattened { format: Format, max_samples_per_pixel: u32 },
|
||||
}
|
||||
|
||||
impl fmt::Display for DecodeWarning {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
DecodeWarning::MoreFrames { total: Some(n) } => write!(f, "only the first of {n} frames was imported"),
|
||||
DecodeWarning::MoreFrames { total: None } => write!(f, "only the first frame of the animation was imported"),
|
||||
DecodeWarning::MorePages { total: Some(n) } => write!(f, "only the first of {n} pages was imported"),
|
||||
DecodeWarning::MorePages { total: None } => write!(f, "only the first page of the file was imported"),
|
||||
DecodeWarning::Truncated { format } => {
|
||||
write!(f, "{} data ends early (the file is truncated or damaged); part of the image is missing", format.name())
|
||||
}
|
||||
DecodeWarning::DeepFlattened { format, max_samples_per_pixel } => write!(
|
||||
f,
|
||||
"{format} deep data was composited into a flat image (up to {max_samples_per_pixel} samples per pixel); per-pixel depth is not kept",
|
||||
format = format.name()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One channel of a [`DeepImage`]: the samples of every pixel, one flat list in pixel order.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DeepChannel {
|
||||
/// The channel name as in the file (e.g. `R`, `G`, `B`, `A`, `Z`).
|
||||
pub name: String,
|
||||
/// The sample type as stored in the file; `samples` always holds the numeric value as
|
||||
/// f32 (half and 32-bit floats exactly; 32-bit unsigned integers exactly up to 2^24).
|
||||
pub sample: SampleType,
|
||||
/// Every sample of the channel, in pixel order; the samples of pixel `i` are
|
||||
/// `counts[i]..counts[i+1]` (see [`DeepImage::counts`]).
|
||||
pub samples: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Deep image data (OpenEXR `deepscanline`/`deeptile`): a variable-length list of samples
|
||||
/// per pixel, each with its own colour, alpha and depth. All channels share the same sample
|
||||
/// layout, so a sample is one index into every channel.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DeepImage {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// The sample channels in file order.
|
||||
pub channels: Vec<DeepChannel>,
|
||||
/// Cumulative sample counts, `len == width as usize * height as usize + 1`: pixel `i`
|
||||
/// holds the samples `counts[i]..counts[i+1]` of every channel. Monotonically
|
||||
/// non-decreasing, starting and ending at the total sample count.
|
||||
pub counts: Vec<u64>,
|
||||
}
|
||||
|
||||
impl DeepImage {
|
||||
/// A channel by its exact name.
|
||||
pub fn channel(&self, name: &str) -> Option<&DeepChannel> {
|
||||
self.channels.iter().find(|c| c.name == name)
|
||||
}
|
||||
|
||||
/// The samples of one pixel, as indices into every channel's `samples`.
|
||||
pub fn sample_range(&self, pixel: usize) -> std::ops::Range<u64> {
|
||||
let end = self.counts.get(pixel + 1).copied().unwrap_or(0);
|
||||
self.counts.get(pixel).copied().unwrap_or(end)..end
|
||||
}
|
||||
|
||||
/// The total number of samples over all pixels.
|
||||
pub fn total_samples(&self) -> u64 {
|
||||
self.counts.last().copied().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +232,9 @@ pub struct Image {
|
||||
/// Embedded ICC profile (must describe `layout`'s colour model).
|
||||
pub icc: Option<Vec<u8>>,
|
||||
pub meta: Metadata,
|
||||
/// What the decoder noticed about the file: empty for a complete, single image and for
|
||||
/// images built in memory. Encoders ignore it.
|
||||
pub warnings: Vec<DecodeWarning>,
|
||||
}
|
||||
|
||||
fn byte_len(width: u32, height: u32, layout: ChannelLayout, sample: SampleType) -> Option<usize> {
|
||||
@@ -147,7 +245,7 @@ impl Image {
|
||||
/// A zero-filled image, or an error if the byte size overflows `usize`.
|
||||
pub fn new(width: u32, height: u32, layout: ChannelLayout, sample: SampleType) -> Result<Self, CodecError> {
|
||||
let len = byte_len(width, height, layout, sample).ok_or_else(|| CodecError::InvalidImage("image size overflows usize".into()))?;
|
||||
Ok(Image { width, height, layout, sample, data: vec![0; len], icc: None, meta: Metadata::default() })
|
||||
Ok(Image { width, height, layout, sample, data: vec![0; len], icc: None, meta: Metadata::default(), warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Wrap raw interleaved, native-endian bytes.
|
||||
@@ -156,7 +254,7 @@ impl Image {
|
||||
if data.len() != expected {
|
||||
return Err(CodecError::InvalidImage(format!("buffer has {} bytes, expected {expected} for {width}x{height} {layout:?} {sample:?}", data.len())));
|
||||
}
|
||||
Ok(Image { width, height, layout, sample, data, icc: None, meta: Metadata::default() })
|
||||
Ok(Image { width, height, layout, sample, data, icc: None, meta: Metadata::default(), warnings: Vec::new() })
|
||||
}
|
||||
|
||||
pub fn from_u8(width: u32, height: u32, layout: ChannelLayout, data: Vec<u8>) -> Result<Self, CodecError> {
|
||||
@@ -324,6 +422,7 @@ impl Image {
|
||||
data,
|
||||
icc: if layout.same_model(self.layout) { self.icc.clone() } else { None },
|
||||
meta: self.meta.clone(),
|
||||
warnings: self.warnings.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,17 +19,20 @@ mod fidelity;
|
||||
mod format;
|
||||
mod image;
|
||||
mod options;
|
||||
pub mod orientation;
|
||||
pub mod web;
|
||||
|
||||
pub use crate::codecs::png::encode_indexed as encode_png_indexed;
|
||||
pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian};
|
||||
pub use crate::error::CodecError;
|
||||
pub use crate::fidelity::{FidelityWarning, fidelity_warnings, fidelity_warnings_with};
|
||||
pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, from_extension};
|
||||
pub use crate::image::{ChannelLayout, Image, Metadata, SampleType};
|
||||
pub use crate::image::{ChannelLayout, DecodeWarning, DeepChannel, DeepImage, Image, Metadata, SampleType};
|
||||
pub use crate::options::{DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
|
||||
pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp};
|
||||
pub use half::f16;
|
||||
|
||||
use crate::codecs::{exr, jpeg, png, pnm, tiff, via_image, webp};
|
||||
use crate::codecs::{exr, heif, jpeg, png, pnm, tiff, via_image, webp};
|
||||
|
||||
/// Detect the format and decode with default [`Limits`].
|
||||
pub fn decode(bytes: &[u8]) -> Result<Image, CodecError> {
|
||||
@@ -49,7 +52,8 @@ pub fn decode_as(format: Format, bytes: &[u8]) -> Result<Image, CodecError> {
|
||||
|
||||
/// Decode as a specific format.
|
||||
pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Result<Image, CodecError> {
|
||||
if !caps(format).read {
|
||||
// HEIF without the `heif` feature: its decoder stub says what is missing from this build.
|
||||
if !caps(format).read && format != Format::Heif {
|
||||
return Err(CodecError::unsupported(format, "decoding is not available for this format"));
|
||||
}
|
||||
let l = &opts.limits;
|
||||
@@ -60,11 +64,38 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
|
||||
Format::WebP => webp::decode(bytes, l),
|
||||
Format::Pnm => pnm::decode(bytes, l),
|
||||
Format::OpenExr => exr::decode(bytes, l),
|
||||
Format::Heif => heif::decode(bytes, l, opts.keep_orientation),
|
||||
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l),
|
||||
}?;
|
||||
// Final guard for decoders whose header we could not pre-inspect.
|
||||
l.check(img.width(), img.height(), img.layout(), img.sample_type())?;
|
||||
Ok(img)
|
||||
if opts.keep_orientation {
|
||||
return Ok(img);
|
||||
}
|
||||
// Turn the pixels upright, like Photoshop: a TIFF records it in its own IFD0, the others
|
||||
// in their EXIF block. The metadata is rewritten to Orientation = 1 on the way. HEIF keeps it
|
||||
// in its container, and its decoder has already applied it.
|
||||
let o = match format {
|
||||
Format::Tiff => exif_orientation(bytes),
|
||||
Format::Heif => 1,
|
||||
_ => img.meta.exif.as_deref().map_or(1, exif_orientation),
|
||||
};
|
||||
// Orientations 5–8 swap width and height: with asymmetric limits a stored landscape
|
||||
// that fit can turn into a portrait that does not, so check the upright size too.
|
||||
if (5..=8).contains(&o) {
|
||||
l.check(img.height(), img.width(), img.layout(), img.sample_type())?;
|
||||
}
|
||||
img.oriented(o)
|
||||
}
|
||||
|
||||
/// Deep OpenEXR samples (`deepscanline`/`deeptile`): the structured per-pixel sample lists
|
||||
/// (colour, alpha and Z per sample), for callers that composite deep data themselves —
|
||||
/// [`decode_as_with`] instead flattens a deep file into a normal [`Image`] with a
|
||||
/// [`DecodeWarning::DeepFlattened`]. `Err(Unsupported)` when the file has no deep part.
|
||||
pub fn decode_deep_exr(bytes: &[u8], limits: &Limits) -> Result<DeepImage, CodecError> {
|
||||
let mut cur = std::io::Cursor::new(bytes);
|
||||
let meta = ::exr::meta::MetaData::read_from_buffered(&mut cur, false).map_err(|e| CodecError::malformed(Format::OpenExr, e))?;
|
||||
codecs::deep_exr::decode_deep(&meta, bytes, limits)
|
||||
}
|
||||
|
||||
/// Encode `image` as `format`. Conversions follow the same plan that
|
||||
@@ -90,6 +121,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
|
||||
Format::WebP => webp::encode(image, plan, opts),
|
||||
Format::Pnm => pnm::encode(image, plan, opts),
|
||||
Format::OpenExr => exr::encode(image, plan, opts),
|
||||
Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")),
|
||||
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts),
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,10 @@ impl Limits {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DecodeOptions {
|
||||
pub limits: Limits,
|
||||
/// Keep the pixels as stored instead of applying the EXIF / TIFF
|
||||
/// orientation (by default they are turned upright and the metadata's
|
||||
/// orientation is rewritten to 1).
|
||||
pub keep_orientation: bool,
|
||||
}
|
||||
|
||||
/// PNG zlib effort.
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
//! EXIF / TIFF orientation (tag 274): reading it, applying it to the pixels,
|
||||
//! and rewriting it to 1 ("top-left", upright) once the pixels are upright.
|
||||
//!
|
||||
//! Decoders apply the orientation by default (like Photoshop and every photo
|
||||
//! viewer), so a document is always edited upright. Encoders then write
|
||||
//! Orientation = 1, so a viewer never rotates the already-upright pixels a
|
||||
//! second time.
|
||||
//!
|
||||
//! Orientation values (TIFF 6.0, EXIF 2.3): 1 = upright, 2 = mirrored, 3 =
|
||||
//! rotated 180°, 4 = flipped vertically, 5 = transposed, 6 = needs a 90°
|
||||
//! clockwise turn, 7 = transverse, 8 = needs a 90° counter-clockwise turn.
|
||||
//! Anything malformed, truncated or out of range reads as 1: an IFD0 that
|
||||
//! starts inside the 8-byte header or is cut off before its next-IFD pointer,
|
||||
//! or an Orientation entry that is not exactly one SHORT or LONG (the spec says
|
||||
//! SHORT, but some cameras and tools write LONG). When IFD0 holds
|
||||
//! several Orientation entries the first one wins (like libexif).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::error::CodecError;
|
||||
use crate::image::Image;
|
||||
|
||||
/// The TIFF/EXIF Orientation tag.
|
||||
const TAG_ORIENTATION: u16 = 274;
|
||||
/// Field types accepted for the Orientation tag: SHORT (the spec) and LONG
|
||||
/// (written by some cameras and tools).
|
||||
const TYPE_SHORT: u16 = 3;
|
||||
const TYPE_LONG: u16 = 4;
|
||||
/// Bytes per IFD entry.
|
||||
const ENTRY: usize = 12;
|
||||
/// The TIFF header is 8 bytes, so no IFD can start before it.
|
||||
const HEADER: usize = 8;
|
||||
/// BigTIFF uses a 16-byte header, 20-byte entries, and 8-byte offsets.
|
||||
const BIG_ENTRY: usize = 20;
|
||||
const BIG_HEADER: usize = 16;
|
||||
|
||||
/// Byte order of a TIFF structure.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Order {
|
||||
Little,
|
||||
Big,
|
||||
}
|
||||
|
||||
impl Order {
|
||||
fn u16(self, b: &[u8], at: usize) -> Option<u16> {
|
||||
let s: [u8; 2] = b.get(at..at.checked_add(2)?)?.try_into().ok()?;
|
||||
Some(match self {
|
||||
Order::Little => u16::from_le_bytes(s),
|
||||
Order::Big => u16::from_be_bytes(s),
|
||||
})
|
||||
}
|
||||
|
||||
fn u32(self, b: &[u8], at: usize) -> Option<u32> {
|
||||
let s: [u8; 4] = b.get(at..at.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(match self {
|
||||
Order::Little => u32::from_le_bytes(s),
|
||||
Order::Big => u32::from_be_bytes(s),
|
||||
})
|
||||
}
|
||||
|
||||
fn u64(self, b: &[u8], at: usize) -> Option<u64> {
|
||||
let s: [u8; 8] = b.get(at..at.checked_add(8)?)?.try_into().ok()?;
|
||||
Some(match self {
|
||||
Order::Little => u64::from_le_bytes(s),
|
||||
Order::Big => u64::from_be_bytes(s),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The TIFF payload of an EXIF block (a JPEG-style `Exif\0\0` prefix is skipped).
|
||||
fn tiff_body(b: &[u8]) -> &[u8] {
|
||||
b.strip_prefix(b"Exif\0\0").unwrap_or(b)
|
||||
}
|
||||
|
||||
/// IFD0 of a TIFF structure: byte order, entry layout, and entry offsets.
|
||||
///
|
||||
/// The full table and next-IFD pointer must be present, and IFD0 must start
|
||||
/// after the corresponding TIFF or BigTIFF header.
|
||||
fn ifd0_entries(b: &[u8]) -> Option<(Order, usize, usize, usize, bool)> {
|
||||
let (order, big_tiff) = match b.get(0..4)? {
|
||||
[b'I', b'I', 42, 0] => (Order::Little, false),
|
||||
[b'M', b'M', 0, 42] => (Order::Big, false),
|
||||
[b'I', b'I', 43, 0] => (Order::Little, true),
|
||||
[b'M', b'M', 0, 43] => (Order::Big, true),
|
||||
_ => return None,
|
||||
};
|
||||
let (ifd, count_bytes, entry_bytes, next_ifd_bytes, header) = if big_tiff {
|
||||
if order.u16(b, 4)? != 8 || order.u16(b, 6)? != 0 {
|
||||
return None;
|
||||
}
|
||||
(usize::try_from(order.u64(b, 8)?).ok()?, 8usize, BIG_ENTRY, 8usize, BIG_HEADER)
|
||||
} else {
|
||||
(usize::try_from(order.u32(b, 4)?).ok()?, 2usize, ENTRY, 4usize, HEADER)
|
||||
};
|
||||
if ifd < header {
|
||||
return None;
|
||||
}
|
||||
let count = if big_tiff { usize::try_from(order.u64(b, ifd)?).ok()? } else { usize::from(order.u16(b, ifd)?) };
|
||||
let first = ifd.checked_add(count_bytes)?;
|
||||
let next_ifd = first.checked_add(count.checked_mul(entry_bytes)?)?;
|
||||
if next_ifd.checked_add(next_ifd_bytes)? > b.len() {
|
||||
return None;
|
||||
}
|
||||
// In bounds: every entry ends at or before `next_ifd`.
|
||||
Some((order, first, count, entry_bytes, big_tiff))
|
||||
}
|
||||
|
||||
/// A well-formed Orientation entry's value: where it sits and its field type.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Value {
|
||||
at: usize,
|
||||
ty: u16,
|
||||
}
|
||||
|
||||
impl Value {
|
||||
/// The stored value (a SHORT or LONG, left-justified in the 4-byte field).
|
||||
fn read(self, order: Order, b: &[u8]) -> Option<u32> {
|
||||
match self.ty {
|
||||
TYPE_SHORT => order.u16(b, self.at).map(u32::from),
|
||||
_ => order.u32(b, self.at),
|
||||
}
|
||||
}
|
||||
|
||||
/// 1 in this entry's own type, width and byte order.
|
||||
fn one(self, order: Order) -> Vec<u8> {
|
||||
match (self.ty, order) {
|
||||
(TYPE_SHORT, Order::Little) => 1u16.to_le_bytes().to_vec(),
|
||||
(TYPE_SHORT, Order::Big) => 1u16.to_be_bytes().to_vec(),
|
||||
(_, Order::Little) => 1u32.to_le_bytes().to_vec(),
|
||||
(_, Order::Big) => 1u32.to_be_bytes().to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of the entry at `e` if it is a well-formed Orientation entry:
|
||||
/// exactly one SHORT or LONG.
|
||||
fn orientation_value(order: Order, b: &[u8], e: usize, big_tiff: bool) -> Option<Value> {
|
||||
let count = if big_tiff { order.u64(b, e.checked_add(4)?)? } else { u64::from(order.u32(b, e.checked_add(4)?)?) };
|
||||
if order.u16(b, e)? != TAG_ORIENTATION || count != 1 {
|
||||
return None;
|
||||
}
|
||||
let ty = order.u16(b, e.checked_add(2)?)?;
|
||||
let value_offset = if big_tiff { 12 } else { 8 };
|
||||
matches!(ty, TYPE_SHORT | TYPE_LONG).then_some(Value { at: e.checked_add(value_offset)?, ty })
|
||||
}
|
||||
|
||||
/// Locates the Orientation value in IFD0.
|
||||
///
|
||||
/// The **first** Orientation entry wins (like libexif and Photoshop); if that
|
||||
/// one is malformed (count ≠ 1, or neither SHORT nor LONG) the tag is ignored,
|
||||
/// even when a later duplicate is well formed.
|
||||
fn find_entry(b: &[u8]) -> Option<(Order, Value)> {
|
||||
let (order, first, count, entry_bytes, big_tiff) = ifd0_entries(b)?;
|
||||
let e = (0..count).filter_map(|i| first.checked_add(i.checked_mul(entry_bytes)?)).find(|&e| order.u16(b, e) == Some(TAG_ORIENTATION))?;
|
||||
Some((order, orientation_value(order, b, e, big_tiff)?))
|
||||
}
|
||||
|
||||
/// The orientation (1–8) recorded in a TIFF-structured block: an EXIF payload
|
||||
/// (with or without the `Exif\0\0` prefix) or a whole TIFF file. Missing,
|
||||
/// malformed or out-of-range values give 1.
|
||||
pub fn exif_orientation(exif: &[u8]) -> u16 {
|
||||
let b = tiff_body(exif);
|
||||
find_entry(b).and_then(|(order, v)| v.read(order, b)).and_then(|v| u16::try_from(v).ok()).filter(|v| (1..=8).contains(v)).unwrap_or(1)
|
||||
}
|
||||
|
||||
/// `exif` with its Orientation rewritten to 1, borrowed when there is nothing
|
||||
/// to change (no well-formed tag, already 1, or unparseable).
|
||||
///
|
||||
/// Only the value bytes of each well-formed Orientation entry change, written
|
||||
/// as 1 in the entry's existing type and width (SHORT or LONG); every other
|
||||
/// byte (other tags, their offset-based values, sub-IFDs) is kept as is.
|
||||
/// Well-formed duplicates are all set to 1, so a reader that lets a later
|
||||
/// duplicate win cannot rotate the upright pixels either.
|
||||
pub fn upright_exif(exif: &[u8]) -> Cow<'_, [u8]> {
|
||||
let body = tiff_body(exif);
|
||||
let prefix = exif.len() - body.len();
|
||||
let Some((order, first, count, entry_bytes, big_tiff)) = ifd0_entries(body) else {
|
||||
return Cow::Borrowed(exif);
|
||||
};
|
||||
let values: Vec<Value> =
|
||||
(0..count).filter_map(|i| first.checked_add(i.checked_mul(entry_bytes)?)).filter_map(|e| orientation_value(order, body, e, big_tiff)).collect();
|
||||
if values.iter().all(|v| v.read(order, body) == Some(1)) {
|
||||
return Cow::Borrowed(exif);
|
||||
}
|
||||
let mut out = exif.to_vec();
|
||||
for v in values {
|
||||
let one = v.one(order);
|
||||
let slot = prefix.checked_add(v.at).and_then(|at| out.get_mut(at..at.checked_add(one.len())?));
|
||||
if let Some(slot) = slot {
|
||||
slot.copy_from_slice(&one);
|
||||
}
|
||||
}
|
||||
Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// `xmp` with any `tiff:Orientation` (attribute or element form) set to 1,
|
||||
/// borrowed when there is nothing to change.
|
||||
pub fn upright_xmp(xmp: &str) -> Cow<'_, str> {
|
||||
const KEY: &str = "tiff:Orientation";
|
||||
if !xmp.contains(KEY) {
|
||||
return Cow::Borrowed(xmp);
|
||||
}
|
||||
let mut out = String::with_capacity(xmp.len());
|
||||
let mut rest = xmp;
|
||||
let mut changed = false;
|
||||
while let Some(i) = rest.find(KEY) {
|
||||
let (head, tail) = rest.split_at(i + KEY.len());
|
||||
out.push_str(head);
|
||||
rest = tail;
|
||||
// `tiff:Orientation="6"`, `tiff:Orientation='6'` or `<tiff:Orientation>6<`.
|
||||
let open = rest.char_indices().take_while(|&(_, c)| c.is_ascii_whitespace() || matches!(c, '=' | '"' | '\'' | '>')).last();
|
||||
let Some((at, c)) = open else { continue };
|
||||
if !matches!(c, '"' | '\'' | '>') {
|
||||
continue;
|
||||
}
|
||||
let start = at + 1;
|
||||
let digits = rest.get(start..).map_or(0, |s| s.bytes().take_while(u8::is_ascii_digit).count());
|
||||
if digits == 0 || rest.get(start..start + digits) == Some("1") {
|
||||
continue;
|
||||
}
|
||||
out.push_str(rest.get(..start).unwrap_or_default());
|
||||
out.push('1');
|
||||
rest = rest.get(start + digits..).unwrap_or_default();
|
||||
changed = true;
|
||||
}
|
||||
out.push_str(rest);
|
||||
if changed { Cow::Owned(out) } else { Cow::Borrowed(xmp) }
|
||||
}
|
||||
|
||||
/// Source pixel index for output pixel `(0, y2)` and the step per output column, for
|
||||
/// orientation `o` of a `w`×`h` source (values 2–8; see the module docs).
|
||||
fn walk(o: u16, w: isize, h: isize, y2: isize) -> (isize, isize) {
|
||||
match o {
|
||||
2 => (y2 * w + w - 1, -1),
|
||||
3 => ((h - 1 - y2) * w + w - 1, -1),
|
||||
4 => ((h - 1 - y2) * w, 1),
|
||||
5 => (y2, w),
|
||||
6 => ((h - 1) * w + y2, -w),
|
||||
7 => ((h - 1) * w + (w - 1 - y2), -w),
|
||||
8 => (w - 1 - y2, w),
|
||||
_ => (y2 * w, 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Output columns per block: a block's source pixels (for a 90° turn, `BLOCK`
|
||||
/// rows × the band height) stay in cache while it is written.
|
||||
const BLOCK: usize = 64;
|
||||
/// Output rows per parallel band.
|
||||
const BAND: usize = 64;
|
||||
|
||||
/// Writes the oriented pixels of one band of output rows, `N` bytes per pixel.
|
||||
fn band<const N: usize>(src: &[[u8; N]], dst: &mut [[u8; N]], o: u16, (w, h): (usize, usize), ow: usize, y0: usize) {
|
||||
let (wi, hi) = (w as isize, h as isize);
|
||||
let rows = dst.len() / ow.max(1);
|
||||
for x0 in (0..ow).step_by(BLOCK) {
|
||||
let x1 = (x0 + BLOCK).min(ow);
|
||||
for r in 0..rows {
|
||||
let (base, step) = walk(o, wi, hi, (y0 + r) as isize);
|
||||
let Some(out) = dst.get_mut(r * ow + x0..r * ow + x1) else { return };
|
||||
let mut i = base + x0 as isize * step;
|
||||
for px in out {
|
||||
if let Some(s) = usize::try_from(i).ok().and_then(|i| src.get(i)) {
|
||||
*px = *s;
|
||||
}
|
||||
i += step;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Orients `src` (`N` bytes per pixel) into a new buffer, a band of rows per task.
|
||||
fn orient_n<const N: usize>(src: &[u8], o: u16, (w, h): (usize, usize), ow: usize) -> Result<Vec<u8>, CodecError> {
|
||||
let mut out = alloc_zeroed(src.len())?;
|
||||
let (s, _) = src.as_chunks::<N>();
|
||||
let (d, _) = out.as_chunks_mut::<N>();
|
||||
let per = (BAND * ow).max(1);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
d.par_chunks_mut(per).enumerate().for_each(|(b, chunk)| band(s, chunk, o, (w, h), ow, b * BAND));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
for (b, chunk) in d.chunks_mut(per).enumerate() {
|
||||
band(s, chunk, o, (w, h), ow, b * BAND);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A zeroed buffer of `n` bytes, or an error (not an abort) when memory runs out.
|
||||
fn alloc_zeroed(n: usize) -> Result<Vec<u8>, CodecError> {
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(n).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {n} bytes to orient the image")))?;
|
||||
out.resize(n, 0);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Pixel-format-agnostic orientation: any byte size per pixel.
|
||||
fn orient_bytes(src: &[u8], bpp: usize, o: u16, (w, h): (usize, usize), ow: usize) -> Result<Vec<u8>, CodecError> {
|
||||
match bpp {
|
||||
1 => orient_n::<1>(src, o, (w, h), ow),
|
||||
2 => orient_n::<2>(src, o, (w, h), ow),
|
||||
3 => orient_n::<3>(src, o, (w, h), ow),
|
||||
4 => orient_n::<4>(src, o, (w, h), ow),
|
||||
5 => orient_n::<5>(src, o, (w, h), ow),
|
||||
6 => orient_n::<6>(src, o, (w, h), ow),
|
||||
8 => orient_n::<8>(src, o, (w, h), ow),
|
||||
10 => orient_n::<10>(src, o, (w, h), ow),
|
||||
12 => orient_n::<12>(src, o, (w, h), ow),
|
||||
16 => orient_n::<16>(src, o, (w, h), ow),
|
||||
_ => orient_n::<20>(src, o, (w, h), ow),
|
||||
}
|
||||
}
|
||||
|
||||
impl Image {
|
||||
/// The image turned upright for orientation `o` (1–8; anything else is
|
||||
/// treated as 1 and returns the image unchanged). Width and height swap
|
||||
/// for 5–8, as does the DPI, and the EXIF and XMP orientation are
|
||||
/// rewritten to 1 so the result is not rotated again on display or export.
|
||||
pub fn oriented(self, o: u16) -> Result<Image, CodecError> {
|
||||
if !(2..=8).contains(&o) {
|
||||
return Ok(self);
|
||||
}
|
||||
let (w, h) = (self.width() as usize, self.height() as usize);
|
||||
let swap = o >= 5;
|
||||
let (ow, oh) = if swap { (h, w) } else { (w, h) };
|
||||
let (layout, sample) = (self.layout(), self.sample_type());
|
||||
let bpp = layout.channels() * sample.bytes();
|
||||
// Every layout/sample pair is 1–20 bytes per pixel and has a fixed-size path.
|
||||
if !matches!(bpp, 1..=6 | 8 | 10 | 12 | 16 | 20) || w == 0 || h == 0 {
|
||||
return Err(CodecError::InvalidImage(format!("cannot orient {layout:?} {sample:?} {w}x{h}")));
|
||||
}
|
||||
let data = orient_bytes(self.data(), bpp, o, (w, h), ow)?;
|
||||
let mut meta = self.meta.clone();
|
||||
if let Some(e) = &mut meta.exif
|
||||
&& let Cow::Owned(fixed) = upright_exif(e)
|
||||
{
|
||||
*e = fixed;
|
||||
}
|
||||
if let Some(x) = &mut meta.xmp
|
||||
&& let Cow::Owned(fixed) = upright_xmp(x)
|
||||
{
|
||||
*x = fixed;
|
||||
}
|
||||
if swap {
|
||||
meta.dpi = meta.dpi.map(|(x, y)| (y, x));
|
||||
}
|
||||
let mut out = Image::from_raw(ow as u32, oh as u32, layout, sample, data)?.with_icc(self.icc).with_meta(meta);
|
||||
out.warnings = self.warnings;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_impossible_allocation_is_an_error_not_an_abort() {
|
||||
assert!(matches!(alloc_zeroed(usize::MAX), Err(CodecError::LimitExceeded(_))));
|
||||
assert!(matches!(orient_n::<1>(&[], 6, (0, 0), 0), Ok(v) if v.is_empty()));
|
||||
assert_eq!(alloc_zeroed(3).unwrap(), vec![0, 0, 0]);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 616 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user