Compare commits
171 Commits
batch-foll
...
v0.3.0
| Author | SHA1 | Date |
|---|---|---|
|
|
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 |
|
|
@ -54,12 +54,58 @@ jobs:
|
|||
- name: Test
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
# The same tests built with the optional craft-fonts input (crates/text/build.rs), so the
|
||||
# Japanese font tests that skip without it run here. Linux only.
|
||||
- name: Check out craft-fonts
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
|
||||
- name: Test with craft-fonts
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets --locked -- -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
# It then runs packaging/freebsd/package.sh, the script the release job runs, and checks the
|
||||
# tarball, so a broken FreeBSD package fails here before it can fail a release. Nothing is
|
||||
# uploaded and no secrets are used.
|
||||
name: FreeBSD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
- .github/workflows/release.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: freebsd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test + package)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build, test and package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --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$'
|
||||
|
|
@ -44,10 +44,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,6 +30,10 @@ env:
|
|||
RUST_BACKTRACE: 1
|
||||
PHOTOCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
# craft-fonts (https://github.com/storytold/craft-fonts): shared font assets every release
|
||||
# embeds (today the Japanese fonts). An optional build input for everyone else; see
|
||||
# docs/development.md › Fonts. Bump deliberately; every desktop build job checks out this commit (not web).
|
||||
CRAFT_FONTS_REF: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
|
||||
jobs:
|
||||
version:
|
||||
|
|
@ -70,6 +74,12 @@ jobs:
|
|||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
|
|
@ -90,7 +100,18 @@ jobs:
|
|||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
# Unpacks the CLI zip and checks the binary users get: Developer ID, hardened runtime,
|
||||
# timestamp, and Gatekeeper's notarization lookup (codesign --verify, spctl). Also the DMG's
|
||||
# stapled ticket. Strict when the signing secrets are present, warnings otherwise.
|
||||
- name: Verify signatures and notarization
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: packaging/macos/verify.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-macos
|
||||
|
|
@ -112,11 +133,20 @@ jobs:
|
|||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
|
||||
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
|
@ -141,6 +171,8 @@ jobs:
|
|||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
|
@ -167,12 +199,18 @@ jobs:
|
|||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
|
||||
desktop-file-utils appstream python3-yaml
|
||||
desktop-file-utils appstream python3-yaml zsync
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
|
|
@ -184,6 +222,9 @@ jobs:
|
|||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
|
|
@ -194,6 +235,9 @@ jobs:
|
|||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
# Update information (#349) is embedded, and the .zsync for delta updates sits beside it.
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
|
@ -201,6 +245,96 @@ 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
|
||||
|
|
@ -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,95 @@
|
|||
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
|
||||
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
|
||||
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
|
||||
# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.)
|
||||
name: Windows ARM64
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "**/build.rs"
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: windows-arm64-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: package (cross-compiled on x64)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Build and package (unsigned)
|
||||
shell: pwsh
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: photocraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
install:
|
||||
name: install and run on ARM64
|
||||
needs: package
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: photocraft-windows-arm64
|
||||
path: dist
|
||||
- name: Install the MSI, check it, run it, uninstall it
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
|
||||
$dir = Join-Path $env:ProgramFiles 'PhotoCraft'
|
||||
foreach ($exe in 'photocraft.exe', 'photocraft-cli.exe') {
|
||||
$path = Join-Path $dir $exe
|
||||
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
|
||||
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
|
||||
Write-Output "ok $path (ARM64)"
|
||||
}
|
||||
# The installed CLI runs natively on ARM64.
|
||||
$cli = Join-Path $dir 'photocraft-cli.exe'
|
||||
$out = & $cli --version 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "photocraft-cli --version exited $LASTEXITCODE" }
|
||||
Write-Output "ok photocraft-cli --version: $($out.Trim())"
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
|
||||
if (Test-Path (Join-Path $dir 'photocraft.exe')) { throw 'uninstall left photocraft.exe behind' }
|
||||
Write-Output 'ok uninstalled'
|
||||
|
|
@ -8,3 +8,4 @@ target/
|
|||
/secrets
|
||||
/temp
|
||||
/book/output
|
||||
/craft-fonts
|
||||
|
|
|
|||
34
AGENTS.md
34
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, 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/`.
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ next to them.
|
|||
|---|---|---|---|---|
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `eraser-background.svg` and `eraser-magic.svg` (108 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `eraser-background.svg` and `eraser-magic.svg` (109 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,lock,minus,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
|
|
@ -20,6 +20,19 @@ next to them.
|
|||
| `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft Coated CMYK (synthetic) ICC profile | PhotoCraft contributors (generated by `photocraft-cms`, `src/synth.rs`) | Original work, see [`crates/cms/README.md`](crates/cms/README.md) | CC0-1.0 |
|
||||
| `assets/app-icon/` (all files) | PhotoCraft app icon (nine-tailed kitsune) | The project owner (drawn in ArtCraft, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) |
|
||||
|
||||
### Optional build input: craft-fonts (not files in this repo)
|
||||
|
||||
Builds made with `CRAFT_FONTS_DIR` (all official releases) embed fonts from
|
||||
[storytold/craft-fonts](https://github.com/storytold/craft-fonts) at a pinned commit; no font file
|
||||
from it is committed here. Per-file authors, sources and licences:
|
||||
[craft-fonts `ATTRIBUTION.md`](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md).
|
||||
|
||||
| Font (embedded at build time) | Use | License |
|
||||
|---|---|---|
|
||||
| BIZ UDPGothic Regular, Bold (Morisawa) | Japanese UI and sans Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-biz-ud-pgothic.txt` |
|
||||
| Shippori Mincho Regular (FONTDASU) | Japanese serif Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-shippori-mincho.txt` |
|
||||
| BIZ UDMincho Regular (Morisawa) | Japanese serif Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-biz-ud-mincho.txt` |
|
||||
|
||||
The other built-in ICC profiles and the generated LUT looks are produced by code at runtime
|
||||
(`crates/cms/src/builtin.rs`, `crates/cms/src/lutfile.rs`) and dedicated to the public domain
|
||||
(CC0-1.0); they are not separate files.
|
||||
|
|
@ -30,6 +43,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 +62,16 @@ 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/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.
|
||||
|
|
|
|||
|
|
@ -248,6 +248,7 @@ dependencies = [
|
|||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
|
|
@ -1556,6 +1557,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"
|
||||
|
|
@ -2614,6 +2621,16 @@ dependencies = [
|
|||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memory-stats"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c73f5c649995a115e1a0220b35e4df0a1294500477f97a91d0660fb5abeb574a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
|
|
@ -3204,6 +3221,16 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
|
|
@ -3303,6 +3330,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,7 +3386,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"eframe",
|
||||
|
|
@ -3366,6 +3404,7 @@ dependencies = [
|
|||
"photocraft-geom",
|
||||
"photocraft-io",
|
||||
"photocraft-raster",
|
||||
"photocraft-tablet",
|
||||
"photocraft-ui-egui",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
|
|
@ -3374,7 +3413,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-algo"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
|
|
@ -3386,7 +3425,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-automation"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"cap-std",
|
||||
|
|
@ -3408,7 +3447,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-automation",
|
||||
"photocraft-codecs",
|
||||
|
|
@ -3421,7 +3460,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-cms"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"moxcms",
|
||||
"rayon",
|
||||
|
|
@ -3429,7 +3468,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-codecs"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"exr",
|
||||
"flate2",
|
||||
|
|
@ -3439,6 +3478,7 @@ dependencies = [
|
|||
"jpeg-encoder",
|
||||
"png 0.18.1",
|
||||
"proptest",
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
|
|
@ -3447,7 +3487,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-color"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"serde",
|
||||
|
|
@ -3455,7 +3495,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-compose"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"photocraft-color",
|
||||
|
|
@ -3470,7 +3510,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-doc"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
|
|
@ -3481,7 +3521,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-engine"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"flate2",
|
||||
|
|
@ -3511,7 +3551,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-format"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"flate2",
|
||||
|
|
@ -3529,14 +3569,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-geom"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-gpu"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"half",
|
||||
"log",
|
||||
|
|
@ -3553,8 +3593,10 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-io"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"photocraft-algo",
|
||||
"photocraft-cms",
|
||||
"photocraft-codecs",
|
||||
"photocraft-color",
|
||||
|
|
@ -3569,19 +3611,20 @@ dependencies = [
|
|||
"photocraft-text",
|
||||
"photocraft-vector",
|
||||
"proptest",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ops"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-doc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-paint"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
|
|
@ -3592,7 +3635,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-plugins"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
|
|
@ -3607,7 +3650,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-psd"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"photocraft-psd",
|
||||
|
|
@ -3618,7 +3661,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-raster"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
|
|
@ -3628,7 +3671,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-raw"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-raw",
|
||||
"png 0.18.1",
|
||||
|
|
@ -3636,17 +3679,31 @@ dependencies = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-testkit"
|
||||
version = "0.2.0"
|
||||
name = "photocraft-tablet"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation 0.3.2",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-testkit"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"memory-stats",
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
"photocraft-raster",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-text"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"parley",
|
||||
|
|
@ -3660,7 +3717,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-ui-egui"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"eframe",
|
||||
|
|
@ -3679,8 +3736,11 @@ dependencies = [
|
|||
"photocraft-gpu",
|
||||
"photocraft-io",
|
||||
"photocraft-plugins",
|
||||
"photocraft-psd",
|
||||
"photocraft-raster",
|
||||
"photocraft-testkit",
|
||||
"photocraft-text",
|
||||
"photocraft-vector",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -3688,7 +3748,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-vector"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-doc",
|
||||
|
|
@ -3699,7 +3759,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-web"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
@ -5084,6 +5144,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"
|
||||
|
|
@ -6121,6 +6192,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 +6287,11 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.2.0"
|
||||
version = "0.3.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.3.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
rust-version = "1.95"
|
||||
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
|
|
@ -33,6 +33,7 @@ photocraft-plugins = { path = "crates/plugins" }
|
|||
photocraft-engine = { path = "crates/engine" }
|
||||
photocraft-ui-egui = { path = "crates/ui-egui" }
|
||||
photocraft-testkit = { path = "crates/testkit" }
|
||||
photocraft-tablet = { path = "crates/tablet" }
|
||||
|
||||
# external
|
||||
thiserror = "2"
|
||||
|
|
@ -68,3 +69,33 @@ opt-level = 1
|
|||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
# The browser build (apps/photocraft-web, `data-cargo-profile` in its index.html). Hosts cap the
|
||||
# size of a single file (Cloudflare Pages/Workers: 25 MiB), and the wasm is downloaded on every
|
||||
# visit, so this profile optimizes for size: fat LTO, and opt-level "s" for everything except the
|
||||
# pixel-processing crates, which keep opt-level 3 so filters and compositing stay fast. panic
|
||||
# stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` is for
|
||||
# the native app shell). packaging/web/package.sh fails the build above 24 MiB. Issue #198:
|
||||
# this took the wasm from 26.3 MiB (release profile) to 18.8 MiB.
|
||||
[profile.wasm-release]
|
||||
inherits = "release"
|
||||
opt-level = "s"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
strip = "debuginfo"
|
||||
|
||||
[profile.wasm-release.package."*"]
|
||||
opt-level = "s"
|
||||
|
||||
[profile.wasm-release.package.photocraft-raster]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-color]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-cms]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-compose]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-algo]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-paint]
|
||||
opt-level = 3
|
||||
|
|
|
|||
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.
|
||||
|
|
|
|||
54
README.md
54
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>
|
||||
|
|
@ -70,7 +70,7 @@
|
|||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🗂️ Real PSD files</h3>
|
||||
Open, edit and save layered Photoshop documents. 134 of 135 real-world PSDs round-trip byte for byte.
|
||||
Open, edit and save layered Photoshop documents. Re-saving keeps the render of 307 of the 309 psd-tools test files.
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🤖 Agent-ready</h3>
|
||||
|
|
@ -213,7 +213,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
|||
|
||||
PhotoCraft's PSD support is a standalone crate written from Adobe's public specification and tested against a corpus of real-world files.
|
||||
|
||||
- **Byte-exact round trips:** 134 of 135 corpus files are written back identical, and anything we don't model yet (raw blocks, descriptors, extras) is preserved verbatim instead of being dropped.
|
||||
- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/corpus.rs`).
|
||||
- **Pixels that match:** a composite oracle compares our render with Photoshop's own merged image, covering gradient interpolation (Classic, Perceptual and Linear), layer effects, shape strokes, clipping and fill opacity.
|
||||
- **Large documents:** PSB, 16 and 32-bit files, and CMYK and Lab documents open natively.
|
||||
|
||||
|
|
@ -231,6 +231,9 @@ photocraft-cli run wave.psd \
|
|||
# Apply one action list to a folder of images
|
||||
photocraft-cli batch --actions grade.json --in ./raw --out ./graded
|
||||
|
||||
# Every subcommand explains itself
|
||||
photocraft-cli batch --help
|
||||
|
||||
# Let an agent drive it over MCP (headless, or bridged to the running app)
|
||||
photocraft-cli mcp
|
||||
```
|
||||
|
|
@ -255,12 +258,42 @@ 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
|
||||
```
|
||||
|
||||
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 +303,13 @@ Developer, architecture, automation, format, and security documentation is maint
|
|||
|
||||
Security architecture, threat modeling, parser hardening, fuzzing, and vulnerability reporting are covered in the [security documentation](book/src/security/) and the repository [security policy](SECURITY.md).
|
||||
|
||||
## Test corpora
|
||||
|
||||
PhotoCraft is tested against real files: our own Photoshop-authored oracle PSDs in
|
||||
[photocraft-corpus](https://github.com/storytold/photocraft-corpus) plus the psd-tools, ag-psd and PngSuite sets, pinned and
|
||||
sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests with
|
||||
`cargo xtask test-corpus` (details in [docs/development.md](docs/development.md#test-corpora)).
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
PhotoCraft is one of the **Crafting Apps**: free, open-source creative tools from the
|
||||
|
|
@ -338,3 +378,7 @@ Forks and modified versions must remove them.
|
|||
|
||||
|
||||
ArtCraft
|
||||
|
||||
## Star history
|
||||
|
||||
[](https://www.star-history.com/?repos=storytold%2Fphotocraft&type=date&legend=top-left)
|
||||
|
|
|
|||
|
|
@ -18,11 +18,14 @@ USAGE:
|
|||
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
|
||||
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>]
|
||||
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]
|
||||
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 +36,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 +49,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: &[], 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: &[], run: run_cmds },
|
||||
Subcommand { name: "batch", values: &["--actions", "--in", "--out", "--format", "--quality"], bare: &["--in-place"], 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 +129,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 +138,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}");
|
||||
|
|
@ -139,8 +168,8 @@ 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();
|
||||
if let Some(q) = a.get("--quality") {
|
||||
let q: u8 = q.parse().map_err(|_| format!("bad --quality `{q}`"))?;
|
||||
o.encode.jpeg_quality = q.clamp(1, 100);
|
||||
o.encode.jpeg_quality =
|
||||
q.parse().ok().filter(|q| (1..=100).contains(q)).ok_or_else(|| format!("bad --quality `{q}`: expected a whole number from 1 to 100"))?;
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
|
@ -160,9 +189,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 +237,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 +247,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 +260,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 +293,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 +327,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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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-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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ photocraft-io = { workspace = true }
|
|||
photocraft-format = { workspace = true }
|
||||
photocraft-automation = { workspace = true }
|
||||
photocraft-ui-egui = { workspace = true }
|
||||
# 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"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
|
@ -29,7 +31,7 @@ log = { workspace = true }
|
|||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd = { workspace = true }
|
||||
arboard = "3"
|
||||
arboard = { version = "3", features = ["wayland-data-control"] }
|
||||
open = "5"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
|
|
|
|||
|
|
@ -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,564 @@
|
|||
//! 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;
|
||||
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 }
|
||||
}
|
||||
|
||||
/// 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let any_ok = adapters.iter().any(|a| a.surface_ok);
|
||||
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok || !any_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, 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;
|
||||
}
|
||||
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::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);
|
||||
}
|
||||
}
|
||||
|
||||
/// `performance.gpuBackend` and `performance.useGpu` from the preferences file, read before the
|
||||
/// app (and its full preference load) exists. Unreadable values are the defaults.
|
||||
pub fn read_prefs(path: Option<&Path>) -> (GpuBackend, bool) {
|
||||
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);
|
||||
(backend, use_gpu)
|
||||
}
|
||||
|
||||
#[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 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 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_prefs(None), (Auto, true));
|
||||
assert_eq!(read_prefs(Some(&p)), (Auto, true));
|
||||
std::fs::write(&p, r#"{"performance": {"gpuBackend": "dx12", "useGpu": false}, "interface": {}}"#).unwrap();
|
||||
assert_eq!(read_prefs(Some(&p)), (Dx12, false));
|
||||
std::fs::write(&p, r#"{"performance": {"gpuBackend": "quantum"}}"#).unwrap();
|
||||
assert_eq!(read_prefs(Some(&p)), (Auto, true));
|
||||
std::fs::write(&p, "{not json").unwrap();
|
||||
assert_eq!(read_prefs(Some(&p)), (Auto, true));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,11 @@
|
|||
//!
|
||||
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
|
||||
//! --control-token-file <path>] [--automation-read-root <dir>]
|
||||
//! [--automation-write-root <dir>] [files…]`
|
||||
//! [--automation-write-root <dir>] [--safe-gpu] [files…]`
|
||||
//!
|
||||
//! `--safe-gpu` starts with the CPU renderer (no GPU canvas; a software adapter for the window
|
||||
//! where the platform has one) for this launch, e.g. after a graphics driver crash. A start that
|
||||
//! crashes inside the driver also falls back by itself next time (see `gpu_startup`).
|
||||
//!
|
||||
//! `--control <port>` (or `PHOTOCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server.
|
||||
//! The first line must authenticate; subsequent request lines get reply lines.
|
||||
|
|
@ -13,12 +17,21 @@
|
|||
#![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 gpu_startup;
|
||||
// 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;
|
||||
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_ui_egui::PhotocraftApp;
|
||||
|
|
@ -26,14 +39,25 @@ use photocraft_ui_egui::PhotocraftApp;
|
|||
/// Matches the `.desktop` file and hicolor icon name, so Wayland docks pick up the icon.
|
||||
const APP_ID: &str = "ai.storyteller.photocraft";
|
||||
|
||||
/// Window, taskbar and (when running unbundled) Dock icon. macOS gets the padded 1024 px render
|
||||
/// on Apple's icon grid; elsewhere the tighter 256 px hicolor render reads better at small sizes.
|
||||
fn app_icon() -> egui::IconData {
|
||||
#[cfg(target_os = "macos")]
|
||||
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png");
|
||||
eframe::icon_data::from_png_bytes(PNG).unwrap_or_default()
|
||||
/// The main window: 1440 × 900 (shrunk to fit the monitor, and maximized on the first frame
|
||||
/// when it still doesn't fit, `work_area::fit_window`), centred on the main monitor. Without
|
||||
/// `centered`, Windows cascades each new window from the top-left corner, so it opened at a
|
||||
/// different offset every launch (#419). Wayland compositors place windows themselves.
|
||||
fn native_options() -> eframe::NativeOptions {
|
||||
eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_icon(app_icon::window_icon())
|
||||
.with_app_id(APP_ID)
|
||||
.with_title("PhotoCraft")
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([760.0, 480.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false),
|
||||
centered: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
|
|
@ -44,6 +68,7 @@ fn main() -> eframe::Result {
|
|||
let mut automation_read_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from);
|
||||
let mut automation_write_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from);
|
||||
let mut files = Vec::new();
|
||||
let mut safe_gpu = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
|
|
@ -52,6 +77,7 @@ fn main() -> eframe::Result {
|
|||
"--control-token-file" => control_token_file = args.next().map(std::path::PathBuf::from),
|
||||
"--automation-read-root" => automation_read_root = args.next().map(std::path::PathBuf::from),
|
||||
"--automation-write-root" => automation_write_root = args.next().map(std::path::PathBuf::from),
|
||||
"--safe-gpu" => safe_gpu = true,
|
||||
"--version" => {
|
||||
println!("photocraft {}", photocraft_engine::build_info::long_version());
|
||||
return Ok(());
|
||||
|
|
@ -62,6 +88,14 @@ fn main() -> eframe::Result {
|
|||
}
|
||||
}
|
||||
|
||||
// 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,26 +130,50 @@ fn main() -> eframe::Result {
|
|||
#[cfg(target_os = "macos")]
|
||||
let apple_events = &apple_events;
|
||||
|
||||
// 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 main display's ICC profile while the window opens (colour-managed canvas).
|
||||
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();
|
||||
// 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, _) = gpu_startup::read_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(pref, 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(
|
||||
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 started_sentinel = sentinel.clone();
|
||||
let result = eframe::run_native(
|
||||
"Photocraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
|
|
@ -124,16 +182,44 @@ fn main() -> eframe::Result {
|
|||
services.preset_store = presets;
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
// 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();
|
||||
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));
|
||||
}
|
||||
// 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 = plan.reason.clone().or_else(|| gpu_note.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone());
|
||||
info.canvas = "cpu".into();
|
||||
if let Some(rs) = cc.wgpu_render_state.clone() {
|
||||
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
|
||||
if plan.backend != photocraft_engine::prefs::GpuBackend::Cpu
|
||||
&& std::env::var_os("PHOTOCRAFT_CPU_CANVAS").is_none()
|
||||
&& app.session.prefs().performance.use_gpu
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
app.perf.span("gpuSentinel", sentinel_ms);
|
||||
// Once the first frames rendered: clear the marker, and keep a crash fallback.
|
||||
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 let Some(b) = remember
|
||||
&& app.session.prefs().performance.gpu_backend != b
|
||||
&& let Err(e) = app.run("prefs.set", serde_json::json!({"path": "performance.gpuBackend", "value": b.name()}))
|
||||
{
|
||||
log::warn!("couldn't remember GPU backend {}: {e}", b.name());
|
||||
}
|
||||
});
|
||||
if let Some((port, token, _)) = control {
|
||||
let rx = control_server::start(port, token, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
|
|
@ -142,9 +228,38 @@ fn main() -> eframe::Result {
|
|||
{
|
||||
app.services.os_events = Some(apple_events.connect(&cc.egui_ctx));
|
||||
}
|
||||
// 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, tablet::DisplayKind::of(cc));
|
||||
// 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);
|
||||
}
|
||||
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();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -22,8 +21,15 @@ const OPEN_EXTS: &[&str] = &[
|
|||
/// 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"]),
|
||||
("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 +42,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 +63,48 @@ 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())
|
||||
}
|
||||
|
||||
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())?;
|
||||
|
|
@ -122,7 +147,7 @@ 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,
|
||||
|
|
@ -132,41 +157,31 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
|||
})),
|
||||
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()?),
|
||||
};
|
||||
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 +191,7 @@ 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,
|
||||
..recovery_services(recovery_dir())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -240,3 +256,94 @@ 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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -36,7 +36,10 @@ craftrules at `assets/app-icons/photocraft/source.png`. License: see `LICENSE.tx
|
|||
- `hicolor/<size>/apps/ai.storyteller.photocraft.png` (16–512) and `hicolor/scalable/...svg`:
|
||||
Linux icon theme.
|
||||
|
||||
The app also sets the window icon and Wayland app ID at runtime (`apps/photocraft/src/main.rs`).
|
||||
The app also sets the window icon and Wayland app ID at runtime (`apps/photocraft/src/app_icon.rs`,
|
||||
`main.rs`). On Windows the taskbar shows the icon of the Start Menu shortcut that launches the
|
||||
exe, so the MSI's `<Icon Id>` keeps the `.exe` extension (ICE50); `app_icon.rs` documents the
|
||||
details and tests the `.ico` sizes and the WiX icon references.
|
||||
|
||||
## Regenerate
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||
<path d="m15 5 4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 377 B |
|
|
@ -13,7 +13,7 @@
|
|||
| `mcp` | Start the MCP server on stdio, optionally bridged to the desktop app |
|
||||
| `serve` | Start the JSON-lines headless server on stdio or loopback TCP |
|
||||
|
||||
Use `photocraft-cli --help` as the executable source of truth. A typical bounded command run is:
|
||||
Use `photocraft-cli --help` (or `photocraft-cli <subcommand> --help`) as the executable source of truth. A typical bounded command run is:
|
||||
|
||||
```sh
|
||||
photocraft-cli run input.psd \
|
||||
|
|
@ -21,6 +21,6 @@ photocraft-cli run input.psd \
|
|||
--out output.png
|
||||
```
|
||||
|
||||
Paths supplied to the current CLI are ordinary operating-system paths. The CLI does not restrict them to a workspace root. Batch output creates the requested output directory, and save operations may create parent directories. Run untrusted action files only in an appropriately isolated account or environment.
|
||||
Paths supplied to the current CLI are ordinary operating-system paths. The CLI does not restrict them to a workspace root. Batch output creates the requested output directory, and save operations may create parent directories. `batch` refuses an output directory that is its input directory (the results would replace the originals) unless `--in-place` is given. Run untrusted action files only in an appropriately isolated account or environment.
|
||||
|
||||
CLI parsing reports malformed flags and JSON as errors, but it is not a security policy layer. Engine commands remain responsible for validating their own parameters.
|
||||
CLI parsing reports malformed flags and JSON as errors, and a flag the subcommand doesn't take (a typo such as `--fromat`) is a usage error (exit status 2) rather than being ignored. It is not a security policy layer. Engine commands remain responsible for validating their own parameters.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ Automation requests are untrusted input with potential effects beyond the active
|
|||
| MCP capability scopes | **Partial:** filesystem read/write capabilities only; general tool scopes are absent |
|
||||
| Allowed read/write roots | **Implemented:** separate launch-time roots, with absent authority failing closed |
|
||||
| Symlink-safe capability filesystem | **Implemented:** relative operations use held directory capabilities and reject link escapes |
|
||||
| Request-byte and JSON-depth limits | **Partial:** 1 MiB request-line limit; no explicit JSON-depth policy |
|
||||
| Request-byte and JSON-depth limits | **Partial:** 1 MiB request-line limit on TCP and headless JSON-lines stdio (MCP stdio input is not byte-capped); no explicit JSON-depth policy |
|
||||
| Batch-step limit | **Implemented:** 256 steps for headless and MCP batches |
|
||||
| Reply-size limit | **Implemented:** 8 MiB encoded JSON/MCP result; aggregate batch reply budget |
|
||||
| Headless preview budgets | **Implemented:** 2048-pixel requested edge, 67,108,864 source pixels, 5 MiB encoded PNG |
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ Default `LoadOptions` apply these maximums:
|
|||
| One decompressed blob | 1 GiB |
|
||||
| Total decompressed tiles and blobs | 16 GiB |
|
||||
|
||||
Layer groups may nest at most 100 levels (`MAX_GROUP_DEPTH`). Saving a deeper document fails with an error instead of writing a bundle that can't be opened, and a manifest whose JSON nesting goes beyond what that depth needs is rejected before it is parsed, so a hostile file can't exhaust the stack.
|
||||
|
||||
The ZIP reader bounds decompression by the caller-supplied maximum, rejects encrypted entries and unsupported compression methods, validates offsets with checked arithmetic, and rejects CRC or declared-size mismatches. Corruption and property tests cover truncation, byte mutation, missing content, invalid manifests, excessive sizes, and random bytes.
|
||||
|
||||
These defaults are intentionally finite but can still be expensive on constrained systems. Future hardening should add context-sensitive budgets and explicit limits for entry count, document complexity, and cumulative work, then test directory bundles against symlink and replacement races.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@
|
|||
|
||||
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 +26,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.
|
||||
|
|
@ -21,6 +24,9 @@ Automation is a privilege boundary because requests can cause filesystem access,
|
|||
- 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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ pub mod scancrop;
|
|||
pub mod seam;
|
||||
pub mod segment;
|
||||
pub mod selection;
|
||||
mod selection_blur;
|
||||
mod sharpen;
|
||||
pub mod stack;
|
||||
mod stylize;
|
||||
|
|
@ -860,6 +861,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 +894,26 @@ pub fn apply_tiled(
|
|||
tile: i32,
|
||||
extent: Option<Rect>,
|
||||
) -> Surface {
|
||||
// Never cancelled, so always `Some`; the fallback (the input unchanged) is unreachable.
|
||||
apply_tiled_with(surface, params, area, bounds, selection, tile, extent, &photocraft_raster::Interrupt::NONE).unwrap_or_else(|| surface.clone())
|
||||
}
|
||||
|
||||
/// [`apply_tiled`] with cancellation (checked before each tile, so a cancel takes effect within
|
||||
/// one tile's work) and progress (after each group of tiles). `None` when cancelled.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn apply_tiled_with(
|
||||
surface: &Surface,
|
||||
params: &FilterParams,
|
||||
area: Rect,
|
||||
bounds: Rect,
|
||||
selection: Option<&Surface>,
|
||||
tile: i32,
|
||||
extent: Option<Rect>,
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
) -> Option<Surface> {
|
||||
let mut out = surface.clone();
|
||||
if area.is_empty() {
|
||||
return out;
|
||||
return Some(out);
|
||||
}
|
||||
let fmt = surface.format();
|
||||
let ctx = Ctx { bounds, mode: fmt.mode, alpha: fmt.alpha };
|
||||
|
|
@ -897,7 +929,19 @@ pub fn apply_tiled(
|
|||
}
|
||||
y += tile;
|
||||
}
|
||||
// Tiles finished so far, for progress reported per tile (from any worker thread).
|
||||
let finished = std::sync::atomic::AtomicUsize::new(0);
|
||||
let total = tiles.len().max(1);
|
||||
let run = |t: &Rect| -> (Rect, Vec<f32>) {
|
||||
// Cancelled: skip the remaining tiles of the group (the result is discarded).
|
||||
if ctl.cancelled() {
|
||||
return (*t, Vec::new());
|
||||
}
|
||||
let tick = || {
|
||||
let n = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
// The last few percent are the writes after each group.
|
||||
ctl.progress(0.98 * n as f32 / total as f32);
|
||||
};
|
||||
let owned;
|
||||
let src = match (&shared, halo) {
|
||||
(Some(s), _) => s,
|
||||
|
|
@ -930,6 +974,7 @@ pub fn apply_tiled(
|
|||
}
|
||||
}
|
||||
}
|
||||
tick();
|
||||
(*t, data)
|
||||
};
|
||||
// Tiles are filtered in groups of about RESULT_BUDGET bytes (at least one per core) and each
|
||||
|
|
@ -942,6 +987,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 +997,16 @@ pub fn apply_tiled(
|
|||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let results: Vec<(Rect, Vec<f32>)> = chunk.iter().map(run).collect();
|
||||
if ctl.cancelled() {
|
||||
return None;
|
||||
}
|
||||
for (t, data) in results {
|
||||
out.write_region(t, &data);
|
||||
}
|
||||
}
|
||||
out.prune();
|
||||
out
|
||||
ctl.progress(1.0);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Bytes of float filter results held at once by [`apply_tiled`].
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -97,10 +99,17 @@ 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 seams = new_w.abs_diff(w).max(1) as f32;
|
||||
let protect_e = |e: &mut [f32], p: Option<&Vec<f32>>| {
|
||||
if let Some(p) = p {
|
||||
for (e, p) in e.iter_mut().zip(p) {
|
||||
|
|
@ -112,6 +121,8 @@ pub fn carve_width(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&
|
|||
let (mut cur, mut cw) = (img.to_vec(), w);
|
||||
let mut prot = protect.map(<[f32]>::to_vec);
|
||||
while cw > new_w {
|
||||
ctl.check()?;
|
||||
ctl.progress((w - cw) as f32 / seams);
|
||||
let mut e = energy(cw, h, ch, &cur);
|
||||
protect_e(&mut e, prot.as_ref());
|
||||
let seam = find_seam(cw, h, &e);
|
||||
|
|
@ -119,7 +130,7 @@ pub fn carve_width(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&
|
|||
prot = prot.map(|p| remove_seam_1(cw, h, &p, &seam));
|
||||
cw -= 1;
|
||||
}
|
||||
return cur;
|
||||
return Ok(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);
|
||||
|
|
@ -132,9 +143,12 @@ pub fn carve_width(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&
|
|||
let mut tprot = prot.clone();
|
||||
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 tw == 0 {
|
||||
break;
|
||||
}
|
||||
ctl.check()?;
|
||||
ctl.progress((cw + (cw - tw)).saturating_sub(w) as f32 / seams);
|
||||
let mut e = energy(tw, h, ch, &tmp);
|
||||
protect_e(&mut e, tprot.as_ref());
|
||||
let seam = find_seam(tw, h, &e);
|
||||
|
|
@ -177,21 +191,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 +278,15 @@ pub fn skin_mask(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec<f32> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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 +361,20 @@ mod tests {
|
|||
assert!((0..4).all(|y| out[y * 8..y * 8 + 8].iter().any(|v| (*v - 0.51).abs() < 1e-6)));
|
||||
}
|
||||
|
||||
#[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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -666,3 +666,25 @@ 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_is_monotonic() {
|
||||
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());
|
||||
let last = AtomicU32::new(0);
|
||||
let calls = AtomicU32::new(0);
|
||||
let no = || false;
|
||||
let progress = |f: f32| {
|
||||
assert!(f >= f32::from_bits(last.load(Ordering::Relaxed)));
|
||||
last.store(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(last.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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,26 @@ 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)
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,7 +4,7 @@
|
|||
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};
|
||||
|
|
@ -49,6 +49,13 @@ impl Headless {
|
|||
Self { session: Session::new(), writers: HashMap::new(), filesystem: 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> {
|
||||
match index {
|
||||
Some(i) if i < self.session.documents().len() => Ok(i),
|
||||
|
|
@ -91,7 +98,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 +145,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 +203,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) {
|
||||
|
|
@ -126,10 +135,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 +152,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 +208,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 +393,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 +509,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 +558,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,7 +72,9 @@ pub struct NewParams {
|
|||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct SaveParams {
|
||||
/// Forward-slash relative target beneath the configured automation write root.
|
||||
/// The extension selects the format. Omit to save to the document's own relative path.
|
||||
/// The extension selects the format. Omit to write back to the document's own file, which
|
||||
/// works only for a PSD, PSB or .pcraft file kept in its own format; any other save needs
|
||||
/// `path`, so a flattened or converted copy never replaces the opened file.
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
/// Format override as an extension (pcraft, psd, png, jpg, tif, webp, exr, …).
|
||||
|
|
@ -112,29 +114,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 +169,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 +279,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
|
||||
|
|
@ -295,8 +323,10 @@ impl PhotocraftMcp {
|
|||
|
||||
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 +402,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 +427,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 +492,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 +518,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 +530,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 +556,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),
|
||||
|
|
@ -533,6 +582,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 +599,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 +623,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> {
|
||||
|
|
@ -643,4 +709,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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;
|
||||
|
|
@ -50,7 +51,10 @@ impl AuthorizedWorkspace {
|
|||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// 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 +62,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 +154,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"
|
||||
|
|
@ -256,6 +279,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");
|
||||
|
|
|
|||
|
|
@ -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,79 @@ 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);
|
||||
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");
|
||||
|
|
@ -317,11 +423,12 @@ 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();
|
||||
|
|
@ -349,6 +456,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;
|
||||
|
|
@ -418,6 +528,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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,6 +7,8 @@ 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.
|
||||
|
|
@ -25,6 +27,10 @@ zune-jpeg = "0.5"
|
|||
zune-core = "0.5"
|
||||
jpeg-encoder = "0.6"
|
||||
|
||||
# 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
|
||||
# Oracle decoders for cross-checking in tests only.
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
@ -73,12 +74,27 @@ the same.
|
|||
files.
|
||||
* **Animation and multi-page files** (APNG, animated GIF/WebP, multi-page TIFF): only the first
|
||||
frame or page is decoded, and a single frame is written. `FormatCaps::animation` marks
|
||||
containers that can hold more frames.
|
||||
containers that can hold more frames. The decoded image then carries a
|
||||
`DecodeWarning::MoreFrames` / `MorePages` in `Image::warnings`, with the total when the file
|
||||
states it (frames and TIFF directories are counted without decoding them; reduced-resolution
|
||||
TIFF directories and transparency masks are not pages).
|
||||
* **EXIF in TIFF** is stored as a sub-IFD rather than a blob, so it is not preserved yet.
|
||||
`caps.exif = false` for TIFF, and a warning is raised.
|
||||
* **EXIF orientation** is preserved as data but not applied to the pixels.
|
||||
* **Orientation** (EXIF tag 274 in JPEG, PNG `eXIf` and WebP; the IFD0 tag in TIFF) is applied on
|
||||
decode, like Photoshop: the pixels come back upright and the EXIF/XMP orientation is rewritten
|
||||
to 1 (`DecodeOptions::keep_orientation` opts out). Encoders always write Orientation = 1
|
||||
(`upright_exif`, `upright_xmp`, which change only the tag's value, kept in its own type), so upright pixels
|
||||
are never rotated twice. Parsing is strict: an IFD0 inside the 8-byte header or cut off before
|
||||
its next-IFD pointer, an Orientation that is not exactly one SHORT or LONG, or an out-of-range value
|
||||
reads as 1; with duplicate entries the first wins. `Limits` are checked again on the upright
|
||||
size (5–8 swap width and height), and the rotated buffer is allocated fallibly.
|
||||
`Image::oriented` turns any layout and depth in parallel bands (about 10 ms for 24 MP RGB8 in
|
||||
release).
|
||||
* **JPEG**
|
||||
* 8-bit only. Neither decoder backend supports 12-bit.
|
||||
* A file cut off inside its image data decodes leniently (a baseline JPEG's missing rows come
|
||||
out grey) but never silently: the image carries `DecodeWarning::Truncated`. A file that ends
|
||||
before any scan data is an error.
|
||||
* CMYK is always written 4:4:4 (subsampled CMYK is not portable) as Adobe-inverted CMYK with an
|
||||
APP14 marker.
|
||||
* Text is not written because there is no COM-segment support.
|
||||
|
|
@ -102,7 +118,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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use photocraft_codecs::{decode_as_with, DecodeOptions, Format, Limits};
|
|||
fuzz_target!(|data: &[u8]| {
|
||||
let opts = DecodeOptions {
|
||||
limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 },
|
||||
..Default::default()
|
||||
};
|
||||
let _ = decode_as_with(Format::Jpeg, data, &opts);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use photocraft_codecs::{decode_as_with, DecodeOptions, Format, Limits};
|
|||
fuzz_target!(|data: &[u8]| {
|
||||
let opts = DecodeOptions {
|
||||
limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 },
|
||||
..Default::default()
|
||||
};
|
||||
let _ = decode_as_with(Format::Png, data, &opts);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use photocraft_codecs::{decode_as_with, DecodeOptions, Format, Limits};
|
|||
fuzz_target!(|data: &[u8]| {
|
||||
let opts = DecodeOptions {
|
||||
limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 },
|
||||
..Default::default()
|
||||
};
|
||||
let _ = decode_as_with(Format::Tiff, data, &opts);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ use zune_core::options::DecoderOptions;
|
|||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, Image, Metadata, SampleType};
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits};
|
||||
use crate::orientation::{upright_exif, upright_xmp};
|
||||
|
||||
const F: Format = Format::Jpeg;
|
||||
const EXIF_HEADER: &[u8] = b"Exif\0\0";
|
||||
|
|
@ -100,11 +101,70 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta {
|
|||
m
|
||||
}
|
||||
|
||||
/// How a JPEG's data ends, seen from its marker structure.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum DataEnd {
|
||||
/// An end-of-image marker follows the scans (or the structure is too odd to tell).
|
||||
Complete,
|
||||
/// The file ends inside the image data, before the end-of-image marker.
|
||||
Truncated,
|
||||
/// The file ends before the first scan holds any data: nothing can be decoded.
|
||||
Empty,
|
||||
}
|
||||
|
||||
/// Walks the marker segments (skipped by their length) and the entropy-coded scan data
|
||||
/// (skipped up to the next marker that isn't a stuffed `FF 00`, a restart or a fill byte),
|
||||
/// so an embedded thumbnail's end marker or data after the end of the image don't count.
|
||||
fn data_end(b: &[u8]) -> DataEnd {
|
||||
// Where the bytes run out: inside the image data once a scan has held some.
|
||||
let cut = |scan_data: bool| if scan_data { DataEnd::Truncated } else { DataEnd::Empty };
|
||||
let mut i = 2; // past SOI
|
||||
let mut scan_data = false;
|
||||
loop {
|
||||
let Some(&byte) = b.get(i) else { return cut(scan_data) };
|
||||
if byte != 0xFF {
|
||||
// Not where a marker should be: too odd to judge.
|
||||
return DataEnd::Complete;
|
||||
}
|
||||
let Some(&code) = b.get(i + 1) else { return cut(scan_data) };
|
||||
match code {
|
||||
0xD9 => return DataEnd::Complete,
|
||||
0xFF => i += 1,
|
||||
0x01 | 0xD0..=0xD8 => i += 2,
|
||||
_ => {
|
||||
let Some(len) = b.get(i + 2..i + 4).and_then(|s| <[u8; 2]>::try_from(s).ok()).map(u16::from_be_bytes) else {
|
||||
return cut(scan_data);
|
||||
};
|
||||
i = i.saturating_add(2 + usize::from(len));
|
||||
if code == 0xDA {
|
||||
let start = i;
|
||||
loop {
|
||||
let Some(p) = b.get(i..).and_then(|r| r.iter().position(|&v| v == 0xFF)) else {
|
||||
return cut(scan_data || b.len() > start);
|
||||
};
|
||||
i += p;
|
||||
match b.get(i + 1) {
|
||||
Some(0x00 | 0xD0..=0xD7) => i += 2,
|
||||
Some(0xFF) => i += 1,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
scan_data |= i > start;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> CodecError {
|
||||
CodecError::malformed(F, e)
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
let end = data_end(bytes);
|
||||
if end == DataEnd::Empty {
|
||||
return Err(err("the file ends before any image data"));
|
||||
}
|
||||
let meta = scan_metadata(bytes);
|
||||
if let Some((w, h, nc)) = meta.frame {
|
||||
limits.check_bytes(w, h, u64::from(nc.max(1)))?;
|
||||
|
|
@ -149,6 +209,9 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
let mut img = Image::from_raw(w, h, layout, SampleType::U8, px)?;
|
||||
img.icc = meta.icc;
|
||||
img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, text: Vec::new() };
|
||||
if end == DataEnd::Truncated {
|
||||
img.warnings.push(DecodeWarning::Truncated { format: F });
|
||||
}
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
|
|
@ -186,13 +249,14 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
|||
enc.set_density(jpeg_encoder::Density::Inch { x: x.round().min(65535.0) as u16, y: y.round().min(65535.0) as u16 });
|
||||
}
|
||||
if let Some(exif) = &img.meta.exif {
|
||||
// The pixels are written as they are shown: never let a viewer rotate them again.
|
||||
let mut seg = EXIF_HEADER.to_vec();
|
||||
seg.extend_from_slice(exif);
|
||||
seg.extend_from_slice(&upright_exif(exif));
|
||||
enc.add_app_segment(1, &seg).map_err(e)?;
|
||||
}
|
||||
if let Some(xmp) = &img.meta.xmp {
|
||||
let mut seg = XMP_HEADER.to_vec();
|
||||
seg.extend_from_slice(xmp.as_bytes());
|
||||
seg.extend_from_slice(upright_xmp(xmp).as_bytes());
|
||||
enc.add_app_segment(1, &seg).map_err(e)?;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use std::io::{Cursor, Write};
|
|||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, Image, Metadata, SampleType};
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits, PngCompression};
|
||||
|
||||
const F: Format = Format::Png;
|
||||
|
|
@ -27,6 +27,8 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
limits.check_bytes(w, h, 8)?;
|
||||
}
|
||||
let mut reader = decoder.read_info().map_err(map_png_err)?;
|
||||
// APNG: the IDAT image is the first frame, or an extra default image when no fcTL precedes it.
|
||||
let images = reader.info().animation_control.map_or(1, |a| u64::from(a.num_frames) + u64::from(reader.info().frame_control.is_none()));
|
||||
let (color, depth) = reader.output_color_type();
|
||||
let layout = match color {
|
||||
png::ColorType::Grayscale => ChannelLayout::Gray,
|
||||
|
|
@ -82,6 +84,9 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
}
|
||||
}
|
||||
img.meta = meta;
|
||||
if images > 1 {
|
||||
img.warnings.push(DecodeWarning::MoreFrames { total: u32::try_from(images).ok() });
|
||||
}
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
|
|
@ -131,7 +136,8 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
|||
}
|
||||
if opts.embed_metadata {
|
||||
if let Some(exif) = &img.meta.exif {
|
||||
info.exif_metadata = Some(exif.clone().into());
|
||||
// The pixels are written as they are shown: never let a viewer rotate them again.
|
||||
info.exif_metadata = Some(crate::orientation::upright_exif(exif).into_owned().into());
|
||||
}
|
||||
if let Some((x, y)) = img.meta.dpi
|
||||
&& x > 0.0
|
||||
|
|
@ -164,7 +170,7 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
|||
res.map_err(|e| CodecError::encode(F, e))?;
|
||||
}
|
||||
if let Some(xmp) = &img.meta.xmp {
|
||||
encoder.add_itxt_chunk(XMP_KEYWORD.into(), xmp.clone()).map_err(|e| CodecError::encode(F, e))?;
|
||||
encoder.add_itxt_chunk(XMP_KEYWORD.into(), crate::orientation::upright_xmp(xmp).into_owned()).map_err(|e| CodecError::encode(F, e))?;
|
||||
}
|
||||
}
|
||||
let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! TIFF via the `tiff` crate: 8/16-bit integer and 32-bit float, gray/RGB/
|
||||
//! CMYK with optional (unassociated) alpha, ICC (34675), XMP (700), DPI and
|
||||
//! a few ASCII text tags. Only the first IFD is read.
|
||||
//! a few ASCII text tags. Only the first IFD is read; further pages are counted and reported
|
||||
//! as a [`DecodeWarning::MorePages`].
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::io::Cursor;
|
||||
|
|
@ -13,7 +14,7 @@ use tiff::tags::{PhotometricInterpretation, ResolutionUnit, SampleFormat, Tag, T
|
|||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, Image, Metadata, SampleType};
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits, TiffCompression};
|
||||
|
||||
const F: Format = Format::Tiff;
|
||||
|
|
@ -230,9 +231,36 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
}
|
||||
}
|
||||
img.meta = meta;
|
||||
img.warnings.extend(more_pages(&mut dec));
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// Counts the pages after the first IFD without decoding their pixels, skipping
|
||||
/// reduced-resolution copies (thumbnails) and transparency masks. The total is unknown when
|
||||
/// a directory can't be read or there are more than `MAX_IFDS`.
|
||||
fn more_pages(dec: &mut Decoder<Cursor<&[u8]>>) -> Option<DecodeWarning> {
|
||||
const MAX_IFDS: u32 = 10_000;
|
||||
let mut pages = 1u32;
|
||||
let mut walked = 0;
|
||||
let mut total = None;
|
||||
loop {
|
||||
if !dec.more_images() {
|
||||
total = Some(pages);
|
||||
break;
|
||||
}
|
||||
walked += 1;
|
||||
if walked > MAX_IFDS || dec.next_image().is_err() {
|
||||
break;
|
||||
}
|
||||
// NewSubfileType bit 0: a reduced-resolution image; bit 2: a transparency mask.
|
||||
let kind = dec.find_tag_unsigned::<u32>(Tag::NewSubfileType).ok().flatten().unwrap_or(0);
|
||||
if kind & 0b101 == 0 {
|
||||
pages += 1;
|
||||
}
|
||||
}
|
||||
(pages > 1).then_some(DecodeWarning::MorePages { total })
|
||||
}
|
||||
|
||||
fn rational_f64(v: tiff::decoder::ifd::Value) -> Option<f64> {
|
||||
use tiff::decoder::ifd::Value;
|
||||
match v {
|
||||
|
|
@ -383,9 +411,11 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
|||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
// TIFF writes no Orientation tag (= 1): keep the XMP from contradicting it.
|
||||
let xmp = if opts.embed_metadata { img.meta.xmp.as_deref().map(crate::orientation::upright_xmp) } else { None };
|
||||
let tags = TagSet {
|
||||
icc: if opts.embed_icc { img.icc.as_deref() } else { None },
|
||||
xmp: if opts.embed_metadata { img.meta.xmp.as_deref().map(str::as_bytes) } else { None },
|
||||
xmp: xmp.as_deref().map(str::as_bytes),
|
||||
dpi: if opts.embed_metadata { img.meta.dpi.filter(|d| d.0 > 0.0 && d.1 > 0.0) } else { None },
|
||||
text,
|
||||
alpha: img.layout().has_alpha(),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use image::{DynamicImage, ImageDecoder, ImageFormat};
|
|||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, Image, SampleType};
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits};
|
||||
|
||||
fn image_format(f: Format) -> Option<ImageFormat> {
|
||||
|
|
@ -49,9 +49,49 @@ pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image,
|
|||
let mut img = from_dynamic(f, dynimg)?;
|
||||
img.icc = icc;
|
||||
img.meta.exif = exif;
|
||||
if f == Format::Gif {
|
||||
img.warnings.extend(gif_more_frames(bytes));
|
||||
}
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// Counts a GIF's frames (image descriptors) by walking its blocks, without decompressing any.
|
||||
/// The total is unknown when the blocks end before the trailer or break off.
|
||||
fn gif_more_frames(b: &[u8]) -> Option<DecodeWarning> {
|
||||
// Size of the colour table a packed-fields byte announces.
|
||||
let table = |flags: u8| if flags & 0x80 != 0 { 3usize << ((flags & 7) + 1) } else { 0 };
|
||||
// Skips the data sub-blocks starting at `i` (each length-prefixed, ended by a zero length).
|
||||
let skip = |mut i: usize| -> Option<usize> {
|
||||
loop {
|
||||
let n = usize::from(*b.get(i)?);
|
||||
i = i.checked_add(1 + n)?;
|
||||
if n == 0 {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Header (6 bytes), logical screen descriptor (7) and the global colour table.
|
||||
let mut i = 13 + table(*b.get(10)?);
|
||||
let mut frames = 0u32;
|
||||
let total = loop {
|
||||
let next = match b.get(i) {
|
||||
Some(0x3B) => break Some(frames),
|
||||
Some(0x21) => skip(i + 2),
|
||||
Some(0x2C) => {
|
||||
frames = frames.saturating_add(1);
|
||||
// Descriptor (10 bytes, packed fields last), local colour table, LZW code size.
|
||||
b.get(i + 9).and_then(|&flags| skip(i + 11 + table(flags)))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match next {
|
||||
Some(n) => i = n,
|
||||
None => break None,
|
||||
}
|
||||
};
|
||||
(frames > 1).then_some(DecodeWarning::MoreFrames { total })
|
||||
}
|
||||
|
||||
pub(crate) fn from_dynamic(f: Format, d: DynamicImage) -> Result<Image, CodecError> {
|
||||
let (w, h) = (d.width(), d.height());
|
||||
Ok(match d {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use std::io::Cursor;
|
|||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, Image, Metadata, SampleType};
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits};
|
||||
|
||||
const F: Format = Format::WebP;
|
||||
|
|
@ -30,6 +30,9 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
let mut img = Image::from_u8(w, h, layout, buf)?;
|
||||
img.icc = icc;
|
||||
img.meta = Metadata { exif, xmp, ..Default::default() };
|
||||
if dec.is_animated() && dec.num_frames() > 1 {
|
||||
img.warnings.push(DecodeWarning::MoreFrames { total: Some(dec.num_frames()) });
|
||||
}
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
|
|
@ -54,10 +57,11 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
|||
}
|
||||
if opts.embed_metadata {
|
||||
if let Some(exif) = &img.meta.exif {
|
||||
enc.set_exif_metadata(exif.clone());
|
||||
// The pixels are written as they are shown: never let a viewer rotate them again.
|
||||
enc.set_exif_metadata(crate::orientation::upright_exif(exif).into_owned());
|
||||
}
|
||||
if let Some(xmp) = &img.meta.xmp {
|
||||
enc.set_xmp_metadata(xmp.as_bytes().to_vec());
|
||||
enc.set_xmp_metadata(crate::orientation::upright_xmp(xmp).as_bytes().to_vec());
|
||||
}
|
||||
}
|
||||
enc.encode(img.data(), img.width(), img.height(), ct).map_err(|e| CodecError::encode(F, e))?;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@
|
|||
//! in **native endianness**. Integer samples are unsigned and normalized to
|
||||
//! `[0, max]`; float samples are nominally `[0, 1]` but may exceed it (HDR).
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::error::CodecError;
|
||||
use crate::format::Format;
|
||||
use half::f16;
|
||||
|
||||
/// Channel layout of an [`Image`]. Alpha, when present, is always the last
|
||||
|
|
@ -126,6 +129,33 @@ impl Metadata {
|
|||
}
|
||||
}
|
||||
|
||||
/// Something a decoder noticed about the file that the decoded pixels don't show: the file
|
||||
/// held more than was decoded, or less than it should.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DecodeWarning {
|
||||
/// Only the first frame of an animation was decoded; `total` counts every frame when known.
|
||||
MoreFrames { total: Option<u32> },
|
||||
/// Only the first page of a multi-page file was decoded; `total` counts every page when known.
|
||||
MorePages { total: Option<u32> },
|
||||
/// The image data ends early (truncated or damaged file); the decoder filled in the rest
|
||||
/// (a baseline JPEG's missing rows come out grey).
|
||||
Truncated { format: Format },
|
||||
}
|
||||
|
||||
impl fmt::Display for DecodeWarning {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
DecodeWarning::MoreFrames { total: Some(n) } => write!(f, "only the first of {n} frames was imported"),
|
||||
DecodeWarning::MoreFrames { total: None } => write!(f, "only the first frame of the animation was imported"),
|
||||
DecodeWarning::MorePages { total: Some(n) } => write!(f, "only the first of {n} pages was imported"),
|
||||
DecodeWarning::MorePages { total: None } => write!(f, "only the first page of the file was imported"),
|
||||
DecodeWarning::Truncated { format } => {
|
||||
write!(f, "{} data ends early (the file is truncated or damaged); part of the image is missing", format.name())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single flat raster image.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Image {
|
||||
|
|
@ -137,6 +167,9 @@ pub struct Image {
|
|||
/// Embedded ICC profile (must describe `layout`'s colour model).
|
||||
pub icc: Option<Vec<u8>>,
|
||||
pub meta: Metadata,
|
||||
/// What the decoder noticed about the file: empty for a complete, single image and for
|
||||
/// images built in memory. Encoders ignore it.
|
||||
pub warnings: Vec<DecodeWarning>,
|
||||
}
|
||||
|
||||
fn byte_len(width: u32, height: u32, layout: ChannelLayout, sample: SampleType) -> Option<usize> {
|
||||
|
|
@ -147,7 +180,7 @@ impl Image {
|
|||
/// A zero-filled image, or an error if the byte size overflows `usize`.
|
||||
pub fn new(width: u32, height: u32, layout: ChannelLayout, sample: SampleType) -> Result<Self, CodecError> {
|
||||
let len = byte_len(width, height, layout, sample).ok_or_else(|| CodecError::InvalidImage("image size overflows usize".into()))?;
|
||||
Ok(Image { width, height, layout, sample, data: vec![0; len], icc: None, meta: Metadata::default() })
|
||||
Ok(Image { width, height, layout, sample, data: vec![0; len], icc: None, meta: Metadata::default(), warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Wrap raw interleaved, native-endian bytes.
|
||||
|
|
@ -156,7 +189,7 @@ impl Image {
|
|||
if data.len() != expected {
|
||||
return Err(CodecError::InvalidImage(format!("buffer has {} bytes, expected {expected} for {width}x{height} {layout:?} {sample:?}", data.len())));
|
||||
}
|
||||
Ok(Image { width, height, layout, sample, data, icc: None, meta: Metadata::default() })
|
||||
Ok(Image { width, height, layout, sample, data, icc: None, meta: Metadata::default(), warnings: Vec::new() })
|
||||
}
|
||||
|
||||
pub fn from_u8(width: u32, height: u32, layout: ChannelLayout, data: Vec<u8>) -> Result<Self, CodecError> {
|
||||
|
|
@ -324,6 +357,7 @@ impl Image {
|
|||
data,
|
||||
icc: if layout.same_model(self.layout) { self.icc.clone() } else { None },
|
||||
meta: self.meta.clone(),
|
||||
warnings: self.warnings.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,14 +19,16 @@ mod fidelity;
|
|||
mod format;
|
||||
mod image;
|
||||
mod options;
|
||||
pub mod orientation;
|
||||
pub mod web;
|
||||
|
||||
pub use crate::codecs::png::encode_indexed as encode_png_indexed;
|
||||
pub use crate::error::CodecError;
|
||||
pub use crate::fidelity::{FidelityWarning, fidelity_warnings, fidelity_warnings_with};
|
||||
pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, from_extension};
|
||||
pub use crate::image::{ChannelLayout, Image, Metadata, SampleType};
|
||||
pub use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
|
||||
pub use crate::options::{DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
|
||||
pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp};
|
||||
pub use half::f16;
|
||||
|
||||
use crate::codecs::{exr, jpeg, png, pnm, tiff, via_image, webp};
|
||||
|
|
@ -64,7 +66,21 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
|
|||
}?;
|
||||
// Final guard for decoders whose header we could not pre-inspect.
|
||||
l.check(img.width(), img.height(), img.layout(), img.sample_type())?;
|
||||
Ok(img)
|
||||
if opts.keep_orientation {
|
||||
return Ok(img);
|
||||
}
|
||||
// Turn the pixels upright, like Photoshop: a TIFF records it in its own IFD0, the others
|
||||
// in their EXIF block. The metadata is rewritten to Orientation = 1 on the way.
|
||||
let o = match format {
|
||||
Format::Tiff => exif_orientation(bytes),
|
||||
_ => img.meta.exif.as_deref().map_or(1, exif_orientation),
|
||||
};
|
||||
// Orientations 5–8 swap width and height: with asymmetric limits a stored landscape
|
||||
// that fit can turn into a portrait that does not, so check the upright size too.
|
||||
if (5..=8).contains(&o) {
|
||||
l.check(img.height(), img.width(), img.layout(), img.sample_type())?;
|
||||
}
|
||||
img.oriented(o)
|
||||
}
|
||||
|
||||
/// Encode `image` as `format`. Conversions follow the same plan that
|
||||
|
|
|
|||
|
|
@ -67,6 +67,10 @@ impl Limits {
|
|||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DecodeOptions {
|
||||
pub limits: Limits,
|
||||
/// Keep the pixels as stored instead of applying the EXIF / TIFF
|
||||
/// orientation (by default they are turned upright and the metadata's
|
||||
/// orientation is rewritten to 1).
|
||||
pub keep_orientation: bool,
|
||||
}
|
||||
|
||||
/// PNG zlib effort.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,341 @@
|
|||
//! EXIF / TIFF orientation (tag 274): reading it, applying it to the pixels,
|
||||
//! and rewriting it to 1 ("top-left", upright) once the pixels are upright.
|
||||
//!
|
||||
//! Decoders apply the orientation by default (like Photoshop and every photo
|
||||
//! viewer), so a document is always edited upright. Encoders then write
|
||||
//! Orientation = 1, so a viewer never rotates the already-upright pixels a
|
||||
//! second time.
|
||||
//!
|
||||
//! Orientation values (TIFF 6.0, EXIF 2.3): 1 = upright, 2 = mirrored, 3 =
|
||||
//! rotated 180°, 4 = flipped vertically, 5 = transposed, 6 = needs a 90°
|
||||
//! clockwise turn, 7 = transverse, 8 = needs a 90° counter-clockwise turn.
|
||||
//! Anything malformed, truncated or out of range reads as 1: an IFD0 that
|
||||
//! starts inside the 8-byte header or is cut off before its next-IFD pointer,
|
||||
//! or an Orientation entry that is not exactly one SHORT or LONG (the spec says
|
||||
//! SHORT, but some cameras and tools write LONG). When IFD0 holds
|
||||
//! several Orientation entries the first one wins (like libexif).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::error::CodecError;
|
||||
use crate::image::Image;
|
||||
|
||||
/// The TIFF/EXIF Orientation tag.
|
||||
const TAG_ORIENTATION: u16 = 274;
|
||||
/// Field types accepted for the Orientation tag: SHORT (the spec) and LONG
|
||||
/// (written by some cameras and tools).
|
||||
const TYPE_SHORT: u16 = 3;
|
||||
const TYPE_LONG: u16 = 4;
|
||||
/// Bytes per IFD entry.
|
||||
const ENTRY: usize = 12;
|
||||
/// The TIFF header is 8 bytes, so no IFD can start before it.
|
||||
const HEADER: usize = 8;
|
||||
|
||||
/// Byte order of a TIFF structure.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Order {
|
||||
Little,
|
||||
Big,
|
||||
}
|
||||
|
||||
impl Order {
|
||||
fn u16(self, b: &[u8], at: usize) -> Option<u16> {
|
||||
let s: [u8; 2] = b.get(at..at.checked_add(2)?)?.try_into().ok()?;
|
||||
Some(match self {
|
||||
Order::Little => u16::from_le_bytes(s),
|
||||
Order::Big => u16::from_be_bytes(s),
|
||||
})
|
||||
}
|
||||
|
||||
fn u32(self, b: &[u8], at: usize) -> Option<u32> {
|
||||
let s: [u8; 4] = b.get(at..at.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(match self {
|
||||
Order::Little => u32::from_le_bytes(s),
|
||||
Order::Big => u32::from_be_bytes(s),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The TIFF payload of an EXIF block (a JPEG-style `Exif\0\0` prefix is skipped).
|
||||
fn tiff_body(b: &[u8]) -> &[u8] {
|
||||
b.strip_prefix(b"Exif\0\0").unwrap_or(b)
|
||||
}
|
||||
|
||||
/// IFD0 of a TIFF structure: its byte order and the offsets of its entries.
|
||||
///
|
||||
/// The whole table must be present, up to and including the 4-byte next-IFD
|
||||
/// pointer, and it must start after the 8-byte header; anything else is
|
||||
/// treated as unparseable.
|
||||
fn ifd0_entries(b: &[u8]) -> Option<(Order, impl Iterator<Item = usize>)> {
|
||||
let order = match b.get(0..4)? {
|
||||
[b'I', b'I', 42, 0] => Order::Little,
|
||||
[b'M', b'M', 0, 42] => Order::Big,
|
||||
_ => return None,
|
||||
};
|
||||
let ifd = usize::try_from(order.u32(b, 4)?).ok()?;
|
||||
if ifd < HEADER {
|
||||
return None;
|
||||
}
|
||||
let count = usize::from(order.u16(b, ifd)?);
|
||||
let first = ifd.checked_add(2)?;
|
||||
let next_ifd = first.checked_add(count.checked_mul(ENTRY)?)?;
|
||||
if next_ifd.checked_add(4)? > b.len() {
|
||||
return None;
|
||||
}
|
||||
// In bounds: every entry ends at or before `next_ifd`.
|
||||
Some((order, (0..count).map(move |i| first + i * ENTRY)))
|
||||
}
|
||||
|
||||
/// A well-formed Orientation entry's value: where it sits and its field type.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Value {
|
||||
at: usize,
|
||||
ty: u16,
|
||||
}
|
||||
|
||||
impl Value {
|
||||
/// The stored value (a SHORT or LONG, left-justified in the 4-byte field).
|
||||
fn read(self, order: Order, b: &[u8]) -> Option<u32> {
|
||||
match self.ty {
|
||||
TYPE_SHORT => order.u16(b, self.at).map(u32::from),
|
||||
_ => order.u32(b, self.at),
|
||||
}
|
||||
}
|
||||
|
||||
/// 1 in this entry's own type, width and byte order.
|
||||
fn one(self, order: Order) -> Vec<u8> {
|
||||
match (self.ty, order) {
|
||||
(TYPE_SHORT, Order::Little) => 1u16.to_le_bytes().to_vec(),
|
||||
(TYPE_SHORT, Order::Big) => 1u16.to_be_bytes().to_vec(),
|
||||
(_, Order::Little) => 1u32.to_le_bytes().to_vec(),
|
||||
(_, Order::Big) => 1u32.to_be_bytes().to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of the entry at `e` if it is a well-formed Orientation entry:
|
||||
/// exactly one SHORT or LONG.
|
||||
fn orientation_value(order: Order, b: &[u8], e: usize) -> Option<Value> {
|
||||
if order.u16(b, e)? != TAG_ORIENTATION || order.u32(b, e.checked_add(4)?)? != 1 {
|
||||
return None;
|
||||
}
|
||||
let ty = order.u16(b, e.checked_add(2)?)?;
|
||||
matches!(ty, TYPE_SHORT | TYPE_LONG).then_some(Value { at: e.checked_add(8)?, ty })
|
||||
}
|
||||
|
||||
/// Locates the Orientation value in IFD0.
|
||||
///
|
||||
/// The **first** Orientation entry wins (like libexif and Photoshop); if that
|
||||
/// one is malformed (count ≠ 1, or neither SHORT nor LONG) the tag is ignored,
|
||||
/// even when a later duplicate is well formed.
|
||||
fn find_entry(b: &[u8]) -> Option<(Order, Value)> {
|
||||
let (order, mut entries) = ifd0_entries(b)?;
|
||||
let e = entries.find(|&e| order.u16(b, e) == Some(TAG_ORIENTATION))?;
|
||||
Some((order, orientation_value(order, b, e)?))
|
||||
}
|
||||
|
||||
/// The orientation (1–8) recorded in a TIFF-structured block: an EXIF payload
|
||||
/// (with or without the `Exif\0\0` prefix) or a whole TIFF file. Missing,
|
||||
/// malformed or out-of-range values give 1.
|
||||
pub fn exif_orientation(exif: &[u8]) -> u16 {
|
||||
let b = tiff_body(exif);
|
||||
find_entry(b).and_then(|(order, v)| v.read(order, b)).and_then(|v| u16::try_from(v).ok()).filter(|v| (1..=8).contains(v)).unwrap_or(1)
|
||||
}
|
||||
|
||||
/// `exif` with its Orientation rewritten to 1, borrowed when there is nothing
|
||||
/// to change (no well-formed tag, already 1, or unparseable).
|
||||
///
|
||||
/// Only the value bytes of each well-formed Orientation entry change, written
|
||||
/// as 1 in the entry's existing type and width (SHORT or LONG); every other
|
||||
/// byte (other tags, their offset-based values, sub-IFDs) is kept as is.
|
||||
/// Well-formed duplicates are all set to 1, so a reader that lets a later
|
||||
/// duplicate win cannot rotate the upright pixels either.
|
||||
pub fn upright_exif(exif: &[u8]) -> Cow<'_, [u8]> {
|
||||
let body = tiff_body(exif);
|
||||
let prefix = exif.len() - body.len();
|
||||
let Some((order, entries)) = ifd0_entries(body) else {
|
||||
return Cow::Borrowed(exif);
|
||||
};
|
||||
let values: Vec<Value> = entries.filter_map(|e| orientation_value(order, body, e)).collect();
|
||||
if values.iter().all(|v| v.read(order, body) == Some(1)) {
|
||||
return Cow::Borrowed(exif);
|
||||
}
|
||||
let mut out = exif.to_vec();
|
||||
for v in values {
|
||||
let one = v.one(order);
|
||||
let slot = prefix.checked_add(v.at).and_then(|at| out.get_mut(at..at.checked_add(one.len())?));
|
||||
if let Some(slot) = slot {
|
||||
slot.copy_from_slice(&one);
|
||||
}
|
||||
}
|
||||
Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// `xmp` with any `tiff:Orientation` (attribute or element form) set to 1,
|
||||
/// borrowed when there is nothing to change.
|
||||
pub fn upright_xmp(xmp: &str) -> Cow<'_, str> {
|
||||
const KEY: &str = "tiff:Orientation";
|
||||
if !xmp.contains(KEY) {
|
||||
return Cow::Borrowed(xmp);
|
||||
}
|
||||
let mut out = String::with_capacity(xmp.len());
|
||||
let mut rest = xmp;
|
||||
let mut changed = false;
|
||||
while let Some(i) = rest.find(KEY) {
|
||||
let (head, tail) = rest.split_at(i + KEY.len());
|
||||
out.push_str(head);
|
||||
rest = tail;
|
||||
// `tiff:Orientation="6"`, `tiff:Orientation='6'` or `<tiff:Orientation>6<`.
|
||||
let open = rest.char_indices().take_while(|&(_, c)| c.is_ascii_whitespace() || matches!(c, '=' | '"' | '\'' | '>')).last();
|
||||
let Some((at, c)) = open else { continue };
|
||||
if !matches!(c, '"' | '\'' | '>') {
|
||||
continue;
|
||||
}
|
||||
let start = at + 1;
|
||||
let digits = rest.get(start..).map_or(0, |s| s.bytes().take_while(u8::is_ascii_digit).count());
|
||||
if digits == 0 || rest.get(start..start + digits) == Some("1") {
|
||||
continue;
|
||||
}
|
||||
out.push_str(rest.get(..start).unwrap_or_default());
|
||||
out.push('1');
|
||||
rest = rest.get(start + digits..).unwrap_or_default();
|
||||
changed = true;
|
||||
}
|
||||
out.push_str(rest);
|
||||
if changed { Cow::Owned(out) } else { Cow::Borrowed(xmp) }
|
||||
}
|
||||
|
||||
/// Source pixel index for output pixel `(0, y2)` and the step per output column, for
|
||||
/// orientation `o` of a `w`×`h` source (values 2–8; see the module docs).
|
||||
fn walk(o: u16, w: isize, h: isize, y2: isize) -> (isize, isize) {
|
||||
match o {
|
||||
2 => (y2 * w + w - 1, -1),
|
||||
3 => ((h - 1 - y2) * w + w - 1, -1),
|
||||
4 => ((h - 1 - y2) * w, 1),
|
||||
5 => (y2, w),
|
||||
6 => ((h - 1) * w + y2, -w),
|
||||
7 => ((h - 1) * w + (w - 1 - y2), -w),
|
||||
8 => (w - 1 - y2, w),
|
||||
_ => (y2 * w, 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Output columns per block: a block's source pixels (for a 90° turn, `BLOCK`
|
||||
/// rows × the band height) stay in cache while it is written.
|
||||
const BLOCK: usize = 64;
|
||||
/// Output rows per parallel band.
|
||||
const BAND: usize = 64;
|
||||
|
||||
/// Writes the oriented pixels of one band of output rows, `N` bytes per pixel.
|
||||
fn band<const N: usize>(src: &[[u8; N]], dst: &mut [[u8; N]], o: u16, (w, h): (usize, usize), ow: usize, y0: usize) {
|
||||
let (wi, hi) = (w as isize, h as isize);
|
||||
let rows = dst.len() / ow.max(1);
|
||||
for x0 in (0..ow).step_by(BLOCK) {
|
||||
let x1 = (x0 + BLOCK).min(ow);
|
||||
for r in 0..rows {
|
||||
let (base, step) = walk(o, wi, hi, (y0 + r) as isize);
|
||||
let Some(out) = dst.get_mut(r * ow + x0..r * ow + x1) else { return };
|
||||
let mut i = base + x0 as isize * step;
|
||||
for px in out {
|
||||
if let Some(s) = usize::try_from(i).ok().and_then(|i| src.get(i)) {
|
||||
*px = *s;
|
||||
}
|
||||
i += step;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Orients `src` (`N` bytes per pixel) into a new buffer, a band of rows per task.
|
||||
fn orient_n<const N: usize>(src: &[u8], o: u16, (w, h): (usize, usize), ow: usize) -> Result<Vec<u8>, CodecError> {
|
||||
let mut out = alloc_zeroed(src.len())?;
|
||||
let (s, _) = src.as_chunks::<N>();
|
||||
let (d, _) = out.as_chunks_mut::<N>();
|
||||
let per = (BAND * ow).max(1);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
d.par_chunks_mut(per).enumerate().for_each(|(b, chunk)| band(s, chunk, o, (w, h), ow, b * BAND));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
for (b, chunk) in d.chunks_mut(per).enumerate() {
|
||||
band(s, chunk, o, (w, h), ow, b * BAND);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A zeroed buffer of `n` bytes, or an error (not an abort) when memory runs out.
|
||||
fn alloc_zeroed(n: usize) -> Result<Vec<u8>, CodecError> {
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(n).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {n} bytes to orient the image")))?;
|
||||
out.resize(n, 0);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Pixel-format-agnostic orientation: any byte size per pixel.
|
||||
fn orient_bytes(src: &[u8], bpp: usize, o: u16, (w, h): (usize, usize), ow: usize) -> Result<Vec<u8>, CodecError> {
|
||||
match bpp {
|
||||
1 => orient_n::<1>(src, o, (w, h), ow),
|
||||
2 => orient_n::<2>(src, o, (w, h), ow),
|
||||
3 => orient_n::<3>(src, o, (w, h), ow),
|
||||
4 => orient_n::<4>(src, o, (w, h), ow),
|
||||
5 => orient_n::<5>(src, o, (w, h), ow),
|
||||
6 => orient_n::<6>(src, o, (w, h), ow),
|
||||
8 => orient_n::<8>(src, o, (w, h), ow),
|
||||
10 => orient_n::<10>(src, o, (w, h), ow),
|
||||
12 => orient_n::<12>(src, o, (w, h), ow),
|
||||
16 => orient_n::<16>(src, o, (w, h), ow),
|
||||
_ => orient_n::<20>(src, o, (w, h), ow),
|
||||
}
|
||||
}
|
||||
|
||||
impl Image {
|
||||
/// The image turned upright for orientation `o` (1–8; anything else is
|
||||
/// treated as 1 and returns the image unchanged). Width and height swap
|
||||
/// for 5–8, as does the DPI, and the EXIF and XMP orientation are
|
||||
/// rewritten to 1 so the result is not rotated again on display or export.
|
||||
pub fn oriented(self, o: u16) -> Result<Image, CodecError> {
|
||||
if !(2..=8).contains(&o) {
|
||||
return Ok(self);
|
||||
}
|
||||
let (w, h) = (self.width() as usize, self.height() as usize);
|
||||
let swap = o >= 5;
|
||||
let (ow, oh) = if swap { (h, w) } else { (w, h) };
|
||||
let (layout, sample) = (self.layout(), self.sample_type());
|
||||
let bpp = layout.channels() * sample.bytes();
|
||||
// Every layout/sample pair is 1–20 bytes per pixel and has a fixed-size path.
|
||||
if !matches!(bpp, 1..=6 | 8 | 10 | 12 | 16 | 20) || w == 0 || h == 0 {
|
||||
return Err(CodecError::InvalidImage(format!("cannot orient {layout:?} {sample:?} {w}x{h}")));
|
||||
}
|
||||
let data = orient_bytes(self.data(), bpp, o, (w, h), ow)?;
|
||||
let mut meta = self.meta.clone();
|
||||
if let Some(e) = &mut meta.exif
|
||||
&& let Cow::Owned(fixed) = upright_exif(e)
|
||||
{
|
||||
*e = fixed;
|
||||
}
|
||||
if let Some(x) = &mut meta.xmp
|
||||
&& let Cow::Owned(fixed) = upright_xmp(x)
|
||||
{
|
||||
*x = fixed;
|
||||
}
|
||||
if swap {
|
||||
meta.dpi = meta.dpi.map(|(x, y)| (y, x));
|
||||
}
|
||||
let mut out = Image::from_raw(ow as u32, oh as u32, layout, sample, data)?.with_icc(self.icc).with_meta(meta);
|
||||
out.warnings = self.warnings;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_impossible_allocation_is_an_error_not_an_abort() {
|
||||
assert!(matches!(alloc_zeroed(usize::MAX), Err(CodecError::LimitExceeded(_))));
|
||||
assert!(matches!(orient_n::<1>(&[], 6, (0, 0), 0), Ok(v) if v.is_empty()));
|
||||
assert_eq!(alloc_zeroed(3).unwrap(), vec![0, 0, 0]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
//! Decode warnings: a file that holds more than was decoded (#523: animations, multi-page
|
||||
//! TIFF) or less than it should (#518: truncated JPEG) says so; complete single images don't.
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
use photocraft_codecs::*;
|
||||
|
||||
const W: u32 = 24;
|
||||
const H: u32 = 16;
|
||||
|
||||
fn solid(rgb: [u8; 3]) -> Image {
|
||||
Image::from_u8(W, H, ChannelLayout::Rgb, rgb.repeat((W * H) as usize)).unwrap()
|
||||
}
|
||||
|
||||
const COLOURS: [[u8; 3]; 3] = [[200, 30, 10], [10, 200, 30], [30, 10, 200]];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #518: truncated JPEG
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn jpeg(layout: ChannelLayout, progressive: bool) -> Vec<u8> {
|
||||
let img = synth(64, 48, layout, SampleType::U8, 3, 0.3);
|
||||
let ct = match layout {
|
||||
ChannelLayout::Gray => jpeg_encoder::ColorType::Luma,
|
||||
ChannelLayout::Cmyk => jpeg_encoder::ColorType::Cmyk,
|
||||
_ => jpeg_encoder::ColorType::Rgb,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut enc = jpeg_encoder::Encoder::new(&mut out, 90);
|
||||
enc.set_progressive(progressive);
|
||||
enc.encode(img.data(), 64, 48, ct).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// Offset of the first byte of entropy-coded data (just past the first SOS header).
|
||||
fn scan_start(b: &[u8]) -> usize {
|
||||
let sos = b.windows(2).position(|w| w == [0xFF, 0xDA]).unwrap();
|
||||
sos + 2 + u16::from_be_bytes([b[sos + 2], b[sos + 3]]) as usize
|
||||
}
|
||||
|
||||
const TRUNCATED: DecodeWarning = DecodeWarning::Truncated { format: Format::Jpeg };
|
||||
|
||||
#[test]
|
||||
fn truncated_jpeg_decodes_with_a_warning() {
|
||||
for layout in [ChannelLayout::Gray, ChannelLayout::Rgb, ChannelLayout::Cmyk] {
|
||||
for progressive in [false, true] {
|
||||
let full = jpeg(layout, progressive);
|
||||
assert_eq!(decode(&full).unwrap().warnings, [], "{layout:?} progressive {progressive}");
|
||||
let half = decode(&full[..full.len() / 2]).unwrap();
|
||||
assert_eq!(half.warnings, [TRUNCATED], "{layout:?} progressive {progressive}");
|
||||
assert_eq!(half.dimensions(), (64, 48));
|
||||
// One byte short: the end-of-image marker is incomplete.
|
||||
assert_eq!(decode(&full[..full.len() - 1]).unwrap().warnings, [TRUNCATED]);
|
||||
// A few bytes of scan data still decode, with the warning.
|
||||
let start = scan_start(&full);
|
||||
assert_eq!(decode(&full[..start + 8]).unwrap().warnings, [TRUNCATED]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_jpeg_message_names_the_problem() {
|
||||
let s = TRUNCATED.to_string();
|
||||
assert!(s.starts_with("JPEG data ends early") && s.contains("truncated"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_without_any_scan_data_is_an_error() {
|
||||
let full = jpeg(ChannelLayout::Rgb, false);
|
||||
let start = scan_start(&full);
|
||||
for cut in [start - 3, start - 1, start] {
|
||||
let e = decode(&full[..cut]).unwrap_err().to_string();
|
||||
assert!(e.contains("before any image data"), "cut {cut}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_after_the_end_of_image_is_not_truncation() {
|
||||
let mut b = jpeg(ChannelLayout::Rgb, false);
|
||||
b.extend_from_slice(b"\0\0trailing bytes \xFF\xD8\xFF");
|
||||
assert_eq!(decode(&b).unwrap().warnings, []);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_end_marker_inside_metadata_does_not_hide_truncation() {
|
||||
// An EXIF block holding FF D9 (as an embedded thumbnail does) must be skipped as a segment.
|
||||
let mut img = synth(64, 48, ChannelLayout::Rgb, SampleType::U8, 3, 0.3);
|
||||
let mut exif = sample_exif();
|
||||
exif.extend_from_slice(&[0xFF, 0xD8, 0x00, 0xFF, 0xD9]);
|
||||
img.meta.exif = Some(exif);
|
||||
let full = encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(decode(&full).unwrap().warnings, []);
|
||||
assert_eq!(decode(&full[..full.len() * 2 / 3]).unwrap().warnings, [TRUNCATED]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_markers_and_fill_bytes_are_scan_data() {
|
||||
// Restart markers (FF D0–D7) and fill bytes (FF FF) inside a scan don't end it.
|
||||
let full = jpeg(ChannelLayout::Rgb, false);
|
||||
let start = scan_start(&full);
|
||||
let mut b = full[..start].to_vec();
|
||||
b.extend_from_slice(&[0x12, 0xFF, 0x00, 0x34, 0xFF, 0xD0, 0x56, 0xFF, 0xFF, 0xD7, 0x78]);
|
||||
assert_eq!(decode(&b).unwrap().warnings, [TRUNCATED]);
|
||||
b.extend_from_slice(&[0xFF, 0xFF, 0xD9]);
|
||||
assert_eq!(decode(&b).unwrap().warnings, []);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #523: only the first frame or page is decoded
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn more_frames(n: u32) -> Vec<DecodeWarning> {
|
||||
vec![DecodeWarning::MoreFrames { total: Some(n) }]
|
||||
}
|
||||
|
||||
fn gif(frames: usize) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut enc = image::codecs::gif::GifEncoder::new(&mut out);
|
||||
let frames = COLOURS.iter().take(frames).map(|c| image::Frame::new(image::RgbaImage::from_pixel(W, H, image::Rgba([c[0], c[1], c[2], 255]))));
|
||||
enc.encode_frames(frames).unwrap();
|
||||
drop(enc);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animated_gif_warns_and_keeps_the_first_frame() {
|
||||
let one = decode(&gif(1)).unwrap();
|
||||
assert_eq!(one.warnings, []);
|
||||
for n in [2, 3] {
|
||||
let img = decode(&gif(n)).unwrap();
|
||||
assert_eq!(img.warnings, more_frames(n as u32));
|
||||
assert_eq!(img.to_rgba8(), one.to_rgba8(), "{n} frames: first frame pixels");
|
||||
}
|
||||
// A single-frame GIF written by our own encoder.
|
||||
let ours = encode(&solid(COLOURS[0]), Format::Gif, &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(decode(&ours).unwrap().warnings, []);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gif_without_a_trailer_warns_without_a_total() {
|
||||
let b = gif(3);
|
||||
assert_eq!(b.last(), Some(&0x3B));
|
||||
let img = decode(&b[..b.len() - 1]).unwrap();
|
||||
assert_eq!(img.warnings, [DecodeWarning::MoreFrames { total: None }]);
|
||||
assert_eq!(img.warnings[0].to_string(), "only the first frame of the animation was imported");
|
||||
}
|
||||
|
||||
/// An APNG of `frames` solid frames at 8 or 16 bits; with `separate_default` the IDAT image is
|
||||
/// an extra default image outside the animation.
|
||||
fn apng(frames: u32, sixteen: bool, separate_default: bool) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut enc = png::Encoder::new(&mut out, W, H);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.set_depth(if sixteen { png::BitDepth::Sixteen } else { png::BitDepth::Eight });
|
||||
let images = frames + u32::from(separate_default);
|
||||
if frames > 0 {
|
||||
enc.set_animated(frames, 0).unwrap();
|
||||
enc.set_sep_def_img(separate_default).unwrap();
|
||||
}
|
||||
let mut w = enc.write_header().unwrap();
|
||||
for c in COLOURS.iter().cycle().take(images.max(1) as usize) {
|
||||
let px: Vec<u8> = if sixteen { c.iter().flat_map(|&v| [v, v]).collect::<Vec<u8>>().repeat((W * H) as usize) } else { c.repeat((W * H) as usize) };
|
||||
w.write_image_data(&px).unwrap();
|
||||
}
|
||||
w.finish().unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apng_warns_and_keeps_the_first_frame() {
|
||||
for sixteen in [false, true] {
|
||||
let still = decode(&apng(0, sixteen, false)).unwrap();
|
||||
assert_eq!(still.warnings, []);
|
||||
assert_eq!(decode(&apng(1, sixteen, false)).unwrap().warnings, [], "a one-frame APNG is a still");
|
||||
let img = decode(&apng(3, sixteen, false)).unwrap();
|
||||
assert_eq!(img.warnings, more_frames(3), "16-bit {sixteen}");
|
||||
assert_eq!((img.sample_type(), img.data()), (still.sample_type(), still.data()), "16-bit {sixteen}: first frame pixels");
|
||||
// Default image + 2 animation frames: three images, the default one kept.
|
||||
let img = decode(&apng(2, sixteen, true)).unwrap();
|
||||
assert_eq!(img.warnings, more_frames(3));
|
||||
assert_eq!(img.data(), still.data());
|
||||
}
|
||||
}
|
||||
|
||||
fn chunk(id: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
let mut c = id.to_vec();
|
||||
c.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
c.extend_from_slice(data);
|
||||
if data.len() % 2 == 1 {
|
||||
c.push(0);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn u24(v: u32) -> [u8; 3] {
|
||||
let b = v.to_le_bytes();
|
||||
[b[0], b[1], b[2]]
|
||||
}
|
||||
|
||||
/// An animated WebP: each frame is a full-canvas lossless still wrapped in an ANMF chunk.
|
||||
fn animated_webp(frames: &[Image]) -> Vec<u8> {
|
||||
let mut body = b"WEBP".to_vec();
|
||||
let mut vp8x = vec![0x02, 0, 0, 0]; // animation flag
|
||||
vp8x.extend_from_slice(&u24(W - 1));
|
||||
vp8x.extend_from_slice(&u24(H - 1));
|
||||
body.extend(chunk(b"VP8X", &vp8x));
|
||||
body.extend(chunk(b"ANIM", &[0, 0, 0, 0, 0, 0]));
|
||||
for f in frames {
|
||||
let still = encode(f, Format::WebP, &EncodeOptions::default()).unwrap();
|
||||
let at = still.windows(4).position(|w| w == b"VP8L").unwrap();
|
||||
let len = u32::from_le_bytes(still[at + 4..at + 8].try_into().unwrap()) as usize;
|
||||
let mut anmf = vec![0; 6]; // frame x, y
|
||||
anmf.extend_from_slice(&u24(W - 1));
|
||||
anmf.extend_from_slice(&u24(H - 1));
|
||||
anmf.extend_from_slice(&u24(100)); // duration
|
||||
anmf.push(0);
|
||||
anmf.extend(chunk(b"VP8L", &still[at + 8..at + 8 + len]));
|
||||
body.extend(chunk(b"ANMF", &anmf));
|
||||
}
|
||||
let mut out = b"RIFF".to_vec();
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend(body);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animated_webp_warns_and_keeps_the_first_frame() {
|
||||
let frames: Vec<Image> = COLOURS.iter().map(|&c| solid(c)).collect();
|
||||
let still = decode(&encode(&frames[0], Format::WebP, &EncodeOptions::default()).unwrap()).unwrap();
|
||||
assert_eq!(still.warnings, []);
|
||||
assert_eq!(decode(&animated_webp(&frames[..1])).unwrap().warnings, [], "a one-frame animation");
|
||||
for n in [2, 3] {
|
||||
let img = decode(&animated_webp(&frames[..n])).unwrap();
|
||||
assert_eq!(img.warnings, more_frames(n as u32));
|
||||
// The animation decoder composites the frame onto its canvas: within one level.
|
||||
let (a, b) = (img.to_rgba8(), still.to_rgba8());
|
||||
assert!(a.len() == b.len() && a.iter().zip(&b).all(|(x, y)| x.abs_diff(*y) <= 1), "{n} frames: first frame pixels");
|
||||
}
|
||||
}
|
||||
|
||||
/// A TIFF with one IFD per entry: `(NewSubfileType, sample type)`.
|
||||
fn tiff(ifds: &[(u32, SampleType)]) -> Vec<u8> {
|
||||
use tiff::encoder::{TiffEncoder, colortype};
|
||||
use tiff::tags::Tag;
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
let mut enc = TiffEncoder::new(&mut out).unwrap();
|
||||
let n = (W * H) as usize;
|
||||
// Each page is a flat grey at its own level (`k`), written at `sample` depth.
|
||||
for (k, &(kind, sample)) in ifds.iter().enumerate() {
|
||||
macro_rules! page {
|
||||
($ct:ty, $data:expr) => {{
|
||||
let mut im = enc.new_image::<$ct>(W, H).unwrap();
|
||||
if kind != 0 {
|
||||
im.encoder().write_tag(Tag::NewSubfileType, kind).unwrap();
|
||||
}
|
||||
im.write_data($data).unwrap();
|
||||
}};
|
||||
}
|
||||
let v = 40 * k as u8 + 10;
|
||||
match sample {
|
||||
SampleType::U8 => page!(colortype::Gray8, &vec![v; n]),
|
||||
SampleType::U16 => page!(colortype::Gray16, &vec![u16::from(v) * 257; n]),
|
||||
_ => page!(colortype::Gray32Float, &vec![f32::from(v) / 255.0; n]),
|
||||
}
|
||||
}
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_page_tiff_warns_and_keeps_the_first_page() {
|
||||
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let one = decode(&tiff(&[(0, sample)])).unwrap();
|
||||
assert_eq!(one.warnings, [], "{sample:?}");
|
||||
let img = decode(&tiff(&[(0, sample), (2, sample)])).unwrap();
|
||||
assert_eq!(img.warnings, [DecodeWarning::MorePages { total: Some(2) }], "{sample:?}");
|
||||
assert_eq!(img.data(), one.data(), "{sample:?}: first page pixels");
|
||||
let img = decode(&tiff(&[(0, sample), (0, sample), (0, sample)])).unwrap();
|
||||
assert_eq!(img.warnings, [DecodeWarning::MorePages { total: Some(3) }], "{sample:?}");
|
||||
assert_eq!(img.warnings[0].to_string(), "only the first of 3 pages was imported");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiff_thumbnails_and_masks_are_not_pages() {
|
||||
let u8 = SampleType::U8;
|
||||
// A reduced-resolution copy (bit 0) or a transparency mask (bit 2) is part of the page.
|
||||
assert_eq!(decode(&tiff(&[(0, u8), (1, u8)])).unwrap().warnings, []);
|
||||
assert_eq!(decode(&tiff(&[(0, u8), (4, u8)])).unwrap().warnings, []);
|
||||
assert_eq!(decode(&tiff(&[(0, u8), (1, u8), (0, u8)])).unwrap().warnings, [DecodeWarning::MorePages { total: Some(2) }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_images_carry_no_warnings() {
|
||||
for f in rw_formats() {
|
||||
let img = synth(9, 7, ChannelLayout::Rgba, SampleType::U8, 5, 0.2);
|
||||
let Ok(bytes) = encode(&img, f, &EncodeOptions::default()) else { continue };
|
||||
assert_eq!(decode(&bytes).unwrap().warnings, [], "{f:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warnings_survive_orientation() {
|
||||
let mut img = synth(8, 4, ChannelLayout::Rgb, SampleType::U8, 1, 0.0);
|
||||
img.warnings.push(DecodeWarning::MorePages { total: None });
|
||||
for o in 1..=8 {
|
||||
assert_eq!(img.clone().oriented(o).unwrap().warnings, img.warnings, "orientation {o}");
|
||||
}
|
||||
assert_eq!(img.convert(ChannelLayout::Rgba, SampleType::U16).warnings, img.warnings);
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ use common::*;
|
|||
use photocraft_codecs::*;
|
||||
|
||||
fn opts(l: Limits) -> DecodeOptions {
|
||||
DecodeOptions { limits: l }
|
||||
DecodeOptions { limits: l, ..Default::default() }
|
||||
}
|
||||
|
||||
fn is_limit(r: Result<Image, CodecError>) -> bool {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use photocraft_codecs::*;
|
|||
use proptest::prelude::*;
|
||||
|
||||
fn tight() -> DecodeOptions {
|
||||
DecodeOptions { limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 64 << 20 } }
|
||||
DecodeOptions { limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 64 << 20 }, ..Default::default() }
|
||||
}
|
||||
|
||||
fn samples() -> Vec<(Format, Vec<u8>)> {
|
||||
|
|
@ -56,6 +56,13 @@ fn truncated_files_mostly_error() {
|
|||
let r = decode_as_with(f, &bytes[..bytes.len() / 2], &tight());
|
||||
assert!(r.is_err(), "{f:?} decoded half a file");
|
||||
}
|
||||
// JPEG decodes leniently (the missing part grey) but never silently (#518).
|
||||
if f == Format::Jpeg {
|
||||
match decode_as_with(f, &bytes[..bytes.len() / 2], &tight()) {
|
||||
Ok(img) => assert_eq!(img.warnings, [DecodeWarning::Truncated { format: f }]),
|
||||
Err(e) => assert!(e.to_string().contains("before any image data"), "{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,556 @@
|
|||
//! EXIF / TIFF orientation (#285): files are opened upright, and every export
|
||||
//! writes Orientation = 1 so the upright pixels are never rotated twice.
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
use photocraft_codecs::*;
|
||||
use proptest::prelude::{any, prop_assert, prop_assert_eq, proptest};
|
||||
|
||||
/// A minimal EXIF (TIFF-structured) block: a filler tag, then Orientation.
|
||||
fn exif(o: u32, big: bool, long: bool) -> Vec<u8> {
|
||||
let u16b = |v: u16| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
let mut v = if big { b"MM\0*".to_vec() } else { b"II*\0".to_vec() };
|
||||
v.extend_from_slice(&u32b(8));
|
||||
v.extend_from_slice(&u16b(2));
|
||||
// ImageDescription-ish filler (ASCII, 4 bytes inline).
|
||||
v.extend_from_slice(&u16b(0x010E));
|
||||
v.extend_from_slice(&u16b(2));
|
||||
v.extend_from_slice(&u32b(4));
|
||||
v.extend_from_slice(b"abc\0");
|
||||
v.extend_from_slice(&u16b(0x0112));
|
||||
if long {
|
||||
v.extend_from_slice(&u16b(4));
|
||||
v.extend_from_slice(&u32b(1));
|
||||
v.extend_from_slice(&u32b(o));
|
||||
} else {
|
||||
v.extend_from_slice(&u16b(3));
|
||||
v.extend_from_slice(&u32b(1));
|
||||
v.extend_from_slice(&u16b(o as u16));
|
||||
v.extend_from_slice(&[0, 0]);
|
||||
}
|
||||
v.extend_from_slice(&u32b(0));
|
||||
v
|
||||
}
|
||||
|
||||
/// The "upright" test picture: `w`×`h` gray values, distinct per pixel.
|
||||
fn upright(w: usize, h: usize, value: impl Fn(usize, usize) -> u8) -> Vec<u8> {
|
||||
(0..h).flat_map(|y| (0..w).map(move |x| (x, y))).map(|(x, y)| value(x, y)).collect()
|
||||
}
|
||||
|
||||
/// What a camera stores for orientation `o` when the scene is `up` (`w`×`h`): the inverse
|
||||
/// of the turn a viewer applies. Returns the stored pixels and their size.
|
||||
fn stored(up: &[u8], w: usize, h: usize, o: u16) -> (Vec<u8>, usize, usize) {
|
||||
let (sw, sh) = if o >= 5 { (h, w) } else { (w, h) };
|
||||
let mut s = vec![0u8; up.len()];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// Where upright pixel (x, y) sits in the stored image.
|
||||
let (sx, sy) = match o {
|
||||
1 => (x, y),
|
||||
2 => (w - 1 - x, y), // mirrored horizontally
|
||||
3 => (w - 1 - x, h - 1 - y), // rotated 180°
|
||||
4 => (x, h - 1 - y), // mirrored vertically
|
||||
5 => (y, x), // transposed
|
||||
6 => (y, w - 1 - x), // viewer turns it 90° clockwise
|
||||
7 => (h - 1 - y, w - 1 - x), // transverse
|
||||
8 => (h - 1 - y, x), // viewer turns it 90° counter-clockwise
|
||||
_ => unreachable!(),
|
||||
};
|
||||
s[sy * sw + sx] = up[y * w + x];
|
||||
}
|
||||
}
|
||||
(s, sw, sh)
|
||||
}
|
||||
|
||||
/// Inserts a JPEG APP1 EXIF segment right after SOI.
|
||||
fn jpeg_with_exif(jpeg: &[u8], exif: &[u8]) -> Vec<u8> {
|
||||
let mut seg = b"Exif\0\0".to_vec();
|
||||
seg.extend_from_slice(exif);
|
||||
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
|
||||
}
|
||||
|
||||
/// Inserts a PNG eXIf chunk before the first IDAT.
|
||||
fn png_with_exif(png: &[u8], exif: &[u8]) -> Vec<u8> {
|
||||
let at = png.windows(4).position(|w| w == b"IDAT").unwrap() - 4;
|
||||
let mut chunk = (exif.len() as u32).to_be_bytes().to_vec();
|
||||
let mut body = b"eXIf".to_vec();
|
||||
body.extend_from_slice(exif);
|
||||
let mut crc = flate2::Crc::new();
|
||||
crc.update(&body);
|
||||
chunk.extend_from_slice(&body);
|
||||
chunk.extend_from_slice(&crc.sum().to_be_bytes());
|
||||
let mut out = png[..at].to_vec();
|
||||
out.extend_from_slice(&chunk);
|
||||
out.extend_from_slice(&png[at..]);
|
||||
out
|
||||
}
|
||||
|
||||
fn tiff_with_orientation(data: &[u8], w: u32, h: u32, o: u16) -> Vec<u8> {
|
||||
use tiff::encoder::{TiffEncoder, colortype::Gray8};
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
let mut enc = TiffEncoder::new(&mut cursor).unwrap();
|
||||
let mut im = enc.new_image::<Gray8>(w, h).unwrap();
|
||||
im.encoder().write_tag(tiff::tags::Tag::Orientation, o).unwrap();
|
||||
im.write_data(data).unwrap();
|
||||
cursor.into_inner()
|
||||
}
|
||||
|
||||
fn gray(w: usize, h: usize, px: Vec<u8>) -> Image {
|
||||
Image::from_u8(w as u32, h as u32, ChannelLayout::Gray, px).unwrap()
|
||||
}
|
||||
|
||||
/// Exact pixels: distinct values per pixel.
|
||||
fn exact_scene(w: usize, h: usize) -> Vec<u8> {
|
||||
upright(w, h, |x, y| (y * w + x) as u8 * 3 + 1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_orientation_in_every_encoding() {
|
||||
for o in 1..=8 {
|
||||
for big in [false, true] {
|
||||
for long in [false, true] {
|
||||
let e = exif(o, big, long);
|
||||
assert_eq!(exif_orientation(&e), o as u16, "o={o} big={big} long={long}");
|
||||
let mut prefixed = b"Exif\0\0".to_vec();
|
||||
prefixed.extend_from_slice(&e);
|
||||
assert_eq!(exif_orientation(&prefixed), o as u16);
|
||||
let fixed = upright_exif(&prefixed);
|
||||
assert_eq!(exif_orientation(&fixed), 1);
|
||||
assert_eq!(fixed.len(), prefixed.len(), "rewritten in place");
|
||||
assert_eq!(&fixed[..6], b"Exif\0\0");
|
||||
// Rewritten in the entry's own type and width: exactly `exif(1, ..)`.
|
||||
assert_eq!(&fixed[6..], &exif(1, big, long)[..], "o={o} big={big} long={long}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Already upright, no tag, or garbage: borrowed unchanged.
|
||||
assert!(matches!(upright_exif(&exif(1, false, false)), std::borrow::Cow::Borrowed(_)));
|
||||
assert!(matches!(upright_exif(&sample_exif()), std::borrow::Cow::Borrowed(_)));
|
||||
assert!(matches!(upright_exif(b"nonsense"), std::borrow::Cow::Borrowed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_out_of_range_orientation_reads_as_1() {
|
||||
for bad in [0, 9, 255, 0xFFFF, 0x1_0006] {
|
||||
assert_eq!(exif_orientation(&exif(bad, false, true)), 1, "{bad}");
|
||||
}
|
||||
let good = exif(6, false, false);
|
||||
assert_eq!(exif_orientation(&good), 6);
|
||||
for n in 0..good.len() {
|
||||
// Any cut, even one that keeps the Orientation entry but loses the next-IFD pointer.
|
||||
assert_eq!(exif_orientation(&good[..n]), 1, "truncated at {n}");
|
||||
assert!(matches!(upright_exif(&good[..n]), std::borrow::Cow::Borrowed(_)), "truncated at {n}");
|
||||
}
|
||||
// IFD offset past the end, a huge entry count, a wrong field type.
|
||||
let mut far = good.clone();
|
||||
far[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert_eq!(exif_orientation(&far), 1);
|
||||
let mut many = good.clone();
|
||||
many[8..10].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||
assert_eq!(exif_orientation(&many), 1, "an entry count past the end is a truncated table");
|
||||
let mut typed = good.clone();
|
||||
typed[24..26].copy_from_slice(&2u16.to_le_bytes()); // ASCII
|
||||
assert_eq!(exif_orientation(&typed), 1);
|
||||
assert_eq!(exif_orientation(&[]), 1);
|
||||
assert_eq!(exif_orientation(b"II*\0"), 1);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn random_exif_never_panics(bytes in proptest::collection::vec(any::<u8>(), 0..256), le in any::<bool>()) {
|
||||
let mut b = if le { b"II*\0\x08\0\0\0".to_vec() } else { b"MM\0*\0\0\0\x08".to_vec() };
|
||||
b.extend_from_slice(&bytes);
|
||||
let o = exif_orientation(&b);
|
||||
prop_assert!((1..=8).contains(&o));
|
||||
prop_assert_eq!(exif_orientation(&upright_exif(&b)), 1);
|
||||
let _ = exif_orientation(&bytes);
|
||||
let _ = upright_exif(&bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_xmp_never_panics(s in ".{0,64}", tail in ".{0,16}") {
|
||||
let x = format!("{s}tiff:Orientation{tail}");
|
||||
let _ = upright_xmp(&x);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xmp_orientation_is_rewritten() {
|
||||
let attr = r#"<rdf:Description tiff:Orientation="6" tiff:Make="X"/>"#;
|
||||
assert_eq!(upright_xmp(attr), r#"<rdf:Description tiff:Orientation="1" tiff:Make="X"/>"#);
|
||||
let elem = "<tiff:Orientation>8</tiff:Orientation><tiff:Orientation>3</tiff:Orientation>";
|
||||
assert_eq!(upright_xmp(elem), "<tiff:Orientation>1</tiff:Orientation><tiff:Orientation>1</tiff:Orientation>");
|
||||
assert_eq!(upright_xmp("tiff:Orientation='12'"), "tiff:Orientation='1'");
|
||||
for same in [r#"tiff:Orientation="1""#, "no orientation", "tiff:Orientation", "tiff:Orientation=\"\"", "tiff:Orientation: 6"] {
|
||||
assert!(matches!(upright_xmp(same), std::borrow::Cow::Borrowed(_)), "{same}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_opens_upright_for_every_orientation() {
|
||||
let (w, h) = (5, 3);
|
||||
let up = exact_scene(w, h);
|
||||
for o in 1..=8u16 {
|
||||
let (s, sw, sh) = stored(&up, w, h, o);
|
||||
let png = encode(&gray(sw, sh, s.clone()), Format::Png, &EncodeOptions::default()).unwrap();
|
||||
let file = png_with_exif(&png, &exif(u32::from(o), o % 2 == 0, false));
|
||||
let img = decode(&file).unwrap();
|
||||
assert_eq!(img.dimensions(), (w as u32, h as u32), "o={o}");
|
||||
assert_eq!(img.data(), &up[..], "o={o}");
|
||||
assert_eq!(exif_orientation(img.meta.exif.as_deref().unwrap()), 1, "o={o}: metadata says upright");
|
||||
// Opting out keeps the stored pixels and the tag.
|
||||
let raw = decode_with(&file, &DecodeOptions { keep_orientation: true, ..Default::default() }).unwrap();
|
||||
assert_eq!((raw.dimensions(), raw.data()), ((sw as u32, sh as u32), &s[..]), "o={o}");
|
||||
assert_eq!(exif_orientation(raw.meta.exif.as_deref().unwrap()), o);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiff_opens_upright_for_every_orientation() {
|
||||
let (w, h) = (4, 7);
|
||||
let up = exact_scene(w, h);
|
||||
for o in 1..=8u16 {
|
||||
let (s, sw, sh) = stored(&up, w, h, o);
|
||||
let img = decode(&tiff_with_orientation(&s, sw as u32, sh as u32, o)).unwrap();
|
||||
assert_eq!(img.dimensions(), (w as u32, h as u32), "o={o}");
|
||||
assert_eq!(img.data(), &up[..], "o={o}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_opens_upright() {
|
||||
let (w, h) = (6, 2);
|
||||
let up = exact_scene(w, h);
|
||||
for o in [3, 6, 8] {
|
||||
let (s, sw, sh) = stored(&up, w, h, o);
|
||||
let mut out = Vec::new();
|
||||
let mut enc = image_webp::WebPEncoder::new(&mut out);
|
||||
enc.set_exif_metadata(exif(u32::from(o), false, false));
|
||||
enc.encode(&s, sw as u32, sh as u32, image_webp::ColorType::L8).unwrap();
|
||||
// WebP has no gray mode: it comes back as RGB.
|
||||
let img = decode(&out).unwrap().convert(ChannelLayout::Gray, SampleType::U8);
|
||||
assert_eq!((img.dimensions(), img.data()), ((w as u32, h as u32), &up[..]), "o={o}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 8×8-block scene so JPEG keeps each block's value: block (bx, by) is distinct.
|
||||
fn block_scene(w: usize, h: usize) -> Vec<u8> {
|
||||
upright(w, h, |x, y| (20 + 25 * (x / 8 + (w / 8) * (y / 8))) as u8)
|
||||
}
|
||||
|
||||
fn assert_blocks(img: &Image, up: &[u8], w: usize, h: usize, what: &str) {
|
||||
assert_eq!(img.dimensions(), (w as u32, h as u32), "{what}");
|
||||
let d = img.data();
|
||||
for by in 0..h / 8 {
|
||||
for bx in 0..w / 8 {
|
||||
let i = (by * 8 + 4) * w + bx * 8 + 4;
|
||||
assert!((i32::from(d[i]) - i32::from(up[i])).abs() <= 4, "{what}: block ({bx},{by}) is {} not {}", d[i], up[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_opens_upright_for_every_orientation() {
|
||||
let (w, h) = (32, 16);
|
||||
let up = block_scene(w, h);
|
||||
for o in 1..=8u16 {
|
||||
let (s, sw, sh) = stored(&up, w, h, o);
|
||||
let jpeg = encode(&gray(sw, sh, s), Format::Jpeg, &EncodeOptions { jpeg_quality: 100, ..Default::default() }).unwrap();
|
||||
let file = jpeg_with_exif(&jpeg, &exif(u32::from(o), o > 4, o == 7));
|
||||
let img = decode(&file).unwrap();
|
||||
assert_blocks(&img, &up, w, h, &format!("o={o}"));
|
||||
assert_eq!(exif_orientation(img.meta.exif.as_deref().unwrap()), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_jpeg_with_dpi_swaps_resolution_on_a_quarter_turn() {
|
||||
let img = Image::from_u8(16, 8, ChannelLayout::Rgb, vec![128; 16 * 8 * 3]).unwrap().with_meta(Metadata { dpi: Some((300.0, 150.0)), ..Default::default() });
|
||||
let jpeg = encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
let back = decode(&jpeg_with_exif(&jpeg, &exif(6, false, false))).unwrap();
|
||||
assert_eq!(back.dimensions(), (8, 16));
|
||||
assert_eq!(back.meta.dpi, Some((150.0, 300.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_writes_orientation_1_and_keeps_pixels() {
|
||||
let (w, h) = (32, 16);
|
||||
let up = block_scene(w, h);
|
||||
let (s, sw, sh) = stored(&up, w, h, 6);
|
||||
let jpeg = encode(&gray(sw, sh, s), Format::Jpeg, &EncodeOptions { jpeg_quality: 100, ..Default::default() }).unwrap();
|
||||
let opened = decode(&jpeg_with_exif(&jpeg, &exif(6, false, false))).unwrap();
|
||||
assert_blocks(&opened, &up, w, h, "opened");
|
||||
|
||||
// Even an image whose metadata still claims 6 (e.g. from a PSD) is written as 1.
|
||||
let mut stale = opened.clone();
|
||||
stale.meta.exif = Some(exif(6, true, false));
|
||||
stale.meta.xmp = Some(r#"<x tiff:Orientation="6"/>"#.into());
|
||||
for format in [Format::Png, Format::Jpeg, Format::WebP, Format::Tiff] {
|
||||
let bytes = encode(&stale, format, &EncodeOptions { jpeg_quality: 100, ..Default::default() }).unwrap();
|
||||
let raw = decode_with(&bytes, &DecodeOptions { keep_orientation: true, ..Default::default() }).unwrap();
|
||||
if let Some(e) = &raw.meta.exif {
|
||||
assert_eq!(exif_orientation(e), 1, "{format:?}");
|
||||
}
|
||||
if let Some(x) = &raw.meta.xmp {
|
||||
assert!(x.contains(r#"tiff:Orientation="1""#), "{format:?}: {x}");
|
||||
}
|
||||
assert_eq!(exif_orientation(&bytes), 1, "{format:?} file");
|
||||
// Pixels as shown, both with and without applying orientation on reopen.
|
||||
let back = decode(&bytes).unwrap();
|
||||
assert_eq!(back.dimensions(), raw.dimensions(), "{format:?}");
|
||||
if format == Format::Jpeg {
|
||||
assert_blocks(&back, &up, w, h, "jpeg reopen");
|
||||
} else {
|
||||
assert_eq!(back.convert(ChannelLayout::Gray, SampleType::U8).data(), opened.data(), "{format:?} lossless");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_open_save_reopen_is_stable() {
|
||||
let (w, h) = (24, 16);
|
||||
let up = block_scene(w, h);
|
||||
for o in 1..=8u16 {
|
||||
let (s, sw, sh) = stored(&up, w, h, o);
|
||||
let jpeg = encode(&gray(sw, sh, s), Format::Jpeg, &EncodeOptions { jpeg_quality: 100, ..Default::default() }).unwrap();
|
||||
let first = decode(&jpeg_with_exif(&jpeg, &exif(u32::from(o), false, false))).unwrap();
|
||||
let saved = encode(&first, Format::Jpeg, &EncodeOptions { jpeg_quality: 100, ..Default::default() }).unwrap();
|
||||
let second = decode(&saved).unwrap();
|
||||
assert_blocks(&second, &up, w, h, &format!("reopened o={o}"));
|
||||
assert_eq!(exif_orientation(second.meta.exif.as_deref().unwrap()), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_exif_in_a_jpeg_opens_as_stored() {
|
||||
let img = gray(16, 8, vec![90; 128]);
|
||||
let jpeg = encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
let mut rng = Rng::new(285);
|
||||
let mut cases = vec![Vec::new(), b"II*\0".to_vec(), b"MM\0*\xff\xff\xff\xff".to_vec(), exif(6, false, false)[..20].to_vec()];
|
||||
cases.extend((0..64).map(|i| {
|
||||
let mut b = if i % 2 == 0 { b"II*\0\x08\0\0\0".to_vec() } else { b"MM\0*\0\0\0\x08".to_vec() };
|
||||
b.extend(rng.bytes(i * 7));
|
||||
b
|
||||
}));
|
||||
for e in cases {
|
||||
let back = decode(&jpeg_with_exif(&jpeg, &e)).unwrap();
|
||||
let o = exif_orientation(&e);
|
||||
let want = if o >= 5 { (8, 16) } else { (16, 8) };
|
||||
assert_eq!(back.dimensions(), want, "{e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_layout_and_depth_orients_and_inverts() {
|
||||
let inverse = |o: u16| match o {
|
||||
6 => 8,
|
||||
8 => 6,
|
||||
o => o,
|
||||
};
|
||||
for layout in ChannelLayout::ALL {
|
||||
for sample in SampleType::ALL {
|
||||
let img = test_image(layout, sample);
|
||||
for o in 0..=9u16 {
|
||||
let turned = img.clone().oriented(o).unwrap();
|
||||
if (2..=8).contains(&o) && o >= 5 {
|
||||
assert_eq!(turned.dimensions(), (img.height(), img.width()));
|
||||
} else {
|
||||
assert_eq!(turned.dimensions(), img.dimensions());
|
||||
}
|
||||
let back = turned.oriented(inverse(o)).unwrap();
|
||||
assert_eq!(back.data(), img.data(), "{layout:?} {sample:?} o={o}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #285 performance budget: turning a 24 MP photo upright on open adds ≤ 50 ms
|
||||
/// (release build: `cargo test --release -p photocraft-codecs --test orientation -- --ignored`).
|
||||
#[test]
|
||||
#[ignore = "timing; run in release"]
|
||||
fn rotating_24_mp_is_fast() {
|
||||
let (w, h) = (6000u32, 4000u32);
|
||||
let img = Image::from_u8(w, h, ChannelLayout::Rgb, (0..w as usize * h as usize * 3).map(|i| i as u8).collect()).unwrap();
|
||||
for o in [6u16, 3, 8] {
|
||||
let mut best = f64::MAX;
|
||||
for _ in 0..5 {
|
||||
let c = img.clone();
|
||||
let t = std::time::Instant::now();
|
||||
let r = c.oriented(o).unwrap();
|
||||
best = best.min(t.elapsed().as_secs_f64() * 1000.0);
|
||||
std::hint::black_box(r);
|
||||
}
|
||||
eprintln!("orientation {o}: 24 MP RGB8 in {best:.1} ms");
|
||||
assert!(best <= 50.0, "orientation {o} took {best:.1} ms");
|
||||
}
|
||||
}
|
||||
|
||||
/// A TIFF-structured EXIF block: IFD0 at offset 8 holding `entries`
|
||||
/// (`tag, type, count, 4-byte value field`), a zero next-IFD pointer, then `tail`.
|
||||
fn ifd(big: bool, entries: &[(u16, u16, u32, [u8; 4])], tail: &[u8]) -> Vec<u8> {
|
||||
let u16b = |v: u16| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
let mut v = if big { b"MM\0*".to_vec() } else { b"II*\0".to_vec() };
|
||||
v.extend_from_slice(&u32b(8));
|
||||
v.extend_from_slice(&u16b(entries.len() as u16));
|
||||
for &(tag, ty, count, value) in entries {
|
||||
v.extend_from_slice(&u16b(tag));
|
||||
v.extend_from_slice(&u16b(ty));
|
||||
v.extend_from_slice(&u32b(count));
|
||||
v.extend_from_slice(&value);
|
||||
}
|
||||
v.extend_from_slice(&u32b(0));
|
||||
v.extend_from_slice(tail);
|
||||
v
|
||||
}
|
||||
|
||||
/// The 4-byte value field of a SHORT.
|
||||
fn short(v: u16, big: bool) -> [u8; 4] {
|
||||
let b = if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
[b[0], b[1], 0, 0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_orientation_entries_first_wins() {
|
||||
for big in [false, true] {
|
||||
let o = |v| (0x0112, 3, 1, short(v, big));
|
||||
assert_eq!(exif_orientation(&ifd(big, &[o(6), o(3)], &[])), 6);
|
||||
assert_eq!(exif_orientation(&ifd(big, &[o(3), o(6)], &[])), 3);
|
||||
assert_eq!(exif_orientation(&ifd(big, &[o(1), o(8)], &[])), 1);
|
||||
// A malformed first entry is ignored as a whole: a later good one does not count.
|
||||
let bad_first = ifd(big, &[(0x0112, 3, 2, short(6, big)), o(8)], &[]);
|
||||
assert_eq!(exif_orientation(&bad_first), 1);
|
||||
let long = |v: u32| (0x0112, 4, 1, if big { v.to_be_bytes() } else { v.to_le_bytes() });
|
||||
let long_first = ifd(big, &[long(6), o(8)], &[]);
|
||||
assert_eq!(exif_orientation(&long_first), 6, "a LONG first entry counts");
|
||||
assert_eq!(&upright_exif(&long_first)[..], &ifd(big, &[long(1), o(1)], &[])[..], "each in its own width");
|
||||
let rational_first = ifd(big, &[(0x0112, 5, 1, short(6, big)), o(8)], &[]);
|
||||
assert_eq!(exif_orientation(&rational_first), 1);
|
||||
// Upright: every well-formed duplicate becomes 1, so no reader rotates twice.
|
||||
let dup = ifd(big, &[o(6), o(3)], &[]);
|
||||
let fixed = upright_exif(&dup);
|
||||
assert_eq!(fixed.len(), dup.len());
|
||||
assert_eq!(&fixed[..], &ifd(big, &[o(1), o(1)], &[])[..]);
|
||||
// Only a later duplicate disagrees: it is rewritten too.
|
||||
assert_eq!(&upright_exif(&ifd(big, &[o(1), o(8)], &[]))[..], &ifd(big, &[o(1), o(1)], &[])[..]);
|
||||
assert!(matches!(upright_exif(&ifd(big, &[o(1), o(1)], &[])), std::borrow::Cow::Borrowed(_)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_ifd_parsing_ignores_the_tag() {
|
||||
for big in [false, true] {
|
||||
let o6 = (0x0112, 3, 1, short(6, big));
|
||||
let good = ifd(big, &[o6], &[]);
|
||||
assert_eq!(exif_orientation(&good), 6);
|
||||
// Truncated before (or inside) the next-IFD pointer.
|
||||
for cut in 1..=4 {
|
||||
assert_eq!(exif_orientation(&good[..good.len() - cut]), 1, "cut {cut}");
|
||||
}
|
||||
// IFD offsets inside the 8-byte header.
|
||||
for off in 0..8u32 {
|
||||
let mut b = good.clone();
|
||||
b[4..8].copy_from_slice(&if big { off.to_be_bytes() } else { off.to_le_bytes() });
|
||||
assert_eq!(exif_orientation(&b), 1, "offset {off}");
|
||||
assert!(matches!(upright_exif(&b), std::borrow::Cow::Borrowed(_)));
|
||||
}
|
||||
// Count must be exactly 1, type must be SHORT or LONG.
|
||||
let long6 = if big { 6u32.to_be_bytes() } else { 6u32.to_le_bytes() };
|
||||
for count in [0, 2, 3, u32::MAX] {
|
||||
assert_eq!(exif_orientation(&ifd(big, &[(0x0112, 3, count, short(6, big))], &[])), 1, "count {count}");
|
||||
assert_eq!(exif_orientation(&ifd(big, &[(0x0112, 4, count, long6)], &[])), 1, "LONG count {count}");
|
||||
}
|
||||
for ty in [1, 2, 5, 6, 7, 8, 9, 0, 0xFFFF] {
|
||||
let b = ifd(big, &[(0x0112, ty, 1, short(6, big))], &[]);
|
||||
assert_eq!(exif_orientation(&b), 1, "type {ty}");
|
||||
assert!(matches!(upright_exif(&b), std::borrow::Cow::Borrowed(_)), "type {ty}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upright_exif_keeps_every_other_byte() {
|
||||
for big in [false, true] {
|
||||
let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
// IFD0: Make (12-byte ASCII stored after the IFD), Orientation 6, Software (inline ASCII),
|
||||
// ExifIFD pointer to a sub-IFD with its own entries.
|
||||
let ifd_end = 8 + 2 + 4 * 12 + 4;
|
||||
let make_at = ifd_end as u32;
|
||||
let sub_at = make_at + 12;
|
||||
let mut tail = b"PhotoCraftCo".to_vec();
|
||||
// Sub-IFD: one DateTimeOriginal-ish SHORT entry, then next-IFD 0.
|
||||
tail.extend_from_slice(&if big { 1u16.to_be_bytes() } else { 1u16.to_le_bytes() });
|
||||
tail.extend_from_slice(&if big { 0x9000u16.to_be_bytes() } else { 0x9000u16.to_le_bytes() });
|
||||
tail.extend_from_slice(&if big { 7u16.to_be_bytes() } else { 7u16.to_le_bytes() });
|
||||
tail.extend_from_slice(&u32b(4));
|
||||
tail.extend_from_slice(b"0232");
|
||||
tail.extend_from_slice(&u32b(0));
|
||||
let e = ifd(big, &[(0x010F, 2, 12, u32b(make_at)), (0x0112, 3, 1, short(6, big)), (0x0131, 2, 4, *b"PC1\0"), (0x8769, 4, 1, u32b(sub_at))], &tail);
|
||||
let mut prefixed = b"Exif\0\0".to_vec();
|
||||
prefixed.extend_from_slice(&e);
|
||||
let fixed = upright_exif(&prefixed).into_owned();
|
||||
assert_eq!(exif_orientation(&fixed), 1);
|
||||
assert_eq!(fixed.len(), prefixed.len());
|
||||
// Only the Orientation value (2 bytes in the second entry) differs.
|
||||
let value = 6 + 8 + 2 + 12 + 8;
|
||||
let changed: Vec<usize> = (0..fixed.len()).filter(|&i| fixed[i] != prefixed[i]).collect();
|
||||
assert_eq!(changed, vec![if big { value + 1 } else { value }], "big={big}");
|
||||
assert_eq!(&fixed[..value], &prefixed[..value]);
|
||||
assert_eq!(&fixed[value + 2..], &prefixed[value + 2..]);
|
||||
// The offset-based Make string and the inline Software tag are intact.
|
||||
assert_eq!(&fixed[6 + make_at as usize..][..12], b"PhotoCraftCo");
|
||||
assert!(fixed.windows(4).any(|w| w == b"PC1\0"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_apply_to_the_upright_size() {
|
||||
// Stored 32×16 landscape; Orientation 6 makes it a 16×32 portrait.
|
||||
let jpeg = encode(&gray(32, 16, vec![90; 32 * 16]), Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
let tall = jpeg_with_exif(&jpeg, &exif(6, false, false));
|
||||
let limits = Limits { max_width: 32, max_height: 16, ..Limits::default() };
|
||||
let opts = DecodeOptions { limits, ..Default::default() };
|
||||
assert!(matches!(decode_with(&tall, &opts), Err(CodecError::LimitExceeded(_))), "the upright portrait is too tall");
|
||||
// As stored it fits, and a 180° turn keeps the size.
|
||||
let kept = decode_with(&tall, &DecodeOptions { keep_orientation: true, ..opts.clone() }).unwrap();
|
||||
assert_eq!(kept.dimensions(), (32, 16));
|
||||
let flipped = decode_with(&jpeg_with_exif(&jpeg, &exif(3, false, false)), &opts).unwrap();
|
||||
assert_eq!(flipped.dimensions(), (32, 16));
|
||||
// The same holds for a TIFF, whose orientation lives in its own IFD0.
|
||||
let tiff = tiff_with_orientation(&[7; 32 * 16], 32, 16, 8);
|
||||
assert!(matches!(decode_with(&tiff, &opts), Err(CodecError::LimitExceeded(_))));
|
||||
assert_eq!(decode_with(&tiff, &DecodeOptions::default()).unwrap().dimensions(), (16, 32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_orientation_reads_and_rewrites_in_both_byte_orders() {
|
||||
for big in [false, true] {
|
||||
let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
let long = |v: u32| (0x0112u16, 4u16, 1u32, u32b(v));
|
||||
for o in 1..=8u32 {
|
||||
let e = ifd(big, &[(0x0131, 2, 4, *b"PC1\0"), long(o)], b"tail");
|
||||
assert_eq!(exif_orientation(&e), o as u16, "o={o} big={big}");
|
||||
let fixed = upright_exif(&e);
|
||||
assert_eq!(exif_orientation(&fixed), 1);
|
||||
// Still a LONG, all 4 value bytes set to 1 in the file's byte order; nothing else moved.
|
||||
assert_eq!(&fixed[..], &ifd(big, &[(0x0131, 2, 4, *b"PC1\0"), long(1)], b"tail")[..], "o={o} big={big}");
|
||||
}
|
||||
// Out of range, including values that only fit in a LONG, reads as 1 and is left alone.
|
||||
for bad in [0, 9, 0x1_0006, u32::MAX] {
|
||||
let e = ifd(big, &[long(bad)], &[]);
|
||||
assert_eq!(exif_orientation(&e), 1, "{bad}");
|
||||
}
|
||||
// A LONG 6 in a JPEG opens upright.
|
||||
let jpeg = encode(&gray(32, 16, vec![90; 32 * 16]), Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
let img = decode(&jpeg_with_exif(&jpeg, &ifd(big, &[long(6)], &[]))).unwrap();
|
||||
assert_eq!(img.dimensions(), (16, 32));
|
||||
let kept = img.meta.exif.as_deref().unwrap();
|
||||
assert_eq!(kept.strip_prefix(b"Exif\0\0").unwrap_or(kept), &ifd(big, &[long(1)], &[])[..]);
|
||||
}
|
||||
}
|
||||
|
|
@ -196,14 +196,16 @@ fn tiff_f32_output_matches_oracle() {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Optional PngSuite corpus (repo-root corpus/pngsuite/*.png)
|
||||
// PngSuite corpus (repo-root corpus/pngsuite/*.png; feature `corpus`, fetched by
|
||||
// `cargo xtask corpus --all`; missing = failure)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(feature = "corpus")]
|
||||
#[test]
|
||||
fn pngsuite_corpus_matches_oracle() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/pngsuite");
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
return;
|
||||
panic!("{} is missing: run `cargo xtask corpus --all`", dir.display());
|
||||
};
|
||||
let mut checked = 0;
|
||||
for e in entries.flatten() {
|
||||
|
|
@ -224,4 +226,5 @@ fn pngsuite_corpus_matches_oracle() {
|
|||
}
|
||||
}
|
||||
eprintln!("pngsuite: {checked} files compared");
|
||||
assert!(checked > 100, "{}: only {checked} PNGs compared: run `cargo xtask corpus --all`", dir.display());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,17 @@ pub mod convert;
|
|||
|
||||
pub use blend::BlendMode;
|
||||
|
||||
/// Per-pixel gradient dither noise in `[0, 1)` from a position hash (decorrelated, no visible
|
||||
/// pattern). Shared by the Gradient tool and Gradient Fill layers (and mirrored in the GPU
|
||||
/// compositor), so a live gradient dithers exactly like the painted one.
|
||||
pub 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
|
||||
}
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Storage type of one channel sample.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use crate::Buffer;
|
|||
/// work in linear light (Exposure). Until ICC profiles are wired in (M8) this
|
||||
/// approximates the Photoshop defaults: sRGB for colour documents and the
|
||||
/// "Dot Gain 20%" grey profile (≈ gamma 1.73, fitted on the corpus) for
|
||||
/// grayscale documents.
|
||||
/// grayscale documents. 32-bit documents store linear light already.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Transfer {
|
||||
/// sRGB piecewise curve.
|
||||
|
|
@ -23,13 +23,27 @@ pub enum Transfer {
|
|||
}
|
||||
|
||||
impl Transfer {
|
||||
/// Default transfer for a document colour mode.
|
||||
pub fn for_mode(mode: photocraft_color::ColorMode) -> Self {
|
||||
/// Default transfer for a document's colour mode and depth. 32-bit samples are linear, and
|
||||
/// Photoshop's Exposure works on them as stored (photoshop corpus rgb32 and gray32
|
||||
/// exposure.psd: within 0.5/255, against 69/255 through the 2.2 curve).
|
||||
pub fn for_document(mode: photocraft_color::ColorMode, depth: photocraft_color::SampleType) -> Self {
|
||||
if depth == photocraft_color::SampleType::F32 {
|
||||
return Transfer::Gamma(1.0);
|
||||
}
|
||||
match mode {
|
||||
photocraft_color::ColorMode::Grayscale | photocraft_color::ColorMode::Duotone | photocraft_color::ColorMode::Bitmap => Transfer::Gamma(1.732),
|
||||
_ => Transfer::Srgb,
|
||||
}
|
||||
}
|
||||
/// The tone curve Exposure linearises through: RGB documents use a pure 2.2 power, not the
|
||||
/// sRGB curve (psd-tools adjustment_nested_composition_4: an offset of 0.1738 lifts 76 to 134,
|
||||
/// not 136; ag-psd masks), grey documents their dot-gain gamma.
|
||||
pub fn for_exposure(self) -> Self {
|
||||
match self {
|
||||
Transfer::Srgb => Transfer::Gamma(2.2),
|
||||
t => t,
|
||||
}
|
||||
}
|
||||
fn decode(self, v: f32) -> f32 {
|
||||
match self {
|
||||
Transfer::Srgb => photocraft_color::convert::srgb_to_linear(v.max(0.0)),
|
||||
|
|
@ -51,6 +65,13 @@ pub fn apply(adj: &Adjustment, buf: &mut Buffer) {
|
|||
|
||||
/// Applies an adjustment with the document's tone transfer.
|
||||
pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
|
||||
apply_depth(adj, buf, transfer, None);
|
||||
}
|
||||
|
||||
/// Applies an adjustment with the document's tone transfer, on samples with `quantum` steps
|
||||
/// per unit (the document's integer depth, see `adjustment_quantum`): Levels then works on
|
||||
/// whole levels like Photoshop's (see [`levels_q`]).
|
||||
pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, quantum: Option<f32>) {
|
||||
match adj {
|
||||
Adjustment::Invert => map_rgb(buf, |c| [1.0 - c[0], 1.0 - c[1], 1.0 - c[2]]),
|
||||
Adjustment::Threshold { level } => {
|
||||
|
|
@ -89,6 +110,7 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
|
|||
// through the document tone curve (fitted on the corpus).
|
||||
let m = 2f32.powf(*exposure);
|
||||
let g = gamma.max(0.01);
|
||||
let transfer = transfer.for_exposure();
|
||||
map_rgb(buf, |c| {
|
||||
c.map(|v| {
|
||||
let lin = (transfer.decode(v) * m + offset).max(0.0).powf(1.0 / g);
|
||||
|
|
@ -97,7 +119,7 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
|
|||
})
|
||||
}
|
||||
Adjustment::Levels { space, .. } | Adjustment::Curves { space, .. } => {
|
||||
let luts = tone_luts(adj);
|
||||
let luts = tone_luts_q(adj, quantum);
|
||||
match space {
|
||||
ToneSpace::Rgb => map_rgb(buf, |c| std::array::from_fn(|i| lut(&luts[i], c[i]))),
|
||||
ToneSpace::Cmyk | ToneSpace::Lab => map_rgb(buf, |c| tone_in_space(*space, &luts, c)),
|
||||
|
|
@ -203,12 +225,18 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
|
|||
/// `per_channel` channels then black (identity unless the space is CMYK). In Lab there is no
|
||||
/// composite record, so the master is ignored.
|
||||
pub fn tone_luts(adj: &Adjustment) -> [Vec<f32>; 4] {
|
||||
tone_luts_q(adj, None)
|
||||
}
|
||||
|
||||
/// [`tone_luts`] for samples with `quantum` steps per unit (Levels works on whole levels; see
|
||||
/// [`levels_q`]).
|
||||
pub fn tone_luts_q(adj: &Adjustment, quantum: Option<f32>) -> [Vec<f32>; 4] {
|
||||
let x = |k: usize| k as f32 / (LUT_SIZE - 1) as f32;
|
||||
match adj {
|
||||
Adjustment::Levels { master, per_channel, space, black } => {
|
||||
let ident = LevelsChannel::default();
|
||||
let m = if *space == ToneSpace::Lab { &ident } else { master };
|
||||
let row = |c: &LevelsChannel| (0..LUT_SIZE).map(|k| levels(c, levels(m, x(k)))).collect();
|
||||
let row = |c: &LevelsChannel| (0..LUT_SIZE).map(|k| levels_q(c, levels_q(m, x(k), quantum), quantum)).collect();
|
||||
[row(&per_channel[0]), row(&per_channel[1]), row(&per_channel[2]), row(if *space == ToneSpace::Cmyk { black } else { &ident })]
|
||||
}
|
||||
Adjustment::Curves { master, per_channel, space, black } => {
|
||||
|
|
@ -458,20 +486,37 @@ pub fn posterize(v: f32, levels: u32) -> f32 {
|
|||
}
|
||||
|
||||
pub fn levels(ch: &LevelsChannel, v: f32) -> f32 {
|
||||
levels_q(ch, v, None)
|
||||
}
|
||||
|
||||
/// [`levels`] on samples with `quantum` steps per unit (8-bit: 255). Photoshop works on whole
|
||||
/// levels: the input is a level, and the input-range stretch is rounded (half up) to a whole
|
||||
/// level before the midtone gamma, so a 44..214 range maps 45, 46, 47, 48 → 1.5, 3, 4.5, 6 →
|
||||
/// 2, 3, 5, 6 (psd-tools levels_grayscale, where the gamma toe magnifies the steps). In a LUT
|
||||
/// the result is a staircase that is exact at every level.
|
||||
pub fn levels_q(ch: &LevelsChannel, v: f32, quantum: Option<f32>) -> f32 {
|
||||
let range = (ch.in_white - ch.in_black).max(1e-6);
|
||||
let u = ((v - ch.in_black) / range).clamp(0.0, 1.0);
|
||||
let u = match quantum.filter(|q| q.is_finite() && *q >= 1.0) {
|
||||
Some(q) => {
|
||||
let v = (v * q).round() / q;
|
||||
(((v - ch.in_black) / range).clamp(0.0, 1.0) * q + 0.5 + 1e-3).floor() / q
|
||||
}
|
||||
None => ((v - ch.in_black) / range).clamp(0.0, 1.0),
|
||||
};
|
||||
let g = ch.gamma.max(0.01);
|
||||
let t = if g > 1.0 {
|
||||
// A midtone gamma > 1 lifts shadows, and a pure `u^(1/g)` has infinite slope at black —
|
||||
// which Photoshop bounds, giving a soft shadow toe (initial slope 2^gamma, verified against
|
||||
// the real app). Soft-min (p-norm) of the power curve with the slope-2^gamma line; reduces
|
||||
// to the exact power curve in the body, so highlights are unchanged. gamma <= 1 is untouched.
|
||||
// which Photoshop bounds, giving a soft shadow toe (initial slope ≈ 2^gamma, verified against
|
||||
// the real app). Soft-min (p-norm) of the power curve with that line; reduces to the exact
|
||||
// power curve in the body, so highlights are unchanged. gamma <= 1 is untouched. Slope and
|
||||
// sharpness fitted on whole-level input (`levels_q`): a 0..255 ramp at gamma 2.0 and
|
||||
// psd-tools levels_grayscale's gamma 1.78 (within one level of Photoshop on both).
|
||||
let power = u.powf(1.0 / g);
|
||||
let line = 2.0f32.powf(g) * u;
|
||||
let line = 0.93 * 2.0f32.powf(g) * u;
|
||||
if power <= 1e-6 || line <= 1e-6 {
|
||||
power.min(line)
|
||||
} else {
|
||||
let p = 1.8 + 8.8 / g; // p-norm sharpness, fit to Photoshop ground truth
|
||||
let p = 10.0; // p-norm sharpness, fit to Photoshop ground truth
|
||||
(power.powf(-p) + line.powf(-p)).powf(-1.0 / p)
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -5,17 +5,19 @@
|
|||
//! fill opacity) with pattern/gradient/colour overlays, satin, inner glows,
|
||||
//! inner shadows, strokes and the inner bevel on top. Exterior effects blend
|
||||
//! straight into the backdrop with their own modes; the layer and its
|
||||
//! interior effects blend with the layer's mode. Layer opacity applies to
|
||||
//! the whole stack; fill opacity only to the layer's own pixels.
|
||||
//! interior effects blend with the layer's mode. Emboss and pillow emboss then
|
||||
//! shade the composited result (inside and outside halves). Layer opacity
|
||||
//! applies to the whole stack; fill opacity only to the layer's own pixels.
|
||||
//!
|
||||
//! Interior effects are painted relative to the layer's shape and then take the shape's alpha
|
||||
//! (an overlay recolours a half-transparent edge without adding coverage). The outside parts
|
||||
//! of strokes blend onto the backdrop with their own modes, an upper stroke covering the
|
||||
//! lower ones. Drop shadows are knocked out only through see-through fill.
|
||||
//!
|
||||
//! Shapes come from the layer's alpha (after its mask). Strokes measure distances with a
|
||||
//! 5 × 5 chamfer metric seeded at sub-pixel edge offsets (as Photoshop does); other effects
|
||||
//! use an exact Euclidean distance transform. Soft falloffs are Gaussian blurs.
|
||||
//! Shapes come from the layer's alpha (after its mask). Strokes and spread / choke measure
|
||||
//! distances with a 5 × 5 chamfer metric seeded at sub-pixel edge offsets (as Photoshop does);
|
||||
//! precise glows and chiselled bevels use an exact Euclidean distance transform. Soft falloffs
|
||||
//! are two box blurs (a tent).
|
||||
|
||||
use photocraft_color::blend::BlendMode;
|
||||
use photocraft_doc::Pattern;
|
||||
|
|
@ -242,9 +244,9 @@ fn dist_outside(s: &Map) -> Vec<f32> {
|
|||
/// Distance metric for effect distance fields.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Metric {
|
||||
/// Exact Euclidean (spread/choke dilation, glows, bevels).
|
||||
/// Exact Euclidean (precise glows, bevels).
|
||||
Euclidean,
|
||||
/// 5 × 5 chamfer (strokes; see [`chamfer_nearest`]).
|
||||
/// 5 × 5 chamfer (strokes, spread / choke; see [`chamfer_nearest`]).
|
||||
Chamfer,
|
||||
}
|
||||
|
||||
|
|
@ -326,12 +328,16 @@ fn blur(m: &mut Map, size: f32) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Grows the shape by `r` pixels (anti-aliased), keeping the original soft edge.
|
||||
/// Grows the shape by `r` pixels (anti-aliased), keeping the original soft edge. Distances use
|
||||
/// the 5 × 5 chamfer metric, as for strokes: Photoshop's spread of a group drop shadow around an
|
||||
/// ellipse (ag-psd group-drop-shadows) is round at 0° and 45° and falls short in between, peaking
|
||||
/// near 15° where the chamfer metric overestimates most (5/255 lighter with an exact Euclidean
|
||||
/// dilation, within 2/255 with the chamfer).
|
||||
fn dilate(s: &Map, r: f32) -> Map {
|
||||
if r <= 0.0 {
|
||||
return s.clone();
|
||||
}
|
||||
let d = dist_outside(s);
|
||||
let d = dist_outside_by(s, Metric::Chamfer);
|
||||
let mut out = s.clone();
|
||||
for (o, d) in out.v.iter_mut().zip(d) {
|
||||
// Partly covered pixels (distance sentinel < 0) grow by `r` from their own coverage, so a
|
||||
|
|
@ -411,6 +417,22 @@ fn offset(angle: f32, distance: f32) -> (f32, f32) {
|
|||
((-a.cos() * distance).round(), (a.sin() * distance).round())
|
||||
}
|
||||
|
||||
/// The lengths a 100 % gradient at `angle` spans in a `w` × `h` frame: the chord (Linear and
|
||||
/// Reflected) and the ellipse-norm length (Radial, Angle, Diamond; its half is the radius).
|
||||
pub fn gradient_units(angle: f32, w: f32, h: f32) -> (f32, f32) {
|
||||
let (s, c) = angle.to_radians().sin_cos();
|
||||
// Gradient length: the bounds' extent along the angle as an ellipse
|
||||
// norm (a unit gradient scaled to the bounds); fitted on psd-tools
|
||||
// gradient-styles.psd (cached Photoshop renderings of every style).
|
||||
let len = ((c * w).powi(2) + (s * h).powi(2)).sqrt().max(1.0);
|
||||
// Linear / Reflected span the chord of the bounds through their centre along the angle:
|
||||
// min(w / |cos|, h / |sin|). A 45° gradient on a 29 px square runs 41 px (psd-tools
|
||||
// shape-fx2), an 87° one on a 600 × 60 text line 60 px (layer_effects); axis-aligned angles
|
||||
// span the width / height.
|
||||
let chord = (w / c.abs().max(1e-6)).min(h / s.abs().max(1e-6)).max(1.0);
|
||||
(chord, len)
|
||||
}
|
||||
|
||||
/// Gradient parameter `t` in `0..=1` for pixel centre `(x, y)` inside `bounds`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn gradient_t(style: GradientStyle, angle: f32, scale: f32, reverse: bool, offset: (f32, f32), bounds: Rect, x: f32, y: f32) -> f32 {
|
||||
|
|
@ -423,18 +445,15 @@ pub fn gradient_t(style: GradientStyle, angle: f32, scale: f32, reverse: bool, o
|
|||
// Distance along the gradient direction (y axis points down).
|
||||
let along = dx * c - dy * s;
|
||||
let across = dx * s + dy * c;
|
||||
// Gradient length: the bounds' extent along the angle as an ellipse
|
||||
// norm (a unit gradient scaled to the bounds); fitted on psd-tools
|
||||
// gradient-styles.psd (cached Photoshop renderings of every style).
|
||||
let len = ((c * w).powi(2) + (s * h).powi(2)).sqrt().max(1.0) * scale.max(1e-3);
|
||||
// Linear / Reflected span the chord of the bounds through their centre along the angle:
|
||||
// min(w / |cos|, h / |sin|). A 45° gradient on a 29 px square runs 41 px (psd-tools
|
||||
// shape-fx2), an 87° one on a 600 × 60 text line 60 px (layer_effects); axis-aligned angles
|
||||
// span the width / height.
|
||||
let chord = (w / c.abs().max(1e-6)).min(h / s.abs().max(1e-6)).max(1.0) * scale.max(1e-3);
|
||||
let (chord, len) = gradient_units(angle, w, h);
|
||||
let (chord, len) = (chord * scale.max(1e-3), len * scale.max(1e-3));
|
||||
// Linear / Reflected sample the pixel's top-left corner, half a pixel before its centre
|
||||
// (with the whole-pixel end points of `fill_layout`: layer_effects' overlay 5.8 → 1.8/255,
|
||||
// shape-fx2 2.8 → 1.1/255, gradient-fill.psd 1.9 → 0.5/255).
|
||||
let corner = 0.5 * (c - s);
|
||||
let mut t = match style {
|
||||
GradientStyle::Linear => along / chord + 0.5,
|
||||
GradientStyle::Reflected => (along / (chord / 2.0)).abs(),
|
||||
GradientStyle::Linear => (along - corner) / chord + 0.5,
|
||||
GradientStyle::Reflected => ((along - corner) / (chord / 2.0)).abs(),
|
||||
GradientStyle::Radial => (dx * dx + dy * dy).sqrt() / (len / 2.0),
|
||||
GradientStyle::Diamond => (along.abs() + across.abs()) / (len / 2.0),
|
||||
// Clockwise sweep starting at the gradient angle.
|
||||
|
|
@ -615,13 +634,14 @@ fn paint_fx(
|
|||
FxPaint::Color(c) => paint_color(dst, m, rgb(c), blend, opacity),
|
||||
FxPaint::Gradient(g) => {
|
||||
let prepared = PreparedGradient::new(g);
|
||||
let (angle, scale, offset) = crate::fill_layout::gradient_layout(g.style, g.angle, g.scale, g.offset, shape_bounds);
|
||||
let w = big.width() as usize;
|
||||
paint(
|
||||
dst,
|
||||
m,
|
||||
|i| {
|
||||
let (x, y) = ((big.x0 + (i % w) as i32) as f32 + 0.5, (big.y0 + (i / w) as i32) as f32 + 0.5);
|
||||
prepared.sample(gradient_t(g.style, g.angle, g.scale, g.reverse, g.offset, shape_bounds, x, y))
|
||||
prepared.sample(gradient_t(g.style, angle, scale, g.reverse, offset, shape_bounds, x, y))
|
||||
},
|
||||
blend,
|
||||
opacity,
|
||||
|
|
@ -704,8 +724,9 @@ fn tent_line(src: &[f32], dst: &mut [f32], bw: f32) {
|
|||
}
|
||||
|
||||
/// Rows of a `w`-wide row-major buffer, each through `f(src_row, dst_row)`, in parallel on
|
||||
/// scoped OS threads. Not rayon: maps are built inside a `OnceLock` while rayon renders tiles,
|
||||
/// and a rayon worker that stole another tile here would block on that same lock (deadlock).
|
||||
/// scoped OS threads. Not rayon: maps can be built from inside a rayon tile, where a waiting
|
||||
/// worker steals other tiles. Map builds no longer block on each other (#276), but the blur
|
||||
/// keeps out of the tile pool.
|
||||
fn rows_par(src: &[f32], dst: &mut [f32], w: usize, f: impl Fn(&[f32], &mut [f32]) + Sync) {
|
||||
if w == 0 {
|
||||
return;
|
||||
|
|
@ -811,7 +832,7 @@ pub const BEVEL_H_EPS: f32 = 1e-5;
|
|||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct BevelGeom {
|
||||
pub paint: BevelPaint,
|
||||
/// Box width of the smooth height blur (size; half the size for emboss styles).
|
||||
/// Box width of the smooth height blur (size; half the size, rounded up, for emboss styles).
|
||||
pub width: f32,
|
||||
/// Height-map gradient scale: depth × width (smooth) or depth × size (chisel), signed by
|
||||
/// direction.
|
||||
|
|
@ -830,7 +851,10 @@ pub fn bevel_geom(b: &Bevel) -> BevelGeom {
|
|||
BevelStyle::Emboss | BevelStyle::PillowEmboss => BevelPaint::Both,
|
||||
BevelStyle::InnerBevel | BevelStyle::StrokeEmboss => BevelPaint::Inner,
|
||||
};
|
||||
let width = if emboss { size / 2.0 } else { size };
|
||||
// Emboss styles straddle the edge with half the size, rounded up to whole pixels: a 41 px
|
||||
// emboss blurs over 21 (psd-tools layer_effects: 20.5 left narrow letter parts up to
|
||||
// 5.7/255 dark, 21 is within 2.2).
|
||||
let width = if emboss { (size / 2.0).ceil() } else { size };
|
||||
let smooth = b.technique == BevelTechnique::Smooth;
|
||||
let sign = if b.up { 1.0 } else { -1.0 };
|
||||
BevelGeom {
|
||||
|
|
@ -922,7 +946,16 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
|
|||
let (sa, ca) = angle.to_radians().sin_cos();
|
||||
let (se, ce) = altitude.to_radians().sin_cos();
|
||||
let light_v = [ca * ce, -sa * ce, se];
|
||||
let shade_into = |depth: f32, outer: bool| -> (Map, Map) {
|
||||
// Region: 0 = inside (× the shape's alpha), 1 = under the layer's edge and outside.
|
||||
// A pixel is on the bevel where its height is raised. Emboss styles also shade the last pixel
|
||||
// past the blur's reach (a raised 4-neighbour: it still slopes; psd-tools layer_effects'
|
||||
// emboss shadow runs one row past it), outer bevels don't (Photoshop oracle
|
||||
// bevel-outer-smooth).
|
||||
let reach_past = g.paint == BevelPaint::Both;
|
||||
let on_bevel = |x: i64, y: i64| {
|
||||
hmap.get(x, y) > BEVEL_H_EPS || (reach_past && [(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| hmap.get(x + dx, y + dy) > BEVEL_H_EPS))
|
||||
};
|
||||
let shade_into = |depth: f32, region_kind: u8| -> (Map, Map) {
|
||||
let (mut hi, mut sh) = (Map::new(shape.w, shape.h, 0.0), Map::new(shape.w, shape.h, 0.0));
|
||||
for y in 0..shape.h as i64 {
|
||||
for x in 0..shape.w as i64 {
|
||||
|
|
@ -932,7 +965,7 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
|
|||
let n = [-gx, -gy, 1.0];
|
||||
let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt();
|
||||
let shade = (n[0] * light_v[0] + n[1] * light_v[1] + n[2] * light_v[2]) / len;
|
||||
let region = if outer { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && hmap.v[i] > BEVEL_H_EPS) } else { shape.v[i] };
|
||||
let region = if region_kind == 0 { shape.v[i] } else { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && on_bevel(x, y)) };
|
||||
let k = shade - se;
|
||||
if k > 0.0 {
|
||||
hi.v[i] = (k / (1.0 - se).max(1e-3)).clamp(0.0, 1.0) * region;
|
||||
|
|
@ -945,13 +978,21 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
|
|||
};
|
||||
let maps = match g.paint {
|
||||
BevelPaint::Inner | BevelPaint::Outer => {
|
||||
let (hi, sh) = shade_into(g.depth, g.paint == BevelPaint::Outer);
|
||||
let (hi, sh) = shade_into(g.depth, u8::from(g.paint == BevelPaint::Outer));
|
||||
vec![hi, sh]
|
||||
}
|
||||
BevelPaint::Both => {
|
||||
let (hi, sh) = shade_into(g.depth, false);
|
||||
let (ho, so) = shade_into(if g.pillow { -g.depth } else { g.depth }, true);
|
||||
vec![hi, sh, ho, so]
|
||||
// One map per colour over the whole bevel: the inside half where the shape is (× its
|
||||
// alpha), the outside half (pillow: facing the other way) for the rest of each pixel.
|
||||
// A 1 % edge pixel of a pillow emboss takes the outside shading (Photoshop oracle
|
||||
// bevel-pillow-smooth), a 75 % one of an emboss mostly the inside (layer_effects).
|
||||
let (hi, sh) = shade_into(g.depth, 0);
|
||||
let (ho, so) = shade_into(if g.pillow { -g.depth } else { g.depth }, 1);
|
||||
let mix = |a: Map, b: Map| -> Map {
|
||||
let v = a.v.iter().zip(&b.v).zip(&shape.v).map(|((x, y), s)| if *s > INSIDE_EPS { *x } else { 0.0 } + y * (1.0 - s.min(1.0))).collect();
|
||||
Map { w: a.w, h: a.h, v }
|
||||
};
|
||||
vec![mix(hi, ho), mix(sh, so)]
|
||||
}
|
||||
};
|
||||
(maps, g.paint)
|
||||
|
|
@ -970,6 +1011,17 @@ pub struct FxMaps {
|
|||
dout: Option<Vec<f32>>,
|
||||
/// Shape layers: distance outside the vector outline (from local coverage).
|
||||
vdout: Option<Vec<f32>>,
|
||||
/// `shape` joins the alpha with a filled shape's outline (`crate::effect_outline`).
|
||||
outline: bool,
|
||||
/// Frames of gradient strokes by outside width (bits): see [`stroke_frame`].
|
||||
frames: Vec<(u32, Rect)>,
|
||||
}
|
||||
|
||||
impl FxMaps {
|
||||
/// The frame of a gradient stroke `st` of this layer (see [`stroke_frame`]), if built.
|
||||
pub fn stroke_frame(&self, st: &photocraft_doc::StrokeFx) -> Option<Rect> {
|
||||
self.frames.iter().find(|f| f.0 == stroke_widths(st).1.to_bits()).map(|f| f.1)
|
||||
}
|
||||
}
|
||||
|
||||
impl FxMaps {
|
||||
|
|
@ -1059,8 +1111,21 @@ pub(crate) fn build_maps_prepared(
|
|||
let has_stroke = items.iter().any(|e| matches!(e, Effect::Stroke(_)));
|
||||
let vector_shape = matches!(layer.content, photocraft_doc::LayerContent::Shape(_));
|
||||
let (din, dout) = if has_stroke { (Some(dist_inside_by(&shape, Metric::Chamfer)), Some(dist_outside_by(&shape, Metric::Chamfer))) } else { (None, None) };
|
||||
let vdout = (has_stroke && vector_shape).then(|| dist_outside_by(&local_coverage(&shape), Metric::Chamfer));
|
||||
FxMaps { rect, shape, per, bevel_paint, din, dout, vdout }
|
||||
// Without an outline (unfilled shapes), estimate the vector outline from local coverage.
|
||||
let outline = crate::effect_outline(layer).is_some();
|
||||
let vdout = (has_stroke && vector_shape && !outline).then(|| dist_outside_by(&local_coverage(&shape), Metric::Chamfer));
|
||||
let mut frames: Vec<(u32, Rect)> = Vec::new();
|
||||
for e in &items {
|
||||
if let Effect::Stroke(st) = e
|
||||
&& matches!(st.paint, FxPaint::Gradient(_))
|
||||
{
|
||||
let out_w = stroke_widths(st).1;
|
||||
if !frames.iter().any(|f| f.0 == out_w.to_bits()) {
|
||||
frames.push((out_w.to_bits(), stroke_frame(&shape, out_w, rect)));
|
||||
}
|
||||
}
|
||||
}
|
||||
FxMaps { rect, shape, per, bevel_paint, din, dout, vdout, outline, frames }
|
||||
}
|
||||
|
||||
/// Far outside any shape (distance fill for cropped distance fields).
|
||||
|
|
@ -1070,7 +1135,7 @@ const FAR: f32 = 1.0e9;
|
|||
/// masked, clipped layers applied) plus its effects into `backdrop`.
|
||||
pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Buffer, maps: &FxMaps, layer_bounds: Rect, patterns: &[Pattern]) {
|
||||
let prepared = PreparedPatterns::new(patterns, PREPARED_PATTERN_BYTES);
|
||||
composite_with_effects_prepared(layer, content, backdrop, maps, layer_bounds, &prepared);
|
||||
composite_with_effects_prepared(layer, content, backdrop, maps, layer_bounds, &prepared, None);
|
||||
}
|
||||
|
||||
pub(crate) fn composite_with_effects_prepared(
|
||||
|
|
@ -1080,10 +1145,21 @@ pub(crate) fn composite_with_effects_prepared(
|
|||
maps: &FxMaps,
|
||||
layer_bounds: Rect,
|
||||
patterns: &PreparedPatterns<'_>,
|
||||
vstroke: Option<VectorStroke<'_>>,
|
||||
) {
|
||||
let big = content.rect;
|
||||
let (w, h) = (big.width() as usize, big.height() as usize);
|
||||
let shape = Map { w, h, v: content.px.iter().map(|p| p[3]).collect() };
|
||||
// The effect shape: the content's alpha (with a split-off vector stroke, `content` is the
|
||||
// unmasked fill and the mask applies to fill ∪ stroke), joined with a filled shape's outline.
|
||||
let kmask = |i: usize| vstroke.as_ref().and_then(|v| v.mask).and_then(|m| m.get(i)).copied().unwrap_or(1.0);
|
||||
let union = |i: usize, a: f32| vstroke.as_ref().and_then(|v| v.stroke.px.get(i)).map_or(a, |s| kmask(i) * (a + s[3] * (1.0 - a)));
|
||||
let shape = if maps.outline {
|
||||
let o = maps.crop(&maps.shape, big, 0.0);
|
||||
Map { w, h, v: o.v.iter().zip(&content.px).enumerate().map(|(i, (o, p))| o.max(union(i, p[3]))).collect() }
|
||||
} else {
|
||||
Map { w, h, v: content.px.iter().enumerate().map(|(i, p)| union(i, p[3])).collect() }
|
||||
};
|
||||
let relative = maps.outline || vstroke.is_some();
|
||||
let fx = |i: usize, k: usize| maps.crop(&maps.per[i][k], big, 0.0);
|
||||
// Layer bounds (gradients aligned with the layer use the whole layer,
|
||||
// independent of the render rect).
|
||||
|
|
@ -1132,7 +1208,18 @@ pub(crate) fn composite_with_effects_prepared(
|
|||
// keeps its alpha, as in Photoshop.
|
||||
let fill = layer.fill_opacity;
|
||||
let inside = |a: f32| a > INSIDE_EPS;
|
||||
let mut lay = Buffer { rect: big, px: content.px.iter().map(|p| [p[0], p[1], p[2], if inside(p[3]) { fill } else { 0.0 }]).collect() };
|
||||
// Within an outline (or a split-off vector stroke) the content's own transparency (a fading
|
||||
// gradient fill) acts like fill opacity: the effects still cover the whole shape.
|
||||
let lay_alpha = |i: usize, p: &[f32; 4], a: f32| {
|
||||
if !inside(a) {
|
||||
0.0
|
||||
} else if relative {
|
||||
fill * (kmask(i) * p[3] / a).min(1.0)
|
||||
} else {
|
||||
fill
|
||||
}
|
||||
};
|
||||
let mut lay = Buffer { rect: big, px: content.px.iter().zip(&shape.v).enumerate().map(|(i, (p, a))| [p[0], p[1], p[2], lay_alpha(i, p, *a)]).collect() };
|
||||
let rel = |m: Map| -> Map {
|
||||
let v = m.v.iter().zip(&shape.v).map(|(m, a)| if inside(*a) { (m / a).min(1.0) } else { 0.0 }).collect();
|
||||
Map { w: m.w, h: m.h, v }
|
||||
|
|
@ -1171,6 +1258,15 @@ pub(crate) fn composite_with_effects_prepared(
|
|||
paint_color(&mut lay, &m, rgb(&s.color), s.common.blend, s.common.opacity);
|
||||
}
|
||||
}
|
||||
// A stroked shape's vector stroke: Photoshop draws it above the fill's clipped layers and
|
||||
// interior effects, below the stroke effect (psd-tools stroke-composite).
|
||||
if let Some(vs) = &vstroke {
|
||||
for (i, ((p, s), a)) in lay.px.iter_mut().zip(&vs.stroke.px).zip(&shape.v).enumerate() {
|
||||
if s[3] > 0.0 && inside(*a) {
|
||||
*p = psblend::composite(BlendMode::Normal, *p, [s[0], s[1], s[2], fill * (kmask(i) * s[3] / a).min(1.0)], 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Strokes. Inside parts are painted over the layer (bottom instance first); outside parts
|
||||
// are slid beneath it, so the first listed (top) instance is processed first.
|
||||
let strokes: Vec<&photocraft_doc::StrokeFx> = items.iter().filter_map(|e| if let Effect::Stroke(s) = e { Some(s) } else { None }).collect();
|
||||
|
|
@ -1178,11 +1274,9 @@ pub(crate) fn composite_with_effects_prepared(
|
|||
(Some(a), Some(b)) if !strokes.is_empty() => (maps.crop_vec(a, big, 0.0), maps.crop_vec(b, big, FAR)),
|
||||
_ => (Vec::new(), Vec::new()),
|
||||
};
|
||||
let widths = |st: &photocraft_doc::StrokeFx| match st.position {
|
||||
StrokePosition::Outside => (0.0, st.size),
|
||||
StrokePosition::Inside => (st.size, 0.0),
|
||||
StrokePosition::Center => (st.size / 2.0, st.size / 2.0),
|
||||
};
|
||||
let widths = stroke_widths;
|
||||
// A gradient stroke aligned with the layer spans the stroke's own extent.
|
||||
let frame = |st: &photocraft_doc::StrokeFx| maps.stroke_frame(st).unwrap_or(sb);
|
||||
for st in strokes.iter().rev().copied() {
|
||||
let (in_w, _) = widths(st);
|
||||
if in_w <= 0.0 {
|
||||
|
|
@ -1192,11 +1286,11 @@ pub(crate) fn composite_with_effects_prepared(
|
|||
for ((mv, a), dv) in m.v.iter_mut().zip(&shape.v).zip(&din) {
|
||||
*mv = if inside(*a) { (in_w + 0.5 - dv).clamp(0.0, 1.0) } else { 0.0 };
|
||||
}
|
||||
paint_fx(&mut lay, &m, &st.paint, sb, anchor, big, st.common.blend, st.common.opacity, patterns);
|
||||
paint_fx(&mut lay, &m, &st.paint, frame(st), anchor, big, st.common.blend, st.common.opacity, patterns);
|
||||
}
|
||||
for (i, e) in rev() {
|
||||
if let Effect::BevelEmboss(b) = e
|
||||
&& maps.bevel_paint[i] != BevelPaint::Outer
|
||||
&& maps.bevel_paint[i] == BevelPaint::Inner
|
||||
{
|
||||
let (hi, sh) = (rel(fx(i, 0)), rel(fx(i, 1)));
|
||||
paint_color(&mut lay, &hi, rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity);
|
||||
|
|
@ -1222,19 +1316,27 @@ pub(crate) fn composite_with_effects_prepared(
|
|||
if out_w <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
// Shape layers: Photoshop strokes the vector outline, estimated from
|
||||
// local coverage; the stroke never shows through the shape's pixels.
|
||||
// Shape layers: Photoshop strokes the vector outline (estimated from local coverage
|
||||
// for unfilled shapes); the stroke never shows through the shape's pixels. Along a
|
||||
// filled shape's outline it covers the part of each edge pixel outside the path.
|
||||
let d = vdout.as_ref().unwrap_or(&dout);
|
||||
let mut share = vec![0.0f32; w * h];
|
||||
for (i, sh) in share.iter_mut().enumerate() {
|
||||
let k = if inside(shape.v[i]) { if vector_shape { 0.0 } else { 1.0 } } else { (out_w + 0.5 - d[i]).clamp(0.0, 1.0) };
|
||||
let band = (out_w + 0.5 - d[i]).clamp(0.0, 1.0);
|
||||
let k = if maps.outline {
|
||||
band * outline_share(shape.v[i], lay.px[i][3])
|
||||
} else if inside(shape.v[i]) {
|
||||
if vector_shape { 0.0 } else { 1.0 }
|
||||
} else {
|
||||
band
|
||||
};
|
||||
*sh = k * st.common.opacity * (1.0 - cover[i]);
|
||||
cover[i] += *sh;
|
||||
}
|
||||
// The stroke blended over the backdrop at full coverage.
|
||||
let mut blended = base.clone();
|
||||
let ones = Map { w, h, v: share.iter().map(|&s| if s > 0.0 { 1.0 } else { 0.0 }).collect() };
|
||||
paint_fx(&mut blended, &ones, &st.paint, sb, anchor, big, st.common.blend, 1.0, patterns);
|
||||
paint_fx(&mut blended, &ones, &st.paint, frame(st), anchor, big, st.common.blend, 1.0, patterns);
|
||||
for ((a, b), s) in acc.iter_mut().zip(&blended.px).zip(&share) {
|
||||
if *s > 0.0 {
|
||||
for c in 0..3 {
|
||||
|
|
@ -1257,10 +1359,9 @@ pub(crate) fn composite_with_effects_prepared(
|
|||
}
|
||||
for (i, e) in rev() {
|
||||
if let Effect::BevelEmboss(b) = e
|
||||
&& maps.bevel_paint[i] != BevelPaint::Inner
|
||||
&& maps.bevel_paint[i] == BevelPaint::Outer
|
||||
{
|
||||
let k = if maps.bevel_paint[i] == BevelPaint::Both { 2 } else { 0 };
|
||||
let (hi, sh) = (fx(i, k), fx(i, k + 1));
|
||||
let (hi, sh) = (fx(i, 0), fx(i, 1));
|
||||
paint_color(&mut work, &hi, rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity);
|
||||
paint_color(&mut work, &sh, rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity);
|
||||
}
|
||||
|
|
@ -1274,6 +1375,17 @@ pub(crate) fn composite_with_effects_prepared(
|
|||
*wp = psblend::composite_gamma(mode, *wp, *lp, 1.0, gamma);
|
||||
}
|
||||
}
|
||||
// Emboss styles shade the composited layer (their maps cover inside and outside). Painting
|
||||
// them into the layer before its (text-gamma) composite left a type layer's lit top edges up
|
||||
// to 6/255 dark (psd-tools layer_effects Emboss: 7.8 → 5.7/255).
|
||||
for (i, e) in rev() {
|
||||
if let Effect::BevelEmboss(b) = e
|
||||
&& maps.bevel_paint[i] == BevelPaint::Both
|
||||
{
|
||||
paint_color(&mut work, &fx(i, 0), rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity);
|
||||
paint_color(&mut work, &fx(i, 1), rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity);
|
||||
}
|
||||
}
|
||||
// Layer opacity applies to the whole stack.
|
||||
let op = layer.opacity;
|
||||
for y in rect.y0..rect.y1 {
|
||||
|
|
@ -1286,6 +1398,55 @@ pub(crate) fn composite_with_effects_prepared(
|
|||
}
|
||||
}
|
||||
|
||||
/// Coverage of an outside stroke beneath a filled shape's edge pixel, for a pixel the outline
|
||||
/// covers `cov` of and the layer (with its interior effects) ends at alpha `l`. Photoshop treats
|
||||
/// the stroke and the shape as disjoint areas: the stroke fills the part outside the path and
|
||||
/// the layer adds its own alpha (psd-tools stroke-effects: a 58 % covered edge pixel of an
|
||||
/// opaque fill ends opaque, one of a faded fill at 42 % + its alpha). Composited beneath the
|
||||
/// layer, `share + l (1 - share) = (1 - cov) + l`.
|
||||
pub fn outline_share(cov: f32, l: f32) -> f32 {
|
||||
let outside = (1.0 - cov.clamp(0.0, 1.0)).max(0.0);
|
||||
let rest = 1.0 - l.clamp(0.0, 1.0);
|
||||
if rest <= 1e-6 { 1.0 } else { (outside / rest).min(1.0) }
|
||||
}
|
||||
|
||||
/// (inside width, outside width) of a stroke.
|
||||
pub fn stroke_widths(st: &photocraft_doc::StrokeFx) -> (f32, f32) {
|
||||
match st.position {
|
||||
StrokePosition::Outside => (0.0, st.size),
|
||||
StrokePosition::Inside => (st.size, 0.0),
|
||||
StrokePosition::Center => (st.size / 2.0, st.size / 2.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame a gradient stroke aligned with the layer is laid out in: the pixel bounds of the
|
||||
/// effect shape (over `rect`) grown by the stroke's outside width less one pixel. Photoshop
|
||||
/// spans the gradient over the stroke's extent, not the layer's: a 4 px outside stroke around a
|
||||
/// 22 px square runs its 90° gradient over 28 px (psd-tools stroke-effects), a 3 px one around
|
||||
/// 36 px of type over 40 px (psd-tools effect-stroke-gradient).
|
||||
fn stroke_frame(shape: &Map, out_w: f32, rect: Rect) -> Rect {
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize);
|
||||
for y in 0..shape.h {
|
||||
for x in 0..shape.w {
|
||||
if shape.v[y * shape.w + x] > INSIDE_EPS {
|
||||
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
if x0 == usize::MAX {
|
||||
return rect;
|
||||
}
|
||||
let grow = if out_w.is_finite() { (out_w.min(MAX_REACH).ceil() as i32 - 1).max(0) } else { 0 };
|
||||
Rect::new(rect.x0 + x0 as i32, rect.y0 + y0 as i32, rect.x0 + x1 as i32, rect.y0 + y1 as i32).inflate(grow)
|
||||
}
|
||||
|
||||
/// A stroked shape's vector stroke, composited apart from its fill (which the compositor then
|
||||
/// passes unmasked): the stroke's pixels over the render rect and the layer's mask values.
|
||||
pub(crate) struct VectorStroke<'a> {
|
||||
pub stroke: &'a Buffer,
|
||||
pub mask: Option<&'a [f32]>,
|
||||
}
|
||||
|
||||
/// Premultiplied linear interpolation between two straight-alpha pixels.
|
||||
fn mix_premul(a: [f32; 4], b: [f32; 4], k: f32) -> [f32; 4] {
|
||||
let alpha = a[3] + (b[3] - a[3]) * k;
|
||||
|
|
@ -1305,15 +1466,15 @@ fn mix_premul(a: [f32; 4], b: [f32; 4], k: f32) -> [f32; 4] {
|
|||
/// `glow_map`, `bevel_maps` and `build_maps`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum FieldKind {
|
||||
/// `dist_outside` of the shape: spread of drop shadows and softer outer glows, precise outer
|
||||
/// and centre glows, bevels.
|
||||
/// `dist_outside` of the shape: precise outer and centre glows, bevels.
|
||||
Outside,
|
||||
/// `dist_inside` of the shape: precise edge inner glows, bevels.
|
||||
Inside,
|
||||
/// `dist_outside` of `1 - alpha`: choke of inner shadows, softer inner glows.
|
||||
OutsideInverse,
|
||||
/// Stroke distance outside the shape.
|
||||
/// Chamfer distance outside the shape: outside strokes, and the spread of drop shadows and
|
||||
/// softer outer glows ([`dilate`]).
|
||||
StrokeOutside,
|
||||
/// Chamfer distance outside `1 - alpha`: the choke of inner shadows and softer inner glows.
|
||||
ChokeInside,
|
||||
/// Stroke distance inside the shape.
|
||||
StrokeInside,
|
||||
/// Stroke distance outside a shape layer's outline (from its local coverage).
|
||||
|
|
@ -1326,8 +1487,8 @@ pub fn distance_field(kind: FieldKind, alpha: Vec<f32>, w: usize, h: usize) -> V
|
|||
match kind {
|
||||
FieldKind::Outside => dist_outside(&s),
|
||||
FieldKind::Inside => dist_inside(&s),
|
||||
FieldKind::OutsideInverse => dist_outside(&s.map(|a| 1.0 - a)),
|
||||
FieldKind::StrokeOutside => dist_outside_by(&s, Metric::Chamfer),
|
||||
FieldKind::ChokeInside => dist_outside_by(&s.map(|a| 1.0 - a), Metric::Chamfer),
|
||||
FieldKind::StrokeInside => dist_inside_by(&s, Metric::Chamfer),
|
||||
FieldKind::StrokeOutsideVector => dist_outside_by(&local_coverage(&s), Metric::Chamfer),
|
||||
}
|
||||
|
|
@ -1374,9 +1535,10 @@ mod tests {
|
|||
let b = Rect::new(0, 0, 100, 100);
|
||||
let t = |s, x, y| gradient_t(s, 0.0, 1.0, false, (0.0, 0.0), b, x, y);
|
||||
assert!(t(GradientStyle::Linear, 0.0, 50.0) < 0.01);
|
||||
assert!((t(GradientStyle::Linear, 50.0, 50.0) - 0.5).abs() < 1e-6);
|
||||
// Linear / reflected sample pixel corners: the centre is reached half a pixel later.
|
||||
assert!((t(GradientStyle::Linear, 50.5, 50.0) - 0.5).abs() < 1e-6);
|
||||
assert!(t(GradientStyle::Linear, 100.0, 50.0) > 0.99);
|
||||
assert!(t(GradientStyle::Reflected, 50.0, 50.0) < 1e-6);
|
||||
assert!(t(GradientStyle::Reflected, 50.5, 50.0) < 1e-6);
|
||||
assert!((t(GradientStyle::Reflected, 0.0, 50.0) - 1.0).abs() < 1e-6);
|
||||
assert!(t(GradientStyle::Radial, 50.0, 50.0) < 1e-6);
|
||||
assert!((t(GradientStyle::Radial, 50.0, 0.0) - 1.0).abs() < 1e-6);
|
||||
|
|
@ -1387,6 +1549,18 @@ mod tests {
|
|||
assert!(rev > 0.99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_gradients_snap_end_points_and_sample_corners() {
|
||||
// 87° over 600 × 60 at (99, 422): end points (397, 482) and (400, 422), so the overlay runs
|
||||
// along (3, -60) from its midpoint (398.5, 452), sampled at pixel corners.
|
||||
let frame = Rect::new(99, 422, 699, 482);
|
||||
let (a, sc, o) = crate::fill_layout::gradient_layout(GradientStyle::Linear, 87.0, 1.0, (0.0, 0.0), frame);
|
||||
assert!((a - (60f32).atan2(3.0).to_degrees()).abs() < 1e-3, "{a}");
|
||||
let t = |x: f32, y: f32| gradient_t(GradientStyle::Linear, a, sc, false, o, frame, x, y);
|
||||
assert!((t(399.0, 452.5) - 0.5).abs() < 1e-4, "{}", t(399.0, 452.5));
|
||||
assert!(t(400.5, 422.5) > 0.99 && t(397.5, 482.5) < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chamfer_uses_euclidean_step_lengths() {
|
||||
let (w, h) = (9, 9);
|
||||
|
|
@ -1402,6 +1576,19 @@ mod tests {
|
|||
assert!(near.iter().all(|&n| n == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spread_dilates_with_the_chamfer_metric() {
|
||||
// One opaque pixel grown by 3 px: at offset (1, 3) the chamfer distance is √5 + 1 = 3.236
|
||||
// (Euclidean 3.162), so the anti-aliased rim keeps 3 + 0.5 − (3.236 − 0.5) = 0.764.
|
||||
let mut m = Map::new(9, 9, 0.0);
|
||||
m.v[4 * 9 + 4] = 1.0;
|
||||
let d = dilate(&m, 3.0);
|
||||
assert!((d.v[7 * 9 + 5] - 0.764).abs() < 1e-3, "{}", d.v[7 * 9 + 5]);
|
||||
// (2, 3): √5 + √2 = 3.650 (Euclidean 3.606) leaves 0.350; axis steps are exact.
|
||||
assert!((d.v[7 * 9 + 6] - (4.0 - 5f32.sqrt() - std::f32::consts::SQRT_2)).abs() < 1e-4, "{}", d.v[7 * 9 + 6]);
|
||||
assert_eq!(d.v[4 * 9 + 7], 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_sum_tent_matches_the_kernel() {
|
||||
let (w, h) = (37usize, 23usize);
|
||||
|
|
@ -1494,13 +1681,33 @@ mod tests {
|
|||
let l = GlobalLight::default();
|
||||
let (e, paint) =
|
||||
bevel_maps(&shape, &bevel_of(BevelStyle::Emboss, BevelTechnique::Smooth), &l, &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES));
|
||||
assert_eq!((paint, e.len()), (BevelPaint::Both, 4));
|
||||
assert_eq!((paint, e.len()), (BevelPaint::Both, 2));
|
||||
let at = |m: &Map, x: usize, y: usize| m.v[y * 40 + x];
|
||||
// Emboss: the slope across the top edge faces the light on both sides.
|
||||
assert!(at(&e[0], 20, 10) > 0.3 && at(&e[2], 20, 9) > 0.3);
|
||||
assert!(at(&e[0], 20, 10) > 0.3 && at(&e[0], 20, 9) > 0.3);
|
||||
let (p, _) =
|
||||
bevel_maps(&shape, &bevel_of(BevelStyle::PillowEmboss, BevelTechnique::Smooth), &l, &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES));
|
||||
assert!(at(&p[0], 20, 10) > 0.3 && at(&p[3], 20, 9) > 0.3, "pillow: outside top edge in shadow");
|
||||
assert!(at(&p[0], 20, 10) > 0.3 && at(&p[1], 20, 9) > 0.3, "pillow: outside top edge in shadow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emboss_width_rounds_up_and_shades_past_the_blur() {
|
||||
let mut b = bevel_of(BevelStyle::Emboss, BevelTechnique::Smooth);
|
||||
b.size = 41.0;
|
||||
let g = bevel_geom(&b);
|
||||
assert_eq!((g.width, g.depth), (21.0, 21.0));
|
||||
b.size = 5.0;
|
||||
assert_eq!(bevel_geom(&b).width, 3.0);
|
||||
// The first row past the height blur's reach still slopes (its upper neighbour is raised),
|
||||
// so it shades; the row after it does not.
|
||||
let shape = square(60, 20, 40);
|
||||
b.size = 9.0;
|
||||
b.angle = 90.0;
|
||||
let (m, _) = bevel_maps(&shape, &b, &GlobalLight::default(), &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES));
|
||||
let reach = tent_kernel(bevel_geom(&b).width).0 as usize;
|
||||
let row = 39 + reach + 1; // last shape row + reach + 1: height 0, neighbour above raised
|
||||
assert!(m[1].v[row * 60 + 30] > 0.0, "{}", m[1].v[row * 60 + 30]);
|
||||
assert_eq!(m[1].v[(row + 1) * 60 + 30], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
//! Gradient fill layer geometry.
|
||||
//!
|
||||
//! Photoshop lays a gradient fill layer's Linear and Reflected gradients out between whole-pixel
|
||||
//! end points: the chord through the frame's centre along the angle (as [`effects::gradient_t`])
|
||||
//! ends at points truncated to the pixel grid. On large frames that is invisible, on small ones it
|
||||
//! changes the effective angle: a 30° Reflected fill on a 4 × 4 canvas ends at the corner
|
||||
//! `(4, 0)` instead of `(4, 0.845)` and renders as a 45° one (fitted on the psd-tools `colormodes`
|
||||
//! files, whose 16/32-bit versions store no fill pixels).
|
||||
//!
|
||||
//! The snapped end points are expressed as the angle / scale / centre offset that
|
||||
//! [`effects::gradient_t`] (and the GPU's `gradient_t`) already take, so both compositors share
|
||||
//! one layout.
|
||||
|
||||
use photocraft_doc::GradientStyle;
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use crate::effects;
|
||||
|
||||
/// Effective `(angle, scale, centre offset)` for a gradient fill with `style`, `angle` (degrees)
|
||||
/// and `scale` laid out in `frame`.
|
||||
pub fn fill_gradient_layout(style: GradientStyle, angle: f32, scale: f32, frame: Rect) -> (f32, f32, (f32, f32)) {
|
||||
gradient_layout(style, angle, scale, (0.0, 0.0), frame)
|
||||
}
|
||||
|
||||
/// [`fill_gradient_layout`] for a gradient centred `offset` (a fraction of the frame) off the
|
||||
/// frame's centre: layer-effect gradients (overlays, gradient glows and strokes) snap the same
|
||||
/// way (psd-tools layer_effects: an 87° overlay on a 600 × 60 text line runs along (3, 60), i.e.
|
||||
/// 87.14°).
|
||||
pub fn gradient_layout(style: GradientStyle, angle: f32, scale: f32, offset: (f32, f32), frame: Rect) -> (f32, f32, (f32, f32)) {
|
||||
let unchanged = (angle, scale, offset);
|
||||
if !matches!(style, GradientStyle::Linear | GradientStyle::Reflected)
|
||||
|| !angle.is_finite()
|
||||
|| !scale.is_finite()
|
||||
|| !offset.0.is_finite()
|
||||
|| !offset.1.is_finite()
|
||||
{
|
||||
return unchanged;
|
||||
}
|
||||
let w = f64::from(frame.width().max(1));
|
||||
let h = f64::from(frame.height().max(1));
|
||||
let (cx, cy) = (f64::from(frame.x0) + w / 2.0 + f64::from(offset.0) * w, f64::from(frame.y0) + h / 2.0 + f64::from(offset.1) * h);
|
||||
// Unscaled chord length along `a` (radians), as `effects::gradient_t`.
|
||||
let chord_of = |a: f64| {
|
||||
let (s, c) = a.sin_cos();
|
||||
(w / c.abs().max(1e-6)).min(h / s.abs().max(1e-6)).max(1.0)
|
||||
};
|
||||
let a = f64::from(angle).to_radians();
|
||||
let scale64 = f64::from(scale.max(1e-3));
|
||||
let half = chord_of(a) * scale64 / 2.0;
|
||||
let (s, c) = a.sin_cos();
|
||||
let (dx, dy) = (c * half, -s * half);
|
||||
// Truncate to the pixel grid (with a little slack for rounding error in exact cases).
|
||||
let snap = |v: f64| (v + 1e-4).floor();
|
||||
let end = (snap(cx + dx), snap(cy + dy));
|
||||
let (start, mid) = match style {
|
||||
GradientStyle::Reflected => ((cx, cy), (cx, cy)),
|
||||
_ => {
|
||||
let s0 = (snap(cx - dx), snap(cy - dy));
|
||||
(s0, ((s0.0 + end.0) / 2.0, (s0.1 + end.1) / 2.0))
|
||||
}
|
||||
};
|
||||
let v = (end.0 - start.0, end.1 - start.1);
|
||||
let len = v.0.hypot(v.1);
|
||||
if len < 0.5 {
|
||||
return unchanged;
|
||||
}
|
||||
let a2 = (-v.1).atan2(v.0);
|
||||
// Reflected spans half the chord from the centre; Linear the whole chord.
|
||||
let span = if style == GradientStyle::Reflected { 2.0 * len } else { len };
|
||||
let scale2 = span / chord_of(a2);
|
||||
let shift = (((mid.0 - cx) / w) as f32, ((mid.1 - cy) / h) as f32);
|
||||
(a2.to_degrees() as f32, scale2 as f32, (offset.0 + shift.0, offset.1 + shift.1))
|
||||
}
|
||||
|
||||
/// Gradient parameter `t` of a gradient fill at pixel centre `(x, y)` (see [`fill_gradient_layout`]).
|
||||
pub fn fill_gradient_t(style: GradientStyle, angle: f32, scale: f32, reverse: bool, frame: Rect, x: f32, y: f32) -> f32 {
|
||||
let (a, s, o) = fill_gradient_layout(style, angle, scale, frame);
|
||||
effects::gradient_t(style, a, s, reverse, o, frame, x, y)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn grid(style: GradientStyle, angle: f32, frame: Rect) -> Vec<f32> {
|
||||
let mut v = Vec::new();
|
||||
for y in frame.y0..frame.y1 {
|
||||
for x in frame.x0..frame.x1 {
|
||||
v.push(fill_gradient_t(style, angle, 1.0, false, frame, x as f32 + 0.5, y as f32 + 0.5));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_reflected_30_degrees_snaps_to_the_corner() {
|
||||
// Photoshop's 4 × 4 30° Reflected fill: t = |x − y| / 4.
|
||||
let t = grid(GradientStyle::Reflected, 30.0, Rect::new(0, 0, 4, 4));
|
||||
for y in 0..4 {
|
||||
for x in 0..4 {
|
||||
let want = (x as f32 - y as f32).abs() / 4.0;
|
||||
assert!((t[y * 4 + x] - want).abs() < 1e-4, "({x},{y}) {} vs {want}", t[y * 4 + x]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_layouts_are_unchanged() {
|
||||
for (style, angle, frame) in [
|
||||
(GradientStyle::Linear, 90.0, Rect::new(0, 0, 33, 20)),
|
||||
(GradientStyle::Linear, 0.0, Rect::new(-5, 3, 40, 17)),
|
||||
(GradientStyle::Reflected, 45.0, Rect::new(0, 0, 32, 32)),
|
||||
(GradientStyle::Linear, -90.0, Rect::new(10, 10, 210, 210)),
|
||||
] {
|
||||
for y in [frame.y0, (frame.y0 + frame.y1) / 2, frame.y1 - 1] {
|
||||
for x in [frame.x0, (frame.x0 + frame.x1) / 2, frame.x1 - 1] {
|
||||
let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
|
||||
let a = fill_gradient_t(style, angle, 1.0, false, frame, px, py);
|
||||
let b = effects::gradient_t(style, angle, 1.0, false, (0.0, 0.0), frame, px, py);
|
||||
assert!((a - b).abs() < 1e-3, "{style:?} {angle} {frame:?} ({x},{y}): {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_centres_snap_like_the_frame_centre() {
|
||||
// A zero offset is the plain layout exactly.
|
||||
let f = Rect::new(0, 0, 7, 5);
|
||||
for style in [GradientStyle::Linear, GradientStyle::Reflected] {
|
||||
assert_eq!(gradient_layout(style, 30.0, 1.0, (0.0, 0.0), f), fill_gradient_layout(style, 30.0, 1.0, f));
|
||||
}
|
||||
// Moving the centre by whole pixels moves the snapped layout by the same pixels.
|
||||
let big = Rect::new(0, 0, 40, 20);
|
||||
let (a0, s0, o0) = gradient_layout(GradientStyle::Linear, 30.0, 0.5, (0.0, 0.0), big);
|
||||
let (a1, s1, o1) = gradient_layout(GradientStyle::Linear, 30.0, 0.5, (0.25, -0.1), big);
|
||||
assert!((a0 - a1).abs() < 1e-4 && (s0 - s1).abs() < 1e-4, "{a0} {a1} {s0} {s1}");
|
||||
assert!((o1.0 - o0.0 - 0.25).abs() < 1e-4 && (o1.1 - o0.1 + 0.1).abs() < 1e-4, "{o0:?} {o1:?}");
|
||||
// Non-snapping styles and bad offsets pass through.
|
||||
assert_eq!(gradient_layout(GradientStyle::Radial, 30.0, 1.0, (0.2, 0.1), big), (30.0, 1.0, (0.2, 0.1)));
|
||||
let (_, _, o) = gradient_layout(GradientStyle::Linear, 30.0, 1.0, (f32::NAN, 0.0), big);
|
||||
assert!(o.0.is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_frames_barely_move_and_bad_input_is_harmless() {
|
||||
let f = Rect::new(0, 0, 3000, 2000);
|
||||
let (a, s, o) = fill_gradient_layout(GradientStyle::Linear, 30.0, 1.0, f);
|
||||
assert!((a - 30.0).abs() < 0.05 && (s - 1.0).abs() < 1e-3 && o.0.abs() < 1e-3 && o.1.abs() < 1e-3);
|
||||
for (ang, sc) in [(f32::NAN, 1.0), (30.0, f32::INFINITY), (30.0, 0.0), (1e30, 1.0)] {
|
||||
let t = fill_gradient_t(GradientStyle::Linear, ang, sc, false, Rect::new(0, 0, 4, 4), 1.5, 1.5);
|
||||
assert!(t.is_nan() || (0.0..=1.0).contains(&t));
|
||||
}
|
||||
assert_eq!(fill_gradient_layout(GradientStyle::Radial, 30.0, 1.0, f), (30.0, 1.0, (0.0, 0.0)));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
//! Gradient Fill layers: their colour ramp (colour stops with midpoints, opacity stops, dither)
|
||||
//! and the on-canvas geometry of the Gradient tool's live gradients, where a start and an end
|
||||
//! point stand for the fill's angle, scale and centre offset.
|
||||
//!
|
||||
//! The mapping matches the Gradient tool's destructive drag (`photocraft_algo::paint`): a
|
||||
//! Linear gradient runs from the start (t = 0) to the end point (t = 1); Radial, Angle,
|
||||
//! Reflected and Diamond gradients are centred on the start point and reach t = 1 at the end
|
||||
//! point's distance. A fill made from a drag therefore renders the same pixels as painting that
|
||||
//! drag (see the tests).
|
||||
|
||||
use photocraft_doc::{Fill, GradientStyle};
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use crate::effects::gradient_units;
|
||||
|
||||
/// Smallest gradient length a handle drag produces (pixels), so the fill stays invertible.
|
||||
const MIN_LEN: f32 = 0.5;
|
||||
|
||||
/// A gradient fill's colour ramp, prepared for one render (colours converted once, while the
|
||||
/// document's CMYK profile is active).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Ramp {
|
||||
color: Vec<(f32, [f32; 4])>,
|
||||
mids: Vec<f32>,
|
||||
opacity: Vec<(f32, f32)>,
|
||||
}
|
||||
|
||||
impl Ramp {
|
||||
/// The ramp of a gradient fill (`None` for other fills).
|
||||
pub fn new(f: &Fill) -> Option<Ramp> {
|
||||
let Fill::Gradient { stops, opacity_stops, midpoints, .. } = f else { return None };
|
||||
let mut color: Vec<(f32, [f32; 4])> = stops
|
||||
.iter()
|
||||
.map(|(p, c)| {
|
||||
let rgb = c.to_rgb();
|
||||
(*p, [rgb[0], rgb[1], rgb[2], c.alpha])
|
||||
})
|
||||
.collect();
|
||||
color.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut opacity = opacity_stops.clone();
|
||||
opacity.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
Some(Ramp { color, mids: midpoints.clone(), opacity })
|
||||
}
|
||||
|
||||
/// Colour and alpha at `t` (`0..=1`).
|
||||
pub fn sample(&self, t: f32) -> [f32; 4] {
|
||||
let mut c = sample_color(&self.color, &self.mids, t);
|
||||
if !self.opacity.is_empty() {
|
||||
c[3] *= sample_opacity(&self.opacity, t);
|
||||
}
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a segment of relative position `u` lands once its midpoint is `m` (0.5 = unchanged):
|
||||
/// the colour is half-way at `m`, linear on either side.
|
||||
pub fn midpoint_remap(u: f32, m: f32) -> f32 {
|
||||
let m = m.clamp(0.05, 0.95);
|
||||
if (m - 0.5).abs() < 1e-6 {
|
||||
u
|
||||
} else if u <= m {
|
||||
0.5 * u / m
|
||||
} else {
|
||||
0.5 + 0.5 * (u - m) / (1.0 - m)
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_color(stops: &[(f32, [f32; 4])], mids: &[f32], t: f32) -> [f32; 4] {
|
||||
let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return [0.0; 4] };
|
||||
if t <= first.0 {
|
||||
return first.1;
|
||||
}
|
||||
for (i, w) in stops.windows(2).enumerate() {
|
||||
let (a, b) = (&w[0], &w[1]);
|
||||
if t <= b.0 {
|
||||
let u = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 };
|
||||
let k = midpoint_remap(u, mids.get(i).copied().unwrap_or(0.5));
|
||||
return std::array::from_fn(|c| a.1[c] + (b.1[c] - a.1[c]) * k);
|
||||
}
|
||||
}
|
||||
last.1
|
||||
}
|
||||
|
||||
fn sample_opacity(stops: &[(f32, f32)], t: f32) -> f32 {
|
||||
let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return 1.0 };
|
||||
if t <= first.0 {
|
||||
return first.1;
|
||||
}
|
||||
for w in stops.windows(2) {
|
||||
if t <= w[1].0 {
|
||||
let u = if w[1].0 > w[0].0 { (t - w[0].0) / (w[1].0 - w[0].0) } else { 0.0 };
|
||||
return w[0].1 + (w[1].1 - w[0].1) * u;
|
||||
}
|
||||
}
|
||||
last.1
|
||||
}
|
||||
|
||||
/// Adds one level of the shared dither noise to a colour (as the Gradient tool does).
|
||||
pub fn dither(c: &mut [f32; 4], x: i32, y: i32) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a gradient fill over `rect`, laid out in `frame` (row-major RGBA).
|
||||
pub fn render(f: &Fill, rect: Rect, frame: Rect) -> Vec<[f32; 4]> {
|
||||
let mut px = vec![[0.0f32; 4]; rect.width() as usize * rect.height() as usize];
|
||||
let (Some(ramp), Fill::Gradient { angle, scale, style, reverse, offset, dither: dith, .. }) = (Ramp::new(f), f) else { return px };
|
||||
let w = rect.width() as usize;
|
||||
if w == 0 {
|
||||
return px;
|
||||
}
|
||||
// Linear and Reflected end points on whole pixels, as Photoshop (crate::fill_layout).
|
||||
let (angle, scale, offset) = &crate::fill_layout::gradient_layout(*style, *angle, *scale, *offset, frame);
|
||||
let row = |y: i32, out: &mut [[f32; 4]]| {
|
||||
for (i, p) in out.iter_mut().enumerate() {
|
||||
let x = rect.x0 + i as i32;
|
||||
let t = crate::effects::gradient_t(*style, *angle, *scale, *reverse, *offset, frame, x as f32 + 0.5, y as f32 + 0.5);
|
||||
*p = ramp.sample(t);
|
||||
if *dith {
|
||||
dither(p, x, y);
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
px.par_chunks_mut(w).enumerate().for_each(|(j, out)| row(rect.y0 + j as i32, out));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
for (j, out) in px.chunks_mut(w).enumerate() {
|
||||
row(rect.y0 + j as i32, out);
|
||||
}
|
||||
px
|
||||
}
|
||||
|
||||
fn frame_dims(frame: Rect) -> (f32, f32) {
|
||||
(frame.width().max(1) as f32, frame.height().max(1) as f32)
|
||||
}
|
||||
|
||||
/// The centre a gradient is laid out around: the frame's centre moved by `offset`.
|
||||
fn centre(offset: (f32, f32), frame: Rect) -> [f32; 2] {
|
||||
let (w, h) = frame_dims(frame);
|
||||
[frame.x0 as f32 + w / 2.0 + offset.0 * w, frame.y0 as f32 + h / 2.0 + offset.1 * h]
|
||||
}
|
||||
|
||||
/// The gradient's reach from its centre at scale 1: the full chord for Linear, half the chord
|
||||
/// for Reflected, the radius for the others.
|
||||
fn unit_reach(style: GradientStyle, angle: f32, frame: Rect) -> f32 {
|
||||
let (w, h) = frame_dims(frame);
|
||||
let (chord, len) = gradient_units(angle, w, h);
|
||||
match style {
|
||||
GradientStyle::Linear => chord,
|
||||
GradientStyle::Reflected => chord / 2.0,
|
||||
GradientStyle::Radial | GradientStyle::Angle | GradientStyle::Diamond => len / 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The start and end points (document pixels) of a gradient laid out in `frame`.
|
||||
pub fn handles(style: GradientStyle, angle: f32, scale: f32, offset: (f32, f32), frame: Rect) -> ([f32; 2], [f32; 2]) {
|
||||
let c = centre(offset, frame);
|
||||
let (s, co) = angle.to_radians().sin_cos();
|
||||
let dir = [co, -s];
|
||||
let reach = unit_reach(style, angle, frame) * scale.max(1e-3);
|
||||
match style {
|
||||
GradientStyle::Linear => {
|
||||
let h = reach / 2.0;
|
||||
([c[0] - dir[0] * h, c[1] - dir[1] * h], [c[0] + dir[0] * h, c[1] + dir[1] * h])
|
||||
}
|
||||
_ => (c, [c[0] + dir[0] * reach, c[1] + dir[1] * reach]),
|
||||
}
|
||||
}
|
||||
|
||||
/// The handles of a gradient fill in `frame` (`None` for other fills).
|
||||
pub fn fill_handles(f: &Fill, frame: Rect) -> Option<([f32; 2], [f32; 2])> {
|
||||
match f {
|
||||
Fill::Gradient { style, angle, scale, offset, .. } => Some(handles(*style, *angle, *scale, *offset, frame)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Angle (degrees), scale and centre offset that put a gradient's start and end points at
|
||||
/// `from` and `to` in `frame` (inverse of [`handles`]). A zero-length drag keeps the direction
|
||||
/// of `fallback_angle`.
|
||||
pub fn from_handles(style: GradientStyle, from: [f32; 2], to: [f32; 2], frame: Rect, fallback_angle: f32) -> (f32, f32, (f32, f32)) {
|
||||
let (dx, dy) = (to[0] - from[0], to[1] - from[1]);
|
||||
let len = dx.hypot(dy);
|
||||
let angle = if len > 1e-4 { (-dy).atan2(dx).to_degrees() } else { fallback_angle };
|
||||
let len = len.max(MIN_LEN);
|
||||
let c = match style {
|
||||
GradientStyle::Linear => [(from[0] + to[0]) / 2.0, (from[1] + to[1]) / 2.0],
|
||||
_ => from,
|
||||
};
|
||||
let (w, h) = frame_dims(frame);
|
||||
let offset = ((c[0] - frame.x0 as f32 - w / 2.0) / w, (c[1] - frame.y0 as f32 - h / 2.0) / h);
|
||||
let scale = len / unit_reach(style, angle, frame);
|
||||
(angle, scale, offset)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_doc::Color;
|
||||
|
||||
const STYLES: [GradientStyle; 5] = [GradientStyle::Linear, GradientStyle::Radial, GradientStyle::Angle, GradientStyle::Reflected, GradientStyle::Diamond];
|
||||
|
||||
#[test]
|
||||
fn handles_round_trip() {
|
||||
let frame = Rect::new(10, -4, 210, 96);
|
||||
for style in STYLES {
|
||||
for (from, to) in [([30.0, 40.0], [180.0, 10.0]), ([100.0, 50.0], [100.0, 90.0]), ([5.0, 5.0], [-40.0, 70.0])] {
|
||||
let (a, s, o) = from_handles(style, from, to, frame, 0.0);
|
||||
let (f2, t2) = handles(style, a, s, o, frame);
|
||||
for (p, q) in [(from, f2), (to, t2)] {
|
||||
assert!((p[0] - q[0]).abs() < 1e-2 && (p[1] - q[1]).abs() < 1e-2, "{style:?}: {p:?} vs {q:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_map_to_the_ends_of_the_ramp() {
|
||||
let frame = Rect::new(0, 0, 100, 60);
|
||||
let (a, s, o) = from_handles(GradientStyle::Linear, [20.0, 30.0], [80.0, 30.0], frame, 0.0);
|
||||
// Linear gradients sample each pixel's top-left corner (effects::gradient_t): the pixel
|
||||
// whose corner is the handle, centred half a pixel further, is where t is 0 or 1.
|
||||
let t = |x: f32| crate::effects::gradient_t(GradientStyle::Linear, a, s, false, o, frame, x + 0.5, 30.5);
|
||||
assert!(t(20.0).abs() < 1e-4 && (t(80.0) - 1.0).abs() < 1e-4 && (t(50.0) - 0.5).abs() < 1e-4);
|
||||
let (a, s, o) = from_handles(GradientStyle::Radial, [40.0, 20.0], [40.0, 50.0], frame, 0.0);
|
||||
let r = |x: f32, y: f32| crate::effects::gradient_t(GradientStyle::Radial, a, s, false, o, frame, x, y);
|
||||
assert!(r(40.0, 20.0) < 1e-4 && (r(70.0, 20.0) - 1.0).abs() < 1e-3 && (r(55.0, 20.0) - 0.5).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ramp_midpoints_and_opacity() {
|
||||
let f = Fill::Gradient {
|
||||
stops: vec![(1.0, Color::WHITE), (0.0, Color::BLACK)],
|
||||
angle: 0.0,
|
||||
scale: 1.0,
|
||||
style: GradientStyle::Linear,
|
||||
reverse: false,
|
||||
opacity_stops: vec![(0.0, 1.0), (1.0, 0.0)],
|
||||
midpoints: vec![0.25],
|
||||
offset: (0.0, 0.0),
|
||||
dither: false,
|
||||
align: true,
|
||||
};
|
||||
let r = Ramp::new(&f).unwrap();
|
||||
// Unsorted stops are sorted; the midpoint puts 50 % gray at t = 0.25.
|
||||
assert!((r.sample(0.25)[0] - 0.5).abs() < 1e-5);
|
||||
assert!((r.sample(0.25)[3] - 0.75).abs() < 1e-5);
|
||||
assert_eq!(r.sample(1.0), [1.0, 1.0, 1.0, 0.0]);
|
||||
assert_eq!(midpoint_remap(0.3, 0.5), 0.3);
|
||||
assert!(Ramp::new(&Fill::Solid(Color::BLACK)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_handles_empty_and_degenerate_rects() {
|
||||
let f = Fill::gradient(vec![], 0.0, 0.0, GradientStyle::Angle, true);
|
||||
assert!(render(&f, Rect::new(0, 0, 0, 5), Rect::new(0, 0, 0, 0)).is_empty());
|
||||
let px = render(&f, Rect::new(0, 0, 3, 2), Rect::new(0, 0, 0, 0));
|
||||
assert_eq!(px.len(), 6);
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,8 @@
|
|||
pub mod adjust;
|
||||
pub mod bounds;
|
||||
pub mod effects;
|
||||
pub mod fill_layout;
|
||||
pub mod gradient_fill;
|
||||
pub mod masks;
|
||||
pub mod multichannel;
|
||||
pub mod pattern;
|
||||
|
|
@ -108,6 +110,14 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer
|
|||
buf
|
||||
});
|
||||
}
|
||||
// Effect maps are built once, here, before any tile needs them (#276).
|
||||
prepare_effects(&doc.layers, rect, cx, |f| {
|
||||
photocraft_color::convert::with_cmyk_space(cmyk, || {
|
||||
psblend::LAB_MIX.with(|l| l.set(lab));
|
||||
f();
|
||||
psblend::LAB_MIX.with(|l| l.set(false));
|
||||
});
|
||||
});
|
||||
let run = |t: Rect| {
|
||||
photocraft_color::convert::with_cmyk_space(cmyk, || {
|
||||
let mut b = multichannel::backdrop(doc, t);
|
||||
|
|
@ -258,6 +268,7 @@ pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer {
|
|||
mode: photocraft_color::ColorMode::Rgb,
|
||||
depth: photocraft_color::SampleType::F32,
|
||||
vector_masks: RenderVectorMasks::default(),
|
||||
fx_maps: Default::default(),
|
||||
},
|
||||
);
|
||||
buf
|
||||
|
|
@ -425,18 +436,69 @@ struct Ctx<'a> {
|
|||
mode: photocraft_color::ColorMode,
|
||||
depth: photocraft_color::SampleType,
|
||||
vector_masks: RenderVectorMasks,
|
||||
/// Effect maps used by this render, by cache key: built before the parallel tiles (see
|
||||
/// [`prepare_effects`]) and held for the whole render, so eviction can't force a rebuild.
|
||||
fx_maps: std::sync::Mutex<std::collections::HashMap<u64, std::sync::Arc<effects::FxMaps>>>,
|
||||
}
|
||||
|
||||
impl<'a> Ctx<'a> {
|
||||
fn for_doc(doc: &Document, patterns: &'a pattern::PreparedPatterns<'a>) -> Self {
|
||||
Self {
|
||||
canvas: doc.bounds(),
|
||||
transfer: adjust::Transfer::for_mode(doc.mode),
|
||||
transfer: adjust::Transfer::for_document(doc.mode, doc.depth),
|
||||
light: doc.global_light,
|
||||
patterns,
|
||||
mode: doc.mode,
|
||||
depth: doc.depth,
|
||||
vector_masks: RenderVectorMasks::default(),
|
||||
fx_maps: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deepest group nesting [`prepare_effects`] walks (deeper layers build their maps on demand).
|
||||
const PREPARE_DEPTH: u32 = 64;
|
||||
|
||||
/// Build the effect maps of every visible layer in `layers` (groups included) that can reach
|
||||
/// `rect` before tiles render in parallel, layers concurrently on native targets, each build
|
||||
/// inside `setting` (the tiles' thread-local colour setting). Tiles then only read finished maps:
|
||||
/// none builds one while others need it (#276), and none duplicates another's build. Builds never
|
||||
/// wait on each other (`cached_effect_maps`), so building them in parallel can't deadlock.
|
||||
fn prepare_effects(layers: &[Layer], rect: Rect, cx: &Ctx, setting: impl Fn(&mut dyn FnMut()) + Sync) {
|
||||
let mut todo = Vec::new();
|
||||
effect_layers(layers, rect, 0, &mut todo);
|
||||
let build = |l: &&Layer| {
|
||||
setting(&mut || {
|
||||
let _ = effect_maps(l, cx);
|
||||
});
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if todo.len() > 1 {
|
||||
use rayon::prelude::*;
|
||||
todo.par_iter().for_each(build);
|
||||
return;
|
||||
}
|
||||
todo.iter().for_each(build);
|
||||
}
|
||||
|
||||
/// The visible layers of `layers` (groups included) with effects that can reach `rect`.
|
||||
fn effect_layers<'l>(layers: &'l [Layer], rect: Rect, depth: u32, out: &mut Vec<&'l Layer>) {
|
||||
let mut base_visible = true;
|
||||
for l in layers {
|
||||
if !l.clipped {
|
||||
base_visible = l.visible;
|
||||
}
|
||||
// A clipping group is drawn only when its base is.
|
||||
if !l.visible || !base_visible {
|
||||
continue;
|
||||
}
|
||||
if effects::has_effects(l) && !empty_in(l, rect) {
|
||||
out.push(l);
|
||||
}
|
||||
if let LayerContent::Group(g) = &l.content
|
||||
&& depth < PREPARE_DEPTH
|
||||
{
|
||||
effect_layers(&g.children, rect, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -510,24 +572,90 @@ pub fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect {
|
|||
}
|
||||
}),
|
||||
LayerContent::Fill(_) => canvas,
|
||||
_ => layer.surface().map_or(Rect::EMPTY, bounds::content_bounds),
|
||||
_ => {
|
||||
let b = layer.surface().map_or(Rect::EMPTY, bounds::content_bounds);
|
||||
// Effects follow a filled shape's outline, also where its fill is transparent.
|
||||
match effect_outline(layer).and_then(|_| paint_bounds(layer)).filter(|_| effects::has_effects(layer)) {
|
||||
Some(p) if !b.is_empty() => b.union(&p),
|
||||
Some(p) => p,
|
||||
None => b,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The path a shape layer's effects are shaped by: its fill path. Photoshop builds a filled
|
||||
/// shape's layer effects from its vector outline, not from its pixels: a stroke runs along the
|
||||
/// whole path even where a gradient fill fades out (psd-tools stroke-effects). `None` for other
|
||||
/// layers, shapes without fill, empty and inverted paths, and shapes whose vector stroke reaches
|
||||
/// past the path (their pixels give the outline: psd-tools double-stroke-effects).
|
||||
pub fn effect_outline(layer: &Layer) -> Option<&photocraft_doc::vector::Path> {
|
||||
let LayerContent::Shape(sh) = &layer.content else { return None };
|
||||
let stroke_inside = sh.stroke.as_ref().is_none_or(|s| s.width <= 0.0 || s.align == photocraft_doc::vector::StrokeAlign::Inside);
|
||||
(sh.fill.is_some() && stroke_inside && !sh.path.is_empty() && !sh.path.inverted).then_some(&sh.path)
|
||||
}
|
||||
|
||||
/// The shape a layer's effects are built from over `rect` (row-major): its content's alpha
|
||||
/// (masks applied), completed for shapes with an [`effect_outline`] by the outline's coverage
|
||||
/// where the fill is see-through. With `l` the alpha and `f` the fill's own opacity (the largest
|
||||
/// alpha around), the shape is `l + (1 - f) × coverage`: Photoshop's own rasterization where the
|
||||
/// fill is opaque (it can differ from ours by a fraction of a pixel: psd-tools shape-fx2), the
|
||||
/// path where the fill fades out (psd-tools stroke-effects).
|
||||
fn effect_shape(layer: &Layer, rect: Rect, cx: &Ctx) -> Vec<f32> {
|
||||
let n = rect.width() as usize * rect.height() as usize;
|
||||
let Some(path) = effect_outline(layer).filter(|_| n > 0) else {
|
||||
return render_content(layer, rect, cx).map(|b| b.px.iter().map(|p| p[3]).collect()).unwrap_or_else(|| vec![0.0; n]);
|
||||
};
|
||||
// Unmasked: the masks scale the shape, they aren't the fill's transparency.
|
||||
let a: Vec<f32> = layer.surface().map(|s| surface_to_buffer(s, rect).px.iter().map(|p| p[3]).collect()).unwrap_or_else(|| vec![0.0; n]);
|
||||
let cov = photocraft_vector::path_coverage(path, rect);
|
||||
let mv = mask_vals(layer, rect, cx);
|
||||
let (w, h) = (rect.width() as usize, rect.height() as usize);
|
||||
let mut out = vec![0.0; n];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
// A pixel the outline covers is inside, whatever the fill's opacity.
|
||||
let v = if cov[i] >= 1.0 - 1e-3 {
|
||||
1.0
|
||||
} else {
|
||||
let mut f = 0.0f32;
|
||||
for yy in y.saturating_sub(1)..(y + 2).min(h) {
|
||||
for xx in x.saturating_sub(1)..(x + 2).min(w) {
|
||||
f = f.max(a[yy * w + xx]);
|
||||
}
|
||||
}
|
||||
(a[i] + (1.0 - f.min(1.0)) * cov[i]).clamp(0.0, 1.0)
|
||||
};
|
||||
out[i] = v * mask_k(&mv, i);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The frame layer-effect gradients and linked patterns are laid out in, when it isn't the
|
||||
/// layer's pixel bounds: a shape layer's path bounds (rounded out to whole pixels). Its rendered
|
||||
/// layer's pixel bounds: a shape layer's path bounds, truncated to whole pixels. Its rendered
|
||||
/// pixels can extend past the path (transparent anti-aliasing margin), which Photoshop ignores:
|
||||
/// psd-tools shape-fx2's 45° overlay spans the 29 px path, not the 32 px of pixels.
|
||||
/// psd-tools shape-fx2's 45° overlay spans the 0.92–29.61 path as 0–29 (29 px, centred at 14.5),
|
||||
/// not the 32 px of pixels.
|
||||
pub fn paint_bounds(layer: &Layer) -> Option<Rect> {
|
||||
let LayerContent::Shape(sh) = &layer.content else { return None };
|
||||
let (x0, y0, x1, y1) = sh.path.control_bounds()?;
|
||||
let r = Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32);
|
||||
let r = Rect::new(x0.floor() as i32, y0.floor() as i32, x1.floor() as i32, y1.floor() as i32);
|
||||
(!r.is_empty()).then_some(r)
|
||||
}
|
||||
|
||||
/// The layer's effective mask over `rect` (row-major), read once per tile: the pixel mask
|
||||
/// times the rasterized vector mask.
|
||||
fn mask_vals(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Vec<f32>> {
|
||||
// Feathers blur across tiles: read the cached, canvas-wide combined mask.
|
||||
if masks::has_feather(layer)
|
||||
&& let Some(s) = masks::combined_mask(layer, cx.canvas)
|
||||
{
|
||||
let mut v = Vec::new();
|
||||
s.read_region_into(rect, &mut v);
|
||||
return Some(v);
|
||||
}
|
||||
let vector = layer.vector_mask.as_ref().map(|vm| cx.vector_masks.values(vm, rect));
|
||||
let Some(m) = layer.mask.as_ref() else { return vector };
|
||||
let mut v = Vec::new();
|
||||
|
|
@ -573,12 +701,27 @@ fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Buffer> {
|
|||
Some(buf)
|
||||
}
|
||||
|
||||
/// The alpha of `layer`'s own content over `rect` (masks applied, row-major): the shape its
|
||||
/// effect maps are built from (for the GPU compositor). Zero for adjustment layers.
|
||||
/// The shape `layer`'s effect maps are built from over `rect` (row-major; for the GPU
|
||||
/// compositor): its content's alpha (masks applied), joined with the [`effect_outline`] of a
|
||||
/// filled shape. Zero for adjustment layers.
|
||||
///
|
||||
/// See also [`stroke_frame`].
|
||||
pub fn layer_shape(doc: &Document, layer: &Layer, rect: Rect) -> Vec<f32> {
|
||||
let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES);
|
||||
let cx = Ctx::for_doc(doc, &patterns);
|
||||
render_content(layer, rect, &cx).map(|b| b.px.iter().map(|p| p[3]).collect()).unwrap_or_else(|| vec![0.0; rect.width() as usize * rect.height() as usize])
|
||||
effect_shape(layer, rect, &cx)
|
||||
}
|
||||
|
||||
/// The frame a gradient stroke `st` of `layer` is laid out in (`effects::FxMaps::stroke_frame`),
|
||||
/// from the layer's cached effect maps; `None` when `st` isn't a gradient stroke of the layer.
|
||||
/// For the GPU compositor.
|
||||
pub fn stroke_frame(doc: &Document, layer: &Layer, st: &photocraft_doc::StrokeFx) -> Option<Rect> {
|
||||
if !matches!(st.paint, photocraft_doc::FxPaint::Gradient(_)) || !effects::has_effects(layer) {
|
||||
return None;
|
||||
}
|
||||
let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES);
|
||||
let cx = Ctx::for_doc(doc, &patterns);
|
||||
effect_maps(layer, &cx).stroke_frame(st)
|
||||
}
|
||||
|
||||
pub fn surface_to_buffer(s: &Surface, rect: Rect) -> Buffer {
|
||||
|
|
@ -592,6 +735,10 @@ pub fn surface_to_buffer(s: &Surface, rect: Rect) -> Buffer {
|
|||
/// the vector mask's path; otherwise the canvas.
|
||||
pub fn fill_frame(layer: &Layer, canvas: Rect) -> Rect {
|
||||
let mut frame = canvas;
|
||||
// "Align with layer" off: the canvas (the Gradient tool's live gradients).
|
||||
if let LayerContent::Fill(Fill::Gradient { align: false, .. }) = &layer.content {
|
||||
return frame;
|
||||
}
|
||||
if let Some(m) = &layer.mask
|
||||
&& m.enabled
|
||||
&& m.surface.default_pixel().first().is_some_and(|v| *v <= 0.0)
|
||||
|
|
@ -627,26 +774,8 @@ fn render_fill(f: &Fill, rect: Rect, canvas: Rect, patterns: &pattern::PreparedP
|
|||
let rgb = c.to_rgb();
|
||||
Buffer::filled(rect, [rgb[0], rgb[1], rgb[2], c.alpha])
|
||||
}
|
||||
Fill::Gradient { stops, angle, scale, style, reverse } => {
|
||||
// Convert once per render call, while its document CMYK profile is active.
|
||||
let stops: Vec<_> = stops
|
||||
.iter()
|
||||
.map(|(p, c)| {
|
||||
let rgb = c.to_rgb();
|
||||
(*p, [rgb[0], rgb[1], rgb[2], c.alpha])
|
||||
})
|
||||
.collect();
|
||||
// Gradient geometry relative to the layer's frame, independent of the render rect.
|
||||
let mut b = Buffer::transparent(rect);
|
||||
for y in rect.y0..rect.y1 {
|
||||
for x in rect.x0..rect.x1 {
|
||||
let t = effects::gradient_t(*style, *angle, *scale, *reverse, (0.0, 0.0), canvas, x as f32 + 0.5, y as f32 + 0.5);
|
||||
let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize;
|
||||
b.px[i] = sample_stops(&stops, t);
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
// Gradient geometry relative to the layer's frame, independent of the render rect.
|
||||
Fill::Gradient { .. } => Buffer { rect, px: gradient_fill::render(f, rect, canvas) },
|
||||
// Laid out from the layer's frame when linked; transparent if the pattern is missing.
|
||||
Fill::Pattern { name, scale, id, angle, link, phase } => match patterns.get(id, name) {
|
||||
Some(tile) => Buffer { rect, px: pattern::render(&tile, &pattern::Placement::new(canvas, *link, *phase, *scale, *angle), rect) },
|
||||
|
|
@ -655,27 +784,6 @@ fn render_fill(f: &Fill, rect: Rect, canvas: Rect, patterns: &pattern::PreparedP
|
|||
}
|
||||
}
|
||||
|
||||
fn sample_stops(stops: &[(f32, [f32; 4])], t: f32) -> [f32; 4] {
|
||||
match stops {
|
||||
[] => [0.0; 4],
|
||||
[only] => only.1,
|
||||
_ => {
|
||||
if t <= stops[0].0 {
|
||||
return stops[0].1;
|
||||
}
|
||||
for w in stops.windows(2) {
|
||||
let (a, b) = (&w[0], &w[1]);
|
||||
if t <= b.0 {
|
||||
let k = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 };
|
||||
let (ca, cb) = (a.1, b.1);
|
||||
return std::array::from_fn(|i| ca[i] + (cb[i] - ca[i]) * k);
|
||||
}
|
||||
}
|
||||
stops[stops.len() - 1].1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` when a pixel-backed layer has nothing to contribute in `rect`:
|
||||
/// no allocated tiles there, a transparent default, and no effects (which
|
||||
/// could reach in from outside). Clipped layers depend on the base, so they
|
||||
|
|
@ -726,6 +834,55 @@ fn restore_channels(out: &mut Buffer, before: &Buffer, w: [f32; 3]) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Where `layer`'s composite (its pixels and effects; a group's visible children with theirs) can
|
||||
/// be non-transparent, or `None` when it may draw anywhere (a surface with an opaque default
|
||||
/// pixel). Outside it, compositing the layer leaves the backdrop unchanged in every blend mode.
|
||||
/// Adjustment layers count as empty: they only change pixels already there (which is what an
|
||||
/// isolated group needs; see [`change_bounds`] for the document-level question).
|
||||
pub fn composite_bounds(layer: &Layer, canvas: Rect) -> Option<Rect> {
|
||||
if !transparent_outside(layer) {
|
||||
return None;
|
||||
}
|
||||
let own = match &layer.content {
|
||||
LayerContent::Group(g) if g.artboard.is_none() => {
|
||||
let mut acc = Rect::EMPTY;
|
||||
for c in g.children.iter().filter(|c| c.visible) {
|
||||
let b = composite_bounds(c, canvas)?;
|
||||
if !b.is_empty() {
|
||||
acc = if acc.is_empty() { b } else { acc.union(&b) };
|
||||
}
|
||||
}
|
||||
acc
|
||||
}
|
||||
LayerContent::Adjustment(_) => Rect::EMPTY,
|
||||
_ => layer_bounds(layer, canvas),
|
||||
};
|
||||
if own.is_empty() {
|
||||
return Some(Rect::EMPTY);
|
||||
}
|
||||
let m = if effects::has_effects(layer) { effects::margin(layer) } else { 0 };
|
||||
Some(own.inflate(m).intersect(&canvas))
|
||||
}
|
||||
|
||||
/// The document pixels that showing, hiding, moving or restyling `layer` can change, or `None`
|
||||
/// for anywhere: an adjustment layer (and a pass-through group holding one) reaches everything
|
||||
/// beneath it. Clipped layers above stay within their base's bounds, so they are covered too;
|
||||
/// effects of groups around the layer can reach further (callers grow the rect by that reach).
|
||||
pub fn change_bounds(layer: &Layer, canvas: Rect) -> Option<Rect> {
|
||||
fn reaches_below(l: &Layer) -> bool {
|
||||
match &l.content {
|
||||
// Clipped, it changes only its base's pixels.
|
||||
LayerContent::Adjustment(_) => !l.clipped,
|
||||
LayerContent::Group(g) if l.blend == BlendMode::PassThrough => g.children.iter().any(reaches_below),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
if matches!(layer.content, LayerContent::Adjustment(_)) || reaches_below(layer) {
|
||||
return None;
|
||||
}
|
||||
composite_bounds(layer, canvas)
|
||||
}
|
||||
|
||||
/// Whether the layer's content is transparent outside its bounds (every surface it draws from
|
||||
/// has a transparent default pixel), so its effects can't change pixels beyond its bounds grown
|
||||
/// by their reach.
|
||||
|
|
@ -738,6 +895,37 @@ pub fn transparent_outside(layer: &Layer) -> bool {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether `layer` replaces everything beneath it in its stack: a visible, unclipped fill layer
|
||||
/// in Normal mode at full opacity and fill, without masks, effects, channel restrictions or Blend
|
||||
/// If, whose fill is opaque everywhere (a solid colour, or a gradient whose colour and opacity
|
||||
/// stops are all opaque). Normal blending at alpha 1 returns the layer's colour exactly, so the
|
||||
/// layers below can be skipped (the live Gradient tool's preview recomposites the whole canvas on
|
||||
/// every pointer move).
|
||||
pub fn occludes_below(layer: &Layer, mode: photocraft_color::ColorMode) -> bool {
|
||||
let LayerContent::Fill(f) = &layer.content else { return false };
|
||||
let opaque = match f {
|
||||
Fill::Solid(c) => c.alpha >= 1.0,
|
||||
Fill::Gradient { stops, opacity_stops, .. } => {
|
||||
!stops.is_empty() && stops.iter().all(|(_, c)| c.alpha >= 1.0) && opacity_stops.iter().all(|(_, o)| *o >= 1.0)
|
||||
}
|
||||
Fill::Pattern { .. } => false,
|
||||
};
|
||||
// A cached render in use (a PSD's stored fill pixels) may not be opaque.
|
||||
let cached = layer.fill_cache.as_ref().is_some_and(|c| c.fill == *f);
|
||||
opaque
|
||||
&& !cached
|
||||
&& layer.visible
|
||||
&& !layer.clipped
|
||||
&& layer.blend == photocraft_color::BlendMode::Normal
|
||||
&& layer.opacity >= 1.0
|
||||
&& layer.fill_opacity >= 1.0
|
||||
&& !layer.mask.as_ref().is_some_and(|m| m.enabled)
|
||||
&& !layer.vector_mask.as_ref().is_some_and(|m| m.enabled)
|
||||
&& !effects::has_effects(layer)
|
||||
&& layer.excluded_channels == 0
|
||||
&& !blend_if_active(layer, mode)
|
||||
}
|
||||
|
||||
/// Whether Blending Options › Blend If changes how `layer` composites in a `mode` document.
|
||||
/// RGB documents test Gray and R, G, B; grayscale (and duotone) documents their one channel.
|
||||
/// Other modes composite in display RGB, where ranges over their own channels have no exact
|
||||
|
|
@ -865,9 +1053,12 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
|||
}
|
||||
let opacity = layer.opacity * layer.fill_opacity;
|
||||
|
||||
// Pass-through groups composite their children straight into the backdrop.
|
||||
// Pass-through groups composite their children straight into the backdrop. Below 100% fill
|
||||
// Photoshop renders the group isolated, like Normal (psd-tools passthrough_fill_*: an
|
||||
// adjustment inside no longer reaches the layers beneath).
|
||||
if let LayerContent::Group(g) = &layer.content
|
||||
&& layer.blend == BlendMode::PassThrough
|
||||
&& layer.fill_opacity >= 1.0
|
||||
&& !effects::has_effects(layer)
|
||||
{
|
||||
let needs_mix = opacity < 1.0 || layer.mask.is_some() || layer.vector_mask.is_some();
|
||||
|
|
@ -924,7 +1115,7 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
|||
// Adjustment layers transform the backdrop, then blend the result back in.
|
||||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||||
let mut adjusted = backdrop.clone();
|
||||
adjust::apply_with(adj, &mut adjusted, cx.transfer);
|
||||
adjust::apply_depth(adj, &mut adjusted, cx.transfer, adjustment_quantum(cx.depth));
|
||||
// Clipped layers onto an adjustment are uncommon; they composite atop the adjusted result.
|
||||
for c in clipped.iter().filter(|c| c.visible) {
|
||||
composite_atop(c, &mut adjusted, cx);
|
||||
|
|
@ -950,10 +1141,18 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
|||
if effects::has_effects(layer) {
|
||||
// Neighbourhoods are already captured by the full-region effect maps;
|
||||
// content and effect application only need the output rectangle.
|
||||
let Some(mut content) = render_content(layer, rect, cx) else { return };
|
||||
// A stroked shape's vector stroke stays above its clipped layers and interior effects.
|
||||
let (mut content, vstroke) = match split_parts(layer, rect, cx) {
|
||||
Some((fill, stroke, mask)) => (fill, Some((stroke, mask))),
|
||||
None => {
|
||||
let Some(content) = render_content(layer, rect, cx) else { return };
|
||||
(content, None)
|
||||
}
|
||||
};
|
||||
for c in clipped.iter().filter(|c| c.visible) {
|
||||
composite_atop(c, &mut content, cx);
|
||||
}
|
||||
let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() });
|
||||
let maps = effect_maps(layer, cx);
|
||||
effects::composite_with_effects_prepared(
|
||||
layer,
|
||||
|
|
@ -962,15 +1161,17 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
|||
&maps,
|
||||
paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)),
|
||||
cx.patterns,
|
||||
vstroke,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Some((mut content, stroke)) = shape_parts(layer, clipped, rect, cx) {
|
||||
if let Some((mut content, stroke, mask)) = shape_parts(layer, clipped, rect, cx) {
|
||||
for c in clipped.iter().filter(|c| c.visible) {
|
||||
composite_atop(c, &mut content, cx);
|
||||
}
|
||||
for (p, s) in content.px.iter_mut().zip(&stroke.px) {
|
||||
for (i, (p, s)) in content.px.iter_mut().zip(&stroke.px).enumerate() {
|
||||
*p = psblend::composite(BlendMode::Normal, *p, *s, 1.0);
|
||||
p[3] *= mask_k(&mask, i);
|
||||
}
|
||||
blend_into(backdrop, &content, layer.blend, opacity);
|
||||
return;
|
||||
|
|
@ -990,22 +1191,23 @@ pub fn text_gamma(layer: &Layer) -> f32 {
|
|||
|
||||
/// A stroked shape layer with visible clipped layers: Photoshop draws the shape's vector stroke
|
||||
/// above the clipped layers, so the base content is the fill alone and the stroke is laid on top
|
||||
/// after clipping. Returns (fill, stroke) buffers over `rect`, masks applied.
|
||||
fn shape_parts(layer: &Layer, clipped: &[Layer], rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer)> {
|
||||
let LayerContent::Shape(sh) = &layer.content else { return None };
|
||||
if sh.stroke.is_none() || !clipped.iter().any(|c| c.visible) {
|
||||
/// after clipping. See [`split_parts`].
|
||||
fn shape_parts(layer: &Layer, clipped: &[Layer], rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer, Option<Vec<f32>>)> {
|
||||
if !clipped.iter().any(|c| c.visible) {
|
||||
return None;
|
||||
}
|
||||
split_parts(layer, rect, cx)
|
||||
}
|
||||
|
||||
/// A stroked shape layer's (fill, vector stroke, mask values) over `rect`; `None` for other
|
||||
/// layers. Fill and stroke are unmasked: the mask applies to them together. Layer effects paint
|
||||
/// the fill's interior effects beneath the vector stroke (psd-tools stroke-composite: a colour
|
||||
/// overlay leaves the stroke, the stroke effect covers it).
|
||||
fn split_parts(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer, Option<Vec<f32>>)> {
|
||||
let LayerContent::Shape(sh) = &layer.content else { return None };
|
||||
sh.stroke.as_ref()?;
|
||||
let (fs, ss) = shape_split::split(sh, cx.canvas)?;
|
||||
let mut f = surface_to_buffer(&fs, rect);
|
||||
let mut s = surface_to_buffer(&ss, rect);
|
||||
if let Some(m) = mask_vals(layer, rect, cx) {
|
||||
for ((a, b), k) in f.px.iter_mut().zip(s.px.iter_mut()).zip(&m) {
|
||||
a[3] *= k;
|
||||
b[3] *= k;
|
||||
}
|
||||
}
|
||||
Some((f, s))
|
||||
Some((surface_to_buffer(&fs, rect), surface_to_buffer(&ss, rect), mask_vals(layer, rect, cx)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
|
@ -1062,8 +1264,8 @@ struct FxEntry {
|
|||
bytes: usize,
|
||||
}
|
||||
|
||||
/// Global cache of effect maps (bounded by bytes). Tiles rendered in parallel share one build per
|
||||
/// layer state via a per-key `OnceLock`.
|
||||
/// Global cache of effect maps (bounded by bytes), one slot per layer state. Slots are filled
|
||||
/// with `set`, never `get_or_init`: nothing may block on a build (see `cached_effect_maps`).
|
||||
type FxSlot = std::sync::Arc<std::sync::OnceLock<FxEntry>>;
|
||||
struct FxCache {
|
||||
map: std::collections::HashMap<u64, FxSlot>,
|
||||
|
|
@ -1113,7 +1315,7 @@ fn effect_maps(layer: &Layer, cx: &Ctx) -> std::sync::Arc<effects::FxMaps> {
|
|||
let m = effects::margin(layer);
|
||||
let region = layer_bounds(layer, cx.canvas).inflate(m).intersect(&cx.canvas.inflate(m));
|
||||
if std::env::var_os("PHOTOCRAFT_FX_NOCACHE").is_some() {
|
||||
let shape = render_content(layer, region, cx).map(|b| b.px.iter().map(|p| p[3]).collect()).unwrap_or_default();
|
||||
let shape = if region.is_empty() { Vec::new() } else { effect_shape(layer, region, cx) };
|
||||
return std::sync::Arc::new(effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns));
|
||||
}
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
|
|
@ -1121,6 +1323,22 @@ fn effect_maps(layer: &Layer, cx: &Ctx) -> std::sync::Arc<effects::FxMaps> {
|
|||
(region.x0, region.y0, region.x1, region.y1).hash(&mut h);
|
||||
(cx.light.angle.to_bits(), cx.light.altitude.to_bits()).hash(&mut h);
|
||||
let key = h.finish();
|
||||
// Prepared for this render (see `prepare_effects`): no global lookup, no build.
|
||||
if let Some(m) = cx.fx_maps.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
return m.clone();
|
||||
}
|
||||
let maps = cached_effect_maps(layer, region, key, cx);
|
||||
cx.fx_maps.lock().unwrap_or_else(|e| e.into_inner()).entry(key).or_insert(maps).clone()
|
||||
}
|
||||
|
||||
/// The layer's effect maps from the global cache, built on a miss.
|
||||
///
|
||||
/// Never waits for another thread's build (#276): this can run on a Rayon worker inside a tile,
|
||||
/// and the build itself runs Rayon work. A worker waiting on that work may steal another tile of
|
||||
/// the same layer; had it blocked on a once-init held further up its own stack, it would deadlock.
|
||||
/// Instead a miss builds the maps here and the first finished build is kept. Renders build every
|
||||
/// layer's maps before their parallel tiles (`prepare_effects`), so tiles rarely miss.
|
||||
fn cached_effect_maps(layer: &Layer, region: Rect, key: u64, cx: &Ctx) -> std::sync::Arc<effects::FxMaps> {
|
||||
let slot = {
|
||||
let mut c = fx_cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(s) = c.map.get(&key) {
|
||||
|
|
@ -1132,21 +1350,25 @@ fn effect_maps(layer: &Layer, cx: &Ctx) -> std::sync::Arc<effects::FxMaps> {
|
|||
s
|
||||
}
|
||||
};
|
||||
let entry = slot.get_or_init(|| {
|
||||
let shape: Vec<f32> = if region.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
render_content(layer, region, cx)
|
||||
.map(|b| b.px.iter().map(|p| p[3]).collect())
|
||||
.unwrap_or_else(|| vec![0.0; region.width() as usize * region.height() as usize])
|
||||
};
|
||||
let maps = effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns);
|
||||
let bytes = maps.bytes();
|
||||
// Counted exactly once, when the entry is built.
|
||||
fx_cache().lock().unwrap_or_else(|e| e.into_inner()).bytes += bytes;
|
||||
FxEntry { maps: std::sync::Arc::new(maps), _pin: layer.clone(), bytes }
|
||||
});
|
||||
let maps = entry.maps.clone();
|
||||
let maps = match slot.get() {
|
||||
Some(e) => e.maps.clone(),
|
||||
None => {
|
||||
let shape: Vec<f32> = if region.is_empty() { Vec::new() } else { effect_shape(layer, region, cx) };
|
||||
let maps = effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns);
|
||||
let bytes = maps.bytes();
|
||||
let maps = std::sync::Arc::new(maps);
|
||||
if slot.set(FxEntry { maps: maps.clone(), _pin: layer.clone(), bytes }).is_ok() {
|
||||
// Counted exactly once, by the build that filled the slot, and only while the
|
||||
// slot is still cached (eviction may have dropped it meanwhile).
|
||||
let mut c = fx_cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.map.get(&key).is_some_and(|s| std::sync::Arc::ptr_eq(s, &slot)) {
|
||||
c.bytes += bytes;
|
||||
}
|
||||
}
|
||||
// A concurrent build that finished first wins, so every tile shares one map.
|
||||
slot.get().map_or(maps, |e| e.maps.clone())
|
||||
}
|
||||
};
|
||||
// Evict the oldest entries over budget (never the one just used).
|
||||
let mut c = fx_cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let budget = effect_cache_budget();
|
||||
|
|
@ -1189,7 +1411,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
|||
let rect = base.rect;
|
||||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||||
let mut adjusted = base.clone();
|
||||
adjust::apply_with(adj, &mut adjusted, cx.transfer);
|
||||
adjust::apply_depth(adj, &mut adjusted, cx.transfer, adjustment_quantum(cx.depth));
|
||||
let mv = mask_vals(layer, rect, cx);
|
||||
for (i, p) in base.px.iter_mut().enumerate() {
|
||||
let k = layer.opacity * layer.fill_opacity * mask_k(&mv, i);
|
||||
|
|
@ -1205,7 +1427,14 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
|||
// Effects of a clipped layer are clipped to the base too: render
|
||||
// them over the base (treated as opaque) and keep the base's alpha.
|
||||
// The full-region effect maps already capture their neighbourhoods.
|
||||
let Some(content) = render_content(layer, rect, cx) else { return };
|
||||
let (content, vstroke) = match split_parts(layer, rect, cx) {
|
||||
Some((fill, stroke, mask)) => (fill, Some((stroke, mask))),
|
||||
None => {
|
||||
let Some(content) = render_content(layer, rect, cx) else { return };
|
||||
(content, None)
|
||||
}
|
||||
};
|
||||
let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() });
|
||||
let mut opaque = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() };
|
||||
let maps = effect_maps(layer, cx);
|
||||
effects::composite_with_effects_prepared(
|
||||
|
|
@ -1215,6 +1444,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
|||
&maps,
|
||||
paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)),
|
||||
cx.patterns,
|
||||
vstroke,
|
||||
);
|
||||
for (p, o) in base.px.iter_mut().zip(&opaque.px) {
|
||||
if p[3] > 0.0 {
|
||||
|
|
@ -1265,5 +1495,7 @@ fn blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stroke_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
//! A layer's effective mask (pixel mask × vector mask) as one surface, for backends that sample
|
||||
//! masks from textures (the GPU compositor). Rasterising a vector mask costs a path coverage
|
||||
//! pass, so results are cached per mask state; unchanged masks return the same tiles (cheap to
|
||||
//! clone, and the GPU sees no change).
|
||||
//! A layer's effective mask (pixel mask × vector mask, each feathered) as one surface, for
|
||||
//! backends that sample masks from textures (the GPU compositor) and for feathered masks on the
|
||||
//! CPU. Rasterising a vector mask costs a path coverage pass and feathering a blur, so results
|
||||
//! are cached per mask state; unchanged masks return the same tiles (cheap to clone, and the
|
||||
//! GPU sees no change).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
|
@ -33,7 +34,7 @@ fn key(layer: &Layer, canvas: Rect) -> u64 {
|
|||
(canvas.x0, canvas.y0, canvas.x1, canvas.y1).hash(&mut h);
|
||||
format!("{:?}", layer.vector_mask).hash(&mut h);
|
||||
if let Some(m) = &layer.mask {
|
||||
(m.enabled, m.density.to_bits()).hash(&mut h);
|
||||
(m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(&mut h);
|
||||
format!("{:?}", m.surface.default_pixel()).hash(&mut h);
|
||||
for (c, t) in m.surface.tiles() {
|
||||
(c.tx, c.ty, Arc::as_ptr(t) as usize).hash(&mut h);
|
||||
|
|
@ -42,12 +43,66 @@ fn key(layer: &Layer, canvas: Rect) -> u64 {
|
|||
h.finish()
|
||||
}
|
||||
|
||||
/// The layer's mask values (pixel mask with density × vector mask, exactly as the CPU
|
||||
/// compositor applies them) as a single-channel f32 surface over `canvas`, or `None` when the
|
||||
/// layer has no enabled vector mask (use the pixel mask directly). Values outside the computed
|
||||
/// area are the surface's default.
|
||||
/// Gaussian sigma of a mask feather of `px` pixels (Properties › Feather). Fitted on the
|
||||
/// psd-tools corpus (layer_mask_data.psd, mask-parameters-no-real-channel.psd: sigma = feather
|
||||
/// beats 0.33, 0.5, 0.7, 1.3 and 1.6 times it).
|
||||
pub fn feather_sigma(px: f32) -> f32 {
|
||||
if px.is_finite() { (px * FEATHER_SIGMA).clamp(0.0, MAX_FEATHER_SIGMA) } else { 0.0 }
|
||||
}
|
||||
|
||||
const FEATHER_SIGMA: f32 = 1.0;
|
||||
/// Photoshop's feather tops out at 1000 px.
|
||||
const MAX_FEATHER_SIGMA: f32 = 1000.0 * FEATHER_SIGMA;
|
||||
|
||||
/// Whether `layer` has an enabled mask with a feather (rendered through [`combined_mask`]).
|
||||
pub fn has_feather(layer: &Layer) -> bool {
|
||||
layer.mask.as_ref().is_some_and(|m| m.enabled && feather_sigma(m.feather) > 0.0)
|
||||
|| layer.vector_mask.as_ref().is_some_and(|v| v.enabled && feather_sigma(v.feather) > 0.0)
|
||||
}
|
||||
|
||||
/// Approximate Gaussian blur of a `w`×`h` plane: three box passes per axis (edges clamp, which
|
||||
/// is exact here since the planes extend into constant mask regions).
|
||||
fn gaussian(v: &mut [f32], w: usize, h: usize, sigma: f32) {
|
||||
if sigma <= 0.0 || w == 0 || h == 0 {
|
||||
return;
|
||||
}
|
||||
// Box widths whose three-pass variance matches sigma² (Wells 1986 / Kovesi).
|
||||
let ideal = (12.0 * sigma * sigma / 3.0 + 1.0).sqrt();
|
||||
let r = (((ideal.floor() as usize) | 1).max(1) - 1) / 2;
|
||||
if r == 0 {
|
||||
return;
|
||||
}
|
||||
let mut line = Vec::new();
|
||||
let mut pass = |v: &mut [f32], len: usize, count: usize, at: &dyn Fn(usize, usize) -> usize| {
|
||||
for k in 0..count {
|
||||
for _ in 0..3 {
|
||||
line.clear();
|
||||
line.extend((0..len).map(|i| v[at(k, i)]));
|
||||
let get = |i: isize| line[i.clamp(0, len as isize - 1) as usize];
|
||||
let mut acc: f32 = (-(r as isize)..=r as isize).map(get).sum();
|
||||
let n = (2 * r + 1) as f32;
|
||||
for i in 0..len {
|
||||
v[at(k, i)] = acc / n;
|
||||
acc += get(i as isize + r as isize + 1) - get(i as isize - r as isize);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
pass(v, w, h, &|row, i| row * w + i);
|
||||
pass(v, h, w, &|col, i| i * w + col);
|
||||
}
|
||||
|
||||
/// The layer's mask values (pixel mask with density × vector mask, each blurred by its feather,
|
||||
/// exactly as the CPU compositor applies them) as a single-channel f32 surface over `canvas`, or
|
||||
/// `None` when the layer has no enabled vector mask and no feathered pixel mask (use the pixel
|
||||
/// mask directly). Values outside the computed area are the surface's default.
|
||||
pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option<Surface> {
|
||||
let vm = layer.vector_mask.as_ref().filter(|v| v.enabled)?;
|
||||
let vm = layer.vector_mask.as_ref().filter(|v| v.enabled);
|
||||
let pixel = layer.mask.as_ref().filter(|m| m.enabled);
|
||||
let (sv, sp) = (vm.map_or(0.0, |v| feather_sigma(v.feather)), pixel.map_or(0.0, |m| feather_sigma(m.feather)));
|
||||
if vm.is_none() && sp <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let k = key(layer, canvas);
|
||||
{
|
||||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
|
@ -60,22 +115,26 @@ pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option<Surface> {
|
|||
}
|
||||
// Far outside the path the vector mask is constant.
|
||||
let far = Rect::from_xywh(canvas.x0 - 1_000_000, canvas.y0 - 1_000_000, 1, 1);
|
||||
let v_out = photocraft_vector::vector_mask_values(vm, far)[0];
|
||||
let pixel = layer.mask.as_ref().filter(|m| m.enabled);
|
||||
let v_out = vm.map_or(1.0, |vm| photocraft_vector::vector_mask_values(vm, far)[0]);
|
||||
let p_def = pixel.map_or(1.0, |m| {
|
||||
let d = m.surface.default_pixel().first().copied().unwrap_or(1.0);
|
||||
1.0 - m.density * (1.0 - d)
|
||||
});
|
||||
let grow = |r: Rect, sigma: f32| {
|
||||
let m = (sigma * 3.0).ceil() as i32 + 2;
|
||||
if r.is_empty() { r } else { Rect::new(r.x0.saturating_sub(m), r.y0.saturating_sub(m), r.x1.saturating_add(m), r.y1.saturating_add(m)) }
|
||||
};
|
||||
// Where the product can differ from `p_def × v_out`: the path's bounds, plus the pixel
|
||||
// mask's painted tiles when the vector mask lets them through outside the path.
|
||||
let mut area = match vm.path.control_bounds() {
|
||||
Some((x0, y0, x1, y1)) => Rect::new(x0.floor() as i32 - 2, y0.floor() as i32 - 2, x1.ceil() as i32 + 2, y1.ceil() as i32 + 2),
|
||||
// mask's painted tiles when the vector mask lets them through outside the path, each grown
|
||||
// by its feather.
|
||||
let mut area = match vm.and_then(|vm| vm.path.control_bounds()) {
|
||||
Some((x0, y0, x1, y1)) => grow(Rect::new(x0.floor() as i32 - 2, y0.floor() as i32 - 2, x1.ceil() as i32 + 2, y1.ceil() as i32 + 2), sv),
|
||||
None => Rect::EMPTY,
|
||||
};
|
||||
if let Some(m) = pixel
|
||||
&& v_out > 0.0
|
||||
{
|
||||
let b = m.surface.content_bounds();
|
||||
let b = grow(m.surface.content_bounds(), sp);
|
||||
area = if area.is_empty() {
|
||||
b
|
||||
} else if b.is_empty() {
|
||||
|
|
@ -87,10 +146,16 @@ pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option<Surface> {
|
|||
let area = area.intersect(&canvas);
|
||||
let mut s = Surface::with_default(FORMAT, &[p_def * v_out]);
|
||||
if !area.is_empty() {
|
||||
let mut v = photocraft_vector::vector_mask_values(vm, area);
|
||||
let (w, h) = (area.width() as usize, area.height() as usize);
|
||||
let mut v = match vm {
|
||||
Some(vm) => photocraft_vector::vector_mask_values(vm, area),
|
||||
None => vec![1.0; w * h],
|
||||
};
|
||||
gaussian(&mut v, w, h, sv);
|
||||
if let Some(m) = pixel {
|
||||
let mut pm = Vec::new();
|
||||
m.values_into(area, &mut pm);
|
||||
gaussian(&mut pm, w, h, sp);
|
||||
for (a, b) in v.iter_mut().zip(pm) {
|
||||
*a *= b;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,15 @@ fn shrink_layer(l: &mut Layer, k: u32) {
|
|||
match &mut l.content {
|
||||
LayerContent::Raster(s) => *s = downsample(s, k),
|
||||
LayerContent::Group(g) => {
|
||||
if let Some(ab) = &mut g.artboard {
|
||||
let divisor = i64::from(k);
|
||||
// Pixel x samples source x*k: both half-open edges round up, including negatives.
|
||||
let edge = |v: i32| {
|
||||
let v = i64::from(v);
|
||||
(v.div_euclid(divisor) + i64::from(v.rem_euclid(divisor) != 0)) as i32
|
||||
};
|
||||
ab.rect = Rect::new(edge(ab.rect.x0), edge(ab.rect.y0), edge(ab.rect.x1), edge(ab.rect.y1));
|
||||
}
|
||||
for c in &mut g.children {
|
||||
shrink_layer(c, k);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,17 +131,21 @@ pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity:
|
|||
}
|
||||
|
||||
/// Photoshop's "Blend Text Colors Using Gamma" (Color Settings › Advanced, on at 1.45 by
|
||||
/// default): type layers mix their colour with the backdrop in a gamma-1.45 space, so
|
||||
/// anti-aliased glyph edges come out lighter over dark backdrops than a linear mix (fitted on
|
||||
/// psd-tools layer_effects.psd: a 69 % edge pixel of a blue overlay over grey is 57/255 in red,
|
||||
/// a linear mix gives 40).
|
||||
/// default): type layers mix their colour with the backdrop in linear light raised to 1 / 1.45
|
||||
/// ([`text_encode`]), so anti-aliased glyph edges come out lighter over dark backdrops than a
|
||||
/// mix of the encoded values (psd-tools layer_effects.psd: a 69 % edge pixel of a blue overlay
|
||||
/// over grey is 57/255 in red, an encoded mix gives 40).
|
||||
pub const TEXT_GAMMA: f32 = 1.45;
|
||||
|
||||
static TEXT_GAMMA_SETTING: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0x3fb9_999a); // 1.45f32
|
||||
|
||||
/// Sets Color Settings › "Blend Text Colors Using Gamma" (1 = off). It is an application
|
||||
/// setting, not stored in documents: files saved with it off (ag-psd float-color) mix type
|
||||
/// linearly. Values are clamped to Photoshop's 1.00–2.20.
|
||||
/// setting, not stored in documents (1 here means switched off: a plain mix of the encoded
|
||||
/// values). Values are clamped to Photoshop's 1.00–2.20.
|
||||
///
|
||||
/// The value is process-wide: a test that changes it races every concurrently running test
|
||||
/// that composites a type layer, so such tests belong in their own test binary (see
|
||||
/// `crates/engine/tests/text_gamma.rs`).
|
||||
pub fn set_text_gamma(gamma: f32) {
|
||||
let g = if gamma.is_finite() { gamma.clamp(1.0, 2.2) } else { TEXT_GAMMA };
|
||||
TEXT_GAMMA_SETTING.store(g.to_bits(), std::sync::atomic::Ordering::Relaxed);
|
||||
|
|
@ -152,7 +156,24 @@ pub fn text_gamma() -> f32 {
|
|||
f32::from_bits(TEXT_GAMMA_SETTING.load(std::sync::atomic::Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// [`composite`] with the coverage mix done in a `gamma` space (`gamma == 1` is [`composite`]).
|
||||
/// Into the text blending space: linear light raised to `1 / gamma`. Gamma 2.2 is close to
|
||||
/// mixing the encoded values, lower settings approach linear light. Fitted on psd-tools
|
||||
/// layer_effects.psd (a blue colour overlay's glyph edges over grey, every coverage level within
|
||||
/// 0.6/255; a plain `v^1.45` power mix is 6/255 too light at 94 % coverage) and ag-psd
|
||||
/// float-color.
|
||||
#[inline]
|
||||
pub fn text_encode(v: f32, gamma: f32) -> f32 {
|
||||
photocraft_color::convert::srgb_to_linear(v.max(0.0)).powf(1.0 / gamma)
|
||||
}
|
||||
|
||||
/// Inverse of [`text_encode`].
|
||||
#[inline]
|
||||
pub fn text_decode(v: f32, gamma: f32) -> f32 {
|
||||
photocraft_color::convert::linear_to_srgb(v.max(0.0).powf(gamma))
|
||||
}
|
||||
|
||||
/// [`composite`] with the coverage mix done in the text blending space of `gamma`
|
||||
/// ([`text_encode`]; `gamma == 1` is [`composite`], the setting switched off).
|
||||
pub fn composite_gamma(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: f32, gamma: f32) -> [f32; 4] {
|
||||
if gamma == 1.0 {
|
||||
return composite(mode, backdrop, source, opacity);
|
||||
|
|
@ -170,10 +191,11 @@ pub fn composite_gamma(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], op
|
|||
let cs = [source[0], source[1], source[2]];
|
||||
let b = blend_rgb(mode, cb, cs);
|
||||
let (wb, ws, wbs) = ((1.0 - as_) * ab / ao, (1.0 - ab) * as_ / ao, as_ * ab / ao);
|
||||
let g = |v: f32| v.max(0.0).powf(gamma);
|
||||
let g = |v: f32| text_encode(v, gamma);
|
||||
let ginv = |v: f32| text_decode(v, gamma);
|
||||
let mut out = [0.0f32; 4];
|
||||
for i in 0..3 {
|
||||
out[i] = (wb * g(cb[i]) + ws * g(cs[i]) + wbs * g(b[i])).powf(1.0 / gamma);
|
||||
out[i] = ginv(wb * g(cb[i]) + ws * g(cs[i]) + wbs * g(b[i]));
|
||||
}
|
||||
out[3] = ao;
|
||||
out
|
||||
|
|
@ -185,7 +207,10 @@ mod tests {
|
|||
fn text_gamma_lightens_dark_edges() {
|
||||
// 69 % blue (0, 51, 153) over grey 129: Photoshop shows (57, 80, 146).
|
||||
let r = composite_gamma(BlendMode::Normal, [129.0 / 255.0, 129.0 / 255.0, 129.0 / 255.0, 1.0], [0.0, 0.2, 0.6, 0.686], 1.0, TEXT_GAMMA);
|
||||
assert!((r[0] * 255.0 - 57.0).abs() < 2.0 && (r[1] * 255.0 - 80.0).abs() < 2.0 && (r[2] * 255.0 - 146.0).abs() < 2.0, "{r:?}");
|
||||
assert!((r[0] * 255.0 - 57.0).abs() < 1.0 && (r[1] * 255.0 - 80.0).abs() < 1.0 && (r[2] * 255.0 - 146.0).abs() < 1.0, "{r:?}");
|
||||
// Near-opaque edges stay dark: 94 % coverage shows (13, 57, 152) (a v^1.45 mix gives 19 red).
|
||||
let r = composite_gamma(BlendMode::Normal, [129.0 / 255.0, 129.0 / 255.0, 129.0 / 255.0, 1.0], [0.0, 0.2, 0.6, 0.937], 1.0, TEXT_GAMMA);
|
||||
assert!((r[0] * 255.0 - 13.0).abs() < 1.0 && (r[1] * 255.0 - 57.0).abs() < 1.0 && (r[2] * 255.0 - 152.0).abs() < 1.0, "{r:?}");
|
||||
assert_eq!(
|
||||
composite_gamma(BlendMode::Multiply, [0.2, 0.4, 0.6, 0.7], [0.5, 0.1, 0.9, 0.5], 0.8, 1.0),
|
||||
composite(BlendMode::Multiply, [0.2, 0.4, 0.6, 0.7], [0.5, 0.1, 0.9, 0.5], 0.8)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ use photocraft_doc::vector::ShapeLayer;
|
|||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
type Entry = ((Surface, Surface), u64);
|
||||
/// (fill, stroke), last use, and the shape's pixels the stroke was fitted to (kept alive so
|
||||
/// their tile addresses, part of the key, can't be reused).
|
||||
type Entry = ((Surface, Surface), u64, Option<Surface>);
|
||||
|
||||
fn cache() -> &'static Mutex<(HashMap<u64, Entry>, u64)> {
|
||||
static C: OnceLock<Mutex<(HashMap<u64, Entry>, u64)>> = OnceLock::new();
|
||||
|
|
@ -21,12 +23,20 @@ fn cache() -> &'static Mutex<(HashMap<u64, Entry>, u64)> {
|
|||
const CAPACITY: usize = 32;
|
||||
|
||||
/// (fill only, stroke only) of a stroked shape, rendered over `canvas` (RGBA8, as the CPU
|
||||
/// compositor's split always was). `None` without a stroke.
|
||||
/// compositor's split always was). `None` without a stroke. Where the shape's pixels
|
||||
/// (Photoshop's rendering, `sh.cache`) reach past our fill, the part the fill doesn't explain is
|
||||
/// stroke: the stroke follows Photoshop's coverage (psd-tools double-stroke-effects).
|
||||
pub fn split(sh: &ShapeLayer, canvas: Rect) -> Option<(Surface, Surface)> {
|
||||
sh.stroke.as_ref()?;
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(canvas.x0, canvas.y0, canvas.x1, canvas.y1).hash(&mut h);
|
||||
format!("{:?}{:?}{:?}{:?}", sh.path, sh.fill, sh.stroke, sh.live).hash(&mut h);
|
||||
if let Some(c) = &sh.cache {
|
||||
format!("{:?}{:?}", c.format(), c.default_pixel()).hash(&mut h);
|
||||
for (k, t) in c.tiles() {
|
||||
(k.tx, k.ty, std::sync::Arc::as_ptr(t) as usize).hash(&mut h);
|
||||
}
|
||||
}
|
||||
let key = h.finish();
|
||||
{
|
||||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
|
@ -40,7 +50,10 @@ pub fn split(sh: &ShapeLayer, canvas: Rect) -> Option<(Surface, Surface)> {
|
|||
let fmt = PixelFormat::RGBA8;
|
||||
let fill_only = ShapeLayer { stroke: None, cache: None, ..sh.clone() };
|
||||
let stroke_only = ShapeLayer { fill: None, cache: None, ..sh.clone() };
|
||||
let parts = (photocraft_vector::render_shape(&fill_only, fmt, canvas), photocraft_vector::render_shape(&stroke_only, fmt, canvas));
|
||||
let mut parts = (photocraft_vector::render_shape(&fill_only, fmt, canvas), photocraft_vector::render_shape(&stroke_only, fmt, canvas));
|
||||
if let Some(cache) = &sh.cache {
|
||||
fit_stroke(&parts.0, &mut parts.1, cache, canvas);
|
||||
}
|
||||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.0.len() >= CAPACITY
|
||||
&& let Some(old) = c.0.iter().min_by_key(|e| (e.1).1).map(|e| *e.0)
|
||||
|
|
@ -48,6 +61,40 @@ pub fn split(sh: &ShapeLayer, canvas: Rect) -> Option<(Surface, Surface)> {
|
|||
c.0.remove(&old);
|
||||
}
|
||||
let tick = c.1;
|
||||
c.0.insert(key, (parts.clone(), tick));
|
||||
c.0.insert(key, (parts.clone(), tick, sh.cache.clone()));
|
||||
Some(parts)
|
||||
}
|
||||
|
||||
/// Fits the stroke part to the shape's own pixels: where they reach past the fill, the stroke
|
||||
/// covers what the fill doesn't (`(pixels - fill) / (1 - fill)`), in the pixels' colour where we
|
||||
/// drew no stroke.
|
||||
fn fit_stroke(fill: &Surface, stroke: &mut Surface, cache: &Surface, canvas: Rect) {
|
||||
// Where either has pixels (the stroke may reach past the shape's pixels, and vice versa).
|
||||
let (cb, sb) = (crate::bounds::content_bounds(cache), crate::bounds::content_bounds(stroke));
|
||||
let r = if cb.is_empty() {
|
||||
sb
|
||||
} else if sb.is_empty() {
|
||||
cb
|
||||
} else {
|
||||
cb.union(&sb)
|
||||
}
|
||||
.intersect(&canvas);
|
||||
if r.is_empty() {
|
||||
return;
|
||||
}
|
||||
let n = r.width() as usize * r.height() as usize;
|
||||
let (mut f, mut s, mut c) = (vec![[0.0f32; 4]; n], vec![[0.0f32; 4]; n], vec![[0.0f32; 4]; n]);
|
||||
fill.read_rgba_into(r, &mut f);
|
||||
stroke.read_rgba_into(r, &mut s);
|
||||
cache.read_rgba_into(r, &mut c);
|
||||
let mut out = Vec::with_capacity(n * 4);
|
||||
for ((fp, sp), cp) in f.iter().zip(&s).zip(&c) {
|
||||
let mut p = *sp;
|
||||
if fp[3] < 1.0 - 1e-3 {
|
||||
let a = ((cp[3] - fp[3]) / (1.0 - fp[3])).clamp(0.0, 1.0);
|
||||
p = if sp[3] <= 0.0 { [cp[0], cp[1], cp[2], a] } else { [sp[0], sp[1], sp[2], a] };
|
||||
}
|
||||
out.extend_from_slice(&p);
|
||||
}
|
||||
stroke.write_region(r, &out);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,167 @@
|
|||
//! Stroke effects on shape layers: the outline, the vector stroke's place, gradient frames.
|
||||
|
||||
use super::*;
|
||||
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::vector::{Path, ShapeLayer, ShapeStroke, StrokeAlign, Subpath};
|
||||
use photocraft_doc::{Effect, FxCommon, FxPaint, Gradient, GradientStyle, Layer, LayerContent, LayerMask, StrokeFx, StrokePosition};
|
||||
use photocraft_geom::Size;
|
||||
|
||||
const E: f32 = 2.0 / 255.0;
|
||||
|
||||
fn close4(a: [f32; 4], b: [f32; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= E)
|
||||
}
|
||||
|
||||
fn doc(w: u32, h: u32) -> Document {
|
||||
Document::with_background("t", Size::new(w, h), ColorMode::Rgb, SampleType::U8, Color::WHITE)
|
||||
}
|
||||
|
||||
fn px(doc: &Document, x: i32, y: i32) -> [f32; 4] {
|
||||
render(doc, Rect::from_xywh(x, y, 1, 1)).px[0]
|
||||
}
|
||||
|
||||
fn stroke(size: f32, position: StrokePosition, paint: FxPaint) -> Effect {
|
||||
Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size, position, paint })
|
||||
}
|
||||
|
||||
fn blue() -> FxPaint {
|
||||
FxPaint::Color(Color::rgb(0.0, 0.0, 1.0))
|
||||
}
|
||||
|
||||
/// A shape layer over the square `x0..x1` with its Photoshop-style cached pixels.
|
||||
fn shape(x0: f64, x1: f64, fill: Fill, stroke: Option<ShapeStroke>) -> Layer {
|
||||
let path = Path::new(vec![Subpath::polygon(&[(x0, x0), (x1, x0), (x1, x1), (x0, x1)])]);
|
||||
let mut sh = ShapeLayer { path, fill: Some(fill), stroke, live: None, cache: None, psd_raw: None };
|
||||
sh.cache = Some(photocraft_vector::render_shape(&sh, PixelFormat::RGBA8, Rect::new(0, 0, 40, 40)));
|
||||
Layer::new("shape", LayerContent::Shape(sh))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_share_treats_stroke_and_shape_as_disjoint_areas() {
|
||||
// Outside the shape the stroke covers the pixel; inside it nothing.
|
||||
assert_eq!(effects::outline_share(0.0, 0.0), 1.0);
|
||||
assert_eq!(effects::outline_share(1.0, 0.3), 0.0);
|
||||
// Beneath an opaque edge pixel the result ends opaque: share + l (1 - share) = 1 - cov + l.
|
||||
let (cov, l) = (0.6, 0.6);
|
||||
let s = effects::outline_share(cov, l);
|
||||
assert!((s + l * (1.0 - s) - (1.0 - cov + l).min(1.0)).abs() < 1e-6);
|
||||
// A faded fill keeps the edge part of the stroke only.
|
||||
let s = effects::outline_share(0.6, 0.0);
|
||||
assert!((s - 0.4).abs() < 1e-6);
|
||||
assert_eq!(effects::outline_share(0.5, 1.0), 1.0);
|
||||
assert!(effects::outline_share(f32::NAN, 0.5).is_finite() || effects::outline_share(f32::NAN, 0.5).is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filled_shape_stroke_follows_the_outline_where_the_fill_fades_out() {
|
||||
let mut d = doc(40, 40);
|
||||
let mut transparent = Color::rgb(1.0, 0.0, 0.0);
|
||||
transparent.alpha = 0.0;
|
||||
let fade = Fill::gradient(vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (1.0, transparent)], 0.0, 1.0, GradientStyle::Linear, false);
|
||||
let mut l = shape(10.0, 30.0, fade, None);
|
||||
l.effects.items = vec![stroke(3.0, StrokePosition::Outside, blue()), stroke(2.0, StrokePosition::Inside, FxPaint::Color(Color::rgb(0.0, 1.0, 0.0)))];
|
||||
d.layers.push(l);
|
||||
// The opaque side and the faded side both carry the outside stroke along the path.
|
||||
assert!(close4(px(&d, 8, 20), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(&d, 8, 20));
|
||||
assert!(close4(px(&d, 31, 20), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(&d, 31, 20));
|
||||
// ... and the inside stroke, even where the fill is transparent.
|
||||
assert!(close4(px(&d, 28, 20), [0.0, 1.0, 0.0, 1.0]), "{:?}", px(&d, 28, 20));
|
||||
// Between them the faded fill shows the background.
|
||||
assert!(px(&d, 25, 20)[0] > 0.9 && px(&d, 25, 20)[1] > 0.5, "{:?}", px(&d, 25, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_stroke_fills_the_rest_of_a_shapes_edge_pixel() {
|
||||
// The right edge at x = 30.5 covers half of column 30: an opaque fill and the stroke beneath
|
||||
// it end opaque there (no seam of backdrop between them).
|
||||
let mut d = Document::new("t", Size::new(40, 40), ColorMode::Rgb, SampleType::U8);
|
||||
let mut l = shape(10.0, 30.5, Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), None);
|
||||
l.effects.items = vec![stroke(3.0, StrokePosition::Outside, blue())];
|
||||
d.layers.clear();
|
||||
d.layers.push(l);
|
||||
let p = px(&d, 30, 20);
|
||||
assert!((p[3] - 1.0).abs() <= E, "{p:?}");
|
||||
assert!((p[0] - 0.5).abs() <= 0.02 && (p[2] - 0.5).abs() <= 0.02, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_stroke_stays_above_interior_effects_and_below_the_stroke_effect() {
|
||||
let mut d = doc(40, 40);
|
||||
let vs = ShapeStroke { width: 4.0, align: StrokeAlign::Inside, paint: Fill::Solid(Color::rgb(0.0, 1.0, 0.0)), ..ShapeStroke::default() };
|
||||
let mut l = shape(10.0, 30.0, Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), Some(vs));
|
||||
l.effects.items = vec![
|
||||
Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::rgb(0.0, 0.0, 1.0) },
|
||||
stroke(1.0, StrokePosition::Inside, FxPaint::Color(Color::rgb(1.0, 1.0, 0.0))),
|
||||
];
|
||||
d.layers.push(l);
|
||||
assert!(close4(px(&d, 10, 20), [1.0, 1.0, 0.0, 1.0]), "stroke effect on top: {:?}", px(&d, 10, 20));
|
||||
assert!(close4(px(&d, 12, 20), [0.0, 1.0, 0.0, 1.0]), "vector stroke over the overlay: {:?}", px(&d, 12, 20));
|
||||
assert!(close4(px(&d, 20, 20), [0.0, 0.0, 1.0, 1.0]), "overlay on the fill: {:?}", px(&d, 20, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroked_shapes_mask_applies_to_fill_and_stroke_together() {
|
||||
let mut d = Document::new("t", Size::new(40, 40), ColorMode::Rgb, SampleType::U8);
|
||||
d.layers.clear();
|
||||
let vs = ShapeStroke { width: 4.0, align: StrokeAlign::Inside, paint: Fill::Solid(Color::rgb(0.0, 1.0, 0.0)), ..ShapeStroke::default() };
|
||||
let mut l = shape(10.0, 30.0, Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), Some(vs));
|
||||
let mut m = LayerMask::reveal_all();
|
||||
m.surface = photocraft_raster::Surface::with_default(PixelFormat::GRAY8, &[0.5]);
|
||||
l.mask = Some(m);
|
||||
let mut clip = Layer::raster("clip", PixelFormat::RGBA8);
|
||||
clip.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 40, 40), &[0.0, 0.0, 1.0, 1.0]);
|
||||
clip.clipped = true;
|
||||
d.layers.push(l.clone());
|
||||
d.layers.push(clip.clone());
|
||||
// On the vector stroke: the stroke's colour at the mask's 50 %, not 75 %.
|
||||
let p = px(&d, 12, 20);
|
||||
assert!(close4(p, [0.0, 1.0, 0.0, 0.5]), "{p:?}");
|
||||
// With effects too.
|
||||
l.effects.items = vec![Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::rgb(1.0, 1.0, 1.0) }];
|
||||
d.layers = vec![l.clone()];
|
||||
let p = px(&d, 12, 20);
|
||||
assert!(close4(p, [0.0, 1.0, 0.0, 0.5]), "without clipped layers: {p:?}");
|
||||
d.layers = vec![l, clip];
|
||||
let p = px(&d, 12, 20);
|
||||
assert!(close4(p, [0.0, 1.0, 0.0, 0.5]), "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_stroke_spans_the_strokes_extent() {
|
||||
let mut d = doc(40, 40);
|
||||
let mut l = Layer::raster("sq", PixelFormat::RGBA8);
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 30), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let g = Gradient { stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], angle: 90.0, ..Gradient::default() };
|
||||
l.effects.items = vec![stroke(4.0, StrokePosition::Outside, FxPaint::Gradient(g))];
|
||||
d.layers.push(l);
|
||||
// The frame is the square grown by 3 px (7..33), laid out like every linear gradient
|
||||
// (fill_layout::gradient_layout); the layer's own bounds would give other values.
|
||||
let t_in = |frame: Rect, x: i32, y: i32| {
|
||||
let (a, s, o) = crate::fill_layout::gradient_layout(GradientStyle::Linear, 90.0, 1.0, (0.0, 0.0), frame);
|
||||
effects::gradient_t(GradientStyle::Linear, a, s, false, o, frame, x as f32 + 0.5, y as f32 + 0.5)
|
||||
};
|
||||
let (stroke_frame, layer_frame) = (Rect::new(7, 7, 33, 33), Rect::new(10, 10, 30, 30));
|
||||
for y in [7, 9, 31, 32] {
|
||||
let p = px(&d, 20, y);
|
||||
assert!((p[0] - t_in(stroke_frame, 20, y)).abs() <= 0.01, "row {y}: {p:?}");
|
||||
}
|
||||
assert!((t_in(stroke_frame, 20, 9) - t_in(layer_frame, 20, 9)).abs() > 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_strokes_stack_top_instance_first() {
|
||||
// Multiple instances: the first listed is on top; an outer wider stroke shows past it.
|
||||
let mut d = doc(60, 60);
|
||||
let mut l = Layer::raster("sq", PixelFormat::RGBA8);
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(20, 20, 40, 40), &[1.0, 0.0, 0.0, 1.0]);
|
||||
l.effects.items = vec![
|
||||
stroke(2.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.0, 1.0, 0.0))),
|
||||
stroke(5.0, StrokePosition::Outside, blue()),
|
||||
stroke(2.0, StrokePosition::Inside, FxPaint::Color(Color::rgb(1.0, 1.0, 0.0))),
|
||||
];
|
||||
d.layers.push(l);
|
||||
assert!(close4(px(&d, 19, 30), [0.0, 1.0, 0.0, 1.0]));
|
||||
assert!(close4(px(&d, 16, 30), [0.0, 0.0, 1.0, 1.0]));
|
||||
assert!(close4(px(&d, 20, 30), [1.0, 1.0, 0.0, 1.0]));
|
||||
assert!(close4(px(&d, 14, 30), [1.0; 4]));
|
||||
}
|
||||
|
|
@ -153,6 +153,41 @@ fn isolated_group_differs_from_pass_through() {
|
|||
assert!((iso[0] - 0.5).abs() <= E, "{iso:?}");
|
||||
}
|
||||
|
||||
/// psd-tools passthrough_fill_adjustment.psd: below 100% fill a pass-through group is isolated,
|
||||
/// so an adjustment inside it no longer changes the layers beneath.
|
||||
#[test]
|
||||
fn pass_through_group_below_full_fill_is_isolated() {
|
||||
let make = |fill: f32| {
|
||||
let mut d = doc_white(2, 2);
|
||||
let mut g = Layer::group("g", vec![Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]);
|
||||
g.blend = BlendMode::PassThrough;
|
||||
g.fill_opacity = fill;
|
||||
d.layers.push(g);
|
||||
px(&d, 0, 0)
|
||||
};
|
||||
assert!(close4(make(1.0), [0.0, 0.0, 0.0, 1.0]), "full fill: the invert reaches the background");
|
||||
assert!(close4(make(0.4), [1.0; 4]), "isolated: nothing beneath to invert");
|
||||
}
|
||||
|
||||
/// psd-tools layer_mask_data.psd: a mask feather blurs the mask edge (CPU and the GPU's
|
||||
/// combined mask share `masks::combined_mask`).
|
||||
#[test]
|
||||
fn mask_feather_blurs_the_edge() {
|
||||
let mut d = doc_white(64, 8);
|
||||
let mut l = solid_layer("k", Rect::new(0, 0, 64, 8), [0.0, 0.0, 0.0, 1.0]);
|
||||
let mut m = LayerMask::hide_all();
|
||||
m.surface.fill_rect(Rect::new(0, 0, 32, 8), &[1.0]);
|
||||
l.mask = Some(m);
|
||||
d.layers.push(l.clone());
|
||||
assert!(px(&d, 31, 4)[0] < 0.01 && px(&d, 32, 4)[0] > 0.99, "sharp without feather");
|
||||
if let Some(m) = &mut d.layers[1].mask {
|
||||
m.feather = 8.0;
|
||||
}
|
||||
let (a, b) = (px(&d, 31, 4)[0], px(&d, 32, 4)[0]);
|
||||
assert!(a > 0.3 && a < 0.5 && b > 0.5 && b < 0.7, "soft edge around 50%: {a} {b}");
|
||||
assert!(px(&d, 4, 4)[0] < 0.01 && px(&d, 60, 4)[0] > 0.99, "far from the edge unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_opacity_applies_once() {
|
||||
let mut d = doc_white(2, 2);
|
||||
|
|
@ -276,13 +311,7 @@ fn solid_and_gradient_fill_layers() {
|
|||
d.layers.push(Layer::new("fill", LayerContent::Fill(Fill::Solid(Color::rgb(0.0, 0.0, 1.0)))));
|
||||
assert!(close4(px(&d, 5, 0), [0.0, 0.0, 1.0, 1.0]));
|
||||
|
||||
let g = Fill::Gradient {
|
||||
stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)],
|
||||
angle: 0.0,
|
||||
scale: 1.0,
|
||||
style: photocraft_doc::GradientStyle::Linear,
|
||||
reverse: false,
|
||||
};
|
||||
let g = Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, photocraft_doc::GradientStyle::Linear, false);
|
||||
let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
|
||||
let buf = render_fill(&g, Rect::new(0, 0, 10, 1), Rect::new(0, 0, 10, 1), &patterns);
|
||||
// tile independence: a 1px render of the right edge equals the full render
|
||||
|
|
@ -748,6 +777,53 @@ fn inside_stroke_respects_partial_edge_coverage() {
|
|||
assert!((p[2] - 0.75).abs() < 0.02 && (p[0] - 0.25).abs() < 0.02, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emboss_shades_the_composited_layer() {
|
||||
// Emboss styles paint over the layer as composited (for type: mixed at the text gamma), so a
|
||||
// Normal white highlight `k` on an edge pixel keeps (1 − R) = (1 − k)(1 − C) whatever C is:
|
||||
// the ratio of type to raster results equals that of their plain composites.
|
||||
let emboss = Effect::BevelEmboss(photocraft_doc::Bevel {
|
||||
enabled: true,
|
||||
style: photocraft_doc::BevelStyle::Emboss,
|
||||
technique: photocraft_doc::BevelTechnique::Smooth,
|
||||
depth: 1.0,
|
||||
up: true,
|
||||
size: 6.0,
|
||||
soften: 0.0,
|
||||
angle: 90.0,
|
||||
altitude: 30.0,
|
||||
use_global_light: false,
|
||||
gloss_contour: photocraft_doc::Contour::Linear,
|
||||
highlight: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
|
||||
highlight_color: Color::WHITE,
|
||||
shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.0),
|
||||
shadow_color: Color::BLACK,
|
||||
contour: None,
|
||||
texture: None,
|
||||
});
|
||||
let make = |text: bool, fx: bool| {
|
||||
let mut d = doc_white(40, 40);
|
||||
let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), [0.2, 0.1, 0.6, 1.0]);
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 11), &[0.2, 0.1, 0.6, 0.5]);
|
||||
if text {
|
||||
let t = photocraft_doc::TextLayer { cache: l.surface().cloned(), ..Default::default() };
|
||||
l = Layer::new("t", LayerContent::Text(t));
|
||||
}
|
||||
if fx {
|
||||
l.effects.items = vec![emboss.clone()];
|
||||
}
|
||||
d.layers.push(l);
|
||||
px(&d, 20, 10)
|
||||
};
|
||||
let (ct, cr, rt, rr) = (make(true, false), make(false, false), make(true, true), make(false, true));
|
||||
assert!(rr[1] > cr[1] + 0.05, "the top edge is lit: {rr:?} vs {cr:?}");
|
||||
assert!((ct[1] - cr[1]).abs() > 0.02, "text gamma changes the edge: {ct:?} {cr:?}");
|
||||
for c in 0..3 {
|
||||
let want = (1.0 - ct[c]) / (1.0 - cr[c]);
|
||||
assert!(((1.0 - rt[c]) / (1.0 - rr[c]) - want).abs() < 2e-3, "channel {c}: {rt:?} {rr:?} {ct:?} {cr:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interior_effects_keep_the_layer_alpha() {
|
||||
// A colour overlay replaces a half-transparent pixel's colour without adding coverage.
|
||||
|
|
@ -1042,6 +1118,88 @@ fn levels_matches_photoshop() {
|
|||
assert!((adjust::levels(&ident(1.0), 0.37) - 0.37).abs() < 1e-6);
|
||||
}
|
||||
|
||||
// Photoshop (psd-tools levels_grayscale, 8-bit): input range 44..214, gamma 1.78. The stretch
|
||||
// is rounded to whole levels before the gamma (45 → 1.5 → 2, 47 → 4.5 → 5), so the shadows
|
||||
// step unevenly: 45, 46, 47, 48, 52, 69 → 7, 10, 17, 20, 37, 88.
|
||||
#[test]
|
||||
fn levels_work_on_whole_levels() {
|
||||
let ch = LevelsChannel { in_black: 44.0 / 255.0, in_white: 214.0 / 255.0, gamma: 1.78, out_black: 0.0, out_white: 1.0 };
|
||||
let adj = Adjustment::Levels {
|
||||
master: LevelsChannel::default(),
|
||||
per_channel: [ch.clone(), ch.clone(), ch.clone()],
|
||||
space: Default::default(),
|
||||
black: LevelsChannel::default(),
|
||||
};
|
||||
let luts = adjust::tone_luts_q(&adj, Some(255.0));
|
||||
let at = |v: u8| {
|
||||
let x = f32::from(v) / 255.0 * 4095.0;
|
||||
let (i, f) = (x.floor() as usize, x.fract());
|
||||
let y = luts[0][i] * (1.0 - f) + luts[0][(i + 1).min(4095)] * f;
|
||||
(y * 255.0).round()
|
||||
};
|
||||
for (v, ps) in [(44u8, 0.0f32), (45, 7.0), (46, 10.0), (47, 17.0), (48, 20.0), (52, 37.0), (69, 88.0), (100, 137.0), (214, 255.0)] {
|
||||
assert!((at(v) - ps).abs() <= 1.0, "levels {v}: got {} want {ps}", at(v));
|
||||
}
|
||||
// 16-bit and float stay smooth (no 8-bit staircase).
|
||||
for q in [Some(32768.0), None] {
|
||||
let a = adjust::levels_q(&ch, 0.2, q);
|
||||
let b = adjust::levels_q(&ch, 0.2 + 1.0 / 2048.0, q);
|
||||
assert!(b > a && b - a < 2.0 / 255.0, "{q:?}: {a} {b}");
|
||||
}
|
||||
// Hostile quantum values fall back to the continuous curve.
|
||||
for q in [Some(0.0), Some(f32::NAN), Some(-3.0)] {
|
||||
assert_eq!(adjust::levels_q(&ch, 0.4, q), adjust::levels(&ch, 0.4));
|
||||
}
|
||||
}
|
||||
|
||||
// Exposure linearises RGB documents through a 2.2 power, not the sRGB curve: Photoshop lifts 76
|
||||
// to 134 with an offset of 0.1738 (psd-tools adjustment_nested_composition_4).
|
||||
#[test]
|
||||
fn exposure_offset_uses_gamma_2_2_in_rgb() {
|
||||
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [76.0 / 255.0, 76.0 / 255.0, 76.0 / 255.0, 1.0]);
|
||||
adjust::apply_with(&Adjustment::Exposure { exposure: 0.0, offset: 0.1738, gamma: 1.0 }, &mut b, adjust::Transfer::Srgb);
|
||||
assert_eq!((b.px[0][0] * 255.0).round(), 134.0);
|
||||
assert_eq!(adjust::Transfer::Gamma(1.732).for_exposure(), adjust::Transfer::Gamma(1.732));
|
||||
}
|
||||
|
||||
// 32-bit samples are linear light: +1 stop doubles them as stored, where an 8-bit document goes
|
||||
// through its tone curve first (photoshop corpus rgb32 and gray32 exposure.psd).
|
||||
#[test]
|
||||
fn exposure_scales_32_bit_samples_as_stored() {
|
||||
let plus_one_stop = Adjustment::Exposure { exposure: 1.0, offset: 0.0, gamma: 1.0 };
|
||||
for (depth, fmt, want) in [(SampleType::F32, PixelFormat::RGBA32F, 0.4), (SampleType::U8, PixelFormat::RGBA8, 0.2744)] {
|
||||
let mut d = Document::new("e", Size::new(1, 1), ColorMode::Rgb, depth);
|
||||
let mut l = Layer::raster("px", fmt);
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &[0.2, 0.2, 0.2, 1.0]);
|
||||
d.layers = vec![l, Layer::new("exp", LayerContent::Adjustment(plus_one_stop.clone()))];
|
||||
let got = px(&d, 0, 0)[0];
|
||||
assert!((got - want).abs() < 2e-3, "{depth:?}: {got}");
|
||||
}
|
||||
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
|
||||
assert_eq!(adjust::Transfer::for_document(mode, SampleType::F32), adjust::Transfer::Gamma(1.0));
|
||||
}
|
||||
}
|
||||
|
||||
// A 30° Reflected gradient fill on a 4 × 4 canvas renders as Photoshop's (its end point snaps to
|
||||
// the corner: t = |x − y| / 4) at every depth.
|
||||
#[test]
|
||||
fn small_gradient_fill_matches_photoshop_at_all_depths() {
|
||||
let stops = vec![(0.0, Color::rgb(0.0, 0.0, 0.0)), (1.0, Color::rgb(1.0, 1.0, 1.0))];
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let mut d = Document::new("g", Size::new(4, 4), ColorMode::Rgb, depth);
|
||||
let fill = Fill::gradient(stops.clone(), 30.0, 1.0, photocraft_doc::GradientStyle::Reflected, false);
|
||||
d.layers.push(Layer::new("g", LayerContent::Fill(fill)));
|
||||
let out = flatten(&d);
|
||||
for y in 0..4usize {
|
||||
for x in 0..4usize {
|
||||
let want = (x as f32 - y as f32).abs() / 4.0;
|
||||
let got = out.px[y * 4 + x][0];
|
||||
assert!((got - want).abs() < 1.0 / 255.0, "{depth:?} ({x},{y}): {got} vs {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tall document with soft content, a translucent region and an adjustment.
|
||||
fn tall_doc(w: u32, h: u32) -> Document {
|
||||
let mut d = doc_white(w, h);
|
||||
|
|
@ -1186,3 +1344,69 @@ fn large_documents_thumbnail_from_a_proxy() {
|
|||
d.layers[1].effects.items.push(photocraft_doc::Effect::default_drop_shadow());
|
||||
assert!(!proxy::proxy_faithful(&d));
|
||||
}
|
||||
|
||||
/// Layers whose effect maps are built with nested Rayon work (a gradient fill's rows render in
|
||||
/// parallel), each carrying a different effect.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn nested_parallel_fx_doc() -> Document {
|
||||
use photocraft_doc::{Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, StrokeFx, StrokePosition};
|
||||
let mut d = doc_white(512, 512);
|
||||
let g = |a: f32| Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::rgb(0.2, 0.5, 0.9))], a, 1.0, photocraft_doc::GradientStyle::Linear, false);
|
||||
let mut shadow = Layer::new("shadow", LayerContent::Fill(g(0.0)));
|
||||
shadow.effects.items = vec![Effect::default_drop_shadow()];
|
||||
let mut stroke = Layer::new("stroke", LayerContent::Fill(g(45.0)));
|
||||
stroke.opacity = 0.6;
|
||||
stroke.effects.items = vec![Effect::Stroke(StrokeFx {
|
||||
common: FxCommon::new(BlendMode::Normal, 1.0),
|
||||
size: 3.0,
|
||||
position: StrokePosition::Inside,
|
||||
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
|
||||
})];
|
||||
let mut glow = Layer::new("glow", LayerContent::Fill(g(90.0)));
|
||||
glow.opacity = 0.5;
|
||||
glow.effects.items = vec![Effect::InnerGlow(Glow {
|
||||
common: FxCommon::new(BlendMode::Screen, 0.75),
|
||||
paint: FxPaint::Color(Color::rgb(1.0, 1.0, 0.7)),
|
||||
technique: GlowTechnique::Softer,
|
||||
spread: 0.0,
|
||||
size: 9.0,
|
||||
contour: Contour::Linear,
|
||||
anti_alias: false,
|
||||
range: 0.5,
|
||||
jitter: 0.0,
|
||||
noise: 0.0,
|
||||
source: GlowSource::Edge,
|
||||
})];
|
||||
// Unaffected layers whose rows also render in parallel keep both workers stealing.
|
||||
let base = Layer::new("base", LayerContent::Fill(g(30.0)));
|
||||
let mut top = Layer::new("top", LayerContent::Fill(g(60.0)));
|
||||
top.opacity = 0.3;
|
||||
d.layers.extend([base, shadow, stroke, glow, top]);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn effect_maps_with_nested_rayon_work_do_not_deadlock() {
|
||||
// #276: a tile building a layer's effect maps inside a once-init ran Rayon work; waiting on
|
||||
// it, its worker stole another tile of the same layer, which then blocked on the init held
|
||||
// further up its own stack. Eight threads (two rarely steal across that wait) and 64 tiles make that stealing likely; the cache is
|
||||
// purged every round so the maps are rebuilt. A watchdog turns a hang into a failure.
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let Ok(pool) = rayon::ThreadPoolBuilder::new().num_threads(8).build() else { return };
|
||||
let d = nested_parallel_fx_doc();
|
||||
let first = pool.install(|| render_tiled(&d, d.bounds(), 64));
|
||||
for _ in 0..100 {
|
||||
purge_effect_cache();
|
||||
let out = pool.install(|| render_tiled(&d, d.bounds(), 64));
|
||||
assert!(out.px == first.px, "renders differ between rounds");
|
||||
}
|
||||
let _ = tx.send(());
|
||||
});
|
||||
match rx.recv_timeout(std::time::Duration::from_secs(120)) {
|
||||
Ok(()) => {}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => panic!("effect-map rendering deadlocked (#276)"),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("render thread panicked"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,17 @@ fn contents_are_clipped_and_background_painted() {
|
|||
assert_eq!(render_tiled(&d, d.bounds(), 7).px, buf.px);
|
||||
}
|
||||
}
|
||||
|
||||
// Proxy pixel (x,y) samples (4*x,4*y); unaligned half-open artboard edges must agree.
|
||||
let original = artboard_doc(SampleType::F32, ArtboardBackground::White, BlendMode::PassThrough);
|
||||
let source = render(&original, original.bounds());
|
||||
let proxy = photocraft_compose::proxy::proxy_document(&original, 4);
|
||||
let reduced = render(&proxy, proxy.bounds());
|
||||
for y in 0..proxy.size.height as i32 {
|
||||
for x in 0..proxy.size.width as i32 {
|
||||
assert!(close(reduced.get(x, y), source.get(4 * x, 4 * y)), "proxy artboard differs at ({x}, {y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -174,13 +174,31 @@ pub struct Group {
|
|||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Fill {
|
||||
Solid(Color),
|
||||
/// Gradient between stops at `angle` degrees (`style` geometry, optionally reversed).
|
||||
/// Gradient between stops at `angle` degrees (`style` geometry, optionally reversed), laid
|
||||
/// out in the layer's frame (or the canvas when `align` is off), its centre moved by `offset`.
|
||||
Gradient {
|
||||
stops: Vec<(f32, Color)>,
|
||||
angle: f32,
|
||||
scale: f32,
|
||||
style: GradientStyle,
|
||||
reverse: bool,
|
||||
/// Opacity stops `(location, opacity)` (both `0..=1`); empty = each colour stop's alpha.
|
||||
/// When set, a pixel's alpha is the colour's alpha times the opacity at that location.
|
||||
#[serde(default)]
|
||||
opacity_stops: Vec<(f32, f32)>,
|
||||
/// Colour midpoints, one per segment between consecutive (sorted) colour stops: where
|
||||
/// the segment is half-way (`0..=1` of the segment). Missing entries are 0.5.
|
||||
#[serde(default)]
|
||||
midpoints: Vec<f32>,
|
||||
/// Centre offset as a fraction of the frame (PSD `Ofst`).
|
||||
#[serde(default)]
|
||||
offset: (f32, f32),
|
||||
/// Dither (breaks 8-bit banding with one level of noise).
|
||||
#[serde(default)]
|
||||
dither: bool,
|
||||
/// "Align with layer": lay out in the layer's frame (its masks' bounds), else the canvas.
|
||||
#[serde(default = "effects::yes")]
|
||||
align: bool,
|
||||
},
|
||||
/// A pattern from [`Document::patterns`] (looked up by `id`, then `name`).
|
||||
Pattern {
|
||||
|
|
@ -200,6 +218,14 @@ pub enum Fill {
|
|||
},
|
||||
}
|
||||
|
||||
impl Fill {
|
||||
/// A gradient fill with the classic settings: opaque stops at their alpha, 50 % midpoints,
|
||||
/// centred, no dither, aligned with the layer.
|
||||
pub fn gradient(stops: Vec<(f32, Color)>, angle: f32, scale: f32, style: GradientStyle, reverse: bool) -> Fill {
|
||||
Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops: Vec::new(), midpoints: Vec::new(), offset: (0.0, 0.0), dither: false, align: true }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TextLayer {
|
||||
pub text: String,
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@ pub enum Kerning {
|
|||
/// Font kerning (`kern` feature).
|
||||
#[default]
|
||||
Metrics,
|
||||
/// Photoshop's optical kerning; rendered like `Metrics` for now.
|
||||
/// Photoshop's optical kerning: pair spacing computed from the glyph outlines.
|
||||
Optical,
|
||||
/// No kerning.
|
||||
/// No automatic kerning (Photoshop's "0" / manual kerning).
|
||||
Off,
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +73,11 @@ pub struct CharStyle {
|
|||
pub strikethrough: bool,
|
||||
pub faux_bold: bool,
|
||||
pub faux_italic: bool,
|
||||
/// Automatic (pair) kerning mode.
|
||||
pub kerning: Kerning,
|
||||
/// Manual kerning in 1/1000 em between this character and the next one (Photoshop stores it
|
||||
/// per character run as `Kerning`). Added to the automatic kerning of the pair.
|
||||
pub kern: f32,
|
||||
pub caps: Caps,
|
||||
/// Standard ligatures (`liga`/`clig`).
|
||||
pub ligatures: bool,
|
||||
|
|
@ -111,6 +115,7 @@ impl Default for CharStyle {
|
|||
faux_bold: false,
|
||||
faux_italic: false,
|
||||
kerning: Kerning::Metrics,
|
||||
kern: 0.0,
|
||||
caps: Caps::Normal,
|
||||
ligatures: true,
|
||||
discretionary_ligatures: false,
|
||||
|
|
@ -217,7 +222,7 @@ pub enum TextShape {
|
|||
pub enum Orientation {
|
||||
#[default]
|
||||
Horizontal,
|
||||
/// Vertical type: modelled for round trip, rendered horizontally for now.
|
||||
/// Vertical type (tategaki): columns top to bottom, advancing right to left.
|
||||
Vertical,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@
|
|||
//! Path operations follow Photoshop: every subpath is filled on its own (with the path's
|
||||
//! [`FillRule`]) and folded, in order, into the result with its [`PathOp`]; the first subpath
|
||||
//! always acts as "combine" (see [`Path::effective_op`]; verified against Photoshop's pixels in
|
||||
//! the PSD corpus). [`Path::inverted`] inverts the final area. A vector mask without subpaths
|
||||
//! the PSD corpus). Subpaths marked [`PathOp::Join`] belong to the shape component before them
|
||||
//! and are filled together with it (a custom shape's holes). [`Path::inverted`] inverts the final area. A vector mask without subpaths
|
||||
//! reveals everything (Photoshop's fresh "Add Vector Mask").
|
||||
|
||||
use std::sync::Arc;
|
||||
|
|
@ -60,6 +61,10 @@ pub enum PathOp {
|
|||
Intersect,
|
||||
/// Exclude overlapping shapes (xor).
|
||||
Exclude,
|
||||
/// Part of the previous subpath's shape component (PSD operation -1 after a component's
|
||||
/// first record): the component's subpaths are filled together, with the path's fill rule,
|
||||
/// so inner subpaths wound the other way cut holes (custom shapes).
|
||||
Join,
|
||||
}
|
||||
|
||||
/// Winding rule used to fill each subpath.
|
||||
|
|
@ -131,6 +136,19 @@ impl Path {
|
|||
pub fn effective_op(&self, i: usize) -> PathOp {
|
||||
if i == 0 { PathOp::Combine } else { self.subpaths[i].op }
|
||||
}
|
||||
/// Shape components: ranges of subpaths filled together (a subpath starts a new component
|
||||
/// unless it is a [`PathOp::Join`]), each folded in with its first subpath's
|
||||
/// [`Path::effective_op`].
|
||||
pub fn components(&self) -> Vec<std::ops::Range<usize>> {
|
||||
let mut out: Vec<std::ops::Range<usize>> = Vec::new();
|
||||
for (i, s) in self.subpaths.iter().enumerate() {
|
||||
match out.last_mut() {
|
||||
Some(r) if s.op == PathOp::Join => r.end = i + 1,
|
||||
_ => out.push(i..i + 1),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn transform(&self, a: &Affine) -> Path {
|
||||
let mut p = self.clone();
|
||||
for s in &mut p.subpaths {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ license.workspace = true
|
|||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# Real-file corpus tests (opt-in; `cargo xtask test-corpus`). Fail when the corpus is missing.
|
||||
corpus = []
|
||||
|
||||
[dependencies]
|
||||
photocraft-geom = { workspace = true }
|
||||
photocraft-color = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
//! Generate a realistic "designer" PSD for Layers-panel QA (#143, #144): about 150 layers in
|
||||
//! groups nested four deep, long and short names (Latin, accented, CJK), many layers with
|
||||
//! effects, clipping masks, layer and vector masks, locks, links, smart objects, adjustment,
|
||||
//! fill, shape and type layers, hidden layers and a mix of open and closed groups. It is built
|
||||
//! through engine commands and saved through the real PSD exporter.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p photocraft-engine --example designer_psd -- out.psd
|
||||
//! ```
|
||||
|
||||
use photocraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const NAMES: &[&str] = &[
|
||||
"BG",
|
||||
"a",
|
||||
"Button / Primary / Hover state with a very long descriptive name that keeps going",
|
||||
"背景のテクスチャ 夕焼け",
|
||||
"Überschrift – Größe ½ (Entwurf)",
|
||||
"Shadow copy 3",
|
||||
"Rectangle 12",
|
||||
"Photo — Model portrait, retouched final FINAL v7 (approved by client)",
|
||||
"图层 12 副本",
|
||||
"Icon",
|
||||
"Highlight",
|
||||
"Card / Product thumbnail / Hover",
|
||||
"Ελληνικά κείμενο",
|
||||
"Glow",
|
||||
"Divider line",
|
||||
"카드 배경",
|
||||
];
|
||||
|
||||
struct Gen {
|
||||
s: Session,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
impl Gen {
|
||||
fn run(&mut self, id: &str, p: Value) -> Value {
|
||||
match self.s.execute(id, p.clone()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("{id} {p}: {e}");
|
||||
Value::Null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn active(&self) -> u64 {
|
||||
self.s.active().and_then(|d| d.active_layer).map_or(0, |l| l.0)
|
||||
}
|
||||
|
||||
/// One leaf layer of a kind picked from the running counter; returns its id.
|
||||
fn leaf(&mut self) -> u64 {
|
||||
let i = self.n;
|
||||
self.n += 1;
|
||||
let name = format!("{} {}", NAMES[i % NAMES.len()], if i.is_multiple_of(3) { String::new() } else { format!("#{i}") }).trim().to_string();
|
||||
let (x, y) = ((i * 97 % 1400) as i64, (i * 53 % 1000) as i64);
|
||||
let colour = format!("#{:02x}{:02x}{:02x}", (i * 47 % 256), (i * 91 % 256), (i * 23 % 256));
|
||||
let id = match i % 17 {
|
||||
3 => {
|
||||
let v = self.run("type.create", json!({"x": x, "y": y + 40, "text": format!("Headline {i}: Autumn collection"), "size": 36, "name": name}));
|
||||
v.get("layer").and_then(Value::as_u64).unwrap_or_else(|| self.active())
|
||||
}
|
||||
7 => {
|
||||
self.run("layer.newAdjustmentLayer.curves", json!({"points": [[0, 0], [128, 150], [255, 255]]}));
|
||||
let id = self.active();
|
||||
self.run("layer.setProps", json!({"layer": id, "name": format!("Curves {i}")}));
|
||||
id
|
||||
}
|
||||
11 => {
|
||||
self.run("shape.create", json!({"kind": "roundedRect", "rect": [x, y, 220, 90], "fill": colour, "name": name}));
|
||||
self.active()
|
||||
}
|
||||
14 => {
|
||||
self.run("layer.newFillLayer.solidColor", json!({"color": colour}));
|
||||
self.active()
|
||||
}
|
||||
_ => {
|
||||
self.run("layer.new.layer", json!({"name": name}));
|
||||
self.run("select.rect", json!({"x": x, "y": y, "width": 180 + i % 5 * 40, "height": 120 + i % 3 * 30}));
|
||||
self.run("edit.fill", json!({"contents": "color", "color": colour}));
|
||||
self.run("select.deselect", json!({}));
|
||||
self.active()
|
||||
}
|
||||
};
|
||||
if i % 4 == 1 {
|
||||
self.run("layer.layerStyle.dropShadow", json!({"layer": id, "distance": 6, "size": 10}));
|
||||
if i % 8 == 1 {
|
||||
self.run("layer.select", json!({"layer": id}));
|
||||
self.run("layer.layerStyle.stroke", json!({"size": 2, "color": "#ffffff", "add": true}));
|
||||
}
|
||||
}
|
||||
if i % 6 == 2 {
|
||||
self.run("layer.layerMask.revealAll", json!({"layer": id}));
|
||||
}
|
||||
if i % 10 == 5 {
|
||||
self.run("layer.select", json!({"layer": id}));
|
||||
let path = json!({"subpaths": [{"knots": [[x, y], [x + 300, y], [x + 300, y + 200], [x, y + 200]]}]});
|
||||
self.run("layer.vectorMask.add", json!({"layer": id, "path": path}));
|
||||
}
|
||||
if i % 9 == 4 {
|
||||
self.run("layer.setProps", json!({"layer": id, "locks": {"all": true}}));
|
||||
} else if i % 9 == 6 {
|
||||
self.run("layer.setProps", json!({"layer": id, "locks": {"position": true}}));
|
||||
}
|
||||
if i % 8 == 3 {
|
||||
self.run("layer.setProps", json!({"layer": id, "visible": false}));
|
||||
}
|
||||
if i % 12 == 10 {
|
||||
self.run("layer.setProps", json!({"layer": id, "blend": "Multiply"}));
|
||||
}
|
||||
if i % 13 == 6 {
|
||||
self.run("layer.select", json!({"layer": id}));
|
||||
self.run("layer.smartObjects.convertToSmartObject", json!({"layer": id}));
|
||||
}
|
||||
self.s.active().and_then(|d| d.active_layer).map_or(id, |l| l.0)
|
||||
}
|
||||
|
||||
/// `count` items above the active layer, the item at `nest` (if any) a group built one
|
||||
/// level deeper, then everything grouped as `name`. Returns the group's id.
|
||||
fn group(&mut self, name: &str, depth: usize, count: usize, open: bool) -> u64 {
|
||||
let mut ids = Vec::new();
|
||||
for k in 0..count {
|
||||
if depth < 3 && k == count / 2 {
|
||||
let sub = format!("{name} › Level {} {}", depth + 2, ["Cards", "サブグループ", "Details", "Variants"][depth % 4]);
|
||||
ids.push(self.group(&sub, depth + 1, count.saturating_sub(2).max(3), !self.n.is_multiple_of(3)));
|
||||
} else {
|
||||
let id = self.leaf();
|
||||
// Clip every 7th layer to the one below it (inside the same group).
|
||||
if self.n.is_multiple_of(7) && !ids.is_empty() {
|
||||
self.run("layer.setProps", json!({"layer": id, "clipped": true}));
|
||||
}
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
self.run("layer.select", json!({"layer": ids[0]}));
|
||||
for id in &ids[1..] {
|
||||
self.run("layer.select", json!({"layer": id, "mode": "toggle"}));
|
||||
}
|
||||
let g = self.run("layer.groupLayers", json!({"name": name}))["layer"].as_u64().unwrap_or_else(|| self.active());
|
||||
if depth == 1 && self.n.is_multiple_of(2) {
|
||||
self.run("layer.layerStyle.dropShadow", json!({"layer": g}));
|
||||
}
|
||||
self.run("layer.setExpanded", json!({"layer": g, "expanded": open}));
|
||||
self.run("layer.select", json!({"layer": g}));
|
||||
g
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<(), String> {
|
||||
let out = std::env::args().nth(1).unwrap_or_else(|| "designer.psd".into());
|
||||
let mut g = Gen { s: Session::new(), n: 0 };
|
||||
g.run("file.new", json!({"width": 1600, "height": 1200, "name": "designer"}));
|
||||
let groups = [
|
||||
("Footer", 6, false),
|
||||
("Retouching — dodge & burn, frequency separation", 7, true),
|
||||
("Typography / 本文", 6, false),
|
||||
("Product Grid — Autumn/Winter 2026 campaign (final, do not edit)", 8, true),
|
||||
("ヒーローセクション", 7, true),
|
||||
("Navigation", 5, false),
|
||||
("Header / Logo lockup", 6, true),
|
||||
];
|
||||
for (name, count, open) in groups {
|
||||
g.group(name, 0, count, open);
|
||||
}
|
||||
for _ in 0..10 {
|
||||
g.leaf();
|
||||
}
|
||||
// Two linked layers at the top.
|
||||
let a = g.leaf();
|
||||
let b = g.leaf();
|
||||
g.run("layer.select", json!({"layer": a}));
|
||||
g.run("layer.select", json!({"layer": b, "mode": "toggle"}));
|
||||
g.run("layer.linkLayers", json!({}));
|
||||
let doc = g.s.active().map(|d| (*d.doc).clone()).ok_or("no document")?;
|
||||
let layers = doc.walk().len();
|
||||
let bytes = photocraft_io::export(&doc, &out, &Default::default()).map_err(|e| e.to_string())?.bytes;
|
||||
std::fs::write(&out, &bytes).map_err(|e| e.to_string())?;
|
||||
println!("wrote {out}: {layers} layers, {} bytes", bytes.len());
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -204,9 +204,30 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
|||
|
||||
// ---------- commands ----------
|
||||
|
||||
/// Image › Adjustments run as a smart filter (`image.adjustments.<kind>` recorded on a smart
|
||||
/// object): the adjustment applied to a copy of `surf`. `None` for kinds or params it can't run.
|
||||
pub(crate) fn adjust_as_filter(kind: &str, p: &Value, surf: &Surface) -> Option<Surface> {
|
||||
let mut out = surf.clone();
|
||||
if kind == "shadowsHighlights" {
|
||||
let sh = shadows_highlights_params(p);
|
||||
process_surface(&mut out, None, &mut |px, r| tone::shadows_highlights(px, r.width() as usize, r.height() as usize, &sh));
|
||||
} else {
|
||||
let mode = surf.format().mode;
|
||||
let adj = crate::adjust_params::from_params(kind, p, None, mode).ok()?;
|
||||
crate::pixels::adjust_surface(&mut out, &adj, None, mode);
|
||||
}
|
||||
out.prune();
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn shadows_highlights(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sh = shadows_highlights_params(p);
|
||||
rgba_edit(s, "Shadows/Highlights", p, |px, r| tone::shadows_highlights(px, r.width() as usize, r.height() as usize, &sh))
|
||||
}
|
||||
|
||||
fn shadows_highlights_params(p: &Value) -> ShadowsHighlights {
|
||||
let d = ShadowsHighlights::default();
|
||||
let sh = ShadowsHighlights {
|
||||
ShadowsHighlights {
|
||||
shadow_amount: num(p, "shadowAmount", d.shadow_amount).clamp(0.0, 100.0),
|
||||
shadow_tone: num(p, "shadowTone", d.shadow_tone).clamp(0.0, 100.0),
|
||||
shadow_radius: num(p, "shadowRadius", d.shadow_radius).clamp(0.0, 2500.0),
|
||||
|
|
@ -217,8 +238,7 @@ fn shadows_highlights(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
midtone: num(p, "midtone", d.midtone).clamp(-100.0, 100.0),
|
||||
black_clip: num(p, "blackClip", d.black_clip).clamp(0.0, 50.0),
|
||||
white_clip: num(p, "whiteClip", d.white_clip).clamp(0.0, 50.0),
|
||||
};
|
||||
rgba_edit(s, "Shadows/Highlights", p, |px, r| tone::shadows_highlights(px, r.width() as usize, r.height() as usize, &sh))
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ fn compound<R>(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Res
|
|||
st.history = history;
|
||||
match r {
|
||||
Ok(v) => {
|
||||
st.history.record(label, before);
|
||||
st.history.record(label, before, st.layer_target());
|
||||
st.history.trim(&st.doc);
|
||||
st.coalesce = None;
|
||||
Ok(v)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
//! "Scripts" are PhotoCraft action scripts: JSON (`[[id, params], …]`, `{"steps": …}` or a
|
||||
//! droplet) or a plain-text list with one `command.id {json params}` per line (`#` comments).
|
||||
//! Droplets are JSON files (`.pcdroplet`) holding an action plus batch options; `photocraft-cli
|
||||
//! droplet <file> <inputs…>` runs one, and on macOS/Linux a `.command` shim makes it
|
||||
//! droplet <file> <inputs…>` runs one, and on Unix a `.command` shim makes it
|
||||
//! double-clickable / drop-target-able from the shell.
|
||||
//!
|
||||
//! Script events (Start Application, New / Open / Save / Close Document, Print, Export) are
|
||||
|
|
@ -50,14 +50,7 @@ pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>> {
|
|||
let t = text.trim_start_matches('\u{feff}').trim();
|
||||
if t.starts_with('[') || t.starts_with('{') {
|
||||
let v: Value = serde_json::from_str(t).map_err(|e| bad(cmd, format!("script JSON: {e}")))?;
|
||||
let steps = match &v {
|
||||
Value::Array(_) => &v,
|
||||
Value::Object(o) => {
|
||||
o.get("steps").or_else(|| o.get("action").and_then(|a| a.get("steps").or(Some(a)))).ok_or_else(|| bad(cmd, "script object needs \"steps\""))?
|
||||
}
|
||||
_ => return Err(bad(cmd, "script JSON must be an array of steps or an object")),
|
||||
};
|
||||
return parse_steps(steps, cmd);
|
||||
return parse_action(&v, cmd);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for (n, line) in t.lines().enumerate() {
|
||||
|
|
@ -72,6 +65,20 @@ pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>> {
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
/// The steps of a JSON action: an array of steps (`[id, params]`, `{"command", "params"}` or a
|
||||
/// bare id), `{"steps"|"action": …}` (a recorded action) or a droplet. `cmd` names the caller in
|
||||
/// errors.
|
||||
pub fn parse_action(v: &Value, cmd: &str) -> Result<Vec<(String, Value)>> {
|
||||
let steps = match v {
|
||||
Value::Array(_) => v,
|
||||
Value::Object(o) => {
|
||||
o.get("steps").or_else(|| o.get("action").and_then(|a| a.get("steps").or(Some(a)))).ok_or_else(|| bad(cmd, "an action object needs \"steps\""))?
|
||||
}
|
||||
_ => return Err(bad(cmd, "an action must be an array of steps or an object")),
|
||||
};
|
||||
parse_steps(steps, cmd)
|
||||
}
|
||||
|
||||
fn parse_steps(v: &Value, cmd: &str) -> Result<Vec<(String, Value)>> {
|
||||
let arr = v.as_array().ok_or_else(|| bad(cmd, "steps must be an array"))?;
|
||||
arr.iter()
|
||||
|
|
@ -291,7 +298,7 @@ fn create_droplet(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let droplet = json!({"photocraftDroplet": 1, "name": name, "action": {"name": name, "steps": steps_json}, "options": options});
|
||||
let text = serde_json::to_string_pretty(&droplet).unwrap_or_default();
|
||||
write_file(&path, text.as_bytes())?;
|
||||
let shim = p.get("shim").and_then(Value::as_bool).unwrap_or(cfg!(any(target_os = "macos", target_os = "linux")));
|
||||
let shim = p.get("shim").and_then(Value::as_bool).unwrap_or(cfg!(unix));
|
||||
let shim_path = if shim { Some(write_shim(&path)?) } else { None };
|
||||
let _ = s;
|
||||
Ok(json!({"path": path, "shim": shim_path, "steps": steps.len()}))
|
||||
|
|
@ -307,7 +314,7 @@ fn write_shim(droplet: &str) -> Result<String> {
|
|||
"#!/bin/sh\n# PhotoCraft droplet: runs the action on the files given (or dropped).\nexec \"${{PHOTOCRAFT_CLI:-photocraft-cli}}\" droplet \"{}\" \"$@\"\n",
|
||||
abs.replace('"', "\\\"")
|
||||
);
|
||||
std::fs::write(&shim, body).map_err(|e| EngineError::Other(format!("{shim}: {e}")))?;
|
||||
crate::file_cmds::write_file(&shim, body.as_bytes())?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
|
@ -526,7 +533,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"file.automate.createDroplet",
|
||||
"Create Droplet…",
|
||||
&["File", "Automate"],
|
||||
r##"{"path":str (.pcdroplet),"steps":[[id,params]…] (the action),"name":str?,"output":folder?,"format":"same|png|jpg|…"?,"quality":0..12?,"shim":bool=true on macOS/Linux (writes <name>.command calling `photocraft-cli droplet`)} → {path, shim}"##,
|
||||
r##"{"path":str (.pcdroplet),"steps":[[id,params]…] (the action),"name":str?,"output":folder?,"format":"same|png|jpg|…"?,"quality":0..12?,"shim":bool=true on Unix (writes <name>.command calling `photocraft-cli droplet`)} → {path, shim}"##,
|
||||
native,
|
||||
create_droplet
|
||||
),
|
||||
|
|
@ -534,7 +541,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"file.automate.runDroplet",
|
||||
"Run Droplet",
|
||||
&[],
|
||||
r##"{"droplet":path,"input":[files or folders],"output":folder? (default: droplet's, else <input folder>/droplet-output)} → {files, errors}"##,
|
||||
r##"{"droplet":path,"input":[files or folders],"output":folder? (default: droplet's, else <input folder>/droplet-output)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
|
||||
native,
|
||||
run_droplet
|
||||
),
|
||||
|
|
|
|||
|
|
@ -84,6 +84,11 @@ pub fn parse_points(p: &Value, cmd: &str) -> Result<Vec<StrokePoint>> {
|
|||
/// Deep-merge `patch` into `base` (objects merge key by key; anything else replaces).
|
||||
fn merge(base: &mut Value, patch: &Value) {
|
||||
match (base, patch) {
|
||||
// A different variant of an externally tagged enum (`{"tile":…}` → `{"procedural":…}`)
|
||||
// replaces the old one instead of merging into a two-variant object.
|
||||
(Value::Object(b), Value::Object(p)) if b.len() == 1 && p.len() == 1 && b.keys().next() != p.keys().next() => {
|
||||
*b = p.clone();
|
||||
}
|
||||
(Value::Object(b), Value::Object(p)) => {
|
||||
for (k, v) in p {
|
||||
match b.get_mut(k) {
|
||||
|
|
@ -100,9 +105,36 @@ fn merge(base: &mut Value, patch: &Value) {
|
|||
|
||||
/// Apply a JSON brush patch onto a brush.
|
||||
pub fn merge_brush(base: &BrushSettings, patch: &Value, cmd: &str) -> Result<BrushSettings> {
|
||||
let mut v = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
// Bitmaps the patch doesn't touch (sampled tips, pattern tiles: up to megabytes of base64) skip
|
||||
// the JSON round trip, so a Brush Settings slider on an imported tip stays cheap.
|
||||
let touches = |path: &[&str]| {
|
||||
let mut v = patch;
|
||||
for k in path {
|
||||
match v.get(k) {
|
||||
Some(x) => v = x,
|
||||
// A non-object on the way replaces the whole section.
|
||||
None => return !v.is_object(),
|
||||
}
|
||||
}
|
||||
true
|
||||
};
|
||||
let mut light = base.clone();
|
||||
let tip = (!touches(&["tip"])).then(|| std::mem::take(&mut light.tip));
|
||||
let dual_tip = (!touches(&["dualBrush", "tip"])).then(|| std::mem::take(&mut light.dual_brush.tip));
|
||||
let pattern = (!touches(&["texture", "pattern"])).then(|| std::mem::take(&mut light.texture.pattern));
|
||||
let mut v = serde_json::to_value(&light).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
merge(&mut v, patch);
|
||||
serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid brush: {e}")))
|
||||
let mut out: BrushSettings = serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid brush: {e}")))?;
|
||||
if let Some(t) = tip {
|
||||
out.tip = t;
|
||||
}
|
||||
if let Some(t) = dual_tip {
|
||||
out.dual_brush.tip = t;
|
||||
}
|
||||
if let Some(p) = pattern {
|
||||
out.texture.pattern = p;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn find_preset<'a>(s: &'a Session, name: &str, cmd: &str) -> Result<&'a BrushPreset> {
|
||||
|
|
@ -190,17 +222,37 @@ fn stroke_with(s: &mut Session, p: &Value, label: &str, brush: BrushSettings, pt
|
|||
let mut brush = brush;
|
||||
erase_locked(&mut brush, lock, bg);
|
||||
if auto_erase {
|
||||
// Pencil Auto Erase: starting on foreground-coloured pixels paints the background colour.
|
||||
let c = surf.rgba(pts[0].x.floor() as i32, pts[0].y.floor() as i32);
|
||||
if c[3] > 0.0 && (0..3).all(|i| (c[i] - fg[i]).abs() < 1.5 / 255.0) {
|
||||
brush.color = bg;
|
||||
}
|
||||
apply_auto_erase(&mut brush, surf, pts.first(), fg, bg);
|
||||
}
|
||||
Ok(render_stroke(surf, &brush, &pts, sel.as_ref(), lock, zoom))
|
||||
})?;
|
||||
Ok(damage_json(s, dmg))
|
||||
}
|
||||
|
||||
/// Pencil Auto Erase: a stroke that starts on a pixel of the foreground colour paints the
|
||||
/// background colour instead (Photoshop).
|
||||
fn apply_auto_erase(brush: &mut BrushSettings, surf: &Surface, start: Option<&StrokePoint>, fg: [f32; 4], bg: [f32; 4]) {
|
||||
let Some(p0) = start else { return };
|
||||
let c = surf.rgba(p0.x.floor() as i32, p0.y.floor() as i32);
|
||||
if c[3] > 0.0 && (0..3).all(|i| (c[i] - fg[i]).abs() < 1.5 / 255.0) {
|
||||
brush.color = bg;
|
||||
}
|
||||
}
|
||||
|
||||
/// The Pencil's brush: the session brush made aliased (every pixel fully painted or untouched,
|
||||
/// dabs on the pixel grid), hard and at full flow, with the options-bar mode.
|
||||
fn pencil_brush(s: &Session, p: &Value) -> Result<BrushSettings> {
|
||||
let mut brush = with_blend_mode(resolve_brush(s, p, "paint.pencil")?, p);
|
||||
brush.aliased = true;
|
||||
if num(p, "hardness").is_none() {
|
||||
brush.hardness = 1.0;
|
||||
}
|
||||
if num(p, "flow").is_none() {
|
||||
brush.flow = 1.0;
|
||||
}
|
||||
Ok(brush)
|
||||
}
|
||||
|
||||
/// Applies the options-bar blend `mode` to a brush. `"mode"` accepts any blend-mode name
|
||||
/// (normal|multiply|screen|…). The Eraser has no blend mode in Photoshop, so it is forced to Normal.
|
||||
fn with_blend_mode(mut b: BrushSettings, p: &Value) -> BrushSettings {
|
||||
|
|
@ -241,15 +293,29 @@ pub struct LiveStroke {
|
|||
impl LiveStroke {
|
||||
/// Start from `paint.stroke` params; their `points` are rendered.
|
||||
pub fn begin(s: &Session, p: &Value) -> Result<Self> {
|
||||
Self::begin_with(s, "paint.stroke", p)
|
||||
}
|
||||
|
||||
/// Start a live stroke of `cmd`: `paint.stroke` (the Brush and Eraser) or `paint.pencil`.
|
||||
/// Committing `cmd` with the same params and `"seed": live.seed` gives the same pixels.
|
||||
pub fn begin_with(s: &Session, cmd: &str, p: &Value) -> Result<Self> {
|
||||
has_paintable(s).map_err(EngineError::Other)?;
|
||||
let pts = parse_points(p, "paint.stroke")?;
|
||||
let brush = with_blend_mode(resolve_brush(s, p, "paint.stroke")?, p);
|
||||
let seed = brush.seed;
|
||||
let pts = parse_points(p, cmd)?;
|
||||
let pencil = match cmd {
|
||||
"paint.stroke" => false,
|
||||
"paint.pencil" => true,
|
||||
other => return Err(bad(other, "live strokes are `paint.stroke` or `paint.pencil`")),
|
||||
};
|
||||
let brush = if pencil { pencil_brush(s, p)? } else { with_blend_mode(resolve_brush(s, p, cmd)?, p) };
|
||||
let (seed, fg) = (brush.seed, brush.color);
|
||||
let (layer, mut brush, zoom) = stroke_target(s, p, brush)?;
|
||||
let mut doc = (*s.active().ok_or(EngineError::NoDocument)?.doc).clone();
|
||||
let sel = doc.selection.clone();
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, layer, p)?;
|
||||
erase_locked(&mut brush, lock, s.tools.background);
|
||||
if pencil && flag(p, "autoErase", false) {
|
||||
apply_auto_erase(&mut brush, surf, pts.first(), fg, s.tools.background);
|
||||
}
|
||||
let renderer = StrokeRenderer::new(&brush, Some(surf.format()), zoom);
|
||||
let pre = surf.clone();
|
||||
let mut live = Self { doc: std::sync::Arc::new(doc), seed, renderer, pre, sel, lock, layer, params: p.clone(), tail: Rect::EMPTY };
|
||||
|
|
@ -288,11 +354,7 @@ impl LiveStroke {
|
|||
|
||||
fn pencil(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pts = parse_points(p, "paint.pencil")?;
|
||||
let mut brush = with_blend_mode(resolve_brush(s, p, "paint.pencil")?, p);
|
||||
brush.aliased = true;
|
||||
if num(p, "hardness").is_none() {
|
||||
brush.hardness = 1.0;
|
||||
}
|
||||
let brush = pencil_brush(s, p)?;
|
||||
let label = if brush.erase { "Eraser" } else { "Pencil" };
|
||||
stroke_with(s, p, label, brush, pts, flag(p, "autoErase", false))
|
||||
}
|
||||
|
|
@ -305,8 +367,16 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let cmd = "paint.mixerBrush";
|
||||
let pts = parse_points(p, cmd)?;
|
||||
let brush = resolve_brush(s, p, cmd)?;
|
||||
let m = MixerSettings { wet: pct(p, "wet", 50.0), load: pct(p, "load", 50.0), mix: pct(p, "mix", 50.0), flow: pct(p, "flow", 100.0) };
|
||||
let sample_all = flag(p, "sampleAllLayers", false);
|
||||
// The brush's Mixer Brush options, overridden by the scalar params.
|
||||
let bm = &brush.mixer;
|
||||
let m = MixerSettings {
|
||||
wet: pct(p, "wet", bm.wet * 100.0),
|
||||
load: pct(p, "load", bm.load * 100.0),
|
||||
mix: pct(p, "mix", bm.mix * 100.0),
|
||||
flow: pct(p, "flow", bm.flow * 100.0),
|
||||
sample_all_layers: flag(p, "sampleAllLayers", bm.sample_all_layers),
|
||||
};
|
||||
let sample_all = m.sample_all_layers;
|
||||
let (clean, load_after) = (flag(p, "cleanAfterStroke", true), flag(p, "loadAfterStroke", true));
|
||||
let mut state = s.tools.mixer.clone();
|
||||
if load_after || state.reservoir.is_none() {
|
||||
|
|
@ -511,10 +581,89 @@ fn set_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
}
|
||||
b = merge_brush(&b, &patch, cmd)?;
|
||||
s.tools.brush = b;
|
||||
let before = std::mem::replace(&mut s.tools.brush, b);
|
||||
// A coalesced gesture (one slider drag) journals as one call: remember the brush it started from.
|
||||
let key = p.get("coalesce").and_then(Value::as_str).filter(|_| p.get("preset").is_none() && p.get("reset").is_none());
|
||||
let continuing = |s: &Session, k: &str| {
|
||||
s.tools.brush_gesture.as_ref().is_some_and(|(g, _)| g == k)
|
||||
&& s.journal.last().is_some_and(|(id, lp)| id == cmd && lp.get("coalesce").and_then(Value::as_str) == Some(k))
|
||||
};
|
||||
match key {
|
||||
Some(k) if continuing(s, k) => {}
|
||||
Some(k) => s.tools.brush_gesture = Some((k.to_string(), before)),
|
||||
None => s.tools.brush_gesture = None,
|
||||
}
|
||||
Ok(brush_json(&s.tools.brush))
|
||||
}
|
||||
|
||||
/// The brush as JSON with the bitmaps (sampled tips, pattern tiles) that `skip` names replaced by
|
||||
/// placeholders: they can be megabytes of base64, and skipped ones are equal on both sides.
|
||||
fn light_json(b: &BrushSettings, skip: (bool, bool, bool)) -> Value {
|
||||
let mut c = b.clone();
|
||||
if skip.0 {
|
||||
c.tip = TipShape::Round;
|
||||
}
|
||||
if skip.1 {
|
||||
c.dual_brush.tip = TipShape::Round;
|
||||
}
|
||||
if skip.2 {
|
||||
c.texture.pattern = photocraft_paint::Pattern::default();
|
||||
}
|
||||
serde_json::to_value(&c).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
/// Fields of `new` that differ from `old`, nested objects diffed key by key: the minimal
|
||||
/// `tools.setBrush` `brush` patch that turns `old` into `new` (`{}` when nothing changed).
|
||||
pub fn brush_patch(old: &BrushSettings, new: &BrushSettings) -> Value {
|
||||
let skip = (old.tip == new.tip, old.dual_brush.tip == new.dual_brush.tip, old.texture.pattern == new.texture.pattern);
|
||||
fn diff(a: &Value, b: &Value) -> Option<Value> {
|
||||
match (a, b) {
|
||||
(Value::Object(ao), Value::Object(bo)) => {
|
||||
let mut out = serde_json::Map::new();
|
||||
for (k, bv) in bo {
|
||||
match ao.get(k) {
|
||||
Some(av) => {
|
||||
if let Some(d) = diff(av, bv) {
|
||||
out.insert(k.clone(), d);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
out.insert(k.clone(), bv.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
(!out.is_empty()).then_some(Value::Object(out))
|
||||
}
|
||||
// Enums with payloads (tips, patterns) are replaced whole.
|
||||
_ => (a != b).then(|| b.clone()),
|
||||
}
|
||||
}
|
||||
diff(&light_json(old, skip), &light_json(new, skip)).unwrap_or_else(|| json!({}))
|
||||
}
|
||||
|
||||
/// Journal hook ([`Session::execute`]): consecutive `tools.setBrush` calls with the same
|
||||
/// `coalesce` key (one drag in the options bar or the Brush Settings panel) are one journal
|
||||
/// entry, the patch from the brush before the gesture to the brush now. Returns true when the
|
||||
/// call was folded into the previous entry.
|
||||
pub fn coalesce_journal(s: &mut Session, id: &str, params: &Value) -> bool {
|
||||
if id != "tools.setBrush" {
|
||||
return false;
|
||||
}
|
||||
let Some(key) = params.get("coalesce").and_then(Value::as_str) else { return false };
|
||||
let Some((gk, start)) = s.tools.brush_gesture.as_ref() else { return false };
|
||||
if gk != key {
|
||||
return false;
|
||||
}
|
||||
let patch = brush_patch(start, &s.tools.brush);
|
||||
match s.journal.last_mut() {
|
||||
Some((last, lp)) if last == id && lp.get("coalesce").and_then(Value::as_str) == Some(key) => {
|
||||
*lp = json!({ "brush": patch, "coalesce": key });
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! spec {
|
||||
($id:literal, $label:literal, $params:literal, $en:expr, $run:expr, $journal:expr) => {
|
||||
CommandSpec { id: $id, label: $label, menu: &[], shortcut: None, params: $params, enabled: $en, run: $run, journal: $journal }
|
||||
|
|
@ -535,7 +684,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!(
|
||||
"paint.mixerBrush",
|
||||
"Mixer Brush",
|
||||
r##"{"points":[…],"brush":{…}?,"preset":name?,"size":px?,"wet":0..100=50,"load":0..100=50,"mix":0..100=50,"flow":0..100=100,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=false,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##,
|
||||
r##"{"points":[…],"brush":{…}?,"preset":name?,"size":px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##,
|
||||
has_paintable,
|
||||
mixer_brush,
|
||||
true
|
||||
|
|
|
|||
|
|
@ -176,6 +176,45 @@ fn set_brush_merges_fields() {
|
|||
assert_eq!(s.tools.brush.texture.pattern, chalk_pattern);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_brush_keeps_untouched_bitmaps_and_replaces_touched_ones() {
|
||||
let tile = |w, h, k: u32| GrayTile::from_fn(w, h, move |x, y| ((x * k + y) % 5) as f32 / 4.0);
|
||||
let base = BrushSettings {
|
||||
tip: TipShape::Sampled(tile(900, 700, 3)),
|
||||
dual_brush: photocraft_paint::DualBrush { tip: TipShape::Sampled(tile(30, 20, 2)), ..Default::default() },
|
||||
texture: photocraft_paint::Texture { pattern: photocraft_paint::Pattern::Tile(tile(64, 64, 1)), ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let cmd = "tools.setBrush";
|
||||
// Untouched bitmaps survive any patch that doesn't name them.
|
||||
for patch in [json!({"size": 12}), json!({"dualBrush": {"size": 9}}), json!({"texture": {"depth": 0.5}}), json!({})] {
|
||||
let b = merge_brush(&base, &patch, cmd).unwrap();
|
||||
assert_eq!((&b.tip, &b.dual_brush.tip, &b.texture.pattern), (&base.tip, &base.dual_brush.tip, &base.texture.pattern), "{patch}");
|
||||
}
|
||||
let b = merge_brush(&base, &json!({"size": 12, "dualBrush": {"size": 9}}), cmd).unwrap();
|
||||
assert_eq!((b.size, b.dual_brush.size), (12.0, 9.0));
|
||||
// Named ones are replaced.
|
||||
let b = merge_brush(
|
||||
&base,
|
||||
&json!({"tip": "round", "dualBrush": {"tip": "round"}, "texture": {"pattern": {"procedural": {"style": "dots", "size": 32, "seed": 2}}}}),
|
||||
cmd,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((&b.tip, &b.dual_brush.tip), (&TipShape::Round, &TipShape::Round));
|
||||
assert!(matches!(b.texture.pattern, photocraft_paint::Pattern::Procedural { size: 32, .. }));
|
||||
// A section replaced by a non-object is an error, not a silently kept bitmap.
|
||||
assert!(merge_brush(&base, &json!({"dualBrush": 3}), cmd).is_err());
|
||||
assert!(merge_brush(&base, &json!(null), cmd).is_err());
|
||||
// Speed: a panel edit on a big imported tip doesn't base64 the tip.
|
||||
let big = BrushSettings { tip: TipShape::Sampled(tile(2500, 2500, 7)), ..Default::default() };
|
||||
let t0 = std::time::Instant::now();
|
||||
for i in 0..10 {
|
||||
merge_brush(&big, &json!({"size": 10 + i}), cmd).unwrap();
|
||||
}
|
||||
let ms = t0.elapsed().as_secs_f64() * 100.0;
|
||||
eprintln!("merge_brush on a 2500 px tip: {ms:.2} ms per edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn define_brush_from_selection() {
|
||||
let mut s = session(40, 40);
|
||||
|
|
@ -272,3 +311,209 @@ fn live_stroke_matches_the_committed_stroke() {
|
|||
assert!(LiveStroke::begin(&s, &json!({"points": [[1, 1]], "target": {"channel": 9}})).is_err());
|
||||
assert!(LiveStroke::begin(&Session::new(), &json!({"points": [[1, 1]]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_pencil_matches_the_committed_pencil_and_auto_erases() {
|
||||
let mut s = session(80, 40);
|
||||
s.execute("tools.setColors", json!({"foreground": "#000000", "background": "#ffffff"})).unwrap();
|
||||
// A soft, low-flow session brush: the Pencil is still hard, aliased and full flow.
|
||||
s.execute("tools.setBrush", json!({"brush": {"size": 3, "hardness": 0.0, "flow": 0.3}})).unwrap();
|
||||
let pts = [[5.0, 5.0, 1.0], [30.2, 17.7, 0.6], [70.0, 33.0, 1.0]];
|
||||
for (auto, start) in [(false, pts[0]), (true, [30.4, 17.2, 1.0])] {
|
||||
let p = json!({"points": [start], "autoErase": auto, "smoothing": 0.0});
|
||||
let mut live = LiveStroke::begin_with(&s, "paint.pencil", &p).unwrap();
|
||||
let rest: Vec<StrokePoint> = pts.iter().map(|q| StrokePoint::new(q[0], q[1], q[2] as f32)).collect();
|
||||
live.push(&rest).unwrap();
|
||||
let mut commit = p.clone();
|
||||
let mut all = vec![start];
|
||||
all.extend(pts);
|
||||
commit["points"] = json!(all);
|
||||
commit["seed"] = json!(live.seed);
|
||||
s.execute("paint.pencil", commit).unwrap();
|
||||
let shown = live.doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().clone();
|
||||
assert!(same_pixels(&shown, &surface(&s), Rect::new(0, 0, 80, 40)), "auto erase {auto}");
|
||||
for y in 0..40 {
|
||||
for x in 0..80 {
|
||||
let a = shown.rgba(x, y)[3];
|
||||
assert!(a == 0.0 || a == 1.0, "partial alpha {a} at ({x},{y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
// The second stroke started on the first one's black: Auto Erase painted white.
|
||||
assert_eq!(rgba(&s, 70, 33), [1.0, 1.0, 1.0, 1.0]);
|
||||
// Only the Brush and the Pencil stroke live.
|
||||
assert!(LiveStroke::begin_with(&s, "paint.mixerBrush", &json!({"points": [[1, 1]]})).is_err());
|
||||
assert!(LiveStroke::begin_with(&s, "paint.pencil", &json!({"points": "none"})).is_err());
|
||||
}
|
||||
|
||||
/// Pencil latency on a 24 MP document: the time to start a live stroke, per pointer move, and
|
||||
/// to commit. `cargo test -p photocraft-engine --release --lib pencil_latency -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn pencil_latency_24mp() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 6000, "height": 4000, "background": "white"})).unwrap();
|
||||
for size in [1.0f64, 9.0, 40.0] {
|
||||
let p = json!({"points": [[100.0, 100.0]], "size": size, "smoothing": 0.0});
|
||||
let t = std::time::Instant::now();
|
||||
let mut live = LiveStroke::begin_with(&s, "paint.pencil", &p).unwrap();
|
||||
let begin = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let mut steps = Vec::new();
|
||||
let mut all = vec![[100.0, 100.0]];
|
||||
for i in 1..=300 {
|
||||
let q = [100.0 + i as f64 * 15.0, 100.0 + (i as f64 * 0.07).sin() * 900.0 + 1000.0];
|
||||
all.push(q);
|
||||
let t = std::time::Instant::now();
|
||||
live.push(&[StrokePoint::new(q[0], q[1], 1.0)]).unwrap();
|
||||
steps.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
let mut commit = p.clone();
|
||||
commit["points"] = json!(all);
|
||||
commit["seed"] = json!(live.seed);
|
||||
let t = std::time::Instant::now();
|
||||
s.execute("paint.pencil", commit).unwrap();
|
||||
let done = t.elapsed().as_secs_f64() * 1000.0;
|
||||
steps.sort_by(f64::total_cmp);
|
||||
println!(
|
||||
"pencil {size:>4} px: begin {begin:.2} ms, per move p50 {:.3} ms p95 {:.3} ms max {:.3} ms, commit {done:.1} ms",
|
||||
steps[steps.len() / 2],
|
||||
steps[steps.len() * 95 / 100],
|
||||
steps[steps.len() - 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_stroke_equals_the_commit_at_every_zoom_with_smoothing() {
|
||||
// #189: the preview while dragging must be the committed stroke (within 1/255), with a soft
|
||||
// brush on an opaque Background with content, at any zoom (the smoothing string scales with
|
||||
// it), with smoothing on, pressure, 8 and 16 bits, and points fed one pointer move at a time.
|
||||
let pts: Vec<[f64; 3]> = (0..40)
|
||||
.map(|i| {
|
||||
let t = f64::from(i) / 39.0;
|
||||
[40.0 + 120.0 * t, 60.0 + 30.0 * (t * 7.0).sin(), 0.3 + 0.7 * t]
|
||||
})
|
||||
.collect();
|
||||
for depth in [8, 16] {
|
||||
for zoom in [0.25, 1.0, 4.0, 16.0] {
|
||||
for (amount, pulled) in [(0.1, false), (0.5, false), (0.3, true)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 200, "height": 120, "depth": depth, "background": "white"})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#1a2550"})).unwrap();
|
||||
s.execute("paint.stroke", json!({"points": [[20, 20], [180, 100]], "size": 8, "hardness": 1.0, "color": "#ffffff"})).unwrap();
|
||||
s.tools.foreground = [0.0, 0.0, 0.0, 1.0];
|
||||
let brush = json!({"size": 40, "hardness": 0.0, "pressureSize": true, "smoothing": {"amount": amount, "pulledString": pulled}});
|
||||
let p = json!({"points": [pts[0]], "brush": brush, "zoom": zoom, "target": "pixels"});
|
||||
let mut live = LiveStroke::begin(&s, &p).unwrap();
|
||||
for q in &pts[1..] {
|
||||
live.push(&[StrokePoint::new(q[0], q[1], q[2] as f32)]).unwrap();
|
||||
}
|
||||
let mut commit = p.clone();
|
||||
commit["points"] = json!(pts);
|
||||
commit["seed"] = json!(live.seed);
|
||||
s.execute("paint.stroke", commit).unwrap();
|
||||
let shown = live.doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().clone();
|
||||
let done = surface(&s);
|
||||
let worst = (0..120).flat_map(|y| (0..200).map(move |x| (x, y))).map(|(x, y)| {
|
||||
let (a, b) = (shown.rgba(x, y), done.rgba(x, y));
|
||||
(0..4).map(|i| (a[i] - b[i]).abs()).fold(0.0f32, f32::max)
|
||||
});
|
||||
let worst = worst.fold(0.0f32, f32::max);
|
||||
let label = format!("depth {depth}, zoom {zoom}, smoothing {amount} (pulled string {pulled})");
|
||||
assert!(worst <= 1.0 / 255.0, "{label}: preview differs from the commit by {worst}");
|
||||
// Not two blank canvases: the soft stroke is there, edge included.
|
||||
assert!(done.rgba(40, 60)[0] < 0.1 && (0.05..0.9).contains(&done.rgba(40, 60 - 14)[2]), "{label}: soft stroke");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_brush_controls_round_trip_through_set_brush() {
|
||||
let mut s = session(40, 40);
|
||||
let patch = json!({
|
||||
"spacingEnabled": false,
|
||||
"shapeDynamics": {"tiltScale": 1.5, "brushProjection": true},
|
||||
"transfer": {"wetness": {"jitter": 0.3, "control": "penPressure"}, "mix": {"jitter": 0.6, "minimum": 0.2}},
|
||||
"locks": {"shapeDynamics": true, "texture": true, "noise": true},
|
||||
"mixer": {"wet": 0.8, "load": 0.25, "mix": 0.4, "flow": 0.9, "sampleAllLayers": true},
|
||||
});
|
||||
s.execute("tools.setBrush", json!({ "brush": patch })).unwrap();
|
||||
let b = &s.tools.brush;
|
||||
assert!(!b.spacing_enabled && b.shape_dynamics.brush_projection && b.shape_dynamics.tilt_scale == 1.5);
|
||||
assert_eq!(b.transfer.wetness.control, photocraft_paint::Control::PenPressure);
|
||||
assert_eq!((b.transfer.mix.jitter, b.transfer.mix.minimum), (0.6, 0.2));
|
||||
assert!(b.locks.shape_dynamics && b.locks.texture && b.locks.noise && !b.locks.scattering);
|
||||
assert_eq!((b.mixer.wet, b.mixer.load, b.mixer.mix, b.mixer.flow, b.mixer.sample_all_layers), (0.8, 0.25, 0.4, 0.9, true));
|
||||
// brush.get reports them, and a fresh session gets the same brush from the reported JSON.
|
||||
let got = s.execute("brush.get", json!({})).unwrap();
|
||||
for (path, want) in [
|
||||
("/spacingEnabled", json!(false)),
|
||||
("/shapeDynamics/tiltScale", json!(1.5)),
|
||||
("/shapeDynamics/brushProjection", json!(true)),
|
||||
("/transfer/wetness/control", json!("penPressure")),
|
||||
("/locks/texture", json!(true)),
|
||||
("/mixer/sampleAllLayers", json!(true)),
|
||||
("/mixer/load", json!(0.25)),
|
||||
] {
|
||||
assert_eq!(got.pointer(path), Some(&want), "{path}");
|
||||
}
|
||||
let mut t = session(40, 40);
|
||||
t.execute("tools.setBrush", json!({ "brush": got })).unwrap();
|
||||
assert_eq!(t.tools.brush, s.tools.brush);
|
||||
// Locked sections survive picking a preset through the command.
|
||||
s.execute("tools.setBrush", json!({"preset": "Chalk"})).unwrap();
|
||||
assert!(s.tools.brush.shape_dynamics.brush_projection && s.tools.brush.locks.texture);
|
||||
// Wrong types are errors and leave the brush alone.
|
||||
let before = s.tools.brush.clone();
|
||||
for bad in
|
||||
[json!({"spacingEnabled": 3}), json!({"locks": {"texture": "yes"}}), json!({"mixer": {"wet": "wet"}}), json!({"shapeDynamics": {"tiltScale": []}})]
|
||||
{
|
||||
assert!(s.execute("tools.setBrush", json!({ "brush": bad })).is_err());
|
||||
}
|
||||
assert_eq!(s.tools.brush, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixer_brush_uses_the_brush_mixer_settings() {
|
||||
let mut s = session(200, 30);
|
||||
s.execute("tools.setColors", json!({"foreground": "#ff0000"})).unwrap();
|
||||
// No wet/load params: the brush's Mixer settings (a dry brush with 0 % Load) apply.
|
||||
s.execute("tools.setBrush", json!({"brush": {"pressureSize": false, "spacing": 0.25, "mixer": {"wet": 0, "load": 0, "mix": 0}}})).unwrap();
|
||||
s.execute("paint.mixerBrush", json!({"points": [[5, 15], [195, 15]], "size": 8})).unwrap();
|
||||
assert!(rgba(&s, 10, 15)[3] > 0.95 && rgba(&s, 190, 15)[3] < 0.5, "the brush's 0 % Load dries out");
|
||||
// A param still overrides the brush.
|
||||
s.execute("paint.mixerBrush", json!({"points": [[5, 5], [195, 5]], "size": 8, "load": 100})).unwrap();
|
||||
assert!(rgba(&s, 190, 5)[3] > 0.95);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesced_set_brush_calls_journal_once_per_gesture() {
|
||||
let mut s = session(20, 20);
|
||||
let n = s.journal.len();
|
||||
for k in 1..=10 {
|
||||
s.execute("tools.setBrush", json!({"brush": {"opacity": k as f32 / 20.0}, "coalesce": "bar:1"})).unwrap();
|
||||
s.execute("brush.get", json!({})).unwrap();
|
||||
}
|
||||
s.execute("tools.setBrush", json!({"brush": {"shapeDynamics": {"tiltScale": 0.5}}, "coalesce": "bar:1"})).unwrap();
|
||||
assert_eq!(s.journal.len(), n + 1, "one gesture, one entry");
|
||||
let (id, p) = s.journal.last().unwrap();
|
||||
assert_eq!(id, "tools.setBrush");
|
||||
assert_eq!(p["brush"], json!({"opacity": 0.5, "shapeDynamics": {"tiltScale": 0.5}}));
|
||||
// A new gesture is a new entry; so is an uncoalesced call.
|
||||
s.execute("tools.setBrush", json!({"brush": {"flow": 0.5}, "coalesce": "bar:2"})).unwrap();
|
||||
s.execute("tools.setBrush", json!({"brush": {"flow": 0.4}, "coalesce": "bar:2"})).unwrap();
|
||||
s.execute("tools.setBrush", json!({"brush": {"size": 33}})).unwrap();
|
||||
assert_eq!(s.journal.len(), n + 3);
|
||||
// Another command in between ends the gesture.
|
||||
s.execute("tools.setBrush", json!({"brush": {"size": 34}, "coalesce": "bar:3"})).unwrap();
|
||||
s.execute("paint.stroke", json!({"points": [[2, 2], [10, 10]]})).unwrap();
|
||||
s.execute("tools.setBrush", json!({"brush": {"size": 35}, "coalesce": "bar:3"})).unwrap();
|
||||
assert_eq!(s.journal.len(), n + 6);
|
||||
// Replay gives the same brush.
|
||||
let mut t = session(20, 20);
|
||||
for (id, p) in s.journal.iter().skip(1) {
|
||||
t.execute(id, p.clone()).unwrap();
|
||||
}
|
||||
assert_eq!(t.tools.brush, s.tools.brush);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,336 @@
|
|||
//! Organising brush presets (the Brushes panel's drag and drop and context menus): rename, move a
|
||||
//! preset within or between groups, move, rename and delete whole groups.
|
||||
//!
|
||||
//! The library order is the panel order: groups appear in order of their first preset. Every
|
||||
//! change syncs to the preset store ([`crate::preset_store`]), whose index keeps the group order
|
||||
//! and the order of every preset, built-ins included. A built-in that is renamed or moved to
|
||||
//! another group becomes the user's preset (built-ins are regenerated with their own group).
|
||||
|
||||
use photocraft_paint::BrushPreset;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||||
}
|
||||
fn always(_: &Session) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Longest preset or group name accepted.
|
||||
pub const MAX_NAME: usize = 255;
|
||||
|
||||
fn text(p: &Value, k: &str, cmd: &str) -> Result<String> {
|
||||
let v = p.get(k).and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, format!("missing `{k}`")))?;
|
||||
if v.chars().count() > MAX_NAME {
|
||||
return Err(bad(cmd, format!("`{k}` is longer than {MAX_NAME} characters")));
|
||||
}
|
||||
Ok(v.to_string())
|
||||
}
|
||||
|
||||
/// A group name param: may be empty (the ungrouped presets), never absurdly long.
|
||||
fn group_param(p: &Value, k: &str, cmd: &str) -> Result<Option<String>> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(g)) if g.chars().count() <= MAX_NAME => Ok(Some(g.trim().to_string())),
|
||||
Some(_) => Err(bad(cmd, format!("`{k}` must be a group name (string, at most {MAX_NAME} characters)"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn index_of(presets: &[BrushPreset], name: &str) -> Option<usize> {
|
||||
presets.iter().position(|x| x.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
fn find(s: &Session, p: &Value, k: &str, cmd: &str) -> Result<usize> {
|
||||
let name = text(p, k, cmd)?;
|
||||
index_of(&s.tools.presets, &name).ok_or_else(|| bad(cmd, format!("no brush preset named `{name}`")))
|
||||
}
|
||||
|
||||
/// Groups in panel order (first appearance).
|
||||
pub fn group_order(presets: &[BrushPreset]) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for p in presets {
|
||||
if !out.contains(&p.group) {
|
||||
out.push(p.group.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn position_in_group(presets: &[BrushPreset], i: usize) -> usize {
|
||||
presets.get(i).map_or(0, |pr| presets[..i].iter().filter(|x| x.group == pr.group).count())
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.presets.rename";
|
||||
let i = find(s, p, "name", cmd)?;
|
||||
let new = text(p, "newName", cmd)?;
|
||||
if index_of(&s.tools.presets, &new).is_some_and(|j| j != i) {
|
||||
return Err(bad(cmd, format!("a brush preset named `{new}` already exists")));
|
||||
}
|
||||
let pr = s.tools.presets.get_mut(i).ok_or_else(|| bad(cmd, "no such preset"))?;
|
||||
if pr.name != new {
|
||||
pr.name = new.clone();
|
||||
pr.builtin = false;
|
||||
s.brush_presets_changed();
|
||||
}
|
||||
Ok(json!({ "name": new }))
|
||||
}
|
||||
|
||||
fn move_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.presets.move";
|
||||
let i = find(s, p, "name", cmd)?;
|
||||
let before = match p.get("before") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => {
|
||||
let b = find(s, p, "before", cmd)?;
|
||||
if b == i {
|
||||
return Err(bad(cmd, "`before` names the preset being moved"));
|
||||
}
|
||||
Some(b)
|
||||
}
|
||||
};
|
||||
let index = match p.get("index") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_u64().ok_or_else(|| bad(cmd, "`index` must be a non-negative integer"))?),
|
||||
};
|
||||
let lib = &mut s.tools.presets;
|
||||
let target = match (group_param(p, "group", cmd)?, before) {
|
||||
(Some(g), _) => g,
|
||||
(None, Some(b)) => lib[b].group.clone(),
|
||||
(None, None) => lib[i].group.clone(),
|
||||
};
|
||||
if let Some(b) = before
|
||||
&& lib[b].group != target
|
||||
{
|
||||
return Err(bad(cmd, format!("`before` is not in group `{target}`")));
|
||||
}
|
||||
let before_name = before.map(|b| lib[b].name.clone());
|
||||
let mut pr = lib.remove(i);
|
||||
if pr.group != target {
|
||||
pr.group = target.clone();
|
||||
// A built-in moved elsewhere is the user's now (built-ins regenerate in their own group).
|
||||
pr.builtin = false;
|
||||
}
|
||||
let members: Vec<usize> = lib.iter().enumerate().filter(|(_, x)| x.group == target).map(|(k, _)| k).collect();
|
||||
let pos = match (before_name, index) {
|
||||
(Some(n), _) => index_of(lib, &n).unwrap_or(lib.len()),
|
||||
(None, Some(n)) => match members.get(n.min(usize::MAX as u64) as usize) {
|
||||
Some(&k) => k,
|
||||
None => members.last().map_or(lib.len(), |k| k + 1),
|
||||
},
|
||||
(None, None) => members.last().map_or(lib.len(), |k| k + 1),
|
||||
};
|
||||
let pos = pos.min(lib.len());
|
||||
let name = pr.name.clone();
|
||||
lib.insert(pos, pr);
|
||||
let at = position_in_group(lib, pos);
|
||||
s.brush_presets_changed();
|
||||
Ok(json!({ "name": name, "group": target, "index": at }))
|
||||
}
|
||||
|
||||
fn group_block(s: &Session, g: &str, cmd: &str) -> Result<()> {
|
||||
if s.tools.presets.iter().any(|x| x.group == g) { Ok(()) } else { Err(bad(cmd, format!("no brush preset group named `{g}`"))) }
|
||||
}
|
||||
|
||||
fn move_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.presets.moveGroup";
|
||||
let g = group_param(p, "group", cmd)?.ok_or_else(|| bad(cmd, "missing `group`"))?;
|
||||
group_block(s, &g, cmd)?;
|
||||
let before = group_param(p, "before", cmd)?;
|
||||
if let Some(b) = &before {
|
||||
group_block(s, b, cmd)?;
|
||||
if *b == g {
|
||||
return Err(bad(cmd, "`before` names the group being moved"));
|
||||
}
|
||||
}
|
||||
let index = match p.get("index") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_u64().ok_or_else(|| bad(cmd, "`index` must be a non-negative integer"))?),
|
||||
};
|
||||
let lib = std::mem::take(&mut s.tools.presets);
|
||||
let (block, mut rest): (Vec<BrushPreset>, Vec<BrushPreset>) = lib.into_iter().partition(|x| x.group == g);
|
||||
let order = group_order(&rest);
|
||||
let first = |name: &str, rest: &[BrushPreset]| rest.iter().position(|x| x.group == name).unwrap_or(rest.len());
|
||||
let pos = match (&before, index) {
|
||||
(Some(b), _) => first(b, &rest),
|
||||
(None, Some(n)) => order.get(n.min(usize::MAX as u64) as usize).map_or(rest.len(), |name| first(name, &rest)),
|
||||
(None, None) => rest.len(),
|
||||
};
|
||||
let tail = rest.split_off(pos.min(rest.len()));
|
||||
rest.extend(block);
|
||||
rest.extend(tail);
|
||||
s.tools.presets = rest;
|
||||
s.brush_presets_changed();
|
||||
let at = group_order(&s.tools.presets).iter().position(|x| *x == g).unwrap_or(0);
|
||||
Ok(json!({ "group": g, "index": at }))
|
||||
}
|
||||
|
||||
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.presets.renameGroup";
|
||||
let g = group_param(p, "group", cmd)?.ok_or_else(|| bad(cmd, "missing `group`"))?;
|
||||
group_block(s, &g, cmd)?;
|
||||
let new = text(p, "newName", cmd)?;
|
||||
if new != g && s.tools.presets.iter().any(|x| x.group == new) {
|
||||
return Err(bad(cmd, format!("a group named `{new}` already exists")));
|
||||
}
|
||||
if new != g {
|
||||
for x in s.tools.presets.iter_mut().filter(|x| x.group == g) {
|
||||
x.group = new.clone();
|
||||
x.builtin = false;
|
||||
}
|
||||
s.brush_presets_changed();
|
||||
}
|
||||
Ok(json!({ "group": new }))
|
||||
}
|
||||
|
||||
fn delete_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.presets.deleteGroup";
|
||||
let g = group_param(p, "group", cmd)?.ok_or_else(|| bad(cmd, "missing `group`"))?;
|
||||
group_block(s, &g, cmd)?;
|
||||
let before = s.tools.presets.len();
|
||||
s.tools.presets.retain(|x| x.group != g);
|
||||
s.brush_presets_changed();
|
||||
Ok(json!({ "deleted": before - s.tools.presets.len(), "count": s.tools.presets.len() }))
|
||||
}
|
||||
|
||||
macro_rules! spec {
|
||||
($id:literal, $label:literal, $params:literal, $run:expr) => {
|
||||
CommandSpec { id: $id, label: $label, menu: &[], shortcut: None, params: $params, enabled: always, run: $run, journal: true }
|
||||
};
|
||||
}
|
||||
|
||||
/// Brush preset organisation specs.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("brush.presets.rename", "Rename Brush", r##"{"name":string,"newName":string} → {name}"##, rename),
|
||||
spec!(
|
||||
"brush.presets.move",
|
||||
"Move Brush",
|
||||
r##"{"name":string,"group":string?=its group ("" = ungrouped),"before":name? (preset to land before) | "index":n? (position in the group)=end} → {name, group, index}"##,
|
||||
move_preset
|
||||
),
|
||||
spec!(
|
||||
"brush.presets.moveGroup",
|
||||
"Move Brush Group",
|
||||
r##"{"group":string,"before":group? | "index":n? (group position)=end} → {group, index}"##,
|
||||
move_group
|
||||
),
|
||||
spec!("brush.presets.renameGroup", "Rename Brush Group", r##"{"group":string,"newName":string} → {group}"##, rename_group),
|
||||
spec!("brush.presets.deleteGroup", "Delete Brush Group", r##"{"group":string} → {deleted, count}"##, delete_group),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn names(s: &Session, g: &str) -> Vec<String> {
|
||||
s.tools.presets.iter().filter(|x| x.group == g).map(|x| x.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn with_groups() -> Session {
|
||||
let mut s = Session::new();
|
||||
for (n, g) in [("A1", "Alpha"), ("A2", "Alpha"), ("A3", "Alpha"), ("B1", "Beta"), ("B2", "Beta")] {
|
||||
s.tools.presets.push(BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: g.into() });
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_within_and_between_groups() {
|
||||
let mut s = with_groups();
|
||||
let r = s.execute("brush.presets.move", json!({"name": "A3", "before": "A1"})).unwrap();
|
||||
assert_eq!(r, json!({"name": "A3", "group": "Alpha", "index": 0}));
|
||||
assert_eq!(names(&s, "Alpha"), ["A3", "A1", "A2"]);
|
||||
s.execute("brush.presets.move", json!({"name": "A3", "index": 99})).unwrap();
|
||||
assert_eq!(names(&s, "Alpha"), ["A1", "A2", "A3"]);
|
||||
s.execute("brush.presets.move", json!({"name": "A1", "index": 1})).unwrap();
|
||||
assert_eq!(names(&s, "Alpha"), ["A2", "A1", "A3"]);
|
||||
// Into another group, before a member (the group comes from `before`).
|
||||
s.execute("brush.presets.move", json!({"name": "A1", "before": "B2"})).unwrap();
|
||||
assert_eq!((names(&s, "Alpha"), names(&s, "Beta")), (vec!["A2".to_string(), "A3".into()], vec!["B1".to_string(), "A1".into(), "B2".into()]));
|
||||
// Into a group by name (appended), and into a new group.
|
||||
s.execute("brush.presets.move", json!({"name": "A2", "group": "Beta"})).unwrap();
|
||||
assert_eq!(names(&s, "Beta"), ["B1", "A1", "B2", "A2"]);
|
||||
s.execute("brush.presets.move", json!({"name": "A3", "group": "Gamma"})).unwrap();
|
||||
assert_eq!(group_order(&s.tools.presets).last().map(String::as_str), Some("Gamma"));
|
||||
assert!(names(&s, "Alpha").is_empty());
|
||||
// A built-in moved to another group becomes the user's.
|
||||
let b = s.tools.presets.iter().find(|x| x.builtin).unwrap().name.clone();
|
||||
s.execute("brush.presets.move", json!({"name": b, "group": "Beta", "index": 0})).unwrap();
|
||||
let moved = photocraft_paint::presets::find(&s.tools.presets, &b).unwrap();
|
||||
assert!(!moved.builtin && moved.group == "Beta");
|
||||
assert_eq!(names(&s, "Beta")[0], b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_move_rename_and_delete() {
|
||||
let mut s = with_groups();
|
||||
let order = group_order(&s.tools.presets);
|
||||
let n = order.len();
|
||||
assert_eq!(&order[n - 2..], ["Alpha", "Beta"]);
|
||||
let r = s.execute("brush.presets.moveGroup", json!({"group": "Beta", "index": 0})).unwrap();
|
||||
assert_eq!(r["index"], 0);
|
||||
assert_eq!(group_order(&s.tools.presets)[0], "Beta");
|
||||
assert_eq!(names(&s, "Beta"), ["B1", "B2"], "a group moves as a block, in order");
|
||||
s.execute("brush.presets.moveGroup", json!({"group": "Alpha", "before": "Beta"})).unwrap();
|
||||
assert_eq!(&group_order(&s.tools.presets)[..2], ["Alpha", "Beta"]);
|
||||
s.execute("brush.presets.moveGroup", json!({"group": "Alpha"})).unwrap();
|
||||
assert_eq!(group_order(&s.tools.presets).last().map(String::as_str), Some("Alpha"));
|
||||
s.execute("brush.presets.renameGroup", json!({"group": "Alpha", "newName": "First"})).unwrap();
|
||||
assert_eq!(names(&s, "First"), ["A1", "A2", "A3"]);
|
||||
assert!(s.execute("brush.presets.renameGroup", json!({"group": "First", "newName": "Beta"})).is_err());
|
||||
let r = s.execute("brush.presets.deleteGroup", json!({"group": "First"})).unwrap();
|
||||
assert_eq!(r["deleted"], 3);
|
||||
assert!(names(&s, "First").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_checks_names() {
|
||||
let mut s = with_groups();
|
||||
s.execute("brush.presets.rename", json!({"name": "a1", "newName": "Alpha One"})).unwrap();
|
||||
assert!(photocraft_paint::presets::find(&s.tools.presets, "Alpha One").is_some());
|
||||
assert!(s.execute("brush.presets.rename", json!({"name": "A2", "newName": "alpha one"})).is_err());
|
||||
// Case-only renames of itself are fine.
|
||||
s.execute("brush.presets.rename", json!({"name": "Alpha One", "newName": "ALPHA ONE"})).unwrap();
|
||||
let b = s.tools.presets.iter().find(|x| x.builtin).unwrap().name.clone();
|
||||
s.execute("brush.presets.rename", json!({"name": b, "newName": "My Round"})).unwrap();
|
||||
assert!(!photocraft_paint::presets::find(&s.tools.presets, "My Round").unwrap().builtin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_fail_without_changes() {
|
||||
let mut s = with_groups();
|
||||
let before = s.tools.presets.clone();
|
||||
let long = "x".repeat(MAX_NAME + 1);
|
||||
for (id, p) in [
|
||||
("brush.presets.move", json!({})),
|
||||
("brush.presets.move", json!({"name": "Nope"})),
|
||||
("brush.presets.move", json!({"name": "A1", "before": "A1"})),
|
||||
("brush.presets.move", json!({"name": "A1", "before": "Nope"})),
|
||||
("brush.presets.move", json!({"name": "A1", "group": "Alpha", "before": "B1"})),
|
||||
("brush.presets.move", json!({"name": "A1", "index": -1})),
|
||||
("brush.presets.move", json!({"name": "A1", "index": "first"})),
|
||||
("brush.presets.move", json!({"name": "A1", "group": 7})),
|
||||
("brush.presets.move", json!({"name": "A1", "group": long})),
|
||||
("brush.presets.rename", json!({"name": "A1"})),
|
||||
("brush.presets.rename", json!({"name": "A1", "newName": " "})),
|
||||
("brush.presets.rename", json!({"name": "A1", "newName": long})),
|
||||
("brush.presets.moveGroup", json!({"group": "Nope"})),
|
||||
("brush.presets.moveGroup", json!({"group": "Alpha", "before": "Alpha"})),
|
||||
("brush.presets.moveGroup", json!({"group": "Alpha", "index": 1.5})),
|
||||
("brush.presets.renameGroup", json!({"group": "Alpha"})),
|
||||
("brush.presets.deleteGroup", json!({"group": null})),
|
||||
("brush.presets.deleteGroup", json!({"group": "Nope"})),
|
||||
] {
|
||||
assert!(s.execute(id, p.clone()).is_err(), "{id} {p}");
|
||||
}
|
||||
assert_eq!(s.tools.presets, before);
|
||||
// Huge indices clamp to the end.
|
||||
s.execute("brush.presets.move", json!({"name": "A1", "index": u64::MAX})).unwrap();
|
||||
s.execute("brush.presets.moveGroup", json!({"group": "Alpha", "index": u64::MAX})).unwrap();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue