Compare commits
376 Commits
batch-foll
...
v0.5.0
| Author | SHA1 | Date |
|---|---|---|
|
|
e5e3e39752 | |
|
|
bce7e54a63 | |
|
|
35f7e99965 | |
|
|
d2a9295050 | |
|
|
b10b37c14b | |
|
|
c9a7d26f6a | |
|
|
173fc90ecb | |
|
|
5896f0b63e | |
|
|
4c4c58d6e3 | |
|
|
3d003bd9d0 | |
|
|
d64f809e4d | |
|
|
1ae11a4bb1 | |
|
|
74c7980146 | |
|
|
dd963b4b44 | |
|
|
997acf5885 | |
|
|
11f6f00ccc | |
|
|
62b0d0bd60 | |
|
|
eec4af6551 | |
|
|
452672765d | |
|
|
01cd6fffa2 | |
|
|
20fb1903dd | |
|
|
a93fb88064 | |
|
|
e694208a06 | |
|
|
5bfadbadd0 | |
|
|
bdd0141cf3 | |
|
|
244a99c39f | |
|
|
75ff41a26b | |
|
|
ec7302f4b3 | |
|
|
fee08b07b6 | |
|
|
772925caf9 | |
|
|
e040376e15 | |
|
|
d55da47e8d | |
|
|
34a87cedea | |
|
|
3f605afc81 | |
|
|
1d8a32ed83 | |
|
|
5cd50d50ec | |
|
|
52ccb8a800 | |
|
|
1ea96cb288 | |
|
|
44303db546 | |
|
|
aaa946b56d | |
|
|
304322960e | |
|
|
735bb09a98 | |
|
|
fdd7141788 | |
|
|
5902c878e5 | |
|
|
65ea2c973b | |
|
|
83cfc3d9ef | |
|
|
53959abc9c | |
|
|
1d78668b25 | |
|
|
98e347be5f | |
|
|
8f1e0d83ce | |
|
|
00c406f589 | |
|
|
1ee12eb90d | |
|
|
2873afdda9 | |
|
|
6f39aba7f2 | |
|
|
ce87c106a9 | |
|
|
3689754ef2 | |
|
|
07174271d5 | |
|
|
0f16bce0c5 | |
|
|
7a9b591189 | |
|
|
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 | |
|
|
10203e7a60 | |
|
|
20783fdc70 | |
|
|
231bf5fe81 | |
|
|
4195f25f27 | |
|
|
6293f51954 | |
|
|
a202171891 | |
|
|
a6c5fa6732 | |
|
|
177620a58d | |
|
|
3c54849ba6 | |
|
|
28a63fe27e | |
|
|
7862267069 | |
|
|
800f264ee3 | |
|
|
b4b1aab4ad | |
|
|
27e047f19f | |
|
|
bb58aace98 | |
|
|
ad7046bf99 | |
|
|
f9f1efea6e | |
|
|
ac22c5ae1e | |
|
|
e61bfa60eb | |
|
|
1863164182 | |
|
|
57085214da | |
|
|
68e5e6713b | |
|
|
58531372f6 | |
|
|
019d6a4dc2 | |
|
|
f8f581eea0 | |
|
|
66fd3fc0fa | |
|
|
47884ada39 |
|
|
@ -0,0 +1,4 @@
|
|||
# Custom runner labels actionlint can't know about.
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- macos-release # org larger runner (M2 Pro), used by release.yml
|
||||
|
|
@ -18,18 +18,51 @@ env:
|
|||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
# A newer push to a PR branch cancels its older run. Runs on main are never cancelled: a merge
|
||||
# that lands while main's run is in progress waits for it, so every main state that gets a run is
|
||||
# checked to the end. (Cancelling here left main unverified whenever merges came close together.)
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
# Which OSes the test job runs on. main, manual runs and PRs labelled `full-ci` get all three.
|
||||
# Other PRs get Linux, plus macOS and Windows when they touch code whose behaviour differs by
|
||||
# platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile.
|
||||
platforms:
|
||||
name: select platforms
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
os: ${{ steps.pick.outputs.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: pick
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
BASE: ${{ github.event.pull_request.base.sha }}
|
||||
FULL: ${{ contains(github.event.pull_request.labels.*.name, 'full-ci') }}
|
||||
run: |
|
||||
all='["ubuntu-latest","macos-latest","windows-latest"]'
|
||||
if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then
|
||||
echo "os=$all" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
|
||||
echo "os=$all" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'os=["ubuntu-latest"]' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
test:
|
||||
name: test (${{ matrix.os }})
|
||||
needs: platforms
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
os: ${{ fromJSON(needs.platforms.outputs.os) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
|
@ -51,15 +84,71 @@ 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. Keep the exact `--workspace
|
||||
# --features heif` of the Test step: any other package or feature selection resolves a
|
||||
# different feature set, recompiles the whole dependency graph into the same target dir and
|
||||
# runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800).
|
||||
- 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 +174,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,64 @@
|
|||
# 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
|
||||
|
||||
# The VM build takes one to several hours, so it runs nightly on main rather than on every push
|
||||
# or dependency change: a drifted dependency still shows up within a day. PRs that change the
|
||||
# FreeBSD packaging itself still run it.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 2 * * *"
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/freebsd.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,10 +46,20 @@ 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*
|
||||
desktop-file-validate packaging/linux/ai.storyteller.photocraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.photocraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.photocraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.photocraft.metainfo.xml"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.photocraft.yml')); assert m['id'] == 'ai.storyteller.photocraft' and m['command'] == 'photocraft'"
|
||||
python3 - <<'EOF'
|
||||
import yaml
|
||||
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.photocraft.yml"))
|
||||
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml"))
|
||||
for m in (src, bundle):
|
||||
assert m["id"] == "ai.storyteller.photocraft" and m["command"] == "photocraft", m["id"]
|
||||
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
|
||||
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
|
||||
print("Flatpak manifests ok")
|
||||
EOF
|
||||
|
|
|
|||
|
|
@ -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,12 +30,16 @@ 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:
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
|
|
@ -63,13 +67,19 @@ jobs:
|
|||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15
|
||||
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
|
||||
environment: release
|
||||
env:
|
||||
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: 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:
|
||||
|
|
@ -154,7 +186,7 @@ jobs:
|
|||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
|
||||
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
environment: release
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
|
@ -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:
|
||||
|
|
@ -201,11 +245,101 @@ jobs:
|
|||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
|
||||
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
- name: Install flatpak-builder
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
|
||||
# `appstreamcli compose`, which renders the scalable icon and fails without it.
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/photocraft-$PHOTOCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
|
||||
# .github/workflows/freebsd.yml.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
# 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
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
env:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
|
|
@ -226,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
|
||||
|
|
@ -236,7 +372,7 @@ jobs:
|
|||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, web]
|
||||
needs: [version, macos, windows, linux, flatpak, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
# 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
|
||||
|
||||
# Nightly on main (dependency and runner-image drift shows up within a day), and on PRs that
|
||||
# change the Windows packaging itself.
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * *"
|
||||
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
|
||||
|
|
|
|||
52
AGENTS.md
52
AGENTS.md
|
|
@ -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). 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.
|
||||
|
|
@ -113,3 +131,21 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
|
|||
nothing is silently dropped from `.pcraft` saves. When you add a doc field, add it to
|
||||
`crates/format/src/manifest.rs` and `convert.rs` with `#[serde(default)]` so older files still load.
|
||||
If the field has a PSD equivalent, map it in `crates/io` too, and keep unknown PSD blocks verbatim.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
|
||||
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
themselves). See `docs/contributors.md`.
|
||||
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
|
|
|||
|
|
@ -10,16 +10,35 @@ 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/it.tsv` | Italian 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`, `eraser-magic.svg`, `lasso-magnetic.svg` and `direct-select.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) |
|
||||
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/lasso-magnetic.svg` | Magnetic Lasso tool glyph, adapted from "Magnetic Lasso Tool" (redrawn on the 24 px toolbar grid) | Lil' Seal (Noun Project); adapted by PhotoCraft contributors | <https://thenounproject.com/icon/magnetic-lasso-tool-177513/> | CC BY 3.0, [`assets/icons/LICENSE-noun-magnetic-lasso.txt`](assets/icons/LICENSE-noun-magnetic-lasso.txt) |
|
||||
| `assets/dict/en_US-scowl-50.txt.gz` | English word list for Check Spelling (SCOWL 2020.12.07, size 50, en_US; 72,403 words) | Kevin Atkinson and the SCOWL contributors | <http://wordlist.aspell.net/> | SCOWL license (permissive, keep the notice), [`assets/dict/LICENSE-SCOWL.txt`](assets/dict/LICENSE-SCOWL.txt), which also has the build recipe |
|
||||
| `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 +49,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 +68,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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -1018,6 +1025,15 @@ dependencies = [
|
|||
"itertools 0.10.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
|
|
@ -1181,6 +1197,7 @@ checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
|
|||
dependencies = [
|
||||
"bytemuck",
|
||||
"emath",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1209,6 +1226,8 @@ dependencies = [
|
|||
"pollster 1.0.1",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"ron",
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
|
|
@ -1233,6 +1252,8 @@ dependencies = [
|
|||
"log",
|
||||
"nohash-hasher",
|
||||
"profiling",
|
||||
"ron",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"unicode-segmentation",
|
||||
"web-sys",
|
||||
|
|
@ -1274,6 +1295,7 @@ dependencies = [
|
|||
"objc2-ui-kit 0.3.2",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"smithay-clipboard",
|
||||
"web-time",
|
||||
"winit",
|
||||
|
|
@ -1342,6 +1364,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1391,6 +1414,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 +1443,7 @@ dependencies = [
|
|||
"parking_lot",
|
||||
"profiling",
|
||||
"self_cell",
|
||||
"serde",
|
||||
"skrifa",
|
||||
"smallvec",
|
||||
"unicode-general-category",
|
||||
|
|
@ -1556,6 +1591,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 +1650,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1997,6 +2039,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"
|
||||
|
|
@ -2409,6 +2460,16 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyboard-types"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos-egl"
|
||||
version = "6.0.0"
|
||||
|
|
@ -2614,6 +2675,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"
|
||||
|
|
@ -2655,6 +2726,24 @@ dependencies = [
|
|||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a7b000cca0783af26837fe2ebd9ddf1c42279463c085b97622ee1d2f444b89e"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dpi",
|
||||
"keyboard-types",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"png 0.18.1",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "naga"
|
||||
version = "30.0.1"
|
||||
|
|
@ -3204,6 +3293,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.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
|
|
@ -3303,6 +3402,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 +3458,17 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fmv-macos-events",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"open",
|
||||
"photocraft-automation",
|
||||
"photocraft-codecs",
|
||||
|
|
@ -3366,15 +3480,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.5.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
|
|
@ -3386,7 +3504,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-automation"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"cap-std",
|
||||
|
|
@ -3408,7 +3526,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"photocraft-automation",
|
||||
"photocraft-codecs",
|
||||
|
|
@ -3421,7 +3539,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-cms"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"moxcms",
|
||||
"rayon",
|
||||
|
|
@ -3429,7 +3547,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-codecs"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"exr",
|
||||
"flate2",
|
||||
|
|
@ -3437,8 +3555,10 @@ dependencies = [
|
|||
"image",
|
||||
"image-webp",
|
||||
"jpeg-encoder",
|
||||
"photocraft-heif",
|
||||
"png 0.18.1",
|
||||
"proptest",
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
|
|
@ -3447,7 +3567,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-color"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"serde",
|
||||
|
|
@ -3455,7 +3575,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-compose"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"photocraft-color",
|
||||
|
|
@ -3470,7 +3590,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-doc"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
|
|
@ -3481,7 +3601,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-engine"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"flate2",
|
||||
|
|
@ -3511,7 +3631,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-format"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"flate2",
|
||||
|
|
@ -3529,14 +3649,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-geom"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-gpu"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"half",
|
||||
"log",
|
||||
|
|
@ -3552,9 +3672,18 @@ dependencies = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-io"
|
||||
version = "0.2.0"
|
||||
name = "photocraft-heif"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-io"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"photocraft-algo",
|
||||
"photocraft-cms",
|
||||
"photocraft-codecs",
|
||||
"photocraft-color",
|
||||
|
|
@ -3569,19 +3698,20 @@ dependencies = [
|
|||
"photocraft-text",
|
||||
"photocraft-vector",
|
||||
"proptest",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ops"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"photocraft-doc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-paint"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
|
|
@ -3592,7 +3722,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-plugins"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
|
|
@ -3607,7 +3737,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-psd"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"photocraft-psd",
|
||||
|
|
@ -3618,7 +3748,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-raster"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
|
|
@ -3628,7 +3758,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-raw"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"photocraft-raw",
|
||||
"png 0.18.1",
|
||||
|
|
@ -3636,17 +3766,31 @@ dependencies = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-testkit"
|
||||
version = "0.2.0"
|
||||
name = "photocraft-tablet"
|
||||
version = "0.5.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.5.0"
|
||||
dependencies = [
|
||||
"memory-stats",
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
"photocraft-raster",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-text"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"parley",
|
||||
|
|
@ -3660,7 +3804,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-ui-egui"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"eframe",
|
||||
|
|
@ -3669,6 +3813,7 @@ dependencies = [
|
|||
"egui_kittest",
|
||||
"log",
|
||||
"photocraft-algo",
|
||||
"photocraft-cms",
|
||||
"photocraft-codecs",
|
||||
"photocraft-color",
|
||||
"photocraft-compose",
|
||||
|
|
@ -3679,16 +3824,21 @@ dependencies = [
|
|||
"photocraft-gpu",
|
||||
"photocraft-io",
|
||||
"photocraft-plugins",
|
||||
"photocraft-psd",
|
||||
"photocraft-raster",
|
||||
"photocraft-testkit",
|
||||
"photocraft-text",
|
||||
"photocraft-vector",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-vector"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-doc",
|
||||
|
|
@ -3699,7 +3849,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-web"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
@ -4334,6 +4484,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 +4813,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 +4994,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 +5260,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 +5286,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 +6298,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 +6320,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 +6415,11 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
37
Cargo.toml
37
Cargo.toml
|
|
@ -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.5.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
|
||||
|
|
|
|||
5
NOTICE
5
NOTICE
|
|
@ -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.
|
||||
|
|
|
|||
107
README.md
107
README.md
|
|
@ -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>
|
||||
|
|
@ -52,6 +52,7 @@
|
|||
<a href="#built-for-agents">Agents</a> ·
|
||||
<a href="#under-the-hood">Under the hood</a> ·
|
||||
<a href="#get-started">Get started</a> ·
|
||||
<a href="#downloads">Downloads</a> ·
|
||||
<a href="#the-crafting-apps">Crafting Apps</a> ·
|
||||
<a href="https://discord.gg/artcraft">Discord</a>
|
||||
</p>
|
||||
|
|
@ -70,7 +71,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>
|
||||
|
|
@ -178,7 +179,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
|||
<tr>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🧰 34 tools</h4>
|
||||
Move · Rectangular and Elliptical Marquee · Lasso · Polygonal Lasso · Magic Wand · Quick Selection · Object Selection · Crop · Eyedropper · Brush · Pencil · Mixer Brush · Color Replacement · Eraser · Clone Stamp · Healing Brush · Spot Healing · History Brush · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Path Selection · Type · five Shape tools · Hand · Zoom
|
||||
Move · Rectangular and Elliptical Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Magic Wand · Quick Selection · Object Selection · Crop · Eyedropper · Brush · Pencil · Mixer Brush · Color Replacement · Eraser · Clone Stamp · Healing Brush · Spot Healing · History Brush · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Path Selection · Type · five Shape tools · Hand · Zoom
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🖌️ A real brush engine</h4>
|
||||
|
|
@ -198,7 +199,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 (lossy and lossless), 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 +214,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 +232,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,12 +259,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). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
|
||||
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
|
||||
|
||||
|
|
@ -270,6 +306,58 @@ 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)).
|
||||
|
||||
## Downloads
|
||||
|
||||
**New to PhotoCraft?** Download it from the [PhotoCraft page on getartcraft.com](https://getartcraft.com/apps/photocraft). That's the easiest way to install it.
|
||||
|
||||
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
|
||||
### Windows
|
||||
|
||||
| Build | Installer | Portable |
|
||||
|---|---|---|
|
||||
| x64 (64-bit Intel/AMD) | `photocraft-<ver>-windows-x64.msi` | `photocraft-<ver>-windows-x64-portable.zip` |
|
||||
| arm64 (Snapdragon and other ARM PCs) | `photocraft-<ver>-windows-arm64.msi` | `photocraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `photocraft-<ver>-windows-x86.msi` | `photocraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Installers and executables are code-signed.
|
||||
|
||||
### macOS
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| App, universal (Apple silicon + Intel) | `photocraft-<ver>-macos-universal.dmg` | Signed and notarized |
|
||||
| Command-line tool, universal | `photocraft-cli-<ver>-macos-universal.zip` | Signed and notarized |
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | aarch64 (ARM64) | Notes |
|
||||
|---|---|---|---|
|
||||
| AppImage | `photocraft-<ver>-linux-x86_64.AppImage` | `photocraft-<ver>-linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
|
||||
| Flatpak | `photocraft-<ver>-linux-x86_64.flatpak` | `photocraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
|
||||
| Debian/Ubuntu | `photocraft-<ver>-linux-x86_64.deb` | `photocraft-<ver>-linux-aarch64.deb` | |
|
||||
| Fedora/RHEL/openSUSE | `photocraft-<ver>-linux-x86_64.rpm` | `photocraft-<ver>-linux-aarch64.rpm` | |
|
||||
| Tarball | `photocraft-<ver>-linux-x86_64.tar.gz` | `photocraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| Build | File |
|
||||
|---|---|
|
||||
| x86_64 | `photocraft-<ver>-freebsd-x86_64.tar.gz` |
|
||||
|
||||
### Web (WebAssembly)
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| Static site | `photocraft-web-<ver>.zip` | Runs in a modern browser; host it on any static server |
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
PhotoCraft is one of the **Crafting Apps**: free, open-source creative tools from the
|
||||
|
|
@ -282,7 +370,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) |
|
||||
|
||||
|
|
@ -336,5 +424,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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -14,15 +14,20 @@ 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).
|
||||
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
|
||||
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 +38,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 +51,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 +131,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 +140,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 +168,14 @@ 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);
|
||||
let q = q.parse().ok().filter(|q| (1..=100).contains(q)).ok_or_else(|| format!("bad --quality `{q}`: expected a whole number from 1 to 100"))?;
|
||||
o.encode.jpeg_quality = q;
|
||||
// Asking for a quality asks for a lossy WebP; the default WebP stays lossless.
|
||||
o.encode.webp_quality = q;
|
||||
o.encode.webp_lossless = false;
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
|
@ -160,9 +195,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 +243,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 +253,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 +266,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 +299,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 +333,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,12 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
|||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
opts.encode.webp_lossless = settings.webp_lossless;
|
||||
if let Some(q) = settings.webp_quality {
|
||||
opts.encode.webp_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,52 @@ 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"] }
|
||||
# The macOS menu bar (mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK
|
||||
# with default features off). Pinned exactly: review the diff before bumping.
|
||||
muda = { version = "=0.21.1", default-features = false }
|
||||
|
||||
# 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"] }
|
||||
|
||||
# tests/mac_menu_appkit.rs: reads AppKit's menu bar back and sends it real key events.
|
||||
[target.'cfg(target_os = "macos")'.dev-dependencies]
|
||||
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSString"] }
|
||||
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSEvent", "NSGraphicsContext", "NSMenu", "NSMenuItem", "NSResponder"] }
|
||||
|
||||
[[test]]
|
||||
name = "mac_menu_appkit"
|
||||
harness = false
|
||||
|
||||
[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,359 @@
|
|||
//! The macOS menu bar, through muda (Tauri's menu crate: AppKit menus behind a safe API, no GTK on
|
||||
//! macOS). The menus themselves, their Mac layout and how their keys reach PhotoCraft are in
|
||||
//! `photocraft_ui_egui::native_menu`; this file only builds and updates the `NSMenu`s.
|
||||
//!
|
||||
//! - Item ids are command ids; a command in two menus (Keyboard Shortcuts is in Edit and Window)
|
||||
//! gets `id#2` for its second copy, as muda ids must be unique.
|
||||
//! - A structure change (a new Open Recent file, a language switch) rebuilds the menu; anything
|
||||
//! else (labels, enabled, checked) is updated in place.
|
||||
//! - A chosen item is a click or a key equivalent: AppKit's current event says which. Key
|
||||
//! equivalents go back to egui as the key press that was made (the event's modifiers, not the
|
||||
//! item's: AppKit may match ⌘R to a ⇧⌘R item), so PhotoCraft's key rules still apply.
|
||||
//! - Hide is a custom item calling `NSApplication hide:`, as the predefined one always takes ⌘H.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use muda::accelerator::{Accelerator, Code, Modifiers};
|
||||
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSApplication, NSEventModifierFlags, NSEventType};
|
||||
use photocraft_ui_egui::native_menu::{self, Backend, Chord, Event, MenuBar, MenuRole, Node, Standard};
|
||||
|
||||
enum Handle {
|
||||
Plain(MenuItem),
|
||||
Check(CheckMenuItem),
|
||||
}
|
||||
|
||||
/// One native item.
|
||||
struct Entry {
|
||||
handle: Handle,
|
||||
/// The command id (several entries can share one).
|
||||
command: String,
|
||||
chord: Option<Chord>,
|
||||
label: String,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
/// How an item was chosen, read from AppKit's current event while it calls the handler.
|
||||
struct Chosen {
|
||||
id: String,
|
||||
/// A key press (not a mouse click), and the modifiers held: ⌘, ⌃, ⌥, ⇧.
|
||||
key: bool,
|
||||
mods: [bool; 4],
|
||||
}
|
||||
|
||||
pub struct MacMenu {
|
||||
menu: Option<Menu>,
|
||||
/// By muda id.
|
||||
entries: HashMap<String, Entry>,
|
||||
rx: Receiver<Chosen>,
|
||||
structure: u64,
|
||||
}
|
||||
|
||||
impl MacMenu {
|
||||
pub fn new(ctx: &egui::Context) -> MacMenu {
|
||||
let (tx, rx) = channel();
|
||||
let repaint = ctx.clone();
|
||||
MenuEvent::set_event_handler(Some(move |e: MenuEvent| {
|
||||
// AppKit calls this on the main thread, from the event that chose the item: a key
|
||||
// equivalent is a key-down, a click a mouse-up (or ↩ inside the open menu).
|
||||
let event = MainThreadMarker::new().and_then(|mtm| NSApplication::sharedApplication(mtm).currentEvent());
|
||||
let key = event.as_ref().is_some_and(|ev| ev.r#type() == NSEventType::KeyDown);
|
||||
let flags = event.as_ref().map(|ev| ev.modifierFlags()).unwrap_or(NSEventModifierFlags::empty());
|
||||
let mods = [NSEventModifierFlags::Command, NSEventModifierFlags::Control, NSEventModifierFlags::Option, NSEventModifierFlags::Shift]
|
||||
.map(|m| flags.contains(m));
|
||||
let _ = tx.send(Chosen { id: e.id.0, key, mods });
|
||||
// Wake egui, so the item runs now rather than on the next mouse move.
|
||||
repaint.request_repaint();
|
||||
}));
|
||||
MacMenu { menu: None, entries: HashMap::new(), rx, structure: 0 }
|
||||
}
|
||||
|
||||
fn build(&mut self, bar: &MenuBar) {
|
||||
if let Some(old) = self.menu.take() {
|
||||
old.remove_for_nsapp();
|
||||
}
|
||||
self.entries.clear();
|
||||
let menu = Menu::new();
|
||||
let mut special = Vec::new();
|
||||
for m in &bar.menus {
|
||||
let sub = Submenu::new(escape(&m.title), true);
|
||||
self.append(&sub, &m.children);
|
||||
let _ = menu.append(&sub);
|
||||
if matches!(m.role, MenuRole::Window | MenuRole::Help) {
|
||||
special.push((m.role, sub));
|
||||
}
|
||||
}
|
||||
menu.init_for_nsapp();
|
||||
// Only once the menu is NSApp's: muda looks the submenus up in the installed main menu.
|
||||
for (role, sub) in special {
|
||||
match role {
|
||||
MenuRole::Window => sub.set_as_windows_menu_for_nsapp(),
|
||||
_ => sub.set_as_help_menu_for_nsapp(),
|
||||
}
|
||||
}
|
||||
self.menu = Some(menu);
|
||||
self.structure = native_menu::structure_key(bar);
|
||||
}
|
||||
|
||||
fn append(&mut self, sub: &Submenu, nodes: &[Node]) {
|
||||
for n in nodes {
|
||||
match n {
|
||||
Node::Separator => {
|
||||
let _ = sub.append(&PredefinedMenuItem::separator());
|
||||
}
|
||||
Node::Submenu { label, children, .. } => {
|
||||
let s = Submenu::new(escape(label), !children.is_empty());
|
||||
self.append(&s, children);
|
||||
let _ = sub.append(&s);
|
||||
}
|
||||
Node::Standard(item) => {
|
||||
let p = match item {
|
||||
Standard::Services => PredefinedMenuItem::services(None),
|
||||
Standard::HideOthers => PredefinedMenuItem::hide_others(None),
|
||||
Standard::ShowAll => PredefinedMenuItem::show_all(None),
|
||||
Standard::Minimize => PredefinedMenuItem::minimize(None),
|
||||
Standard::Zoom => PredefinedMenuItem::maximize(Some(photocraft_ui_egui::i18n::t("Zoom"))),
|
||||
Standard::BringAllToFront => PredefinedMenuItem::bring_all_to_front(None),
|
||||
Standard::CloseWindow => PredefinedMenuItem::close_window(None),
|
||||
};
|
||||
let _ = sub.append(&p);
|
||||
}
|
||||
Node::Item(it) => {
|
||||
let id = self.unique_id(&it.id);
|
||||
let chord = it.shortcut.as_deref().and_then(Chord::parse);
|
||||
let accel = chord.as_ref().filter(|c| c.native_ok()).and_then(accelerator);
|
||||
let handle = match it.checked {
|
||||
Some(c) => {
|
||||
let h = CheckMenuItem::with_id(id.clone(), escape(&it.label), it.enabled, c, accel);
|
||||
let _ = sub.append(&h);
|
||||
Handle::Check(h)
|
||||
}
|
||||
None => {
|
||||
let h = MenuItem::with_id(id.clone(), escape(&it.label), it.enabled, accel);
|
||||
let _ = sub.append(&h);
|
||||
Handle::Plain(h)
|
||||
}
|
||||
};
|
||||
self.entries.insert(id, Entry { handle, command: it.id.clone(), chord, label: it.label.clone(), enabled: it.enabled });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// muda ids must be unique: the second `edit.keyboardShortcuts` becomes `…#2`.
|
||||
fn unique_id(&self, id: &str) -> String {
|
||||
let mut unique = id.to_string();
|
||||
let mut n = 2;
|
||||
while self.entries.contains_key(&unique) {
|
||||
unique = format!("{id}#{n}");
|
||||
n += 1;
|
||||
}
|
||||
unique
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for MacMenu {
|
||||
fn sync(&mut self, bar: &MenuBar) {
|
||||
if self.menu.is_none() || native_menu::structure_key(bar) != self.structure {
|
||||
self.build(bar);
|
||||
return;
|
||||
}
|
||||
let items: HashMap<&str, &native_menu::Item> = bar.items().into_iter().map(|it| (it.id.as_str(), it)).collect();
|
||||
for e in self.entries.values_mut() {
|
||||
let Some(it) = items.get(e.command.as_str()) else { continue };
|
||||
if e.label != it.label {
|
||||
let text = escape(&it.label);
|
||||
match &e.handle {
|
||||
Handle::Plain(h) => h.set_text(text),
|
||||
Handle::Check(h) => h.set_text(text),
|
||||
}
|
||||
e.label = it.label.clone();
|
||||
}
|
||||
if e.enabled != it.enabled {
|
||||
match &e.handle {
|
||||
Handle::Plain(h) => h.set_enabled(it.enabled),
|
||||
Handle::Check(h) => h.set_enabled(it.enabled),
|
||||
}
|
||||
e.enabled = it.enabled;
|
||||
}
|
||||
// Compare with AppKit's own state: muda toggles a check item when it's clicked.
|
||||
if let (Handle::Check(h), Some(c)) = (&e.handle, it.checked)
|
||||
&& h.is_checked() != c
|
||||
{
|
||||
h.set_checked(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drain(&mut self) -> Vec<Event> {
|
||||
let mut out = Vec::new();
|
||||
while let Ok(chosen) = self.rx.try_recv() {
|
||||
// Ids that aren't ours are AppKit's own items (Services, Zoom, …), already handled.
|
||||
let Some(e) = self.entries.get(&chosen.id) else { continue };
|
||||
if e.command == native_menu::HIDE {
|
||||
if let Some(mtm) = MainThreadMarker::new() {
|
||||
NSApplication::sharedApplication(mtm).hide(None);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A key-down chose it: its key equivalent, unless it was ↩ or Space inside the open
|
||||
// menu (no ⌘ or ⌃, and the item's shortcut needs one). The key is the item's (AppKit
|
||||
// matched on it); the modifiers are the ones held.
|
||||
let [cmd, ctrl, alt, shift] = chosen.mods;
|
||||
let key =
|
||||
e.chord.as_ref().filter(|c| chosen.key && (cmd || ctrl || !(c.cmd || c.ctrl))).map(|c| Chord { cmd, ctrl, alt, shift, key: c.key.clone() });
|
||||
out.push(match key {
|
||||
Some(chord) if e.command != native_menu::MINIMIZE => Event::Key(chord),
|
||||
_ => Event::Click(e.command.clone()),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the menu bar now (in eframe's creator closure, so winit's default menu doesn't stay).
|
||||
pub fn install(ctx: &egui::Context, app: &photocraft_ui_egui::PhotocraftApp) -> Option<native_menu::NativeMenu> {
|
||||
let language = &app.session.prefs().interface.language;
|
||||
let lang = photocraft_ui_egui::i18n::Lang::from_pref(language);
|
||||
let bar = native_menu::photocraft_layout(&photocraft_ui_egui::menus::menu_items(app), lang, language).bar;
|
||||
// Never crash for a menu: if AppKit refuses, keep the in-window menus.
|
||||
let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let mut menu = MacMenu::new(ctx);
|
||||
menu.sync(&bar);
|
||||
menu
|
||||
}));
|
||||
match built {
|
||||
Ok(menu) => Some(native_menu::NativeMenu::new(Box::new(menu))),
|
||||
Err(_) => {
|
||||
log::warn!("couldn't install the macOS menu bar; using the in-window menus");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `&` marks a mnemonic in muda labels.
|
||||
fn escape(s: &str) -> String {
|
||||
s.replace('&', "&&")
|
||||
}
|
||||
|
||||
/// A key equivalent for AppKit. `Cmd` is ⌘ (`META`) and `Ctrl` is ⌃: never folded together.
|
||||
fn accelerator(c: &Chord) -> Option<Accelerator> {
|
||||
let mut mods = Modifiers::empty();
|
||||
for (on, m) in [(c.cmd, Modifiers::META), (c.ctrl, Modifiers::CONTROL), (c.alt, Modifiers::ALT), (c.shift, Modifiers::SHIFT)] {
|
||||
if on {
|
||||
mods |= m;
|
||||
}
|
||||
}
|
||||
const LETTERS: [Code; 26] = [
|
||||
Code::KeyA,
|
||||
Code::KeyB,
|
||||
Code::KeyC,
|
||||
Code::KeyD,
|
||||
Code::KeyE,
|
||||
Code::KeyF,
|
||||
Code::KeyG,
|
||||
Code::KeyH,
|
||||
Code::KeyI,
|
||||
Code::KeyJ,
|
||||
Code::KeyK,
|
||||
Code::KeyL,
|
||||
Code::KeyM,
|
||||
Code::KeyN,
|
||||
Code::KeyO,
|
||||
Code::KeyP,
|
||||
Code::KeyQ,
|
||||
Code::KeyR,
|
||||
Code::KeyS,
|
||||
Code::KeyT,
|
||||
Code::KeyU,
|
||||
Code::KeyV,
|
||||
Code::KeyW,
|
||||
Code::KeyX,
|
||||
Code::KeyY,
|
||||
Code::KeyZ,
|
||||
];
|
||||
const DIGITS: [Code; 10] =
|
||||
[Code::Digit0, Code::Digit1, Code::Digit2, Code::Digit3, Code::Digit4, Code::Digit5, Code::Digit6, Code::Digit7, Code::Digit8, Code::Digit9];
|
||||
const F: [Code; 24] = [
|
||||
Code::F1,
|
||||
Code::F2,
|
||||
Code::F3,
|
||||
Code::F4,
|
||||
Code::F5,
|
||||
Code::F6,
|
||||
Code::F7,
|
||||
Code::F8,
|
||||
Code::F9,
|
||||
Code::F10,
|
||||
Code::F11,
|
||||
Code::F12,
|
||||
Code::F13,
|
||||
Code::F14,
|
||||
Code::F15,
|
||||
Code::F16,
|
||||
Code::F17,
|
||||
Code::F18,
|
||||
Code::F19,
|
||||
Code::F20,
|
||||
Code::F21,
|
||||
Code::F22,
|
||||
Code::F23,
|
||||
Code::F24,
|
||||
];
|
||||
let k = c.key.as_str();
|
||||
let mut chars = k.chars();
|
||||
let code = match (chars.next(), chars.next()) {
|
||||
(Some(ch), None) if ch.is_ascii_alphabetic() => *LETTERS.get((ch.to_ascii_uppercase() as usize).checked_sub('A' as usize)?)?,
|
||||
(Some(ch), None) if ch.is_ascii_digit() => *DIGITS.get((ch as usize).checked_sub('0' as usize)?)?,
|
||||
_ if c.is_function_key() => *F.get(k.get(1..)?.parse::<usize>().ok()?.checked_sub(1)?)?,
|
||||
_ => match k {
|
||||
"[" => Code::BracketLeft,
|
||||
"]" => Code::BracketRight,
|
||||
"\\" => Code::Backslash,
|
||||
"/" => Code::Slash,
|
||||
"=" => Code::Equal,
|
||||
"-" => Code::Minus,
|
||||
"'" => Code::Quote,
|
||||
";" => Code::Semicolon,
|
||||
"," => Code::Comma,
|
||||
"." => Code::Period,
|
||||
"`" => Code::Backquote,
|
||||
"Delete" | "Backspace" => Code::Backspace,
|
||||
"Enter" if !mods.is_empty() => Code::Enter,
|
||||
"Tab" if !mods.is_empty() => Code::Tab,
|
||||
"Space" if !mods.is_empty() => Code::Space,
|
||||
_ => return None,
|
||||
},
|
||||
};
|
||||
Some(Accelerator::new(mods, code))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every shortcut the Mac menu shows becomes a key equivalent (or stays egui's, for bare keys).
|
||||
#[test]
|
||||
fn every_menu_shortcut_maps_to_a_key_equivalent() {
|
||||
let app = photocraft_ui_egui::PhotocraftApp::new(photocraft_engine::Session::new(), photocraft_ui_egui::Services::default());
|
||||
let bar = native_menu::photocraft_layout(&photocraft_ui_egui::menus::menu_items(&app), photocraft_ui_egui::i18n::Lang::EN, "auto").bar;
|
||||
let mut native = 0;
|
||||
for it in bar.items() {
|
||||
let Some(c) = it.shortcut.as_deref().and_then(Chord::parse) else { continue };
|
||||
if c.native_ok() {
|
||||
assert!(accelerator(&c).is_some(), "{} ({}) has no key equivalent", c.portable(), it.id);
|
||||
native += 1;
|
||||
}
|
||||
}
|
||||
assert!(native > 70, "{native} key equivalents");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_and_command_stay_distinct() {
|
||||
let a = accelerator(&Chord::parse("Cmd+M").unwrap()).unwrap();
|
||||
let b = accelerator(&Chord::parse("Ctrl+Cmd+M").unwrap()).unwrap();
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,14 @@
|
|||
//!
|
||||
//! 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] [--in-window-menus] [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`).
|
||||
//!
|
||||
//! `--in-window-menus` (or `PHOTOCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
|
||||
//! macOS instead of the macOS menu bar (`mac_menu`).
|
||||
//!
|
||||
//! `--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 +20,27 @@
|
|||
#![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_menu;
|
||||
#[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 +48,111 @@ 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()
|
||||
/// Windows and Linux: no OS title bar; the app's top bar is the title bar, with its own caption
|
||||
/// buttons and edge resizing (`photocraft_ui_egui::titlebar`), as Photoshop does on Windows. macOS
|
||||
/// keeps its traffic lights over the integrated title strip.
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
|
||||
/// 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_decorations(!CUSTOM_TITLEBAR)
|
||||
.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 in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
|
||||
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,
|
||||
"--in-window-menus" => in_window_menus = true,
|
||||
"--version" => {
|
||||
println!("photocraft {}", photocraft_engine::build_info::long_version());
|
||||
return Ok(());
|
||||
|
|
@ -62,6 +163,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 +215,151 @@ 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));
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
// 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);
|
||||
|
|
@ -141,10 +367,105 @@ fn main() -> eframe::Result {
|
|||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
app.services.os_events = Some(apple_events.connect(&cc.egui_ctx));
|
||||
// The macOS menu bar, installed now so winit's default menu doesn't stay up.
|
||||
if !in_window_menus {
|
||||
app.services.native_menu = mac_menu::install(&cc.egui_ctx, &app);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = in_window_menus;
|
||||
// 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,74 @@ 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 `sleep` (found on PATH: NixOS and the Nix build sandbox have no /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("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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,27 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
|||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
opts.encode.webp_lossless = settings.webp_lossless;
|
||||
if let Some(q) = settings.webp_quality {
|
||||
opts.encode.webp_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 +187,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 +237,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 +303,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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
//! The macOS menu bar end to end, on the main thread (`harness = false`): install it through the
|
||||
//! real muda backend (`src/mac_menu.rs`), read AppKit's menu bar back, open a document and see the
|
||||
//! items update in place, then choose items the two ways AppKit can: a key equivalent (a real ⌘R
|
||||
//! key-down through `-[NSMenu performKeyEquivalent:]`) must reach egui as the key press it was, and
|
||||
//! a click (`-[NSMenu performActionForItemAtIndex:]`) must run as a click, also when the click is
|
||||
//! ↩ inside the open menu.
|
||||
|
||||
// The app's own module, built into this test (cargo builds harness = false tests with cfg(test), so its
|
||||
// unit tests compile here too without a harness to run them).
|
||||
#[cfg(target_os = "macos")]
|
||||
#[path = "../src/mac_menu.rs"]
|
||||
#[allow(unused_imports, dead_code)]
|
||||
mod mac_menu;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn main() {
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2::rc::Retained;
|
||||
use objc2_app_kit::{NSApplication, NSEvent, NSEventMask, NSEventModifierFlags, NSEventSubtype, NSEventType, NSMenu};
|
||||
use objc2_foundation::{NSPoint, NSString};
|
||||
use photocraft_ui_egui::PhotocraftApp;
|
||||
use photocraft_ui_egui::native_menu;
|
||||
use serde_json::json;
|
||||
|
||||
let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread");
|
||||
let nsapp = NSApplication::sharedApplication(mtm);
|
||||
nsapp.finishLaunching();
|
||||
|
||||
let titles = |menu: &NSMenu| -> Vec<String> {
|
||||
(0..menu.numberOfItems()).filter_map(|i| menu.itemAtIndex(i)).filter(|it| !it.isSeparatorItem()).map(|it| it.title().to_string()).collect()
|
||||
};
|
||||
let submenu = |menu: &NSMenu, title: &str| -> Retained<NSMenu> {
|
||||
(0..menu.numberOfItems())
|
||||
.filter_map(|i| menu.itemAtIndex(i))
|
||||
.find(|it| it.title().to_string() == title)
|
||||
.and_then(|it| it.submenu())
|
||||
.unwrap_or_else(|| panic!("no {title} submenu in {:?}", titles(menu)))
|
||||
};
|
||||
let index = |menu: &NSMenu, title: &str| -> isize {
|
||||
(0..menu.numberOfItems())
|
||||
.find(|&i| menu.itemAtIndex(i).is_some_and(|it| it.title().to_string() == title))
|
||||
.unwrap_or_else(|| panic!("no {title} in {:?}", titles(menu)))
|
||||
};
|
||||
let main_menu = || nsapp.mainMenu().expect("a main menu");
|
||||
// Make `event` AppKit's current event, as the run loop does before dispatching it.
|
||||
let dequeue = |event: &NSEvent| {
|
||||
nsapp.postEvent_atStart(event, true);
|
||||
let mode = NSString::from_str("kCFRunLoopDefaultMode");
|
||||
let got = nsapp.nextEventMatchingMask_untilDate_inMode_dequeue(NSEventMask::Any, None, &mode, true).expect("the posted event");
|
||||
assert_eq!(got.r#type(), event.r#type());
|
||||
got
|
||||
};
|
||||
let key_down = |chars: &str, code: u16, flags: NSEventModifierFlags| {
|
||||
let s = NSString::from_str(chars);
|
||||
NSEvent::keyEventWithType_location_modifierFlags_timestamp_windowNumber_context_characters_charactersIgnoringModifiers_isARepeat_keyCode(
|
||||
NSEventType::KeyDown,
|
||||
NSPoint::new(0.0, 0.0),
|
||||
flags,
|
||||
0.0,
|
||||
0,
|
||||
None,
|
||||
&s,
|
||||
&s,
|
||||
false,
|
||||
code,
|
||||
)
|
||||
.expect("a key event")
|
||||
};
|
||||
let other = || {
|
||||
NSEvent::otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2(
|
||||
NSEventType::ApplicationDefined,
|
||||
NSPoint::new(0.0, 0.0),
|
||||
NSEventModifierFlags::empty(),
|
||||
0.0,
|
||||
0,
|
||||
None,
|
||||
NSEventSubtype::ApplicationActivated.0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.expect("an application-defined event")
|
||||
};
|
||||
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), photocraft_ui_egui::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |app: &mut PhotocraftApp, raw: egui::RawInput| {
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
native_menu::run(app, ui.ctx());
|
||||
photocraft_ui_egui::shortcuts::handle(app, ui.ctx());
|
||||
native_menu::sync(app, ui.ctx());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
app.services.native_menu = mac_menu::install(&ctx, &app);
|
||||
assert!(app.services.native_menu.is_some(), "the menu bar installed");
|
||||
|
||||
// The Mac layout, as AppKit has it.
|
||||
let bar = main_menu();
|
||||
let tops: Vec<String> = (0..bar.numberOfItems()).filter_map(|i| bar.itemAtIndex(i)?.submenu()).map(|m| m.title().to_string()).collect();
|
||||
assert_eq!(tops, ["PhotoCraft", "File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Window", "Help"]);
|
||||
let app_menu = submenu(&bar, "PhotoCraft");
|
||||
let names = titles(&app_menu);
|
||||
for want in ["About PhotoCraft", "Settings", "Language", "Appearance", "Hide PhotoCraft", "Quit PhotoCraft"] {
|
||||
assert!(names.iter().any(|n| n == want), "{want} missing from the app menu: {names:?}");
|
||||
}
|
||||
let quit = app_menu.itemAtIndex(index(&app_menu, "Quit PhotoCraft")).expect("Quit");
|
||||
assert_eq!(quit.keyEquivalent().to_string(), "q");
|
||||
assert_eq!(quit.keyEquivalentModifierMask(), NSEventModifierFlags::Command);
|
||||
assert!(!titles(&submenu(&bar, "File")).iter().any(|n| n == "Exit"), "Exit moved to the app menu as Quit");
|
||||
assert_eq!(nsapp.windowsMenu().map(|m| m.title().to_string()).as_deref(), Some("Window"), "AppKit knows the Window menu");
|
||||
assert_eq!(nsapp.helpMenu().map(|m| m.title().to_string()).as_deref(), Some("Help"), "AppKit knows the Help menu");
|
||||
|
||||
// Opening a document enables Layer › New › Layer… in place: same NSMenu, no rebuild.
|
||||
let new_layer = |bar: &NSMenu| {
|
||||
let new = submenu(&submenu(bar, "Layer"), "New");
|
||||
new.itemAtIndex(index(&new, "Layer…")).expect("Layer…").isEnabled()
|
||||
};
|
||||
assert!(!new_layer(&bar), "no document: New Layer is disabled");
|
||||
app.run("file.new", json!({"width": 64, "height": 48})).expect("a document");
|
||||
frame(&mut app, egui::RawInput::default());
|
||||
assert!(Retained::as_ptr(&bar) == Retained::as_ptr(&main_menu()), "opening a document updated the menu, not rebuilt it");
|
||||
assert!(new_layer(&main_menu()), "with a document New Layer is enabled");
|
||||
|
||||
// A key equivalent: ⌘R reaches egui as ⌘R and toggles View › Rulers once.
|
||||
let rulers = app.ui.extras.rulers;
|
||||
let press = dequeue(&key_down("r", 15, NSEventModifierFlags::Command));
|
||||
assert!(main_menu().performKeyEquivalent(&press), "⌘R is View › Rulers' key equivalent");
|
||||
let mut raw = egui::RawInput::default();
|
||||
if let Some(m) = app.services.native_menu.as_mut() {
|
||||
m.raw_input(&mut raw);
|
||||
}
|
||||
let keys: Vec<_> = raw
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|e| if let egui::Event::Key { key, pressed, modifiers, .. } = e { Some((*key, *pressed, modifiers.command)) } else { None })
|
||||
.collect();
|
||||
assert_eq!(keys, [(egui::Key::R, true, true), (egui::Key::R, false, true)], "the key press, then its release");
|
||||
frame(&mut app, raw);
|
||||
let ran: Vec<String> = photocraft_ui_egui::shortcut_dispatch::take_log(&ctx).into_iter().map(|(id, _)| id).collect();
|
||||
assert_eq!(ran, ["view.rulers"], "⌘R ran Rulers, whichever ⌘R item AppKit matched");
|
||||
assert_eq!(app.ui.extras.rulers, !rulers, "⌘R toggled Rulers once");
|
||||
|
||||
// A click: View › Rulers chosen with the mouse runs the command.
|
||||
let view = submenu(&main_menu(), "View");
|
||||
let at = index(&view, "Rulers");
|
||||
dequeue(&other());
|
||||
view.performActionForItemAtIndex(at);
|
||||
let mut raw = egui::RawInput::default();
|
||||
if let Some(m) = app.services.native_menu.as_mut() {
|
||||
m.raw_input(&mut raw);
|
||||
}
|
||||
assert!(raw.events.is_empty(), "a click is no key press: {:?}", raw.events);
|
||||
frame(&mut app, raw);
|
||||
assert_eq!(app.ui.extras.rulers, rulers, "the click toggled Rulers back");
|
||||
|
||||
// ↩ inside the open menu is a key-down without ⌘: still a click.
|
||||
dequeue(&key_down("\r", 36, NSEventModifierFlags::empty()));
|
||||
view.performActionForItemAtIndex(at);
|
||||
let mut raw = egui::RawInput::default();
|
||||
if let Some(m) = app.services.native_menu.as_mut() {
|
||||
m.raw_input(&mut raw);
|
||||
}
|
||||
assert!(raw.events.is_empty(), "↩ in the menu is a click: {:?}", raw.events);
|
||||
frame(&mut app, raw);
|
||||
assert_eq!(app.ui.extras.rulers, !rulers, "↩ on Rulers toggled it");
|
||||
|
||||
println!("mac_menu_appkit: ok");
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn main() {}
|
||||
|
|
@ -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,334 @@
|
|||
assets/icons/lasso-magnetic.svg
|
||||
|
||||
Adapted from "Magnetic Lasso Tool" by Lil' Seal (failureiscool) from Noun Project,
|
||||
https://thenounproject.com/icon/magnetic-lasso-tool-177513/
|
||||
licensed under Creative Commons Attribution 3.0 Unported (CC BY 3.0),
|
||||
https://creativecommons.org/licenses/by/3.0/
|
||||
|
||||
Changes by PhotoCraft contributors: redrawn as a 24 x 24 SVG on PhotoCraft's toolbar grid, with
|
||||
round-capped strokes in place of the original line weights, so it can be tinted and rasterized
|
||||
like the other toolbar icons.
|
||||
|
||||
The full text of the licence follows.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Creative Commons Legal Code
|
||||
|
||||
Attribution 3.0 Unported
|
||||
|
||||
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
|
||||
LEGAL SERVICES. DISTRIBUTION OF THIS LICENSE DOES NOT CREATE AN
|
||||
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
|
||||
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
|
||||
REGARDING THE INFORMATION PROVIDED, AND DISCLAIMS LIABILITY FOR
|
||||
DAMAGES RESULTING FROM ITS USE.
|
||||
|
||||
License
|
||||
|
||||
THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE
|
||||
COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY
|
||||
COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS
|
||||
AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED.
|
||||
|
||||
BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE
|
||||
TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY
|
||||
BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS
|
||||
CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND
|
||||
CONDITIONS.
|
||||
|
||||
1. Definitions
|
||||
|
||||
a. "Adaptation" means a work based upon the Work, or upon the Work and
|
||||
other pre-existing works, such as a translation, adaptation,
|
||||
derivative work, arrangement of music or other alterations of a
|
||||
literary or artistic work, or phonogram or performance and includes
|
||||
cinematographic adaptations or any other form in which the Work may be
|
||||
recast, transformed, or adapted including in any form recognizably
|
||||
derived from the original, except that a work that constitutes a
|
||||
Collection will not be considered an Adaptation for the purpose of
|
||||
this License. For the avoidance of doubt, where the Work is a musical
|
||||
work, performance or phonogram, the synchronization of the Work in
|
||||
timed-relation with a moving image ("synching") will be considered an
|
||||
Adaptation for the purpose of this License.
|
||||
b. "Collection" means a collection of literary or artistic works, such as
|
||||
encyclopedias and anthologies, or performances, phonograms or
|
||||
broadcasts, or other works or subject matter other than works listed
|
||||
in Section 1(f) below, which, by reason of the selection and
|
||||
arrangement of their contents, constitute intellectual creations, in
|
||||
which the Work is included in its entirety in unmodified form along
|
||||
with one or more other contributions, each constituting separate and
|
||||
independent works in themselves, which together are assembled into a
|
||||
collective whole. A work that constitutes a Collection will not be
|
||||
considered an Adaptation (as defined above) for the purposes of this
|
||||
License.
|
||||
c. "Distribute" means to make available to the public the original and
|
||||
copies of the Work or Adaptation, as appropriate, through sale or
|
||||
other transfer of ownership.
|
||||
d. "Licensor" means the individual, individuals, entity or entities that
|
||||
offer(s) the Work under the terms of this License.
|
||||
e. "Original Author" means, in the case of a literary or artistic work,
|
||||
the individual, individuals, entity or entities who created the Work
|
||||
or if no individual or entity can be identified, the publisher; and in
|
||||
addition (i) in the case of a performance the actors, singers,
|
||||
musicians, dancers, and other persons who act, sing, deliver, declaim,
|
||||
play in, interpret or otherwise perform literary or artistic works or
|
||||
expressions of folklore; (ii) in the case of a phonogram the producer
|
||||
being the person or legal entity who first fixes the sounds of a
|
||||
performance or other sounds; and, (iii) in the case of broadcasts, the
|
||||
organization that transmits the broadcast.
|
||||
f. "Work" means the literary and/or artistic work offered under the terms
|
||||
of this License including without limitation any production in the
|
||||
literary, scientific and artistic domain, whatever may be the mode or
|
||||
form of its expression including digital form, such as a book,
|
||||
pamphlet and other writing; a lecture, address, sermon or other work
|
||||
of the same nature; a dramatic or dramatico-musical work; a
|
||||
choreographic work or entertainment in dumb show; a musical
|
||||
composition with or without words; a cinematographic work to which are
|
||||
assimilated works expressed by a process analogous to cinematography;
|
||||
a work of drawing, painting, architecture, sculpture, engraving or
|
||||
lithography; a photographic work to which are assimilated works
|
||||
expressed by a process analogous to photography; a work of applied
|
||||
art; an illustration, map, plan, sketch or three-dimensional work
|
||||
relative to geography, topography, architecture or science; a
|
||||
performance; a broadcast; a phonogram; a compilation of data to the
|
||||
extent it is protected as a copyrightable work; or a work performed by
|
||||
a variety or circus performer to the extent it is not otherwise
|
||||
considered a literary or artistic work.
|
||||
g. "You" means an individual or entity exercising rights under this
|
||||
License who has not previously violated the terms of this License with
|
||||
respect to the Work, or who has received express permission from the
|
||||
Licensor to exercise rights under this License despite a previous
|
||||
violation.
|
||||
h. "Publicly Perform" means to perform public recitations of the Work and
|
||||
to communicate to the public those public recitations, by any means or
|
||||
process, including by wire or wireless means or public digital
|
||||
performances; to make available to the public Works in such a way that
|
||||
members of the public may access these Works from a place and at a
|
||||
place individually chosen by them; to perform the Work to the public
|
||||
by any means or process and the communication to the public of the
|
||||
performances of the Work, including by public digital performance; to
|
||||
broadcast and rebroadcast the Work by any means including signs,
|
||||
sounds or images.
|
||||
i. "Reproduce" means to make copies of the Work by any means including
|
||||
without limitation by sound or visual recordings and the right of
|
||||
fixation and reproducing fixations of the Work, including storage of a
|
||||
protected performance or phonogram in digital form or other electronic
|
||||
medium.
|
||||
|
||||
2. Fair Dealing Rights. Nothing in this License is intended to reduce,
|
||||
limit, or restrict any uses free from copyright or rights arising from
|
||||
limitations or exceptions that are provided for in connection with the
|
||||
copyright protection under copyright law or other applicable laws.
|
||||
|
||||
3. License Grant. Subject to the terms and conditions of this License,
|
||||
Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
|
||||
perpetual (for the duration of the applicable copyright) license to
|
||||
exercise the rights in the Work as stated below:
|
||||
|
||||
a. to Reproduce the Work, to incorporate the Work into one or more
|
||||
Collections, and to Reproduce the Work as incorporated in the
|
||||
Collections;
|
||||
b. to create and Reproduce Adaptations provided that any such Adaptation,
|
||||
including any translation in any medium, takes reasonable steps to
|
||||
clearly label, demarcate or otherwise identify that changes were made
|
||||
to the original Work. For example, a translation could be marked "The
|
||||
original work was translated from English to Spanish," or a
|
||||
modification could indicate "The original work has been modified.";
|
||||
c. to Distribute and Publicly Perform the Work including as incorporated
|
||||
in Collections; and,
|
||||
d. to Distribute and Publicly Perform Adaptations.
|
||||
e. For the avoidance of doubt:
|
||||
|
||||
i. Non-waivable Compulsory License Schemes. In those jurisdictions in
|
||||
which the right to collect royalties through any statutory or
|
||||
compulsory licensing scheme cannot be waived, the Licensor
|
||||
reserves the exclusive right to collect such royalties for any
|
||||
exercise by You of the rights granted under this License;
|
||||
ii. Waivable Compulsory License Schemes. In those jurisdictions in
|
||||
which the right to collect royalties through any statutory or
|
||||
compulsory licensing scheme can be waived, the Licensor waives the
|
||||
exclusive right to collect such royalties for any exercise by You
|
||||
of the rights granted under this License; and,
|
||||
iii. Voluntary License Schemes. The Licensor waives the right to
|
||||
collect royalties, whether individually or, in the event that the
|
||||
Licensor is a member of a collecting society that administers
|
||||
voluntary licensing schemes, via that society, from any exercise
|
||||
by You of the rights granted under this License.
|
||||
|
||||
The above rights may be exercised in all media and formats whether now
|
||||
known or hereafter devised. The above rights include the right to make
|
||||
such modifications as are technically necessary to exercise the rights in
|
||||
other media and formats. Subject to Section 8(f), all rights not expressly
|
||||
granted by Licensor are hereby reserved.
|
||||
|
||||
4. Restrictions. The license granted in Section 3 above is expressly made
|
||||
subject to and limited by the following restrictions:
|
||||
|
||||
a. You may Distribute or Publicly Perform the Work only under the terms
|
||||
of this License. You must include a copy of, or the Uniform Resource
|
||||
Identifier (URI) for, this License with every copy of the Work You
|
||||
Distribute or Publicly Perform. You may not offer or impose any terms
|
||||
on the Work that restrict the terms of this License or the ability of
|
||||
the recipient of the Work to exercise the rights granted to that
|
||||
recipient under the terms of the License. You may not sublicense the
|
||||
Work. You must keep intact all notices that refer to this License and
|
||||
to the disclaimer of warranties with every copy of the Work You
|
||||
Distribute or Publicly Perform. When You Distribute or Publicly
|
||||
Perform the Work, You may not impose any effective technological
|
||||
measures on the Work that restrict the ability of a recipient of the
|
||||
Work from You to exercise the rights granted to that recipient under
|
||||
the terms of the License. This Section 4(a) applies to the Work as
|
||||
incorporated in a Collection, but this does not require the Collection
|
||||
apart from the Work itself to be made subject to the terms of this
|
||||
License. If You create a Collection, upon notice from any Licensor You
|
||||
must, to the extent practicable, remove from the Collection any credit
|
||||
as required by Section 4(b), as requested. If You create an
|
||||
Adaptation, upon notice from any Licensor You must, to the extent
|
||||
practicable, remove from the Adaptation any credit as required by
|
||||
Section 4(b), as requested.
|
||||
b. If You Distribute, or Publicly Perform the Work or any Adaptations or
|
||||
Collections, You must, unless a request has been made pursuant to
|
||||
Section 4(a), keep intact all copyright notices for the Work and
|
||||
provide, reasonable to the medium or means You are utilizing: (i) the
|
||||
name of the Original Author (or pseudonym, if applicable) if supplied,
|
||||
and/or if the Original Author and/or Licensor designate another party
|
||||
or parties (e.g., a sponsor institute, publishing entity, journal) for
|
||||
attribution ("Attribution Parties") in Licensor's copyright notice,
|
||||
terms of service or by other reasonable means, the name of such party
|
||||
or parties; (ii) the title of the Work if supplied; (iii) to the
|
||||
extent reasonably practicable, the URI, if any, that Licensor
|
||||
specifies to be associated with the Work, unless such URI does not
|
||||
refer to the copyright notice or licensing information for the Work;
|
||||
and (iv) , consistent with Section 3(b), in the case of an Adaptation,
|
||||
a credit identifying the use of the Work in the Adaptation (e.g.,
|
||||
"French translation of the Work by Original Author," or "Screenplay
|
||||
based on original Work by Original Author"). The credit required by
|
||||
this Section 4 (b) may be implemented in any reasonable manner;
|
||||
provided, however, that in the case of a Adaptation or Collection, at
|
||||
a minimum such credit will appear, if a credit for all contributing
|
||||
authors of the Adaptation or Collection appears, then as part of these
|
||||
credits and in a manner at least as prominent as the credits for the
|
||||
other contributing authors. For the avoidance of doubt, You may only
|
||||
use the credit required by this Section for the purpose of attribution
|
||||
in the manner set out above and, by exercising Your rights under this
|
||||
License, You may not implicitly or explicitly assert or imply any
|
||||
connection with, sponsorship or endorsement by the Original Author,
|
||||
Licensor and/or Attribution Parties, as appropriate, of You or Your
|
||||
use of the Work, without the separate, express prior written
|
||||
permission of the Original Author, Licensor and/or Attribution
|
||||
Parties.
|
||||
c. Except as otherwise agreed in writing by the Licensor or as may be
|
||||
otherwise permitted by applicable law, if You Reproduce, Distribute or
|
||||
Publicly Perform the Work either by itself or as part of any
|
||||
Adaptations or Collections, You must not distort, mutilate, modify or
|
||||
take other derogatory action in relation to the Work which would be
|
||||
prejudicial to the Original Author's honor or reputation. Licensor
|
||||
agrees that in those jurisdictions (e.g. Japan), in which any exercise
|
||||
of the right granted in Section 3(b) of this License (the right to
|
||||
make Adaptations) would be deemed to be a distortion, mutilation,
|
||||
modification or other derogatory action prejudicial to the Original
|
||||
Author's honor and reputation, the Licensor will waive or not assert,
|
||||
as appropriate, this Section, to the fullest extent permitted by the
|
||||
applicable national law, to enable You to reasonably exercise Your
|
||||
right under Section 3(b) of this License (right to make Adaptations)
|
||||
but not otherwise.
|
||||
|
||||
5. Representations, Warranties and Disclaimer
|
||||
|
||||
UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR
|
||||
OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY
|
||||
KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE,
|
||||
INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF
|
||||
LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS,
|
||||
WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION
|
||||
OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU.
|
||||
|
||||
6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE
|
||||
LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR
|
||||
ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES
|
||||
ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS
|
||||
BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
7. Termination
|
||||
|
||||
a. This License and the rights granted hereunder will terminate
|
||||
automatically upon any breach by You of the terms of this License.
|
||||
Individuals or entities who have received Adaptations or Collections
|
||||
from You under this License, however, will not have their licenses
|
||||
terminated provided such individuals or entities remain in full
|
||||
compliance with those licenses. Sections 1, 2, 5, 6, 7, and 8 will
|
||||
survive any termination of this License.
|
||||
b. Subject to the above terms and conditions, the license granted here is
|
||||
perpetual (for the duration of the applicable copyright in the Work).
|
||||
Notwithstanding the above, Licensor reserves the right to release the
|
||||
Work under different license terms or to stop distributing the Work at
|
||||
any time; provided, however that any such election will not serve to
|
||||
withdraw this License (or any other license that has been, or is
|
||||
required to be, granted under the terms of this License), and this
|
||||
License will continue in full force and effect unless terminated as
|
||||
stated above.
|
||||
|
||||
8. Miscellaneous
|
||||
|
||||
a. Each time You Distribute or Publicly Perform the Work or a Collection,
|
||||
the Licensor offers to the recipient a license to the Work on the same
|
||||
terms and conditions as the license granted to You under this License.
|
||||
b. Each time You Distribute or Publicly Perform an Adaptation, Licensor
|
||||
offers to the recipient a license to the original Work on the same
|
||||
terms and conditions as the license granted to You under this License.
|
||||
c. If any provision of this License is invalid or unenforceable under
|
||||
applicable law, it shall not affect the validity or enforceability of
|
||||
the remainder of the terms of this License, and without further action
|
||||
by the parties to this agreement, such provision shall be reformed to
|
||||
the minimum extent necessary to make such provision valid and
|
||||
enforceable.
|
||||
d. No term or provision of this License shall be deemed waived and no
|
||||
breach consented to unless such waiver or consent shall be in writing
|
||||
and signed by the party to be charged with such waiver or consent.
|
||||
e. This License constitutes the entire agreement between the parties with
|
||||
respect to the Work licensed here. There are no understandings,
|
||||
agreements or representations with respect to the Work not specified
|
||||
here. Licensor shall not be bound by any additional provisions that
|
||||
may appear in any communication from You. This License may not be
|
||||
modified without the mutual written agreement of the Licensor and You.
|
||||
f. The rights granted under, and the subject matter referenced, in this
|
||||
License were drafted utilizing the terminology of the Berne Convention
|
||||
for the Protection of Literary and Artistic Works (as amended on
|
||||
September 28, 1979), the Rome Convention of 1961, the WIPO Copyright
|
||||
Treaty of 1996, the WIPO Performances and Phonograms Treaty of 1996
|
||||
and the Universal Copyright Convention (as revised on July 24, 1971).
|
||||
These rights and subject matter take effect in the relevant
|
||||
jurisdiction in which the License terms are sought to be enforced
|
||||
according to the corresponding provisions of the implementation of
|
||||
those treaty provisions in the applicable national law. If the
|
||||
standard suite of rights granted under applicable copyright law
|
||||
includes additional rights not granted under this License, such
|
||||
additional rights are deemed to be included in the License; this
|
||||
License is not intended to restrict the license of any rights under
|
||||
applicable law.
|
||||
|
||||
|
||||
Creative Commons Notice
|
||||
|
||||
Creative Commons is not a party to this License, and makes no warranty
|
||||
whatsoever in connection with the Work. Creative Commons will not be
|
||||
liable to You or any party on any legal theory for any damages
|
||||
whatsoever, including without limitation any general, special,
|
||||
incidental or consequential damages arising in connection to this
|
||||
license. Notwithstanding the foregoing two (2) sentences, if Creative
|
||||
Commons has expressly identified itself as the Licensor hereunder, it
|
||||
shall have all rights and obligations of Licensor.
|
||||
|
||||
Except for the limited purpose of indicating to the public that the
|
||||
Work is licensed under the CCPL, Creative Commons does not authorize
|
||||
the use by either party of the trademark "Creative Commons" or any
|
||||
related trademark or logo of Creative Commons without the prior
|
||||
written consent of Creative Commons. Any permitted use will be in
|
||||
compliance with Creative Commons' then-current trademark usage
|
||||
guidelines, as may be published on its website or otherwise made
|
||||
available upon request from time to time. For the avoidance of doubt,
|
||||
this trademark restriction does not form part of this License.
|
||||
|
||||
Creative Commons may be contacted at https://creativecommons.org/.
|
||||
|
|
@ -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="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z" />
|
||||
<rect x="16.5" y="16.5" width="4.5" height="4.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 410 B |
|
|
@ -0,0 +1,17 @@
|
|||
<!-- Adapted from "Magnetic Lasso Tool" by Lil' Seal from Noun Project (CC BY 3.0): see LICENSE-noun-magnetic-lasso.txt -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9 1.9H16.4A4.6 4.6 0 0 1 21 6.5V7.7A4.6 4.6 0 0 1 16.4 12.3H9V8.95H16.55A0.6 0.6 0 0 0 17.15 8.35V5.85A0.6 0.6 0 0 0 16.55 5.25H9ZM9.75 2.65H12.5V4.5H9.75ZM9.75 9.7H12.5V11.55H9.75Z" fill="currentColor" fill-rule="evenodd" stroke="none" />
|
||||
<path d="M12.4 7.5 1.8 3.6l7.7 15.3-1.8 2.8" />
|
||||
<ellipse cx="9.25" cy="16.65" rx="2.85" ry="2.2" />
|
||||
<path d="m12.1 16.2 10.1-3-1.6-1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 729 B |
|
|
@ -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,24 @@
|
|||
# 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`.
|
||||
|
||||
## WebP
|
||||
|
||||
Lossless WebP (VP8L) is encoded and decoded by `image-webp`. Lossy WebP is written by PhotoCraft's own VP8 key-frame encoder in `crates/codecs/src/codecs/vp8`, a clean-room implementation of RFC 6386: BT.601 4:2:0 conversion, 16×16 and 4×4 intra prediction chosen per macroblock by reconstruction error, the forward DCT and Walsh-Hadamard transforms paired with the bit-exact inverse transforms, quantisation on the RFC's `dc_qlookup`/`ac_qlookup` tables, token coding with per-frame probability updates, and skipped macroblocks for flat areas. `EncodeOptions::webp_lossless` picks the encoder and `webp_quality` (0–100, the libwebp scale) sets the quantiser; alpha travels losslessly in an `ALPH` chunk and ICC/EXIF/XMP in their own chunks of the extended (`VP8X`) container. The decoder applies the frame's loop filter; no C library is involved on either side.
|
||||
|
||||
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 +34,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,34 @@ 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.
|
||||
user-configured script-event paths. The policy also covers every command a command runs on its
|
||||
own behalf (an action's steps, `file.automate.conditionalModeChange` running `image.mode.*`), so
|
||||
an allowed command can't reach a denied one.
|
||||
- `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
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,943 @@
|
|||
{
|
||||
"schema": 1,
|
||||
"repo": "storytold/photocraft",
|
||||
"branch": "main",
|
||||
"head": "aaa946b56df2d74d198c47e67dda35ddce0b3769",
|
||||
"head_date": "2026-10-08T01:20:47Z",
|
||||
"commits": 482,
|
||||
"unattributed_commits": 0,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "echelon",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 124,
|
||||
"commits": 201,
|
||||
"lines_added": 277845,
|
||||
"lines_deleted": 22466,
|
||||
"binary_added": 39,
|
||||
"binary_deleted": 8,
|
||||
"first_commit": "2026-09-30T06:12:04Z",
|
||||
"last_commit": "2026-10-07T22:09:41Z"
|
||||
},
|
||||
{
|
||||
"login": "storybored379",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 8,
|
||||
"commits": 8,
|
||||
"lines_added": 2707,
|
||||
"lines_deleted": 54,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-04T04:57:38Z",
|
||||
"last_commit": "2026-10-05T22:14:20Z"
|
||||
},
|
||||
{
|
||||
"login": "Multipad-cyber",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 171,
|
||||
"lines_deleted": 27,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-04T05:02:49Z",
|
||||
"last_commit": "2026-10-06T11:43:37Z"
|
||||
},
|
||||
{
|
||||
"login": "torstfugl",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 377,
|
||||
"lines_deleted": 8,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-04T05:03:49Z",
|
||||
"last_commit": "2026-10-04T16:21:25Z"
|
||||
},
|
||||
{
|
||||
"login": "sommerper",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 9,
|
||||
"commits": 9,
|
||||
"lines_added": 865,
|
||||
"lines_deleted": 148,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-04T14:13:31Z",
|
||||
"last_commit": "2026-10-07T11:12:58Z"
|
||||
},
|
||||
{
|
||||
"login": "Gh0stlyKn1ght",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 4,
|
||||
"lines_added": 3349,
|
||||
"lines_deleted": 255,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-04T16:31:18Z",
|
||||
"last_commit": "2026-10-05T05:02:40Z"
|
||||
},
|
||||
{
|
||||
"login": "MikeeI",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 22,
|
||||
"commits": 22,
|
||||
"lines_added": 753,
|
||||
"lines_deleted": 178,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T04:59:38Z",
|
||||
"last_commit": "2026-10-05T05:37:25Z"
|
||||
},
|
||||
{
|
||||
"login": "tiredIsa",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 5,
|
||||
"lines_added": 7086,
|
||||
"lines_deleted": 493,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T05:01:36Z",
|
||||
"last_commit": "2026-10-07T21:59:16Z"
|
||||
},
|
||||
{
|
||||
"login": "Ni-zav",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 12,
|
||||
"commits": 12,
|
||||
"lines_added": 488,
|
||||
"lines_deleted": 42,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T05:01:49Z",
|
||||
"last_commit": "2026-10-07T21:57:43Z"
|
||||
},
|
||||
{
|
||||
"login": "kntrttm",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 4228,
|
||||
"lines_deleted": 1172,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T05:02:52Z",
|
||||
"last_commit": "2026-10-05T23:40:10Z"
|
||||
},
|
||||
{
|
||||
"login": "kevin9327",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 15,
|
||||
"lines_deleted": 2,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T08:14:40Z",
|
||||
"last_commit": "2026-10-05T08:14:40Z"
|
||||
},
|
||||
{
|
||||
"login": "mekanix",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 29,
|
||||
"lines_deleted": 8,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T13:01:58Z",
|
||||
"last_commit": "2026-10-05T13:01:58Z"
|
||||
},
|
||||
{
|
||||
"login": "nuIIpointerexception",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 82,
|
||||
"lines_deleted": 9,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T13:02:14Z",
|
||||
"last_commit": "2026-10-05T13:02:14Z"
|
||||
},
|
||||
{
|
||||
"login": "john8675309",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 89,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T13:58:45Z",
|
||||
"last_commit": "2026-10-05T13:58:45Z"
|
||||
},
|
||||
{
|
||||
"login": "CQMHV",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 9,
|
||||
"commits": 9,
|
||||
"lines_added": 2841,
|
||||
"lines_deleted": 23,
|
||||
"binary_added": 2,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T22:13:51Z",
|
||||
"last_commit": "2026-10-07T20:43:03Z"
|
||||
},
|
||||
{
|
||||
"login": "kuishou68",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 6,
|
||||
"lines_deleted": 1931,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T11:43:03Z",
|
||||
"last_commit": "2026-10-06T11:43:03Z"
|
||||
},
|
||||
{
|
||||
"login": "midasdf",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 199,
|
||||
"lines_deleted": 25,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T11:44:08Z",
|
||||
"last_commit": "2026-10-07T22:53:56Z"
|
||||
},
|
||||
{
|
||||
"login": "bommerts",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 30,
|
||||
"lines_deleted": 3,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T14:46:41Z",
|
||||
"last_commit": "2026-10-06T14:46:41Z"
|
||||
},
|
||||
{
|
||||
"login": "leonaci",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 202,
|
||||
"lines_deleted": 26,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T15:27:54Z",
|
||||
"last_commit": "2026-10-06T15:27:54Z"
|
||||
},
|
||||
{
|
||||
"login": "juansebsol",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 800,
|
||||
"lines_deleted": 118,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T15:28:09Z",
|
||||
"last_commit": "2026-10-07T12:40:00Z"
|
||||
},
|
||||
{
|
||||
"login": "WildfootW",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 2027,
|
||||
"lines_deleted": 17,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T15:28:23Z",
|
||||
"last_commit": "2026-10-06T15:28:23Z"
|
||||
},
|
||||
{
|
||||
"login": "Mawfyy",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 2213,
|
||||
"lines_deleted": 4,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T15:28:37Z",
|
||||
"last_commit": "2026-10-07T20:12:00Z"
|
||||
},
|
||||
{
|
||||
"login": "petrleocompel",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 2025,
|
||||
"lines_deleted": 2,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T15:29:44Z",
|
||||
"last_commit": "2026-10-06T15:29:44Z"
|
||||
},
|
||||
{
|
||||
"login": "bflatastic",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 35,
|
||||
"commits": 35,
|
||||
"lines_added": 6009,
|
||||
"lines_deleted": 1253,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T00:59:14Z",
|
||||
"last_commit": "2026-10-08T01:16:38Z"
|
||||
},
|
||||
{
|
||||
"login": "Pedroj-64",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 141,
|
||||
"lines_deleted": 9,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T03:28:01Z",
|
||||
"last_commit": "2026-10-07T03:43:31Z"
|
||||
},
|
||||
{
|
||||
"login": "floflo777",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 4,
|
||||
"lines_added": 163,
|
||||
"lines_deleted": 31,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T03:34:03Z",
|
||||
"last_commit": "2026-10-07T11:05:38Z"
|
||||
},
|
||||
{
|
||||
"login": "HEOJUNFO",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 55,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T03:34:17Z",
|
||||
"last_commit": "2026-10-07T03:34:30Z"
|
||||
},
|
||||
{
|
||||
"login": "Ar-maan05",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 7,
|
||||
"commits": 7,
|
||||
"lines_added": 840,
|
||||
"lines_deleted": 111,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T03:34:44Z",
|
||||
"last_commit": "2026-10-07T20:30:52Z"
|
||||
},
|
||||
{
|
||||
"login": "agorevski",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 30,
|
||||
"commits": 30,
|
||||
"lines_added": 3353,
|
||||
"lines_deleted": 404,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T03:34:59Z",
|
||||
"last_commit": "2026-10-07T21:14:03Z"
|
||||
},
|
||||
{
|
||||
"login": "naumanAhmed3",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 14,
|
||||
"lines_deleted": 2,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T03:38:32Z",
|
||||
"last_commit": "2026-10-07T03:38:32Z"
|
||||
},
|
||||
{
|
||||
"login": "ljkx",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 5,
|
||||
"lines_added": 7478,
|
||||
"lines_deleted": 178,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T03:43:18Z",
|
||||
"last_commit": "2026-10-08T01:12:38Z"
|
||||
},
|
||||
{
|
||||
"login": "carterwsmith",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 4,
|
||||
"lines_added": 1213,
|
||||
"lines_deleted": 152,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T04:32:23Z",
|
||||
"last_commit": "2026-10-07T22:57:50Z"
|
||||
},
|
||||
{
|
||||
"login": "coygeek",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 4,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T04:32:34Z",
|
||||
"last_commit": "2026-10-07T04:32:34Z"
|
||||
},
|
||||
{
|
||||
"login": "neversaynv3r",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 89,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T04:40:40Z",
|
||||
"last_commit": "2026-10-07T04:40:40Z"
|
||||
},
|
||||
{
|
||||
"login": "ianswope",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 82,
|
||||
"lines_deleted": 9,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T09:26:01Z",
|
||||
"last_commit": "2026-10-07T19:59:51Z"
|
||||
},
|
||||
{
|
||||
"login": "TheSeanLavery",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 4,
|
||||
"lines_added": 2205,
|
||||
"lines_deleted": 66,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T10:11:06Z",
|
||||
"last_commit": "2026-10-07T11:45:26Z"
|
||||
},
|
||||
{
|
||||
"login": "mnmlyw",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 4,
|
||||
"lines_added": 330,
|
||||
"lines_deleted": 41,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T10:12:13Z",
|
||||
"last_commit": "2026-10-08T01:20:47Z"
|
||||
},
|
||||
{
|
||||
"login": "rosematcha",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 12,
|
||||
"commits": 12,
|
||||
"lines_added": 1700,
|
||||
"lines_deleted": 202,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T10:49:53Z",
|
||||
"last_commit": "2026-10-08T00:18:27Z"
|
||||
},
|
||||
{
|
||||
"login": "jason4short",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 8,
|
||||
"commits": 8,
|
||||
"lines_added": 1653,
|
||||
"lines_deleted": 101,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T10:58:01Z",
|
||||
"last_commit": "2026-10-07T22:02:34Z"
|
||||
},
|
||||
{
|
||||
"login": "siraxe",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 276,
|
||||
"lines_deleted": 18,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T11:03:49Z",
|
||||
"last_commit": "2026-10-07T11:03:49Z"
|
||||
},
|
||||
{
|
||||
"login": "arpitkjaiswal",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 19,
|
||||
"lines_deleted": 19,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T11:06:21Z",
|
||||
"last_commit": "2026-10-07T11:06:21Z"
|
||||
},
|
||||
{
|
||||
"login": "EthanSuquet",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 5,
|
||||
"lines_added": 405,
|
||||
"lines_deleted": 56,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T11:40:10Z",
|
||||
"last_commit": "2026-10-07T20:50:09Z"
|
||||
},
|
||||
{
|
||||
"login": "alexanderalber",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 1809,
|
||||
"lines_deleted": 56,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T11:42:30Z",
|
||||
"last_commit": "2026-10-07T22:59:39Z"
|
||||
},
|
||||
{
|
||||
"login": "flyyangX",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 242,
|
||||
"lines_deleted": 53,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T11:46:52Z",
|
||||
"last_commit": "2026-10-07T11:46:52Z"
|
||||
},
|
||||
{
|
||||
"login": "leblanc-simon",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 2527,
|
||||
"lines_deleted": 4,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T11:49:30Z",
|
||||
"last_commit": "2026-10-07T11:49:30Z"
|
||||
},
|
||||
{
|
||||
"login": "trokute",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 2484,
|
||||
"lines_deleted": 1,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T11:52:51Z",
|
||||
"last_commit": "2026-10-07T11:52:51Z"
|
||||
},
|
||||
{
|
||||
"login": "ha1des",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1328,
|
||||
"lines_deleted": 161,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T13:02:58Z",
|
||||
"last_commit": "2026-10-07T13:02:58Z"
|
||||
},
|
||||
{
|
||||
"login": "neko233-com",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 2719,
|
||||
"lines_deleted": 73,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T13:06:33Z",
|
||||
"last_commit": "2026-10-07T20:52:55Z"
|
||||
},
|
||||
{
|
||||
"login": "Sirsyorrz",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 61,
|
||||
"lines_deleted": 7,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T13:11:35Z",
|
||||
"last_commit": "2026-10-07T13:11:35Z"
|
||||
},
|
||||
{
|
||||
"login": "Elvis33LE",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 8,
|
||||
"commits": 8,
|
||||
"lines_added": 3929,
|
||||
"lines_deleted": 91,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T13:11:45Z",
|
||||
"last_commit": "2026-10-08T01:10:38Z"
|
||||
},
|
||||
{
|
||||
"login": "cristianmoroaica",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 34,
|
||||
"lines_deleted": 6,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T13:12:11Z",
|
||||
"last_commit": "2026-10-07T13:12:11Z"
|
||||
},
|
||||
{
|
||||
"login": "Webificio",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 232,
|
||||
"lines_deleted": 2,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T13:12:44Z",
|
||||
"last_commit": "2026-10-07T13:12:44Z"
|
||||
},
|
||||
{
|
||||
"login": "mvanhorn",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 4,
|
||||
"lines_added": 2710,
|
||||
"lines_deleted": 168,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T20:09:46Z",
|
||||
"last_commit": "2026-10-07T21:12:52Z"
|
||||
},
|
||||
{
|
||||
"login": "gangldiego",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 2592,
|
||||
"lines_deleted": 10,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T20:11:38Z",
|
||||
"last_commit": "2026-10-07T20:11:38Z"
|
||||
},
|
||||
{
|
||||
"login": "kijoong-kim",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1337,
|
||||
"lines_deleted": 539,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T20:32:47Z",
|
||||
"last_commit": "2026-10-07T20:32:47Z"
|
||||
},
|
||||
{
|
||||
"login": "Cyanoxide",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 9,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T20:34:38Z",
|
||||
"last_commit": "2026-10-07T20:34:38Z"
|
||||
},
|
||||
{
|
||||
"login": "tanish2k09",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 10,
|
||||
"commits": 10,
|
||||
"lines_added": 389,
|
||||
"lines_deleted": 94,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T20:39:39Z",
|
||||
"last_commit": "2026-10-07T23:57:32Z"
|
||||
},
|
||||
{
|
||||
"login": "jerrykimo",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 442,
|
||||
"lines_deleted": 6,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T20:57:21Z",
|
||||
"last_commit": "2026-10-07T20:57:21Z"
|
||||
},
|
||||
{
|
||||
"login": "bfenty",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 10,
|
||||
"commits": 10,
|
||||
"lines_added": 490,
|
||||
"lines_deleted": 21,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T21:45:54Z",
|
||||
"last_commit": "2026-10-08T01:07:50Z"
|
||||
},
|
||||
{
|
||||
"login": "Luna161",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 4,
|
||||
"lines_added": 89,
|
||||
"lines_deleted": 15,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T22:00:59Z",
|
||||
"last_commit": "2026-10-08T01:04:05Z"
|
||||
},
|
||||
{
|
||||
"login": "abc2cb",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1197,
|
||||
"lines_deleted": 27,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T22:40:24Z",
|
||||
"last_commit": "2026-10-07T22:40:24Z"
|
||||
},
|
||||
{
|
||||
"login": "remidesbois1",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1894,
|
||||
"lines_deleted": 400,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T23:05:15Z",
|
||||
"last_commit": "2026-10-07T23:05:15Z"
|
||||
},
|
||||
{
|
||||
"login": "giallorosso84",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 2188,
|
||||
"lines_deleted": 6,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T23:13:01Z",
|
||||
"last_commit": "2026-10-07T23:13:01Z"
|
||||
},
|
||||
{
|
||||
"login": "louwers",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 2528,
|
||||
"lines_deleted": 21,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T23:42:45Z",
|
||||
"last_commit": "2026-10-07T23:42:45Z"
|
||||
},
|
||||
{
|
||||
"login": "simonwjackson",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 3,
|
||||
"lines_deleted": 2,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T00:55:27Z",
|
||||
"last_commit": "2026-10-08T00:55:27Z"
|
||||
},
|
||||
{
|
||||
"login": "ajsongpilot",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 42,
|
||||
"lines_deleted": 16,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T01:09:04Z",
|
||||
"last_commit": "2026-10-08T01:09:04Z"
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
{
|
||||
"id": "anthropic/claude-opus/5.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "5.5",
|
||||
"commits": 228,
|
||||
"lines_added": 153184,
|
||||
"lines_deleted": 22216
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-opus/4.8",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "4.8",
|
||||
"commits": 45,
|
||||
"lines_added": 3896,
|
||||
"lines_deleted": 278
|
||||
},
|
||||
{
|
||||
"id": "github/copilot",
|
||||
"company": "GitHub",
|
||||
"model": "Copilot",
|
||||
"version": "",
|
||||
"commits": 6,
|
||||
"lines_added": 1094,
|
||||
"lines_deleted": 61
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-mythos/5.1",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Mythos",
|
||||
"version": "5.1",
|
||||
"commits": 2,
|
||||
"lines_added": 6195,
|
||||
"lines_deleted": 100
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-sonnet/5.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Sonnet",
|
||||
"version": "5.5",
|
||||
"commits": 2,
|
||||
"lines_added": 4161,
|
||||
"lines_deleted": 1171
|
||||
},
|
||||
{
|
||||
"id": "alibaba/qwen-flash/3.8",
|
||||
"company": "Alibaba",
|
||||
"model": "Qwen Flash",
|
||||
"version": "3.8",
|
||||
"commits": 1,
|
||||
"lines_added": 25,
|
||||
"lines_deleted": 5
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-haiku/4.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Haiku",
|
||||
"version": "4.5",
|
||||
"commits": 1,
|
||||
"lines_added": 134,
|
||||
"lines_deleted": 12
|
||||
},
|
||||
{
|
||||
"id": "cognition/devin",
|
||||
"company": "Cognition",
|
||||
"model": "Devin",
|
||||
"version": "",
|
||||
"commits": 1,
|
||||
"lines_added": 44,
|
||||
"lines_deleted": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
# display_name = "Nickname" # shown when the viewer picks "Display name"
|
||||
# sync_github_name = true # or: use the name on your public GitHub profile as display name
|
||||
#
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
//! Timing: Gaussian Blur radius 10 on a 6016×6016 RGBA8 layer.
|
||||
//! `cargo run --release -p photocraft-algo --example bench_gaussian`
|
||||
use photocraft_algo::{FilterParams, apply, output_area};
|
||||
//! Timing: Gaussian Blur radius 10 on a 6016×6016 RGBA8 layer, as a document layer filling the
|
||||
//! canvas is filtered (edges repeated at the canvas edge).
|
||||
//! `cargo run --release -p photocraft-algo --example bench_gaussian [-- <size> <radius>]`
|
||||
use photocraft_algo::{FilterParams, apply_in, output_area};
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
|
@ -15,8 +16,13 @@ fn main() {
|
|||
s.write_interleaved(r, &bytes);
|
||||
let p = FilterParams::GaussianBlur { radius };
|
||||
let area = output_area(&p, s.content_bounds(), r, None).intersect(&r);
|
||||
let t = std::time::Instant::now();
|
||||
let out = apply(&s, &p, area, r, None);
|
||||
let dt = t.elapsed();
|
||||
// Best of a few runs (the first also pays for page faults).
|
||||
let mut dt = std::time::Duration::MAX;
|
||||
let mut out = s.clone();
|
||||
for _ in 0..5 {
|
||||
let t = std::time::Instant::now();
|
||||
out = apply_in(&s, &p, area, r, None, r);
|
||||
dt = dt.min(t.elapsed());
|
||||
}
|
||||
println!("gaussian radius {radius} on {n}x{n} RGBA8: {:.2?} ({} tiles)", dt, out.tile_count());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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\"")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -264,6 +264,26 @@ fn strokes(x: i32, y: i32, d: (i32, i32), len: f32, wid: f32, seed: u32) -> f32
|
|||
vnoise(u / len.max(0.5), v / wid.max(0.3), seed)
|
||||
}
|
||||
|
||||
/// Repaints in discrete strokes `len` px long and `wid` px wide along `d`: every stroke takes the
|
||||
/// colour of `src` at its middle, and neighbouring strokes start at staggered points, so the
|
||||
/// image reads as separate strokes rather than a smear. Also returns where the pixel lies along
|
||||
/// its stroke (0 at the start, 1 at the end). Reads up to `len / 2 + 1` px away.
|
||||
fn paint_stroke(src: &Win, x: i32, y: i32, d: (i32, i32), len: f32, wid: f32, seed: u32) -> (Px, f32) {
|
||||
let (dx, dy) = (d.0 as f32, d.1 as f32);
|
||||
let l = (dx * dx + dy * dy).sqrt().max(1e-6);
|
||||
let (ux, uy) = (dx / l, dy / l);
|
||||
let (len, wid) = (len.max(1.0), wid.max(0.5));
|
||||
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
|
||||
let u = fx * ux + fy * uy;
|
||||
let v = -fx * uy + fy * ux;
|
||||
let lane = (v / wid).floor() as i32;
|
||||
let off = hash01(lane, 0, 0, seed) * len;
|
||||
let t = ((u + off) / len).rem_euclid(1.0);
|
||||
// Back to the stroke's middle along the direction.
|
||||
let du = (0.5 - t) * len;
|
||||
(src.sample(fx + du * ux, fy + du * uy), t)
|
||||
}
|
||||
|
||||
/// Unit vector toward the light in image coordinates (y down).
|
||||
fn light_dir(name: &str) -> (f32, f32) {
|
||||
let s = std::f32::consts::FRAC_1_SQRT_2;
|
||||
|
|
@ -333,6 +353,11 @@ fn weave(x: f32, y: f32, p: f32, noise: f32) -> f32 {
|
|||
|
||||
/// Texturizer core: relief-shades the window with a procedural texture.
|
||||
fn texturize(win: &mut Win, kind: &str, scaling: f32, relief: f32, light: &str, invert: bool) {
|
||||
texturize_by(win, kind, scaling, relief, light, invert, |_| 1.0);
|
||||
}
|
||||
|
||||
/// [`texturize`] with the relief scaled per pixel by `weight` of its colour (0–1).
|
||||
fn texturize_by(win: &mut Win, kind: &str, scaling: f32, relief: f32, light: &str, invert: bool, weight: impl Fn(&Px) -> f32) {
|
||||
if relief <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
|
@ -345,7 +370,7 @@ fn texturize(win: &mut Win, kind: &str, scaling: f32, relief: f32, light: &str,
|
|||
let (fx, fy) = (x as f32, y as f32);
|
||||
let gx = (texture(kind, fx + 1.0, fy, s) - texture(kind, fx - 1.0, fy, s)) * 0.5 * sign;
|
||||
let gy = (texture(kind, fx, fy + 1.0, s) - texture(kind, fx, fy - 1.0, s)) * 0.5 * sign;
|
||||
let f = shade(gx, gy, l, k).clamp(0.0, 2.0);
|
||||
let f = 1.0 + (shade(gx, gy, l, k).clamp(0.0, 2.0) - 1.0) * weight(&win.c[i]);
|
||||
win.c[i] = map(win.c[i], |v| v * f);
|
||||
}
|
||||
}
|
||||
|
|
@ -387,7 +412,7 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
|
|||
F::Cutout => gs(cutout_sigma(e)),
|
||||
F::DryBrush | F::Fresco => kuwahara_reach(1 + g("brushSize") as usize) + gs(1.0) + 2,
|
||||
F::FilmGrain | F::DiffuseGlow | F::ChalkCharcoal | F::Reticulation | F::Grain | F::MosaicTiles | F::Texturizer => 0,
|
||||
F::RoughPastels => (g("strokeLength") / 2.0) as i32 + 2,
|
||||
F::RoughPastels => gs(0.6 + 1.2) + ((2.0 + g("strokeLength")) / 2.0) as i32 + 3,
|
||||
F::NeonGlow => gs(g("glowSize").abs() * 0.6 + 0.5),
|
||||
F::PaintDaubs => kuwahara_reach(daubs_radius(e)) + gs(1.5) + 1,
|
||||
F::PaletteKnife => kuwahara_reach((g("strokeSize") / 3.0).max(1.0) as usize) + gs(g("softness") * 0.5),
|
||||
|
|
@ -398,7 +423,7 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
|
|||
F::Underpainting => gs(0.5 + g("brushSize") * 0.4),
|
||||
F::Watercolor => kuwahara_reach(watercolor_radius(e)) + 2,
|
||||
F::AccentedEdges => gs(0.4 + g("smoothness") * 0.3) + gs(g("edgeWidth") * 0.5) + 2,
|
||||
F::AngledStrokes => (g("strokeLength") / 3.0) as i32 + gs(1.0) + 2,
|
||||
F::AngledStrokes => gs(0.8) + (g("strokeLength").max(3.0) / 2.0) as i32 + gs(1.0) + 3,
|
||||
F::Crosshatch => (g("strokeLength") / 3.0) as i32 + gs(1.0) + 2,
|
||||
F::DarkStrokes => 9,
|
||||
F::InkOutlines => (g("strokeLength") / 2.0) as i32 + 3,
|
||||
|
|
@ -611,17 +636,23 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
|||
}
|
||||
}
|
||||
F::RoughPastels => {
|
||||
// Chalk strokes up to the right; Stroke Detail keeps more of the image inside them.
|
||||
let len = g("strokeLength");
|
||||
let det = g("strokeDetail") / 20.0;
|
||||
let orig = win.c.clone();
|
||||
line_blur3(&mut win.c, w, h, (1, -1), (len / 2.0) as usize);
|
||||
for i in 0..win.c.len() {
|
||||
let (x, y) = win.xy(i);
|
||||
let n = strokes(x, y, (1, -1), 3.0 + len * 0.8, 1.1, 51) - 0.5;
|
||||
let p = mix(win.c[i], orig[i], det * 0.5);
|
||||
win.c[i] = map(p, |v| v + n * 0.3);
|
||||
let det = (g("strokeDetail") - 1.0) / 19.0;
|
||||
if len >= 1.0 {
|
||||
let mut soft = win.c.clone();
|
||||
blur3(&mut soft, w, h, 0.6 + (1.0 - det) * 1.2);
|
||||
let src = Win { r: win.r, w, h, c: soft };
|
||||
for i in 0..win.c.len() {
|
||||
let (x, y) = win.xy(i);
|
||||
let (c, t) = paint_stroke(&src, x, y, (1, -1), 2.0 + len, 1.5, 51);
|
||||
// A chalk stroke is a little lighter in its middle, grainy along its length.
|
||||
let n = (strokes(x, y, (1, -1), 2.0 + len * 0.5, 0.8, 52) - 0.5) * 0.12 + (0.5 - (t - 0.5).abs()) * 0.06;
|
||||
win.c[i] = map(mix(c, win.c[i], det * 0.6), |v| v + n);
|
||||
}
|
||||
}
|
||||
texturize(win, e.choice("texture"), g("scaling"), g("relief"), e.choice("light"), e.flag("invert"));
|
||||
// Bright areas are thick chalk; in dark ones it's scraped off to show the texture.
|
||||
texturize_by(win, e.choice("texture"), g("scaling"), g("relief"), e.choice("light"), e.flag("invert"), |p| 0.15 + 0.85 * (1.0 - clamp01(lum(p))));
|
||||
}
|
||||
F::SmudgeStick => {
|
||||
line_blur3(&mut win.c, w, h, (1, 1), (g("strokeLength") * 1.5) as usize);
|
||||
|
|
@ -677,33 +708,41 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
|||
if eb >= 0.5 { map(win.c[i], |v| v + (1.0 - v) * k * (eb - 0.5) * 2.0) } else { map(win.c[i], |v| v * (1.0 - k * (0.5 - eb) * 2.0)) };
|
||||
}
|
||||
}
|
||||
F::AngledStrokes | F::Crosshatch => {
|
||||
F::AngledStrokes => {
|
||||
// Light areas are painted in strokes going one way, dark areas the other way;
|
||||
// Direction Balance moves the split (0: all down-right, 100: all up-right).
|
||||
let len = g("strokeLength").max(3.0);
|
||||
let bal = g("directionBalance") / 100.0;
|
||||
let mut soft = win.c.clone();
|
||||
blur3(&mut soft, w, h, 0.8);
|
||||
let l: Vec<f32> = soft.iter().map(lum).collect();
|
||||
let src = Win { r: win.r, w, h, c: soft };
|
||||
for i in 0..win.c.len() {
|
||||
let (x, y) = win.xy(i);
|
||||
let k = smoothstep(1.0 - bal - 0.05, 1.0 - bal + 0.05, l[i]);
|
||||
let (down, _) = paint_stroke(&src, x, y, (1, 1), len, 2.0, 83);
|
||||
let (up, _) = paint_stroke(&src, x, y, (1, -1), len, 2.0, 84);
|
||||
let n = (strokes(x, y, (1, 1), len * 0.5, 0.7, 85) - 0.5) * (1.0 - k) + (strokes(x, y, (1, -1), len * 0.5, 0.7, 86) - 0.5) * k;
|
||||
win.c[i] = map(mix(down, up, k), |v| v + n * 0.1);
|
||||
}
|
||||
unsharp(win, 1.0, 0.2 + g("sharpness") / 10.0 * 2.3);
|
||||
}
|
||||
F::Crosshatch => {
|
||||
let len = g("strokeLength");
|
||||
let r = (len / 3.0) as usize;
|
||||
if e.filter == F::Crosshatch {
|
||||
let st = g("strength");
|
||||
for i in 0..win.c.len() {
|
||||
let (x, y) = win.xy(i);
|
||||
let n = (hash01(x, y, 0, 81) - 0.5) * 0.12 * st;
|
||||
win.c[i] = map(win.c[i], |v| v + n);
|
||||
}
|
||||
let st = g("strength");
|
||||
for i in 0..win.c.len() {
|
||||
let (x, y) = win.xy(i);
|
||||
let n = (hash01(x, y, 0, 81) - 0.5) * 0.12 * st;
|
||||
win.c[i] = map(win.c[i], |v| v + n);
|
||||
}
|
||||
let l = win.lum();
|
||||
let mut a = win.c.clone();
|
||||
line_blur3(&mut a, w, h, (1, 1), r);
|
||||
line_blur3(&mut win.c, w, h, (1, -1), r);
|
||||
if e.filter == F::AngledStrokes {
|
||||
let bal = g("directionBalance") / 100.0;
|
||||
for i in 0..win.c.len() {
|
||||
win.c[i] = mix(a[i], win.c[i], smoothstep(1.0 - bal - 0.15, 1.0 - bal + 0.15, l[i]));
|
||||
}
|
||||
unsharp(win, 1.0, g("sharpness") / 10.0 * 1.5);
|
||||
} else {
|
||||
for i in 0..win.c.len() {
|
||||
win.c[i] = mix(a[i], win.c[i], 0.5);
|
||||
}
|
||||
unsharp(win, 1.0, g("sharpness") / 20.0 * 3.0);
|
||||
for i in 0..win.c.len() {
|
||||
win.c[i] = mix(a[i], win.c[i], 0.5);
|
||||
}
|
||||
unsharp(win, 1.0, g("sharpness") / 20.0 * 3.0);
|
||||
}
|
||||
F::DarkStrokes => {
|
||||
let (bal, bi, wi) = (g("balance") / 10.0, g("blackIntensity") / 10.0, g("whiteIntensity") / 10.0);
|
||||
|
|
@ -1173,6 +1212,33 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rough_pastels_and_angled_strokes_respond_to_every_slider() {
|
||||
// #879: these smeared or drowned the image in texture, and some sliders did nothing.
|
||||
use crate::GalleryFilter as G;
|
||||
let b = Rect::new(0, 0, 56, 40);
|
||||
let s = pattern(SampleType::F32);
|
||||
let run = |e: &GalleryEffect, tile: i32| {
|
||||
let fp = FilterParams::FilterGallery { effects: vec![e.clone()] };
|
||||
apply_tiled(&s, &fp, b, b, None, tile, Some(b)).read_region(b)
|
||||
};
|
||||
for f in [G::RoughPastels, G::AngledStrokes] {
|
||||
let base = run(&GalleryEffect::new(f), 256);
|
||||
for prm in f.params() {
|
||||
let crate::GalleryParamKind::Range { min, max, .. } = prm.kind else { continue };
|
||||
for v in [min, max] {
|
||||
let mut e = GalleryEffect::new(f);
|
||||
e.set(prm.key, v);
|
||||
let a = run(&e, 256);
|
||||
assert_ne!(a, base, "{} {} = {v} changed nothing", f.key(), prm.key);
|
||||
let t = run(&e, 16);
|
||||
let worst = a.iter().zip(&t).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max);
|
||||
assert!(worst < 1e-3, "{} {} = {v} differs across tilings by {worst}", f.key(), prm.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_parse_and_clamp() {
|
||||
for f in crate::GalleryFilter::ALL {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
//! Blurs: Gaussian, box, motion, radial, surface.
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Interrupt;
|
||||
|
||||
use crate::image::{Edge, Image, premultiply, unpremultiply};
|
||||
use crate::{Ctx, RadialMethod};
|
||||
use crate::photo_util::{par_map, par_rows};
|
||||
use crate::{Ctx, FilterParams, RadialMethod};
|
||||
|
||||
/// Normalized Gaussian kernel with standard deviation `sigma` (radius 3σ).
|
||||
pub(crate) fn gaussian_kernel(sigma: f32) -> Vec<f32> {
|
||||
|
|
@ -69,11 +71,13 @@ pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bo
|
|||
pub(crate) fn gaussian(src: &Image, out: Rect, ctx: &Ctx, radius: f32) -> Vec<f32> {
|
||||
// Exact kernel for small radii; beyond that three box passes approximate the Gaussian with
|
||||
// cost independent of the radius (Kovesi, "Fast almost-Gaussian filtering", 2010).
|
||||
if radius <= 4.0 {
|
||||
let k = gaussian_kernel(radius);
|
||||
return conv_sep(src, out, &k, &k, ctx.alpha);
|
||||
match box_widths(&FilterParams::GaussianBlur { radius }) {
|
||||
Some(boxes) => box_passes(src, out, ctx.alpha, &boxes),
|
||||
None => {
|
||||
let k = gaussian_kernel(radius);
|
||||
conv_sep(src, out, &k, &k, ctx.alpha)
|
||||
}
|
||||
}
|
||||
gaussian_boxes(src, out, ctx.alpha, radius)
|
||||
}
|
||||
|
||||
/// Box widths (odd) whose `n`-fold convolution has standard deviation `sigma`.
|
||||
|
|
@ -91,134 +95,219 @@ fn boxes_for_gauss(sigma: f32, n: usize) -> Vec<usize> {
|
|||
(0..n).map(|i| if i < m { wl as usize } else { wu as usize }).collect()
|
||||
}
|
||||
|
||||
/// Running-sum box blur of every row of an interleaved `w × h × n` buffer (edges clamped).
|
||||
fn box_rows(buf: &mut [f32], w: usize, n: usize, r: usize) {
|
||||
if r == 0 || w == 0 {
|
||||
return;
|
||||
/// The box widths (odd) a blur runs as running-sum passes along each axis, or `None` when it uses a
|
||||
/// direct kernel (small radii, where the kernel is quicker).
|
||||
pub(crate) fn box_widths(params: &FilterParams) -> Option<Vec<usize>> {
|
||||
match *params {
|
||||
FilterParams::GaussianBlur { radius } if radius > 4.0 => Some(boxes_for_gauss(radius, 3)),
|
||||
FilterParams::BoxBlur { radius } => {
|
||||
let r = radius.max(0.0).round() as usize;
|
||||
(r > 4).then(|| vec![2 * r + 1])
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scratch buffers of one worker for [`box_blur`].
|
||||
#[derive(Default)]
|
||||
struct Scratch {
|
||||
line: Vec<f32>,
|
||||
next: Vec<f32>,
|
||||
acc: Vec<f32>,
|
||||
}
|
||||
|
||||
/// One running-sum box pass of radius `r` along a line of elements of `w` floats each (a pixel's
|
||||
/// channels, or a strip of a row): element `i` of `dst` is the mean of elements `i ..= i + 2r` of
|
||||
/// `src`, which is `2r` elements longer.
|
||||
fn box_pass(src: &[f32], dst: &mut [f32], w: usize, r: usize, acc: &mut Vec<f32>) {
|
||||
// Pixels are short elements: fixed sizes let the compiler keep the sums in registers.
|
||||
match w {
|
||||
1 => box_pass_px::<1>(src, dst, r),
|
||||
2 => box_pass_px::<2>(src, dst, r),
|
||||
3 => box_pass_px::<3>(src, dst, r),
|
||||
4 => box_pass_px::<4>(src, dst, r),
|
||||
5 => box_pass_px::<5>(src, dst, r),
|
||||
0 => {}
|
||||
_ => {
|
||||
let norm = 1.0 / (2 * r + 1) as f32;
|
||||
acc.clear();
|
||||
acc.resize(w, 0.0);
|
||||
for e in src.chunks_exact(w).take(2 * r + 1) {
|
||||
for (a, s) in acc.iter_mut().zip(e) {
|
||||
*a += s;
|
||||
}
|
||||
}
|
||||
for (i, d) in dst.chunks_exact_mut(w).enumerate() {
|
||||
for (d, a) in d.iter_mut().zip(acc.iter()) {
|
||||
*d = a * norm;
|
||||
}
|
||||
let (add, sub) = ((i + 2 * r + 1) * w, i * w);
|
||||
if let (Some(add), Some(sub)) = (src.get(add..add + w), src.get(sub..sub + w)) {
|
||||
for ((a, add), sub) in acc.iter_mut().zip(add).zip(sub) {
|
||||
*a += add - sub;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`box_pass`] for elements of `N` floats.
|
||||
fn box_pass_px<const N: usize>(src: &[f32], dst: &mut [f32], r: usize) {
|
||||
let (src, dst) = (src.as_chunks::<N>().0, dst.as_chunks_mut::<N>().0);
|
||||
let norm = 1.0 / (2 * r + 1) as f32;
|
||||
let blur_row = |row: &mut [f32]| {
|
||||
// The row with clamped edges materialised (r + 1 pixels each side), so the running sum
|
||||
// reads without bounds clamping, all channels per step.
|
||||
let pad = r + 1;
|
||||
let mut src = Vec::with_capacity((w + 2 * pad) * n);
|
||||
for _ in 0..pad {
|
||||
src.extend_from_slice(&row[..n]);
|
||||
let mut acc = [0.0f32; N];
|
||||
for e in src.iter().take(2 * r + 1) {
|
||||
for c in 0..N {
|
||||
acc[c] += e[c];
|
||||
}
|
||||
}
|
||||
for (i, d) in dst.iter_mut().enumerate() {
|
||||
for c in 0..N {
|
||||
d[c] = acc[c] * norm;
|
||||
}
|
||||
if let (Some(add), Some(sub)) = (src.get(i + 2 * r + 1), src.get(i)) {
|
||||
for c in 0..N {
|
||||
acc[c] += add[c] - sub[c];
|
||||
}
|
||||
}
|
||||
src.extend_from_slice(row);
|
||||
for _ in 0..pad {
|
||||
src.extend_from_slice(&row[(w - 1) * n..w * n]);
|
||||
}
|
||||
let mut acc = [0.0f32; 8];
|
||||
let acc = &mut acc[..n.min(8)];
|
||||
if n > 8 {
|
||||
// Unusual channel counts: per channel.
|
||||
for c in 0..n {
|
||||
let mut a: f32 = (0..=2 * r).map(|i| src[(pad - r + i) * n + c]).sum();
|
||||
for x in 0..w {
|
||||
row[x * n + c] = a * norm;
|
||||
a += src[(x + pad + r + 1) * n + c] - src[(x + pad - r) * n + c];
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for i in 0..=2 * r {
|
||||
let o = (pad - r + i) * n;
|
||||
for c in 0..n {
|
||||
acc[c] += src[o + c];
|
||||
}
|
||||
}
|
||||
for x in 0..w {
|
||||
let (add, sub) = ((x + pad + r + 1) * n, (x + pad - r) * n);
|
||||
for c in 0..n {
|
||||
row[x * n + c] = acc[c] * norm;
|
||||
acc[c] += src[add + c] - src[sub + c];
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
buf.par_chunks_mut(w * n).for_each(blur_row);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
buf.chunks_mut(w * n).for_each(blur_row);
|
||||
}
|
||||
|
||||
fn transpose(buf: &[f32], w: usize, h: usize, n: usize) -> Vec<f32> {
|
||||
let mut out = vec![0.0f32; buf.len()];
|
||||
if w == 0 || h == 0 {
|
||||
return out;
|
||||
}
|
||||
const B: usize = 32; // cache-friendly blocks
|
||||
// Bands of B output rows (= input columns) are independent: transpose them in parallel.
|
||||
let band = |(i, chunk): (usize, &mut [f32])| {
|
||||
let bx = i * B;
|
||||
for by in (0..h).step_by(B) {
|
||||
for y in by..(by + B).min(h) {
|
||||
for x in bx..(bx + B).min(w) {
|
||||
let (s, d) = ((y * w + x) * n, ((x - bx) * h + y) * n);
|
||||
chunk[d..d + n].copy_from_slice(&buf[s..s + n]);
|
||||
}
|
||||
}
|
||||
/// Box passes of the given radii in turn along the line in `s.line` (`w`-float elements, as many
|
||||
/// as `dst` holds plus twice the radii's sum; consumed), ending in `dst`.
|
||||
fn line_passes(dst: &mut [f32], w: usize, radii: &[usize], s: &mut Scratch) {
|
||||
let Scratch { line, next, acc } = s;
|
||||
let Some(len) = dst.len().checked_div(w) else { return };
|
||||
let mut tail: usize = radii.iter().sum();
|
||||
for (k, &r) in radii.iter().enumerate() {
|
||||
tail -= r;
|
||||
if k + 1 == radii.len() {
|
||||
box_pass(line, dst, w, r, acc);
|
||||
} else {
|
||||
next.resize((len + 2 * tail) * w, 0.0);
|
||||
box_pass(line, next, w, r, acc);
|
||||
std::mem::swap(line, next);
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
out.par_chunks_mut(B * h * n).enumerate().for_each(band);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
out.chunks_mut(B * h * n).enumerate().for_each(band);
|
||||
out
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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);
|
||||
let mut p = vec![0.0f32; ww * wh * n];
|
||||
let fill = |yy: usize, row: &mut [f32]| {
|
||||
let y = win.y0 + yy as i32;
|
||||
for (xx, px) in row.chunks_exact_mut(n).enumerate() {
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
*v = src.get(win.x0 + xx as i32, y, c);
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
p.par_chunks_mut(ww * n).enumerate().for_each(|(y, row)| fill(y, row));
|
||||
}
|
||||
#[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 {
|
||||
box_rows(&mut p, ww, n, (w - 1) / 2);
|
||||
}
|
||||
let mut t = transpose(&p, ww, wh, n);
|
||||
for w in &boxes {
|
||||
box_rows(&mut t, wh, n, (w - 1) / 2);
|
||||
}
|
||||
let p = transpose(&t, wh, ww, n);
|
||||
/// Rows of the horizontal passes handed to one worker (they share its scratch buffers).
|
||||
const ROWS_PER_TASK: usize = 8;
|
||||
/// Width in pixels of the column strips the vertical passes work on (a divisor of the tile size).
|
||||
pub(crate) const STRIP: i32 = 64;
|
||||
|
||||
/// Samples of a column strip of an output area.
|
||||
pub(crate) type Strip = (Rect, Vec<f32>);
|
||||
|
||||
/// Box blurs of the given (odd) widths, one after another along each axis, of the `n`-channel
|
||||
/// pixels of `src` extended beyond it by repeating its edge pixels (alpha-weighted when `alpha`).
|
||||
/// `read(row, buf)` fills `buf` with the samples of a one-pixel-high rectangle inside `src`.
|
||||
///
|
||||
/// Running sums make the cost independent of the widths, and only `src`'s own rows and the output
|
||||
/// columns are blurred: the repeated edges are never read or blurred as 2-D margins, so a huge
|
||||
/// radius costs about what a small one does. Returns the straight results for `out` as column
|
||||
/// strips (`None` when `ctl` was cancelled).
|
||||
pub(crate) fn box_blur(
|
||||
src: Rect,
|
||||
read: &(dyn Fn(Rect, &mut Vec<f32>) + Sync),
|
||||
out: Rect,
|
||||
n: usize,
|
||||
alpha: bool,
|
||||
boxes: &[usize],
|
||||
ctl: &Interrupt,
|
||||
) -> Option<Vec<Strip>> {
|
||||
let radii: Vec<usize> = boxes.iter().map(|w| w / 2).collect();
|
||||
let reach = i32::try_from(radii.iter().sum::<usize>()).unwrap_or(i32::MAX);
|
||||
let (ow, oh) = (out.width() as usize, out.height() as usize);
|
||||
let mut res = vec![0.0f32; ow * oh * n];
|
||||
for y in 0..oh {
|
||||
let s = ((y + margin as usize) * ww + margin as usize) * n;
|
||||
res[y * ow * n..(y + 1) * ow * n].copy_from_slice(&p[s..s + ow * n]);
|
||||
let used = src.intersect(&out.inflate(reach));
|
||||
if used.is_empty() || n == 0 {
|
||||
// Nothing to read: transparent, as `Image::get` reads outside an image.
|
||||
return Some(strips(out).map(|t| (t, vec![0.0; t.width() as usize * oh * n])).collect());
|
||||
}
|
||||
// Horizontal passes over `used`'s rows only (the repeated rows above and below it would just
|
||||
// repeat their results), for the output columns. Each row is extended to the passes' reach by
|
||||
// repeating its end pixels.
|
||||
let mut h = vec![0.0f32; used.height() as usize * ow * n];
|
||||
let (left, right) = ((used.x0 - out.x0.saturating_sub(reach)) as usize, (out.x1.saturating_add(reach) - used.x1) as usize);
|
||||
par_rows(&mut h, ow * ROWS_PER_TASK, n, |task, chunk| {
|
||||
let (mut row, mut s) = (Vec::new(), Scratch::default());
|
||||
for (k, dst) in chunk.chunks_exact_mut(ow * n).enumerate() {
|
||||
if ctl.cancelled() {
|
||||
return;
|
||||
}
|
||||
let y = used.y0 + (task * ROWS_PER_TASK + k) as i32;
|
||||
read(Rect::new(used.x0, y, used.x1, y + 1), &mut row);
|
||||
premultiply(&mut row, n, alpha);
|
||||
let (Some(first), Some(last)) = (row.get(..n), row.get(row.len().saturating_sub(n)..)) else { return };
|
||||
s.line.clear();
|
||||
(0..left).for_each(|_| s.line.extend_from_slice(first));
|
||||
s.line.extend_from_slice(&row);
|
||||
(0..right).for_each(|_| s.line.extend_from_slice(last));
|
||||
line_passes(dst, n, &radii, &mut s);
|
||||
}
|
||||
});
|
||||
// Vertical passes, strip by strip, the same way down the columns.
|
||||
let (top, bottom) = ((used.y0 - out.y0.saturating_sub(reach)) as usize, (out.y1.saturating_add(reach) - used.y1) as usize);
|
||||
let (rows, stride) = (used.height() as usize, ow * n);
|
||||
let strips: Vec<Rect> = strips(out).collect();
|
||||
let done = par_map(strips.len(), |k| {
|
||||
let t = strips[k];
|
||||
if ctl.cancelled() {
|
||||
return (t, Vec::new());
|
||||
}
|
||||
let (sx, w) = ((t.x0 - out.x0) as usize * n, t.width() as usize * n);
|
||||
let row = |y: usize| &h[y * stride + sx..y * stride + sx + w];
|
||||
let mut s = Scratch::default();
|
||||
(0..top).for_each(|_| s.line.extend_from_slice(row(0)));
|
||||
(0..rows).for_each(|y| s.line.extend_from_slice(row(y)));
|
||||
(0..bottom).for_each(|_| s.line.extend_from_slice(row(rows - 1)));
|
||||
let mut res = vec![0.0f32; oh * w];
|
||||
line_passes(&mut res, w, &radii, &mut s);
|
||||
unpremultiply(&mut res, n, alpha);
|
||||
(t, res)
|
||||
});
|
||||
(!ctl.cancelled()).then_some(done)
|
||||
}
|
||||
|
||||
/// Column strips of `out` at multiples of [`STRIP`], so each lies inside one tile column.
|
||||
fn strips(out: Rect) -> impl Iterator<Item = Rect> {
|
||||
(out.x0.div_euclid(STRIP) * STRIP..out.x1)
|
||||
.step_by(STRIP as usize)
|
||||
.map(move |x| Rect::new(x.max(out.x0), out.y0, x.saturating_add(STRIP).min(out.x1), out.y1))
|
||||
}
|
||||
const _: () = assert!(photocraft_geom::TILE_SIZE % STRIP == 0);
|
||||
|
||||
/// [`box_blur`] of an image (edges repeated) into one interleaved buffer for `out`.
|
||||
fn box_passes(src: &Image, out: Rect, alpha: bool, boxes: &[usize]) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
let read = |r: Rect, buf: &mut Vec<f32>| {
|
||||
buf.clear();
|
||||
buf.extend_from_slice(src.row(r));
|
||||
};
|
||||
let ow = out.width() as usize * n;
|
||||
let mut res = vec![0.0f32; ow * out.height() as usize];
|
||||
if res.is_empty() {
|
||||
return res;
|
||||
}
|
||||
for (t, data) in box_blur(src.rect, &read, out, n, alpha, boxes, &Interrupt::NONE).unwrap_or_default() {
|
||||
let (x0, w) = ((t.x0 - out.x0) as usize * n, t.width() as usize * n);
|
||||
for (dst, s) in res.chunks_exact_mut(ow).zip(data.chunks_exact(w)) {
|
||||
dst[x0..x0 + w].copy_from_slice(s);
|
||||
}
|
||||
}
|
||||
unpremultiply(&mut res, n, alpha);
|
||||
res
|
||||
}
|
||||
|
||||
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)
|
||||
match box_widths(&FilterParams::BoxBlur { radius }) {
|
||||
Some(boxes) => box_passes(src, out, ctx.alpha, &boxes),
|
||||
None => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn average_samples(src: &Image, out: Rect, ctx: &Ctx, mut offsets: impl FnMut(f32, f32, &mut Vec<(f32, f32)>)) -> Vec<f32> {
|
||||
|
|
@ -305,11 +394,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 {
|
||||
|
|
@ -340,6 +520,8 @@ pub(crate) fn surface(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold:
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
#[test]
|
||||
fn box_gaussian_matches_exact_kernel() {
|
||||
|
|
@ -356,9 +538,168 @@ mod tests {
|
|||
let out = Rect::new(0, 0, 120, 8);
|
||||
let k = gaussian_kernel(12.0);
|
||||
let exact = conv_sep(&img, out, &k, &k, true);
|
||||
let fast = gaussian_boxes(&img, out, true, 12.0);
|
||||
let fast = box_passes(&img, out, true, &boxes_for_gauss(12.0, 3));
|
||||
let err = exact.iter().zip(&fast).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The definition the running sums must match: each box applied in turn as a direct
|
||||
/// convolution (cost per pixel proportional to the radius) to `img`, which covers `out` grown
|
||||
/// by every box's reach (its own edge handling included).
|
||||
fn direct_boxes(img: &Image, out: Rect, boxes: &[usize], alpha: bool) -> Vec<f32> {
|
||||
let mut cur = img.clone();
|
||||
let mut left: i32 = boxes.iter().map(|w| (w / 2) as i32).sum();
|
||||
for &w in boxes {
|
||||
left -= (w / 2) as i32;
|
||||
let k = vec![1.0 / w as f32; w];
|
||||
let rect = out.inflate(left);
|
||||
cur = Image { rect, ch: img.ch, data: conv_sep(&cur, rect, &k, &k, alpha) };
|
||||
}
|
||||
cur.data
|
||||
}
|
||||
|
||||
/// Noisy pixels over `r` (alpha varying, some fully transparent).
|
||||
fn noisy(fmt: PixelFormat, r: Rect, seed: u32) -> Surface {
|
||||
let mut s = Surface::new(fmt);
|
||||
let mut st = seed;
|
||||
let mut v = Vec::new();
|
||||
for _ in 0..r.width() * r.height() {
|
||||
for c in 0..fmt.channels() {
|
||||
st ^= st << 13;
|
||||
st ^= st >> 17;
|
||||
st ^= st << 5;
|
||||
let x = (st % 256) as f32 / 255.0;
|
||||
v.push(if fmt.alpha && c + 1 == fmt.channels() && st.is_multiple_of(5) { 0.0 } else { x });
|
||||
}
|
||||
}
|
||||
s.write_region(r, &v);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_box_blurs_match_direct_convolution_at_any_radius() {
|
||||
// Gaussian and Box Blur at radii up to several times the layer (37 × 23), filtered as a
|
||||
// document layer (canvas edge repeated, as Photoshop does) and on their own (transparent
|
||||
// beyond), at every depth, with and without alpha, 1 to 5 channels.
|
||||
let canvas = Rect::new(0, 0, 37, 23);
|
||||
let formats = [
|
||||
PixelFormat::RGBA8,
|
||||
PixelFormat::RGBA16,
|
||||
PixelFormat::RGBA32F,
|
||||
PixelFormat::GRAY8,
|
||||
PixelFormat::CMYKA8,
|
||||
PixelFormat::new(ColorMode::Lab, SampleType::F32, false),
|
||||
];
|
||||
let params = [
|
||||
FilterParams::GaussianBlur { radius: 4.5 },
|
||||
FilterParams::GaussianBlur { radius: 9.3 },
|
||||
FilterParams::GaussianBlur { radius: 40.0 },
|
||||
FilterParams::BoxBlur { radius: 5.0 },
|
||||
FilterParams::BoxBlur { radius: 70.0 },
|
||||
];
|
||||
for (i, fmt) in formats.into_iter().enumerate() {
|
||||
let s = noisy(fmt, canvas, 0x9e37_79b9 + i as u32);
|
||||
// A level of the depth (colour and alpha are each rounded on write) plus float noise.
|
||||
let tol = match fmt.sample {
|
||||
SampleType::U8 => 1.0 / 255.0,
|
||||
SampleType::U16 => 1.0 / 65535.0,
|
||||
_ => 0.0,
|
||||
} + 2e-5;
|
||||
for p in ¶ms {
|
||||
let boxes = box_widths(p).expect("a box-pass blur");
|
||||
let reach: i32 = boxes.iter().map(|w| (w / 2) as i32).sum();
|
||||
let area = crate::output_area(p, s.content_bounds(), canvas, None);
|
||||
for (got, img, out) in [
|
||||
(crate::apply_in(&s, p, area, canvas, None, canvas), Image::read_clamped(&s, canvas.inflate(reach), canvas), canvas),
|
||||
(crate::apply(&s, p, area, canvas, None), Image::read(&s, area.inflate(reach)), area),
|
||||
] {
|
||||
// Compared alpha-weighted: colour is meaningless where (nearly) transparent.
|
||||
let (mut got, mut want) = (got.read_region(out), direct_boxes(&img, out, &boxes, fmt.alpha));
|
||||
premultiply(&mut got, img.ch, fmt.alpha);
|
||||
premultiply(&mut want, img.ch, fmt.alpha);
|
||||
let err = got.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(err <= tol, "{} on {fmt:?} over {out:?}: max error {err}", p.label());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_box_blurs_mix_by_the_selection_and_stop_when_cancelled() {
|
||||
let canvas = Rect::new(0, 0, 37, 23);
|
||||
let s = noisy(PixelFormat::RGBA32F, canvas, 7);
|
||||
let p = FilterParams::GaussianBlur { radius: 12.0 };
|
||||
let full = crate::apply_in(&s, &p, canvas, canvas, None, canvas);
|
||||
let half = Surface::with_default(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false), &[0.5]);
|
||||
let mixed = crate::apply_in(&s, &p, canvas, canvas, Some(&half), canvas);
|
||||
for (x, y) in [(0, 0), (18, 11), (36, 22)] {
|
||||
let (o, f, m) = (s.pixel(x, y), full.pixel(x, y), mixed.pixel(x, y));
|
||||
for c in 0..4 {
|
||||
assert!((m[c] - (o[c] + f[c]) / 2.0).abs() < 1e-5, "({x},{y}) channel {c}");
|
||||
}
|
||||
}
|
||||
let cancel = || true;
|
||||
let ctl = Interrupt::cancel_only(&cancel);
|
||||
assert!(crate::apply_in_with(&s, &p, canvas, canvas, None, canvas, &ctl).is_none());
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
|
@ -74,6 +74,13 @@ impl Image {
|
|||
if self.rect.contains(x, y) { self.data[self.index(x, y) + c] } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Samples of the pixels of a one-pixel-high rectangle (must be inside).
|
||||
#[inline]
|
||||
pub fn row(&self, r: Rect) -> &[f32] {
|
||||
let i = self.index(r.x0, r.y0);
|
||||
&self.data[i..i + r.width() as usize * self.ch]
|
||||
}
|
||||
|
||||
/// Pixel slice at `(x, y)` (must be inside).
|
||||
#[inline]
|
||||
pub fn px(&self, x: i32, y: i32) -> &[f32] {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -33,10 +33,12 @@ pub mod features;
|
|||
mod fxutil;
|
||||
mod gallery;
|
||||
pub mod hdr;
|
||||
pub mod histogram;
|
||||
mod image;
|
||||
pub mod inpaint;
|
||||
pub mod lens;
|
||||
pub mod liquify;
|
||||
pub mod magnetic;
|
||||
pub mod matting;
|
||||
mod noise;
|
||||
mod oil;
|
||||
|
|
@ -51,6 +53,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 +62,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 +71,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 +413,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 +599,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 +641,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 +776,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 +884,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 +917,29 @@ 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);
|
||||
}
|
||||
if let Some(boxes) = blur::box_widths(params) {
|
||||
return apply_box_blur(out, surface, area, extent, selection, &boxes, ctl);
|
||||
}
|
||||
let fmt = surface.format();
|
||||
let ctx = Ctx { bounds, mode: fmt.mode, alpha: fmt.alpha };
|
||||
|
|
@ -897,7 +955,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,
|
||||
|
|
@ -916,20 +986,9 @@ pub fn apply_tiled(
|
|||
};
|
||||
let mut data = kernel(params, src, *t, &ctx);
|
||||
if let Some(sel) = selection {
|
||||
let n = src.ch;
|
||||
let w = t.width() as usize;
|
||||
for (i, px) in data.chunks_exact_mut(n).enumerate() {
|
||||
let (xx, yy) = (t.x0 + (i % w) as i32, t.y0 + (i / w) as i32);
|
||||
let k = sel.pixel(xx, yy)[0].clamp(0.0, 1.0);
|
||||
if k >= 1.0 {
|
||||
continue;
|
||||
}
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
let o = src.get(xx, yy, c);
|
||||
*v = o + (*v - o) * k;
|
||||
}
|
||||
}
|
||||
mix_selection(&mut data, *t, sel, src);
|
||||
}
|
||||
tick();
|
||||
(*t, data)
|
||||
};
|
||||
// Tiles are filtered in groups of about RESULT_BUDGET bytes (at least one per core) and each
|
||||
|
|
@ -942,6 +1001,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 +1011,97 @@ 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)
|
||||
}
|
||||
|
||||
/// [`apply_tiled_with`] for the blurs run as box passes ([`blur::box_widths`]): the whole area at
|
||||
/// once (in column bands of about [`RESULT_BUDGET`] bytes of floats), so the wide halo of a large
|
||||
/// radius is read once rather than around every tile. Pixels beyond `extent` repeat its edge, as
|
||||
/// [`Image::read_clamped`] does for tiles; without one the surface is read (transparent where empty).
|
||||
fn apply_box_blur(
|
||||
mut out: Surface,
|
||||
surface: &Surface,
|
||||
area: Rect,
|
||||
extent: Option<Rect>,
|
||||
selection: Option<&Surface>,
|
||||
boxes: &[usize],
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
) -> Option<Surface> {
|
||||
let fmt = surface.format();
|
||||
let n = fmt.channels();
|
||||
let reach = i32::try_from(boxes.iter().map(|w| w / 2).sum::<usize>()).unwrap_or(i32::MAX);
|
||||
let grown = area.inflate(reach);
|
||||
let src = extent.map(|e| grown.intersect(&e)).filter(|r| !r.is_empty()).unwrap_or(grown);
|
||||
let read = |r: Rect, buf: &mut Vec<f32>| surface.read_region_into(r, buf);
|
||||
// Per output column: the horizontal results for every source row, plus the final samples.
|
||||
let column_bytes = (src.height() as usize + area.height() as usize) * n * std::mem::size_of::<f32>();
|
||||
let band = i32::try_from(RESULT_BUDGET / column_bytes.max(1)).unwrap_or(i32::MAX).max(blur::STRIP) / blur::STRIP * blur::STRIP;
|
||||
let bands: Vec<Rect> = (area.x0..area.x1).step_by(band as usize).map(|x| Rect::new(x, area.y0, x.saturating_add(band).min(area.x1), area.y1)).collect();
|
||||
for (i, b) in bands.iter().enumerate() {
|
||||
let strips = blur::box_blur(src, &read, *b, n, fmt.alpha, boxes, ctl)?;
|
||||
// Each strip lies in one tile column: the columns' tiles are written in parallel.
|
||||
let mut columns: Vec<(Surface, Vec<blur::Strip>)> = Vec::new();
|
||||
for (t, data) in strips {
|
||||
match columns.last_mut() {
|
||||
Some((_, v)) if v.first().is_some_and(|(r, _)| r.x0.div_euclid(photocraft_geom::TILE_SIZE) == t.x0.div_euclid(photocraft_geom::TILE_SIZE)) => {
|
||||
v.push((t, data))
|
||||
}
|
||||
_ => columns.push((Surface::with_default(fmt, &surface.default_pixel()), vec![(t, data)])),
|
||||
}
|
||||
}
|
||||
for (s, v) in &mut columns {
|
||||
let r = v.iter().fold(Rect::EMPTY, |acc, (t, _)| if acc.is_empty() { *t } else { acc.union(t) });
|
||||
s.put_tiles(out.take_tiles(r));
|
||||
}
|
||||
let write = |(s, v): &mut (Surface, Vec<blur::Strip>)| {
|
||||
for (t, data) in v.iter_mut() {
|
||||
if let Some(sel) = selection {
|
||||
mix_selection(data, *t, sel, &Image::read(surface, *t));
|
||||
}
|
||||
s.write_region(*t, data);
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
columns.par_iter_mut().for_each(write);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
columns.iter_mut().for_each(write);
|
||||
for (s, _) in columns {
|
||||
out.put_tiles(s.tiles().map(|(c, t)| (*c, t.clone())));
|
||||
}
|
||||
ctl.progress(0.98 * (i + 1) as f32 / bands.len() as f32);
|
||||
}
|
||||
out.prune();
|
||||
ctl.progress(1.0);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Mixes filtered `data` for `t` back toward the original pixels in `orig` by the selection's
|
||||
/// coverage (channel 0 of `sel`).
|
||||
fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image) {
|
||||
let w = t.width() as usize;
|
||||
for (i, px) in data.chunks_exact_mut(orig.ch.max(1)).enumerate() {
|
||||
let (x, y) = (t.x0 + (i % w) as i32, t.y0 + (i / w) as i32);
|
||||
let k = sel.pixel(x, y)[0].clamp(0.0, 1.0);
|
||||
if k >= 1.0 {
|
||||
continue;
|
||||
}
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
let o = orig.get(x, y, c);
|
||||
*v = o + (*v - o) * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes of float filter results held at once by [`apply_tiled`].
|
||||
|
|
|
|||
|
|
@ -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] {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,602 @@
|
|||
//! Magnetic Lasso edge tracing: a selection border that snaps to the strongest edges near the
|
||||
//! pointer.
|
||||
//!
|
||||
//! Between two fastening points the border is the cheapest 8-connected pixel path, where pixels on
|
||||
//! a strong, well-localised edge are cheap and the rest are expensive: the "live wire" of
|
||||
//! Mortensen & Barrett, *Intelligent Scissors for Image Composition* (SIGGRAPH 1995). A link costs
|
||||
//! a weighted sum of an edge-strength term, an edge-centre term (non-maximum suppression across the
|
||||
//! edge, in place of the paper's Laplacian zero crossings) and a gradient-direction term that keeps
|
||||
//! the border running along the edge. The search stays in a corridor, the pixels within the
|
||||
//! detection width of the pointer's path, and a small pull towards that path keeps the border on
|
||||
//! the pointer where there is no edge, so it never jumps to an edge the pointer did not go near.
|
||||
//! Points on an edge are placed at its sub-pixel centre, so the border lands on the edge rather than
|
||||
//! half a pixel beside it.
|
||||
//!
|
||||
//! Pixels are read lazily in blocks through a caller-supplied fetch (a layer's pixels or the
|
||||
//! composite), so tracing on a 36 MP image reads only the area around the border.
|
||||
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BinaryHeap, HashMap};
|
||||
use std::f32::consts::{PI, SQRT_2};
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
/// Side of the cached pixel blocks (px).
|
||||
const BLOCK: i32 = 128;
|
||||
/// Blocks kept before the cache starts over (128² RGBA8 is 64 KiB, so at most 64 MiB).
|
||||
const MAX_BLOCKS: usize = 1024;
|
||||
/// Longest stretch of the pointer's path searched at once (px). Longer paths are traced in pieces
|
||||
/// that meet on an edge, which bounds every search to a small area.
|
||||
const PIECE: f64 = 256.0;
|
||||
/// Longest pointer path [`Tracer::trace`] follows (px); a longer one is returned as is.
|
||||
pub const MAX_GUIDE_LENGTH: f64 = 262_144.0;
|
||||
/// Detection width limits (px), as in Photoshop's options bar.
|
||||
pub const WIDTH_RANGE: (f64, f64) = (1.0, 256.0);
|
||||
|
||||
// Link cost weights: Mortensen & Barrett's 0.43 (edge centre) / 0.43 (strength) / 0.14
|
||||
// (direction), a cost per pixel of length so the border never wanders along an edge for free, and
|
||||
// the pull towards the pointer's path.
|
||||
const W_CENTRE: f32 = 0.43;
|
||||
const W_STRENGTH: f32 = 0.43;
|
||||
const W_DIRECTION: f32 = 0.14;
|
||||
const W_LENGTH: f32 = 0.02;
|
||||
const W_GUIDE: f32 = 0.4;
|
||||
/// The direction term where either pixel has no edge direction (the mean of the term).
|
||||
const NEUTRAL_DIRECTION: f32 = 2.0 / 3.0;
|
||||
|
||||
/// The 8 neighbours, in the order `Search::from` records them.
|
||||
const DIRS: [(i32, i32); 8] = [(1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)];
|
||||
|
||||
/// Detection settings: the Magnetic Lasso options bar.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Settings {
|
||||
/// Detection width (px): the border follows only edges this close to the pointer's path.
|
||||
pub width: f64,
|
||||
/// Contrast (0..1): steps weaker than this, in every channel, are not edges.
|
||||
pub contrast: f32,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Settings { width: 10.0, contrast: 0.1 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Settings clamped to their ranges (width 1..256 px, contrast 1..100 %); NaN takes the default.
|
||||
pub fn new(width: f64, contrast: f32) -> Self {
|
||||
let d = Settings::default();
|
||||
let width = if width.is_finite() { width.clamp(WIDTH_RANGE.0, WIDTH_RANGE.1) } else { d.width };
|
||||
let contrast = if contrast.is_finite() { contrast.clamp(0.01, 1.0) } else { d.contrast };
|
||||
Settings { width, contrast }
|
||||
}
|
||||
|
||||
/// The corridor's radius: the detection width plus room for an 8-connected path.
|
||||
fn radius(&self) -> f64 {
|
||||
self.width + 1.5
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads straight-alpha RGBA8 pixels of a rectangle inside the image, row-major. A result of the
|
||||
/// wrong length reads as transparent.
|
||||
pub type Fetch<'a> = dyn FnMut(Rect) -> Vec<[u8; 4]> + 'a;
|
||||
|
||||
/// Edge tracing over one image, caching the pixels it has read.
|
||||
#[derive(Debug)]
|
||||
pub struct Tracer {
|
||||
bounds: Rect,
|
||||
blocks: HashMap<(i32, i32), Vec<[u8; 4]>>,
|
||||
}
|
||||
|
||||
impl Tracer {
|
||||
/// A tracer for an image covering `bounds` (document pixels).
|
||||
pub fn new(bounds: Rect) -> Self {
|
||||
Tracer { bounds, blocks: HashMap::new() }
|
||||
}
|
||||
|
||||
pub fn bounds(&self) -> Rect {
|
||||
self.bounds
|
||||
}
|
||||
|
||||
/// Pixels read so far (for tests and diagnostics).
|
||||
pub fn cached_pixels(&self) -> usize {
|
||||
self.blocks.values().map(Vec::len).sum()
|
||||
}
|
||||
|
||||
/// Where a fastening point near `p` goes: the centre of the most prominent edge within the
|
||||
/// detection width, or `p` itself (kept inside the image) when no edge there reaches the
|
||||
/// contrast. `None` when `p` is not a finite point or the image is empty.
|
||||
pub fn snap(&mut self, fetch: &mut Fetch, p: [f64; 2], s: Settings) -> Option<[f64; 2]> {
|
||||
let s = Settings::new(s.width, s.contrast);
|
||||
let p = self.clamp_point(p)?;
|
||||
let r = s.width;
|
||||
let (px, py) = self.pixel(p);
|
||||
let ri = r.ceil() as i32 + 1;
|
||||
let area = Rect::new(px.saturating_sub(ri), py.saturating_sub(ri), px.saturating_add(ri + 1), py.saturating_add(ri + 1)).intersect(&self.bounds);
|
||||
let f = self.field(fetch, area);
|
||||
let mut best: Option<(f32, [f64; 2])> = None;
|
||||
for y in area.y0..area.y1 {
|
||||
for x in area.x0..area.x1 {
|
||||
let Some(i) = f.index(x, y) else { continue };
|
||||
let strength = f.strength.get(i).copied().unwrap_or(0.0);
|
||||
if strength < s.contrast || !f.ridge.get(i).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let d = dist([x as f64 + 0.5, y as f64 + 0.5], p);
|
||||
if d > r {
|
||||
continue;
|
||||
}
|
||||
// The strongest edge, a little in favour of the nearer one.
|
||||
let score = strength * (1.0 - 0.25 * (d / r) as f32);
|
||||
if best.is_none_or(|(b, _)| score > b) {
|
||||
best = Some((score, f.point(i, x, y, s.contrast)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(best.map_or(p, |(_, q)| q))
|
||||
}
|
||||
|
||||
/// The border from `from` to `to` along the edges near `guide`, the pointer's path between
|
||||
/// them (`from` and `to` are its ends whatever it holds). It starts at `from` and ends at `to`
|
||||
/// exactly (both kept inside the image). Empty when either is not a finite point or the image
|
||||
/// is empty.
|
||||
pub fn trace(&mut self, fetch: &mut Fetch, from: [f64; 2], to: [f64; 2], guide: &[[f64; 2]], s: Settings) -> Vec<[f64; 2]> {
|
||||
let s = Settings::new(s.width, s.contrast);
|
||||
let (Some(from), Some(to)) = (self.clamp_point(from), self.clamp_point(to)) else { return Vec::new() };
|
||||
let mut g = Vec::with_capacity(guide.len().saturating_add(2));
|
||||
g.push(from);
|
||||
g.extend(guide.iter().filter_map(|q| self.clamp_point(*q)));
|
||||
g.push(to);
|
||||
// A coarse guide is enough for the corridor, and much cheaper to draw.
|
||||
let g = simplify(&g, 0.5);
|
||||
if length(&g) > MAX_GUIDE_LENGTH {
|
||||
return g;
|
||||
}
|
||||
let pieces = split(&g, PIECE);
|
||||
let mut out = vec![from];
|
||||
let mut cur = from;
|
||||
let last = pieces.len().saturating_sub(1);
|
||||
for (k, piece) in pieces.iter().enumerate() {
|
||||
let target = if k == last { to } else { piece.last().and_then(|q| self.snap(fetch, *q, s)).unwrap_or(to) };
|
||||
out.extend(self.trace_piece(fetch, cur, target, piece, s).into_iter().skip(1));
|
||||
cur = target;
|
||||
}
|
||||
// Within half a pixel: drops the staircase of pixel steps, keeps the curve of an edge.
|
||||
simplify(&out, 0.5)
|
||||
}
|
||||
|
||||
/// One piece of [`Tracer::trace`]: the cheapest path from `a` to `b` in the corridor around
|
||||
/// `guide`, from `a` to `b` inclusive.
|
||||
fn trace_piece(&mut self, fetch: &mut Fetch, a: [f64; 2], b: [f64; 2], guide: &[[f64; 2]], s: Settings) -> Vec<[f64; 2]> {
|
||||
let rad = s.radius();
|
||||
let (ax, ay) = self.pixel(a);
|
||||
let (bx, by) = self.pixel(b);
|
||||
let mut bbox = Rect::new(ax.min(bx), ay.min(by), ax.max(bx) + 1, ay.max(by) + 1);
|
||||
for q in guide {
|
||||
let (x, y) = self.pixel(*q);
|
||||
bbox = bbox.union(&Rect::new(x, y, x + 1, y + 1));
|
||||
}
|
||||
let area = bbox.inflate(rad.ceil() as i32 + 1).intersect(&self.bounds);
|
||||
let f = self.field(fetch, area);
|
||||
let mut near = corridor(area, guide, rad);
|
||||
let (Some(start), Some(goal)) = (f.index(ax, ay), f.index(bx, by)) else { return vec![a, b] };
|
||||
for i in [start, goal] {
|
||||
if let Some(v) = near.get_mut(i) {
|
||||
*v = 0.0;
|
||||
}
|
||||
}
|
||||
let Some(cells) = search(&f, &near, (rad * rad) as f32, start, goal, s.contrast) else { return vec![a, b] };
|
||||
let mut pts: Vec<[f64; 2]> = cells.iter().map(|&i| f.point_at(i, s.contrast)).collect();
|
||||
match pts.len() {
|
||||
0 | 1 => vec![a, b],
|
||||
n => {
|
||||
pts[0] = a;
|
||||
pts[n - 1] = b;
|
||||
pts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `p` kept inside the image, or `None` if it is not a finite point or the image is empty.
|
||||
fn clamp_point(&self, p: [f64; 2]) -> Option<[f64; 2]> {
|
||||
let b = self.bounds;
|
||||
if b.is_empty() || !p[0].is_finite() || !p[1].is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some([p[0].clamp(b.x0 as f64, b.x1 as f64), p[1].clamp(b.y0 as f64, b.y1 as f64)])
|
||||
}
|
||||
|
||||
/// The pixel under a point (clamped to the image).
|
||||
fn pixel(&self, p: [f64; 2]) -> (i32, i32) {
|
||||
let b = self.bounds;
|
||||
let c = |v: f64, lo: i32, hi: i32| if v.is_finite() { (v.floor() as i32).clamp(lo, (hi - 1).max(lo)) } else { lo };
|
||||
(c(p[0], b.x0, b.x1), c(p[1], b.y0, b.y1))
|
||||
}
|
||||
|
||||
/// Edge features over `area` (inside the image).
|
||||
fn field(&mut self, fetch: &mut Fetch, area: Rect) -> Field {
|
||||
// Sobel reads one ring beyond the gradient, and non-maximum suppression one beyond the area.
|
||||
let ext = area.inflate(2).intersect(&self.bounds);
|
||||
let px = self.pixels(fetch, ext);
|
||||
Field::new(area, ext, &px)
|
||||
}
|
||||
|
||||
/// Straight RGBA8 pixels of `r` (inside the image), through the block cache.
|
||||
fn pixels(&mut self, fetch: &mut Fetch, r: Rect) -> Vec<[u8; 4]> {
|
||||
let w = r.width() as usize;
|
||||
let mut out = vec![[0u8; 4]; w * r.height() as usize];
|
||||
if r.is_empty() {
|
||||
return out;
|
||||
}
|
||||
if self.blocks.len() > MAX_BLOCKS {
|
||||
self.blocks.clear();
|
||||
}
|
||||
let (bx0, by0) = (r.x0.div_euclid(BLOCK), r.y0.div_euclid(BLOCK));
|
||||
let (bx1, by1) = ((r.x1 - 1).div_euclid(BLOCK), (r.y1 - 1).div_euclid(BLOCK));
|
||||
for by in by0..=by1 {
|
||||
for bx in bx0..=bx1 {
|
||||
let br = Rect::new(
|
||||
bx.saturating_mul(BLOCK),
|
||||
by.saturating_mul(BLOCK),
|
||||
bx.saturating_add(1).saturating_mul(BLOCK),
|
||||
by.saturating_add(1).saturating_mul(BLOCK),
|
||||
)
|
||||
.intersect(&self.bounds);
|
||||
if br.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let n = br.width() as usize * br.height() as usize;
|
||||
let block = self.blocks.entry((bx, by)).or_insert_with(|| {
|
||||
let v = fetch(br);
|
||||
if v.len() == n { v } else { vec![[0; 4]; n] }
|
||||
});
|
||||
let ov = br.intersect(&r);
|
||||
let len = ov.width() as usize;
|
||||
for y in ov.y0..ov.y1 {
|
||||
let src = (y - br.y0) as usize * br.width() as usize + (ov.x0 - br.x0) as usize;
|
||||
let dst = (y - r.y0) as usize * w + (ov.x0 - r.x0) as usize;
|
||||
if let (Some(s), Some(d)) = (block.get(src..src + len), out.get_mut(dst..dst + len)) {
|
||||
d.copy_from_slice(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Edge features of the pixels in an area.
|
||||
struct Field {
|
||||
area: Rect,
|
||||
w: usize,
|
||||
/// Edge strength: the steepest channel's Sobel gradient, scaled so a sharp step of height `h`
|
||||
/// reads `h` (capped at 1).
|
||||
strength: Vec<f32>,
|
||||
/// Unit gradient of that channel (zero on flat pixels).
|
||||
grad: Vec<[f32; 2]>,
|
||||
/// The pixel is the strongest across its edge (non-maximum suppression).
|
||||
ridge: Vec<bool>,
|
||||
/// From the pixel centre to the edge's sub-pixel centre, along the gradient.
|
||||
offset: Vec<[f32; 2]>,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
/// Features over `area` from the straight RGBA8 pixels `px` of `ext` (the area plus up to two
|
||||
/// pixels around it, inside the image; beyond it the edge pixels repeat).
|
||||
fn new(area: Rect, ext: Rect, px: &[[u8; 4]]) -> Field {
|
||||
let (ew, eh) = (ext.width() as i32, ext.height() as i32);
|
||||
// Premultiplied channels: an opaque shape on transparency has an edge, transparent
|
||||
// pixels of different colours do not.
|
||||
let ch: Vec<[f32; 4]> = px
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let a = f32::from(p[3]) / 255.0;
|
||||
[f32::from(p[0]) / 255.0 * a, f32::from(p[1]) / 255.0 * a, f32::from(p[2]) / 255.0 * a, a]
|
||||
})
|
||||
.collect();
|
||||
let at = |x: i32, y: i32| -> [f32; 4] {
|
||||
let (x, y) = ((x - ext.x0).clamp(0, (ew - 1).max(0)), (y - ext.y0).clamp(0, (eh - 1).max(0)));
|
||||
ch.get(y as usize * ew.max(0) as usize + x as usize).copied().unwrap_or_default()
|
||||
};
|
||||
// Gradients over the area plus one ring (non-maximum suppression looks across it).
|
||||
let g1 = area.inflate(1).intersect(&ext);
|
||||
let gw = g1.width() as usize;
|
||||
let mut st = vec![0.0f32; gw * g1.height() as usize];
|
||||
let mut gd = vec![[0.0f32; 2]; st.len()];
|
||||
for y in g1.y0..g1.y1 {
|
||||
for x in g1.x0..g1.x1 {
|
||||
let (nw, n, ne) = (at(x - 1, y - 1), at(x, y - 1), at(x + 1, y - 1));
|
||||
let (w, e) = (at(x - 1, y), at(x + 1, y));
|
||||
let (sw, s, se) = (at(x - 1, y + 1), at(x, y + 1), at(x + 1, y + 1));
|
||||
let mut best = (0.0f32, [0.0f32; 2]);
|
||||
for c in 0..4 {
|
||||
let gx = (ne[c] + 2.0 * e[c] + se[c]) - (nw[c] + 2.0 * w[c] + sw[c]);
|
||||
let gy = (sw[c] + 2.0 * s[c] + se[c]) - (nw[c] + 2.0 * n[c] + ne[c]);
|
||||
let m2 = gx * gx + gy * gy;
|
||||
if m2 > best.0 {
|
||||
best = (m2, [gx, gy]);
|
||||
}
|
||||
}
|
||||
let mag = best.0.sqrt();
|
||||
let i = (y - g1.y0) as usize * gw + (x - g1.x0) as usize;
|
||||
if let (Some(sv), Some(gv)) = (st.get_mut(i), gd.get_mut(i)) {
|
||||
*sv = (mag / 4.0).min(1.0);
|
||||
*gv = if mag > 1e-6 { [best.1[0] / mag, best.1[1] / mag] } else { [0.0; 2] };
|
||||
}
|
||||
}
|
||||
}
|
||||
let s_at =
|
||||
|x: i32, y: i32| -> f32 { if g1.contains(x, y) { st.get((y - g1.y0) as usize * gw + (x - g1.x0) as usize).copied().unwrap_or(0.0) } else { 0.0 } };
|
||||
let w = area.width() as usize;
|
||||
let n = w * area.height() as usize;
|
||||
let mut f = Field { area, w, strength: vec![0.0; n], grad: vec![[0.0; 2]; n], ridge: vec![false; n], offset: vec![[0.0; 2]; n] };
|
||||
for y in area.y0..area.y1 {
|
||||
for x in area.x0..area.x1 {
|
||||
let s0 = s_at(x, y);
|
||||
let g = if g1.contains(x, y) { gd.get((y - g1.y0) as usize * gw + (x - g1.x0) as usize).copied().unwrap_or_default() } else { [0.0; 2] };
|
||||
let i = (y - area.y0) as usize * w + (x - area.x0) as usize;
|
||||
if let Some(v) = f.strength.get_mut(i) {
|
||||
*v = s0;
|
||||
}
|
||||
if let Some(v) = f.grad.get_mut(i) {
|
||||
*v = g;
|
||||
}
|
||||
if g == [0.0; 2] {
|
||||
continue;
|
||||
}
|
||||
// The neighbours across the edge: the gradient rounded to one of 8 directions.
|
||||
let q = |v: f32, o: f32| if v.abs() >= 0.4142 * o.abs() { v.signum() as i32 } else { 0 };
|
||||
let (dx, dy) = (q(g[0], g[1]), q(g[1], g[0]));
|
||||
let (sm, sp) = (s_at(x - dx, y - dy), s_at(x + dx, y + dy));
|
||||
if s0 > 0.0 && s0 >= sm && s0 >= sp {
|
||||
if let Some(v) = f.ridge.get_mut(i) {
|
||||
*v = true;
|
||||
}
|
||||
// The peak of the parabola through the three strengths.
|
||||
let denom = sm - 2.0 * s0 + sp;
|
||||
let t = if denom < -1e-6 { (0.5 * (sm - sp) / denom).clamp(-0.5, 0.5) } else { 0.0 };
|
||||
if let Some(v) = f.offset.get_mut(i) {
|
||||
*v = [t * dx as f32, t * dy as f32];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
fn h(&self) -> usize {
|
||||
self.strength.len() / self.w.max(1)
|
||||
}
|
||||
|
||||
fn index(&self, x: i32, y: i32) -> Option<usize> {
|
||||
self.area.contains(x, y).then(|| (y - self.area.y0) as usize * self.w + (x - self.area.x0) as usize)
|
||||
}
|
||||
|
||||
/// Where the border runs through pixel `i` at (`x`, `y`): the edge's sub-pixel centre on an edge
|
||||
/// at least `contrast` strong, else the pixel centre.
|
||||
fn point(&self, i: usize, x: i32, y: i32, contrast: f32) -> [f64; 2] {
|
||||
let on_edge = self.ridge.get(i).copied().unwrap_or(false) && self.strength.get(i).copied().unwrap_or(0.0) >= contrast;
|
||||
let o = if on_edge { self.offset.get(i).copied().unwrap_or_default() } else { [0.0; 2] };
|
||||
[x as f64 + 0.5 + f64::from(o[0]), y as f64 + 0.5 + f64::from(o[1])]
|
||||
}
|
||||
|
||||
fn point_at(&self, i: usize, contrast: f32) -> [f64; 2] {
|
||||
let (x, y) = ((i % self.w.max(1)) as i32 + self.area.x0, (i / self.w.max(1)) as i32 + self.area.y0);
|
||||
self.point(i, x, y, contrast)
|
||||
}
|
||||
}
|
||||
|
||||
/// Squared distance from each pixel centre of `area` to the polyline `guide`, infinite beyond
|
||||
/// `rad`: the corridor the border may use.
|
||||
fn corridor(area: Rect, guide: &[[f64; 2]], rad: f64) -> Vec<f32> {
|
||||
let w = area.width() as usize;
|
||||
let mut near = vec![f32::INFINITY; w * area.height() as usize];
|
||||
let r2 = rad * rad;
|
||||
let single;
|
||||
let segs: &[[f64; 2]] = match guide {
|
||||
[p] => {
|
||||
single = [*p, *p];
|
||||
&single
|
||||
}
|
||||
_ => guide,
|
||||
};
|
||||
for s in segs.windows(2) {
|
||||
let (a, b) = (s[0], s[1]);
|
||||
let lo = |u: f64, v: f64| (u.min(v) - rad).floor() as i32;
|
||||
let hi = |u: f64, v: f64| (u.max(v) + rad).ceil() as i32 + 1;
|
||||
let r = Rect::new(lo(a[0], b[0]), lo(a[1], b[1]), hi(a[0], b[0]), hi(a[1], b[1])).intersect(&area);
|
||||
for y in r.y0..r.y1 {
|
||||
for x in r.x0..r.x1 {
|
||||
let d2 = dist2_segment([x as f64 + 0.5, y as f64 + 0.5], a, b);
|
||||
if d2 <= r2
|
||||
&& let Some(v) = near.get_mut((y - area.y0) as usize * w + (x - area.x0) as usize)
|
||||
{
|
||||
*v = v.min(d2 as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
near
|
||||
}
|
||||
|
||||
/// The cheapest 8-connected path from `start` to `goal` through the corridor `near` (squared
|
||||
/// distances to the guide, `rad2` at its rim), as field indices from `start` to `goal`.
|
||||
fn search(f: &Field, near: &[f32], rad2: f32, start: usize, goal: usize, contrast: f32) -> Option<Vec<usize>> {
|
||||
let (w, h) = (f.w, f.h());
|
||||
let n = w * h;
|
||||
// Cost of entering each pixel, per pixel of length, and its edge tangent.
|
||||
let mut node = vec![f32::INFINITY; n];
|
||||
let mut tangent = vec![[0.0f32; 2]; n];
|
||||
for i in 0..n {
|
||||
let d2 = near.get(i).copied().unwrap_or(f32::INFINITY);
|
||||
if !d2.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let s = f.strength.get(i).copied().unwrap_or(0.0);
|
||||
let on_edge = s >= contrast && f.ridge.get(i).copied().unwrap_or(false);
|
||||
// No pull below the contrast, full pull from twice the contrast, a little more for
|
||||
// stronger edges still.
|
||||
let good = ((s - contrast) / contrast.max(0.02)).clamp(0.0, 1.0) * (0.6 + 0.4 * s.min(1.0));
|
||||
// Grows linearly away from the pointer's path, so the border hugs it where there is no edge.
|
||||
let pull = (d2 / rad2.max(1.0)).sqrt().min(1.0);
|
||||
let centre = if on_edge { 0.0 } else { 1.0 };
|
||||
if let Some(v) = node.get_mut(i) {
|
||||
*v = W_CENTRE * centre + W_STRENGTH * (1.0 - good) + W_LENGTH + W_GUIDE * pull;
|
||||
}
|
||||
if s >= 0.5 * contrast
|
||||
&& let (Some(t), Some(g)) = (tangent.get_mut(i), f.grad.get(i))
|
||||
{
|
||||
*t = [g[1], -g[0]];
|
||||
}
|
||||
}
|
||||
let mut dist = vec![f32::INFINITY; n];
|
||||
let mut from = vec![u8::MAX; n];
|
||||
*dist.get_mut(start)? = 0.0;
|
||||
let mut heap = BinaryHeap::new();
|
||||
// Costs are finite and non-negative, so their bit patterns sort like the values.
|
||||
heap.push(Reverse((0.0f32.to_bits(), start)));
|
||||
while let Some(Reverse((bits, i))) = heap.pop() {
|
||||
let d = f32::from_bits(bits);
|
||||
if d > dist.get(i).copied().unwrap_or(f32::INFINITY) {
|
||||
continue;
|
||||
}
|
||||
if i == goal {
|
||||
break;
|
||||
}
|
||||
let (x, y) = ((i % w) as i32, (i / w) as i32);
|
||||
let tp = tangent.get(i).copied().unwrap_or_default();
|
||||
for (k, &(dx, dy)) in DIRS.iter().enumerate() {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
|
||||
continue;
|
||||
}
|
||||
let j = ny as usize * w + nx as usize;
|
||||
let cost = node.get(j).copied().unwrap_or(f32::INFINITY);
|
||||
if !cost.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let len = if dx != 0 && dy != 0 { SQRT_2 } else { 1.0 };
|
||||
let tq = tangent.get(j).copied().unwrap_or_default();
|
||||
let nd = d + cost * len + W_DIRECTION * direction_cost(tp, tq, dx, dy, len);
|
||||
if let (Some(dj), Some(fj)) = (dist.get_mut(j), from.get_mut(j))
|
||||
&& nd < *dj
|
||||
{
|
||||
*dj = nd;
|
||||
*fj = k as u8;
|
||||
heap.push(Reverse((nd.to_bits(), j)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !dist.get(goal).is_some_and(|d| d.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let mut cells = vec![goal];
|
||||
let mut cur = goal;
|
||||
while cur != start && cells.len() <= n {
|
||||
let &(dx, dy) = DIRS.get(usize::from(*from.get(cur)?))?;
|
||||
let (x, y) = ((cur % w) as i32 - dx, (cur / w) as i32 - dy);
|
||||
if x < 0 || y < 0 {
|
||||
return None;
|
||||
}
|
||||
cur = y as usize * w + x as usize;
|
||||
cells.push(cur);
|
||||
}
|
||||
cells.reverse();
|
||||
Some(cells)
|
||||
}
|
||||
|
||||
/// The gradient-direction term of a link from a pixel with edge tangent `tp` to its neighbour at
|
||||
/// (`dx`, `dy`) with tangent `tq`: 0 running straight along an edge, up to 1 cutting across it or
|
||||
/// turning sharply (Mortensen & Barrett's f_D).
|
||||
fn direction_cost(tp: [f32; 2], tq: [f32; 2], dx: i32, dy: i32, len: f32) -> f32 {
|
||||
if tp == [0.0; 2] || tq == [0.0; 2] {
|
||||
return NEUTRAL_DIRECTION;
|
||||
}
|
||||
let mut l = [dx as f32 / len, dy as f32 / len];
|
||||
if tp[0] * l[0] + tp[1] * l[1] < 0.0 {
|
||||
l = [-l[0], -l[1]];
|
||||
}
|
||||
let dp = (tp[0] * l[0] + tp[1] * l[1]).clamp(-1.0, 1.0);
|
||||
let dq = (l[0] * tq[0] + l[1] * tq[1]).clamp(-1.0, 1.0);
|
||||
2.0 / (3.0 * PI) * (dp.acos() + dq.acos())
|
||||
}
|
||||
|
||||
/// The polyline `g` cut into pieces at most `piece` long; each piece starts where the last ended.
|
||||
fn split(g: &[[f64; 2]], piece: f64) -> Vec<Vec<[f64; 2]>> {
|
||||
let Some(&first) = g.first() else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
let mut cur = vec![first];
|
||||
let mut acc = 0.0;
|
||||
for w in g.windows(2) {
|
||||
let (mut a, b) = (w[0], w[1]);
|
||||
let mut seg = dist(a, b);
|
||||
while acc + seg > piece && seg > 0.0 {
|
||||
let t = ((piece - acc) / seg).clamp(0.0, 1.0);
|
||||
let m = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||
cur.push(m);
|
||||
out.push(std::mem::replace(&mut cur, vec![m]));
|
||||
a = m;
|
||||
seg = dist(a, b);
|
||||
acc = 0.0;
|
||||
}
|
||||
cur.push(b);
|
||||
acc += seg;
|
||||
}
|
||||
out.push(cur);
|
||||
out
|
||||
}
|
||||
|
||||
/// Ramer–Douglas–Peucker: drops points within `tol` of the line through their neighbours that
|
||||
/// stay, keeping both ends.
|
||||
pub fn simplify(p: &[[f64; 2]], tol: f64) -> Vec<[f64; 2]> {
|
||||
let n = p.len();
|
||||
if n < 3 {
|
||||
return p.to_vec();
|
||||
}
|
||||
let mut keep = vec![false; n];
|
||||
keep[0] = true;
|
||||
keep[n - 1] = true;
|
||||
let tol2 = tol * tol;
|
||||
let mut stack = vec![(0usize, n - 1)];
|
||||
while let Some((a, b)) = stack.pop() {
|
||||
if b <= a + 1 {
|
||||
continue;
|
||||
}
|
||||
let (Some(&pa), Some(&pb)) = (p.get(a), p.get(b)) else { continue };
|
||||
let mut far = (0.0, a);
|
||||
for (k, q) in p.iter().enumerate().take(b).skip(a + 1) {
|
||||
let d2 = dist2_segment(*q, pa, pb);
|
||||
if d2 > far.0 {
|
||||
far = (d2, k);
|
||||
}
|
||||
}
|
||||
if far.0 > tol2 {
|
||||
if let Some(k) = keep.get_mut(far.1) {
|
||||
*k = true;
|
||||
}
|
||||
stack.push((a, far.1));
|
||||
stack.push((far.1, b));
|
||||
}
|
||||
}
|
||||
p.iter().zip(&keep).filter(|(_, k)| **k).map(|(q, _)| *q).collect()
|
||||
}
|
||||
|
||||
/// Length of a polyline.
|
||||
pub fn length(p: &[[f64; 2]]) -> f64 {
|
||||
p.windows(2).map(|w| dist(w[0], w[1])).sum()
|
||||
}
|
||||
|
||||
fn dist(a: [f64; 2], b: [f64; 2]) -> f64 {
|
||||
(a[0] - b[0]).hypot(a[1] - b[1])
|
||||
}
|
||||
|
||||
fn dist2_segment(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
|
||||
let (vx, vy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let l2 = vx * vx + vy * vy;
|
||||
let t = if l2 > 0.0 { (((p[0] - a[0]) * vx + (p[1] - a[1]) * vy) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let (dx, dy) = (p[0] - (a[0] + t * vx), p[1] - (a[1] + t * vy));
|
||||
dx * dx + dy * dy
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
use std::cell::Cell;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A procedural image as a fetch that counts the pixels it reads.
|
||||
fn image<'a>(f: impl Fn(i32, i32) -> [u8; 4] + 'a, read: &'a Cell<usize>) -> impl FnMut(Rect) -> Vec<[u8; 4]> + 'a {
|
||||
move |r: Rect| {
|
||||
read.set(read.get() + r.width() as usize * r.height() as usize);
|
||||
(r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| (x, y))).map(|(x, y)| f(x, y)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn grey(v: u8) -> [u8; 4] {
|
||||
[v, v, v, 255]
|
||||
}
|
||||
|
||||
/// Distance from `p` to the polyline `g`.
|
||||
fn off_polyline(p: [f64; 2], g: &[[f64; 2]]) -> f64 {
|
||||
g.windows(2).map(|w| dist2_segment(p, w[0], w[1]).sqrt()).fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snaps_to_the_edge_centre_within_the_width() {
|
||||
let read = Cell::new(0);
|
||||
// Black left of x = 20, white from it.
|
||||
let mut f = image(|x, _| grey(if x < 20 { 0 } else { 255 }), &read);
|
||||
let mut t = Tracer::new(Rect::new(0, 0, 64, 32));
|
||||
let p = t.snap(&mut f, [14.3, 10.5], Settings::new(10.0, 0.1)).unwrap();
|
||||
assert!((p[0] - 20.0).abs() < 0.01 && (p[1] - 10.5).abs() < 0.01, "on the step, not half a pixel off: {p:?}");
|
||||
// From the other side too.
|
||||
let p = t.snap(&mut f, [27.0, 3.2], Settings::new(10.0, 0.1)).unwrap();
|
||||
assert!((p[0] - 20.0).abs() < 0.01, "{p:?}");
|
||||
// Out of reach: the point stays where it is.
|
||||
assert_eq!(t.snap(&mut f, [14.3, 10.5], Settings::new(3.0, 0.1)), Some([14.3, 10.5]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contrast_sets_which_steps_are_edges() {
|
||||
let read = Cell::new(0);
|
||||
// A 5% step.
|
||||
let mut f = image(|x, _| grey(if x < 20 { 100 } else { 113 }), &read);
|
||||
let mut t = Tracer::new(Rect::new(0, 0, 64, 32));
|
||||
assert_eq!(t.snap(&mut f, [14.3, 10.5], Settings::new(10.0, 0.10)), Some([14.3, 10.5]), "too faint at 10%");
|
||||
let p = t.snap(&mut f, [14.3, 10.5], Settings::new(10.0, 0.03)).unwrap();
|
||||
assert!((p[0] - 20.0).abs() < 0.01, "an edge at 3%: {p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snaps_to_the_most_prominent_edge() {
|
||||
let read = Cell::new(0);
|
||||
// A faint step at x = 12 and a strong one at x = 18.
|
||||
let mut f = image(|x, _| grey([0, 40, 255][usize::from(x >= 12) + usize::from(x >= 18)]), &read);
|
||||
let mut t = Tracer::new(Rect::new(0, 0, 40, 20));
|
||||
let p = t.snap(&mut f, [13.0, 10.0], Settings::new(10.0, 0.1)).unwrap();
|
||||
assert!((p[0] - 18.0).abs() < 0.01, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_follows_a_curved_edge() {
|
||||
let read = Cell::new(0);
|
||||
// A white disc of radius 30 at (50, 50) on black.
|
||||
let mut f = image(|x, y| grey(if (f64::from(x) + 0.5 - 50.0).hypot(f64::from(y) + 0.5 - 50.0) < 30.0 { 255 } else { 0 }), &read);
|
||||
let mut t = Tracer::new(Rect::new(0, 0, 100, 100));
|
||||
let s = Settings::default();
|
||||
let a = t.snap(&mut f, [50.0, 21.0], s).unwrap();
|
||||
let b = t.snap(&mut f, [79.0, 50.0], s).unwrap();
|
||||
// The pointer went straight from one to the other; the border bows out along the disc, ~9 px
|
||||
// from the chord at its middle.
|
||||
let path = t.trace(&mut f, a, b, &[], s);
|
||||
assert!(path.len() > 4, "{path:?}");
|
||||
assert_eq!((path[0], path[path.len() - 1]), (a, b));
|
||||
for p in &path {
|
||||
let r = (p[0] - 50.0).hypot(p[1] - 50.0);
|
||||
assert!((r - 30.0).abs() < 1.0, "{p:?} is {r:.2} from the centre: {path:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_edges_the_border_follows_the_pointer() {
|
||||
let read = Cell::new(0);
|
||||
let mut f = image(|_, _| grey(128), &read);
|
||||
let mut t = Tracer::new(Rect::new(0, 0, 120, 80));
|
||||
let s = Settings::default();
|
||||
// A gentle curve: the border runs along it.
|
||||
let (a, b) = ([10.0, 40.0], [110.0, 40.0]);
|
||||
let guide: Vec<[f64; 2]> = (1..10).map(|i| [10.0 + f64::from(i) * 10.0, 40.0 - 15.0 * (f64::from(i) * std::f64::consts::PI / 10.0).sin()]).collect();
|
||||
let g: Vec<[f64; 2]> = std::iter::once(a).chain(guide.iter().copied()).chain([b]).collect();
|
||||
let path = t.trace(&mut f, a, b, &guide, s);
|
||||
for p in &path {
|
||||
assert!(off_polyline(*p, &g) < 2.0, "{p:?} strays from the pointer: {path:?}");
|
||||
}
|
||||
assert!(off_polyline([60.0, 25.0], &path) < 2.0, "the border went where the pointer went: {path:?}");
|
||||
// A sharp turn: the border may round it off, but never leaves the detection width.
|
||||
let guide = [[30.0, 70.0]];
|
||||
let path = t.trace(&mut f, [10.0, 10.0], [100.0, 75.0], &guide, s);
|
||||
for p in &path {
|
||||
assert!(off_polyline(*p, &[[10.0, 10.0], [30.0, 70.0], [100.0, 75.0]]) <= s.width, "{p:?}: {path:?}");
|
||||
}
|
||||
// A straight pointer path over nothing is one straight segment.
|
||||
assert_eq!(t.trace(&mut f, [10.0, 10.0], [110.0, 73.0], &[], s), vec![[10.0, 10.0], [110.0, 73.0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_corridor_keeps_the_border_off_edges_the_pointer_never_went_near() {
|
||||
let read = Cell::new(0);
|
||||
// A modest step at y = 20, under the pointer, and a much stronger one at y = 40.
|
||||
let mut f = image(|_, y| grey([60, 100, 255][usize::from(y >= 20) + usize::from(y >= 40)]), &read);
|
||||
let mut t = Tracer::new(Rect::new(0, 0, 120, 60));
|
||||
let path = t.trace(&mut f, [10.0, 22.0], [110.0, 18.0], &[[60.0, 23.0]], Settings::new(10.0, 0.1));
|
||||
for p in &path {
|
||||
assert!((p[1] - 20.0).abs() <= 2.0 + 1e-9, "{p:?} left the edge under the pointer: {path:?}");
|
||||
}
|
||||
// Away from the ends it runs on the step itself.
|
||||
assert!(path.iter().filter(|p| p[0] > 25.0 && p[0] < 95.0).all(|p| (p[1] - 20.0).abs() < 0.01), "{path:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_guides_are_traced_in_pieces_along_the_edge() {
|
||||
let read = Cell::new(0);
|
||||
// A step at y = 30 across a wide image; the pointer wavers 4 px either side of it.
|
||||
let mut f = image(|_, y| grey(if y < 30 { 20 } else { 200 }), &read);
|
||||
let mut t = Tracer::new(Rect::new(0, 0, 1000, 60));
|
||||
let guide: Vec<[f64; 2]> = (1..20).map(|i| [f64::from(i) * 50.0, if i % 2 == 0 { 26.0 } else { 34.0 }]).collect();
|
||||
let path = t.trace(&mut f, [5.0, 30.0], [995.0, 30.0], &guide, Settings::default());
|
||||
for p in &path {
|
||||
assert!((p[1] - 30.0).abs() < 0.01, "{p:?}");
|
||||
}
|
||||
let len = length(&path);
|
||||
assert!((len - 990.0).abs() < 1.0, "a straight border, not a zigzag: {len}");
|
||||
// Collinear points are dropped.
|
||||
assert!(path.len() < 20, "{}", path.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_only_the_pixels_around_the_border() {
|
||||
let read = Cell::new(0);
|
||||
let mut f = image(|x, _| grey(if x < 3000 { 0 } else { 255 }), &read);
|
||||
// A 24 MP image.
|
||||
let mut t = Tracer::new(Rect::new(0, 0, 6000, 4000));
|
||||
let s = Settings::default();
|
||||
let a = t.snap(&mut f, [2996.0, 2000.0], s).unwrap();
|
||||
let b = t.snap(&mut f, [3004.0, 2100.0], s).unwrap();
|
||||
let path = t.trace(&mut f, a, b, &[], s);
|
||||
assert!(path.iter().all(|p| (p[0] - 3000.0).abs() < 0.01), "{path:?}");
|
||||
let first = read.get();
|
||||
assert!(first <= 6 * 128 * 128, "read {first} px");
|
||||
assert_eq!(t.cached_pixels(), first);
|
||||
// The same area again comes from the cache.
|
||||
t.trace(&mut f, a, b, &[], s);
|
||||
assert_eq!(read.get(), first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_never_panics() {
|
||||
let read = Cell::new(0);
|
||||
let mut f = image(|x, y| grey(((x ^ y) & 255) as u8), &read);
|
||||
let s = Settings::default();
|
||||
// No image at all.
|
||||
let mut empty = Tracer::new(Rect::EMPTY);
|
||||
assert_eq!(empty.snap(&mut f, [1.0, 1.0], s), None);
|
||||
assert!(empty.trace(&mut f, [1.0, 1.0], [5.0, 5.0], &[], s).is_empty());
|
||||
let mut t = Tracer::new(Rect::new(0, 0, 50, 40));
|
||||
// Not points.
|
||||
assert_eq!(t.snap(&mut f, [f64::NAN, 1.0], s), None);
|
||||
assert!(t.trace(&mut f, [f64::INFINITY, 1.0], [5.0, 5.0], &[], s).is_empty());
|
||||
// Far outside the image: kept inside it.
|
||||
let path = t.trace(&mut f, [-1e300, -1e9], [1e9, 1e300], &[[f64::NAN, 3.0], [1e12, -5.0]], s);
|
||||
assert!(path.iter().all(|p| (0.0..=50.0).contains(&p[0]) && (0.0..=40.0).contains(&p[1])), "{path:?}");
|
||||
// Nonsense settings are clamped, whichever way they arrive.
|
||||
for s in [Settings { width: f64::NAN, contrast: f32::NAN }, Settings { width: 1e300, contrast: -4.0 }, Settings { width: -3.0, contrast: 1e30 }] {
|
||||
t.snap(&mut f, [20.0, 20.0], s).unwrap();
|
||||
assert!(!t.trace(&mut f, [2.0, 2.0], [45.0, 30.0], &[[10.0, 35.0]], s).is_empty());
|
||||
}
|
||||
// A fetch that returns the wrong amount of pixels reads as transparent.
|
||||
let mut bad = |_: Rect| vec![[255u8; 4]; 3];
|
||||
let mut t = Tracer::new(Rect::new(0, 0, 300, 300));
|
||||
assert_eq!(t.snap(&mut bad, [150.0, 150.0], s), Some([150.0, 150.0]));
|
||||
let path = t.trace(&mut bad, [10.0, 10.0], [290.0, 280.0], &[], s);
|
||||
assert_eq!((path.first(), path.last()), (Some(&[10.0, 10.0]), Some(&[290.0, 280.0])));
|
||||
// The same point twice.
|
||||
assert_eq!(t.trace(&mut bad, [7.0, 7.0], [7.0, 7.0], &[], s), vec![[7.0, 7.0], [7.0, 7.0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_and_simplify() {
|
||||
let g = [[0.0, 0.0], [600.0, 0.0], [600.0, 100.0]];
|
||||
let pieces = split(&g, 256.0);
|
||||
assert_eq!(pieces.len(), 3);
|
||||
assert_eq!(pieces[0], vec![[0.0, 0.0], [256.0, 0.0]]);
|
||||
assert_eq!(pieces[2].last(), Some(&[600.0, 100.0]));
|
||||
let total: f64 = pieces.iter().map(|p| length(p)).sum();
|
||||
assert!((total - 700.0).abs() < 1e-9);
|
||||
assert_eq!(simplify(&[[0.0, 0.0], [1.0, 0.1], [2.0, 0.0], [3.0, 5.0]], 0.3), vec![[0.0, 0.0], [2.0, 0.0], [3.0, 5.0]]);
|
||||
assert!(split(&[], 256.0).is_empty());
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
//! High-quality separable resampling of surfaces (Image Size).
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_geom::{Rect, TILE_SIZE};
|
||||
use photocraft_raster::Surface;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -68,18 +68,30 @@ impl Resample {
|
|||
struct Taps {
|
||||
start: i32,
|
||||
weights: Vec<f32>,
|
||||
/// Total weight of taps outside the source's content, which all read the default pixel.
|
||||
outside: f32,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Taps outside `content` (`[first, last]`) all read the default pixel, so they are folded into
|
||||
/// `outside` instead of being read: the read window never grows past the content, however small
|
||||
/// the scale (#713).
|
||||
fn taps(dst0: i32, dst1: i32, scale: f64, filter: Resample, edge: Option<(i32, i32)>, content: (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] };
|
||||
let i = clamp((u + 0.5).floor() as i32);
|
||||
return if (content.0..=content.1).contains(&i) {
|
||||
Taps { start: i, weights: vec![1.0], outside: 0.0 }
|
||||
} else {
|
||||
Taps { start: content.0, weights: Vec::new(), outside: 1.0 }
|
||||
};
|
||||
}
|
||||
let lo = (u - support).ceil() as i32;
|
||||
let hi = (u + support).floor() as i32;
|
||||
|
|
@ -90,7 +102,22 @@ 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;
|
||||
}
|
||||
}
|
||||
// Keep the part inside the content; the rest reads the default pixel.
|
||||
let (a, b) = (start.max(content.0), (start + folded.len() as i32 - 1).min(content.1));
|
||||
if a > b {
|
||||
return Taps { start: content.0, weights: Vec::new(), outside: folded.iter().sum() };
|
||||
}
|
||||
let (i0, i1) = ((a - start) as usize, (b - start) as usize + 1);
|
||||
let outside = folded.get(..i0).unwrap_or(&[]).iter().chain(folded.get(i1..).unwrap_or(&[])).sum();
|
||||
Taps { start: a, weights: folded.get(i0..i1).unwrap_or(&[]).to_vec(), outside }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -106,6 +133,26 @@ 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))
|
||||
}
|
||||
|
||||
/// Samples one source read may hold (64 MB of `f32`); larger windows are read in row chunks.
|
||||
const READ_BUDGET: usize = 1 << 24;
|
||||
|
||||
fn resize(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Option<Rect>) -> Surface {
|
||||
resize_with_budget(s, sx, sy, filter, edge, READ_BUDGET)
|
||||
}
|
||||
|
||||
fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Option<Rect>, budget: usize) -> Surface {
|
||||
let fmt = s.format();
|
||||
let mut out = Surface::with_default(fmt, &s.default_pixel());
|
||||
let src = s.content_bounds();
|
||||
|
|
@ -115,47 +162,101 @@ 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 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);
|
||||
// With `edge`, every tap is clamped into the edge area, which holds the content.
|
||||
let area = edge.unwrap_or(src);
|
||||
let hx = taps(dst.x0, dst.x1, sx, filter, edge.map(|r| (r.x0, r.x1 - 1)), (area.x0, area.x1 - 1));
|
||||
let vy = taps(dst.y0, dst.y1, sy, filter, edge.map(|r| (r.y0, r.y1 - 1)), (area.y0, area.y1 - 1));
|
||||
let x_lo = hx.iter().filter(|t| !t.weights.is_empty()).map(|t| t.start).min().unwrap_or(area.x0);
|
||||
let x_hi = hx.iter().map(|t| t.start + t.weights.len() as i32).max().unwrap_or(area.x0).max(x_lo);
|
||||
let mut def = s.default_pixel();
|
||||
premultiply(&mut def, n, alpha);
|
||||
let dw = dst.width() as usize;
|
||||
const BAND: usize = 64;
|
||||
let bands: Vec<(usize, usize)> = (0..vy.len()).step_by(BAND).map(|b| (b, (b + BAND).min(vy.len()))).collect();
|
||||
let run = |&(b0, b1): &(usize, usize)| -> (Rect, Vec<f32>) {
|
||||
let y_lo = vy[b0..b1].iter().map(|t| t.start).min().unwrap_or(0);
|
||||
let y_hi = vy[b0..b1].iter().map(|t| t.start + t.weights.len() as i32).max().unwrap_or(0);
|
||||
let read = Rect::new(x_lo, y_lo, x_hi, y_hi);
|
||||
let mut rows = s.read_region(read);
|
||||
premultiply(&mut rows, n, alpha);
|
||||
let rw = read.width() as usize;
|
||||
// Horizontal pass: every source row in the band → dw samples.
|
||||
let nrows = read.height() as usize;
|
||||
let mut tmp = vec![0.0f32; nrows * dw * n];
|
||||
for r in 0..nrows {
|
||||
let row = &rows[r * rw * n..(r + 1) * rw * n];
|
||||
for (ox, t) in hx.iter().enumerate() {
|
||||
let d = &mut tmp[(r * dw + ox) * n..(r * dw + ox + 1) * n];
|
||||
for (k, w) in t.weights.iter().enumerate() {
|
||||
let sx_ = (t.start + k as i32 - x_lo) as usize;
|
||||
let sp = &row[sx_ * n..(sx_ + 1) * n];
|
||||
// Horizontal pass of one source row (`row` spans `x_lo..x_hi`) into `dw` samples.
|
||||
let horizontal = |row: &[f32], out: &mut [f32]| {
|
||||
for (t, d) in hx.iter().zip(out.chunks_exact_mut(n)) {
|
||||
for (k, w) in t.weights.iter().enumerate() {
|
||||
let sx_ = (t.start + k as i32 - x_lo) as usize;
|
||||
if let Some(sp) = row.get(sx_ * n..(sx_ + 1) * n) {
|
||||
for c in 0..n {
|
||||
d[c] += sp[c] * w;
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in 0..n {
|
||||
d[c] += def[c] * t.outside;
|
||||
}
|
||||
}
|
||||
// Vertical pass.
|
||||
let mut res = vec![0.0f32; (b1 - b0) * dw * n];
|
||||
for (oy, t) in vy[b0..b1].iter().enumerate() {
|
||||
let d = &mut res[oy * dw * n..(oy + 1) * dw * n];
|
||||
for (k, w) in t.weights.iter().enumerate() {
|
||||
let r = (t.start + k as i32 - y_lo) as usize;
|
||||
let sp = &tmp[r * dw * n..(r + 1) * dw * n];
|
||||
for (dv, sv) in d.iter_mut().zip(sp) {
|
||||
*dv += sv * w;
|
||||
};
|
||||
// A source row of nothing but the default pixel, after the horizontal pass.
|
||||
let def_row = {
|
||||
let row: Vec<f32> = def.iter().copied().cycle().take((x_hi - x_lo) as usize * n).collect();
|
||||
let mut out = vec![0.0f32; dw * n];
|
||||
horizontal(&row, &mut out);
|
||||
out
|
||||
};
|
||||
// At most one tile row per read anyway, so this always fits an `i32`.
|
||||
let rows_per_read = (budget / ((x_hi - x_lo).max(1) as usize * n).max(dw * n)).clamp(1, TILE_SIZE as usize) as i32;
|
||||
let run = |&(b0, b1): &(usize, usize)| -> (Rect, Vec<f32>) {
|
||||
let band = vy.get(b0..b1).unwrap_or(&[]);
|
||||
let y_lo = band.iter().filter(|t| !t.weights.is_empty()).map(|t| t.start).min().unwrap_or(area.y0);
|
||||
let y_hi = band.iter().map(|t| t.start + t.weights.len() as i32).max().unwrap_or(area.y0).max(y_lo);
|
||||
let mut res = vec![0.0f32; band.len() * dw * n];
|
||||
// Source rows stream through in bounded reads and go straight into the output rows that
|
||||
// use them, so memory is the read budget plus this band's output, whatever the scale or
|
||||
// the source size (#713). In ascending row order, as one read of the whole window would.
|
||||
let add = |res: &mut [f32], y: i32, row: &[f32]| {
|
||||
for (t, d) in band.iter().zip(res.chunks_exact_mut(dw * n)) {
|
||||
if let Some(w) = t.weights.get((y - t.start) as usize).filter(|_| y >= t.start) {
|
||||
for (dv, sv) in d.iter_mut().zip(row) {
|
||||
*dv += sv * w;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
let mut hrows = Vec::new();
|
||||
let mut y = y_lo;
|
||||
while y < y_hi {
|
||||
let y1 = ((y.div_euclid(TILE_SIZE) + 1) * TILE_SIZE).min(y_hi);
|
||||
if s.has_tiles_in(Rect::new(x_lo, y, x_hi, y1)) {
|
||||
let mut c0 = y;
|
||||
while c0 < y1 {
|
||||
let c1 = (c0 + rows_per_read).min(y1);
|
||||
let read = Rect::new(x_lo, c0, x_hi, c1);
|
||||
s.read_region_into(read, &mut rows);
|
||||
premultiply(&mut rows, n, alpha);
|
||||
hrows.clear();
|
||||
hrows.resize((c1 - c0) as usize * dw * n, 0.0);
|
||||
let rw = read.width() as usize * n;
|
||||
for (row, out) in rows.chunks_exact(rw.max(1)).zip(hrows.chunks_exact_mut(dw * n)) {
|
||||
horizontal(row, out);
|
||||
}
|
||||
for (r, h) in (c0..c1).zip(hrows.chunks_exact(dw * n)) {
|
||||
add(&mut res, r, h);
|
||||
}
|
||||
c0 = c1;
|
||||
}
|
||||
} else {
|
||||
// Nothing stored in this tile row: every row in it is the default row, so each
|
||||
// output row takes it once, with the summed weight of those rows.
|
||||
for (t, d) in band.iter().zip(res.chunks_exact_mut(dw * n)) {
|
||||
let (a, b) = ((y - t.start).max(0) as usize, (y1 - t.start).max(0) as usize);
|
||||
let w: f32 = t.weights.get(a.min(t.weights.len())..b.min(t.weights.len())).unwrap_or(&[]).iter().sum();
|
||||
if w != 0.0 {
|
||||
for (dv, sv) in d.iter_mut().zip(&def_row) {
|
||||
*dv += sv * w;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
y = y1;
|
||||
}
|
||||
for (t, d) in band.iter().zip(res.chunks_exact_mut(dw * n)) {
|
||||
for (dv, sv) in d.iter_mut().zip(&def_row) {
|
||||
*dv += sv * t.outside;
|
||||
}
|
||||
}
|
||||
if alpha {
|
||||
for px in res.chunks_exact_mut(n) {
|
||||
|
|
@ -268,6 +369,103 @@ 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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Two tiles in opposite corners of a 100000 px square: the content bounds are huge but
|
||||
/// almost nothing is stored. Shrinking it to one pixel must neither allocate a window
|
||||
/// proportional to the scale (#713) nor read the empty middle.
|
||||
#[test]
|
||||
fn tiny_target_of_a_huge_sparse_source() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA32F);
|
||||
s.fill_rect(Rect::new(0, 0, 256, 256), &[1.0, 1.0, 1.0, 1.0]);
|
||||
s.fill_rect(Rect::new(99_840, 99_840, 100_096, 100_096), &[1.0, 1.0, 1.0, 1.0]);
|
||||
let k = 1.0 / 100_096.0;
|
||||
let o = resize_surface(&s, k, k, Resample::Bicubic);
|
||||
assert_eq!(o.content_bounds(), Rect::new(0, 0, 1, 1));
|
||||
let p = o.pixel(0, 0);
|
||||
// Two 256² tiles out of 100096²: nearly transparent, and white where it isn't.
|
||||
assert!(p[3] >= 0.0 && p[3] < 1e-3 && p.iter().all(|v| v.is_finite()), "{p:?}");
|
||||
// Squashed to one row but kept wide: the whole height feeds one band of output rows,
|
||||
// which must not hold a full-height intermediate.
|
||||
let o = resize_surface(&s, 1000.0 / 100_096.0, k, Resample::Bicubic);
|
||||
let b = o.content_bounds();
|
||||
assert!(b.height() == 1 && (1000..=1001).contains(&b.width()), "{b:?}");
|
||||
assert!(o.pixel(0, 0)[3] > 0.0 && o.pixel(500, 0)[3] == 0.0, "{:?} {:?}", o.pixel(0, 0), o.pixel(500, 0));
|
||||
}
|
||||
|
||||
/// Tile rows with nothing stored are not read; they count as rows of the default pixel,
|
||||
/// exactly as reading them would (and within rounding for a non-zero default).
|
||||
#[test]
|
||||
fn skipped_empty_rows_match_reading_them() {
|
||||
for (fmt, def, tol) in [(PixelFormat::RGBA32F, vec![0.0, 0.0, 0.0, 0.0], 0.0), (PixelFormat::GRAY8, vec![1.0], 1e-6)] {
|
||||
let mut sparse = Surface::with_default(fmt, &def);
|
||||
sparse.fill_rect(Rect::new(0, 0, 300, 40), &vec![0.5; def.len()]);
|
||||
sparse.fill_rect(Rect::new(100, 1500, 400, 1540), &vec![0.25; def.len()]);
|
||||
// The same pixels with the empty rows stored as tiles of the default pixel.
|
||||
let mut dense = sparse.clone();
|
||||
dense.fill_rect(Rect::new(0, 40, 400, 1500), &def);
|
||||
assert!(dense.tile_count() > sparse.tile_count());
|
||||
for f in [Resample::Bilinear, Resample::Bicubic, Resample::Lanczos] {
|
||||
for (kx, ky) in [(0.013, 0.013), (0.37, 0.37), (1.0, 0.01)] {
|
||||
let (a, b) = (resize_surface(&sparse, kx, ky, f), resize_surface(&dense, kx, ky, f));
|
||||
let r = a.content_bounds().union(&b.content_bounds());
|
||||
let d = a.read_region(r).iter().zip(b.read_region(r)).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max);
|
||||
assert!(d <= tol, "{fmt:?} {f:?} {kx} {ky}: {d}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reading the source in row chunks gives exactly what one read of the whole window gives.
|
||||
#[test]
|
||||
fn chunked_reads_match_a_single_read() {
|
||||
let mut s = ramp(SampleType::F32);
|
||||
s.fill_rect(Rect::new(600, 700, 640, 720), &[0.3, 0.9, 0.1, 0.5]);
|
||||
let mut mask = Surface::with_default(PixelFormat::GRAY8, &[1.0]);
|
||||
mask.fill_rect(Rect::new(0, 0, 40, 30), &[0.0]);
|
||||
mask.fill_rect(Rect::new(500, 900, 530, 940), &[0.25]);
|
||||
for src in [&s, &mask] {
|
||||
for f in [Resample::Nearest, Resample::Bilinear, Resample::Bicubic, Resample::Lanczos] {
|
||||
for k in [0.013, 0.37, 1.6] {
|
||||
let whole = resize_with_budget(src, k, k, f, None, usize::MAX);
|
||||
let chunked = resize_with_budget(src, k, k, f, None, 1);
|
||||
let r = whole.content_bounds().union(&chunked.content_bounds());
|
||||
assert_eq!(whole.read_region(r), chunked.read_region(r), "{f:?} {k}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_keep_default_pixel() {
|
||||
let fmt = PixelFormat::GRAY8;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -34,7 +34,9 @@ fn run(s: &Surface, p: &FilterParams) -> Surface {
|
|||
fn all_filters() -> Vec<FilterParams> {
|
||||
vec![
|
||||
FilterParams::GaussianBlur { radius: 2.5 },
|
||||
FilterParams::GaussianBlur { radius: 9.0 },
|
||||
FilterParams::BoxBlur { radius: 2.0 },
|
||||
FilterParams::BoxBlur { radius: 6.0 },
|
||||
FilterParams::MotionBlur { angle: 30.0, distance: 7.0 },
|
||||
FilterParams::RadialBlur { amount: 20.0, method: RadialMethod::Spin, center_x: 0.5, center_y: 0.5 },
|
||||
FilterParams::RadialBlur { amount: 30.0, method: RadialMethod::Zoom, center_x: 0.3, center_y: 0.6 },
|
||||
|
|
|
|||
|
|
@ -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}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,9 +97,12 @@ 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;
|
||||
opts.encode.webp_quality = q.clamp(1, 100) as u8;
|
||||
opts.encode.webp_lossless = false;
|
||||
}
|
||||
let path = str_of(&p, "path").map(PathBuf::from);
|
||||
self.save(index_of(&p), path.as_deref(), str_of(&p, "format"), &opts)
|
||||
|
|
@ -126,10 +138,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 +155,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 +211,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 +396,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 +512,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 +561,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]
|
||||
|
|
|
|||
|
|
@ -72,15 +72,20 @@ 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, …).
|
||||
#[serde(default)]
|
||||
pub format: Option<String>,
|
||||
/// JPEG quality 1..100 for lossy exports.
|
||||
/// JPEG or WebP quality 1..100. A WebP saved with a quality is lossy; without one it is lossless.
|
||||
#[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,9 +660,11 @@ 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);
|
||||
opts.encode.webp_quality = q.clamp(1, 100);
|
||||
opts.encode.webp_lossless = false;
|
||||
}
|
||||
let path = p.path.map(PathBuf::from);
|
||||
h.save(p.index, path.as_deref(), p.format.as_deref(), &opts)
|
||||
|
|
@ -631,6 +704,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 +717,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"
|
||||
|
|
@ -156,8 +178,8 @@ pub fn authorize_engine_command(id: &str, params: &Value) -> Result<(), Automati
|
|||
| "layer.videoLayers.newVideoLayerFromFile"
|
||||
| "layer.videoLayers.replaceFootage"
|
||||
| "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 +201,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"],
|
||||
"layer.quickExportAsPng" | "layer.exportAs" | "image.applyDataSet" => &["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 +244,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 +303,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,22 +432,212 @@ 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",
|
||||
"measurementLog.export",
|
||||
"layer.videoLayers.reloadFrame",
|
||||
"image.applyDataSet",
|
||||
"edit.colorSettings",
|
||||
] {
|
||||
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());
|
||||
// An allowed command can't reach a denied one by running it on its own behalf.
|
||||
let mut session = photocraft_engine::Session::new();
|
||||
session.execute("file.new", serde_json::json!({"width": 4, "height": 4})).unwrap();
|
||||
session.authorize = Some(authorize_desktop_engine_step);
|
||||
let params = serde_json::json!({"to": "grayscale"});
|
||||
assert!(authorize_desktop_engine_command("file.automate.conditionalModeChange", ¶ms).is_ok());
|
||||
assert!(session.execute("file.automate.conditionalModeChange", params).is_err());
|
||||
assert_eq!(session.active().unwrap().doc.mode, photocraft_engine::doc::ColorMode::Rgb);
|
||||
assert!(authorize_desktop_engine_step("file.saveACopy", &serde_json::json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_data_set_is_judged_by_the_values_it_applies() {
|
||||
let mut headless = crate::Headless::new();
|
||||
headless.command_run("file.new", serde_json::json!({"width": 8, "height": 8})).unwrap();
|
||||
let layer = headless.command_run("layer.new.layer", serde_json::json!({})).unwrap()["layer"].clone();
|
||||
let defs = [
|
||||
serde_json::json!({"name": "shown", "layer": layer, "type": "visibility"}),
|
||||
serde_json::json!({"name": "photo", "layer": layer, "type": "pixelReplacement"}),
|
||||
];
|
||||
headless.command_run("image.variables.define", serde_json::json!({"defs": defs})).unwrap();
|
||||
let sets = serde_json::json!({"dataSets": [
|
||||
{"name": "hidden", "values": [{"variable": "shown", "kind": "visibility", "value": false}]},
|
||||
{"name": "swap", "values": [
|
||||
{"variable": "shown", "kind": "visibility", "value": false},
|
||||
{"variable": "photo", "kind": "pixels", "value": "/outside/photo.png"},
|
||||
]},
|
||||
]});
|
||||
headless.command_run("image.variables.dataSets", sets).unwrap();
|
||||
let visible = |headless: &crate::Headless| headless.session.active().unwrap().doc.layers.iter().all(|l| l.visible);
|
||||
let refused = headless.command_run("image.applyDataSet", serde_json::json!({"name": "swap"})).unwrap_err();
|
||||
assert!(refused.to_string().contains("ambient filesystem paths"), "{refused}");
|
||||
assert!(visible(&headless), "a refused data set applies none of its values");
|
||||
assert!(headless.command_run("image.applyDataSet", serde_json::json!({"name": "hidden"})).is_ok());
|
||||
assert!(!visible(&headless));
|
||||
}
|
||||
|
||||
#[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.moveAnchors"
|
||||
| "path.moveHandle"
|
||||
| "path.bendSegment"
|
||||
| "path.convertPoint"
|
||||
| "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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,15 +21,27 @@ impl ClientHandler for Client {
|
|||
|
||||
type Conn = RunningService<RoleClient, Client>;
|
||||
|
||||
async fn connect(root: &std::path::Path) -> Conn {
|
||||
/// The server side of a test connection. It owns the headless session and its workspace,
|
||||
/// which holds open directory handles on the test folder.
|
||||
type Server = tokio::task::JoinHandle<()>;
|
||||
|
||||
async fn connect(root: &std::path::Path) -> (Conn, Server) {
|
||||
let (s, c) = tokio::io::duplex(1 << 20);
|
||||
let workspace = AuthorizedWorkspace::new(Some(root), Some(root)).expect("test workspace");
|
||||
tokio::spawn(async move {
|
||||
let server = tokio::spawn(async move {
|
||||
if let Ok(running) = PhotocraftMcp::headless_with_workspace(workspace).serve(s).await {
|
||||
let _ = running.waiting().await;
|
||||
}
|
||||
});
|
||||
Client.serve(c).await.expect("client init")
|
||||
(Client.serve(c).await.expect("client init"), server)
|
||||
}
|
||||
|
||||
/// Closes the client and waits for the server task to end, so the session and its workspace
|
||||
/// (with their open directory handles) are dropped before the test removes its folder. Windows
|
||||
/// refuses to delete a directory that still has open handles (os error 32).
|
||||
async fn shutdown(c: Conn, server: Server) {
|
||||
c.cancel().await.unwrap();
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
fn text(r: &CallToolResult) -> String {
|
||||
|
|
@ -96,7 +108,7 @@ fn close(a: &[f64], b: &[f64], tol: f64) -> bool {
|
|||
async fn agent_completes_ten_scripted_tasks() {
|
||||
let dir = tmp("tasks");
|
||||
let png = gradient_png(&dir);
|
||||
let c = connect(&dir).await;
|
||||
let (c, server) = connect(&dir).await;
|
||||
|
||||
// 1. Make a title card: type layer with a drop shadow, exported as PNG.
|
||||
tool(&c, "doc_new", json!({"width": 200, "height": 120, "background": "white"})).await;
|
||||
|
|
@ -211,6 +223,122 @@ async fn agent_completes_ten_scripted_tasks() {
|
|||
assert_eq!((doc["mode"].as_str(), doc["width"].as_u64()), (Some("Cmyk"), Some(45)), "task 10");
|
||||
assert_eq!(doc["layers"].as_array().unwrap().len(), 4, "task 10");
|
||||
|
||||
c.cancel().await.unwrap();
|
||||
shutdown(c, server).await;
|
||||
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, server) = 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;
|
||||
shutdown(c, server).await;
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
async fn connect_headless(headless: photocraft_automation::Headless) -> (Conn, Server) {
|
||||
let (s, c) = tokio::io::duplex(1 << 20);
|
||||
let server = 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"), server)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn agent_records_and_replays_an_action() {
|
||||
let dir = tmp("actions");
|
||||
let (c, server) = 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:?}");
|
||||
|
||||
shutdown(c, server).await;
|
||||
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, server) = 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}");
|
||||
shutdown(c, server).await;
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,22 +85,55 @@ 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). Any TIFF page can still be opened
|
||||
with `decode_tiff_page` (and `photocraft_io::import_tiff_page`).
|
||||
* **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 decoded page's tag in TIFF and
|
||||
BigTIFF) 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.
|
||||
* EXIF and XMP must each fit in one 64 KiB APP1 segment, otherwise the encoder returns an error.
|
||||
Extended XMP is not written.
|
||||
* **TIFF decode**
|
||||
* Supports 1, 2 and 4-bit gray (best effort, not yet covered by tests), 8/16/32-bit integers (32-bit is reduced to U16), F16, F32 and
|
||||
F64 (converted to F32), and WhiteIsZero.
|
||||
* Palette and YCbCr TIFFs, and compressions that the `tiff` crate lacks (CCITT, JPEG-in-TIFF),
|
||||
return `Unsupported`.
|
||||
* Classic TIFF and BigTIFF (version 43, 8-byte offsets), both byte orders, strips or tiles,
|
||||
planar configuration 1 or 2.
|
||||
* Supports 1, 2 and 4-bit gray, 8/16/32/64-bit integers (32 and 64-bit are reduced to U16),
|
||||
F16, F32 and F64 (converted to F32), WhiteIsZero (#691: the tones are no longer inverted),
|
||||
palette images (opened as RGB), associated alpha (made straight) and extra samples (dropped
|
||||
unless the first is alpha).
|
||||
* Every directory is reachable: `tiff_info` lists the main IFD chain and its SubIFDs (tag 330)
|
||||
with each page's size, kind (page, reduced-resolution copy, mask) and storage, walked with a
|
||||
seen-set, a depth limit and a 10 000-directory cap; `decode_tiff_page` opens any of them.
|
||||
`decode` opens the first full-resolution page, like Photoshop (thumbnails are skipped).
|
||||
* Banded decoding: the image is allocated once at its final size and each strip or row of
|
||||
tiles is decoded straight into it, in parallel on native targets, with at most one
|
||||
strip/tile-sized scratch buffer per worker (30 MP RGB8: Deflate 345 → 32 ms, LZW 428 → 43
|
||||
ms in release). Image data cut off by the end of the file decodes with
|
||||
`DecodeWarning::Truncated`; a file far too small for its declared size (more than any
|
||||
compression could expand) is refused before allocating.
|
||||
* YCbCr, CIE Lab, signed integers and compressions that the `tiff` crate lacks (CCITT,
|
||||
JPEG-in-TIFF) return `Unsupported`.
|
||||
* **TIFF encode** writes classic TIFF, or BigTIFF when `EncodeOptions::tiff_bigtiff` is set or
|
||||
the file could pass 4 GiB.
|
||||
* **EXR**
|
||||
* Reads the first valid layer at full resolution, from its data window.
|
||||
* Channel names are matched by suffix, so `layer.R` counts as `R`.
|
||||
|
|
@ -102,7 +147,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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_codecs::{decode_as_with, DecodeOptions, Format, Limits};
|
||||
use photocraft_codecs::{decode_as_with, decode_tiff_page, exif_orientation, tiff_info, tiff_orientation, 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);
|
||||
let _ = exif_orientation(data);
|
||||
let _ = tiff_orientation(data);
|
||||
// Every directory of the chain and its SubIFDs (BigTIFF too), not only the default page.
|
||||
if let Ok(info) = tiff_info(data) {
|
||||
for page in 0..info.pages.len().min(16) {
|
||||
let _ = decode_tiff_page(data, page, &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)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue