mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-16 20:26:53 -04:00
Compare commits
129
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4717de1398 | ||
|
|
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 | ||
|
|
08512df255 | ||
|
|
ab0fa25897 | ||
|
|
60c5df70b5 | ||
|
|
3400412616 | ||
|
|
def1402401 | ||
|
|
bfcbdc0ee8 | ||
|
|
2c7b86bc02 | ||
|
|
7c53a3c1ad | ||
|
|
a0a90c3c15 | ||
|
|
f4cf980a37 | ||
|
|
fb79564f2a | ||
|
|
a65b5ca80f | ||
|
|
7100b0d617 | ||
|
|
4af95fa807 | ||
|
|
d3a6855367 | ||
|
|
5ede5f7ba6 | ||
|
|
ad86321738 | ||
|
|
7e7864afae | ||
|
|
8b0ceea374 | ||
|
|
d8d648cb72 | ||
|
|
2149f7f646 | ||
|
|
ff53be714d | ||
|
|
f5f0837aab | ||
|
|
4f8da9365a | ||
|
|
d3b540234c | ||
|
|
5716c2f6f1 | ||
|
|
d881785fde | ||
|
|
abf785f7b1 | ||
|
|
37c8f81032 | ||
|
|
7a5c1930b3 | ||
|
|
d5eb44e9fd | ||
|
|
5bd3dac660 | ||
|
|
297f7f8521 | ||
|
|
b17c239db8 |
No files matched your search
@@ -52,14 +52,43 @@ jobs:
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Test
|
||||
run: cargo test --workspace
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
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,60 @@
|
||||
# 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
|
||||
# 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
|
||||
@@ -201,6 +201,88 @@ 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: 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
|
||||
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
|
||||
@@ -236,7 +318,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:
|
||||
|
||||
@@ -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, 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/`.
|
||||
|
||||
## 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,10 +23,11 @@ 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
|
||||
io L4 document <-> PSD / flat formats
|
||||
io plugins L4 document <-> PSD / flat formats; sandboxed WebAssembly plug-ins
|
||||
engine L5 Session + command registry (every action is a command)
|
||||
ui-egui automation L6 egui shell (thin: all actions go through the engine); MCP server
|
||||
testkit test helpers
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
+15
-4
@@ -12,9 +12,10 @@ 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` (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) |
|
||||
| `assets/dict/en_US-scowl-50.txt.gz` | English word list for Check Spelling (SCOWL 2020.12.07, size 50, en_US; 72,403 words) | Kevin Atkinson and the SCOWL contributors | <http://wordlist.aspell.net/> | SCOWL license (permissive, keep the notice), [`assets/dict/LICENSE-SCOWL.txt`](assets/dict/LICENSE-SCOWL.txt), which also has the build recipe |
|
||||
| `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft Coated CMYK (synthetic) ICC profile | PhotoCraft contributors (generated by `photocraft-cms`, `src/synth.rs`) | Original work, see [`crates/cms/README.md`](crates/cms/README.md) | CC0-1.0 |
|
||||
| `assets/app-icon/` (all files) | PhotoCraft app icon (nine-tailed kitsune) | The project owner (drawn in ArtCraft, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) |
|
||||
@@ -47,6 +48,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.
|
||||
Generated
+393
-28
@@ -167,6 +167,12 @@ version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "ambient-authority"
|
||||
version = "0.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b"
|
||||
|
||||
[[package]]
|
||||
name = "android-activity"
|
||||
version = "0.6.1"
|
||||
@@ -242,6 +248,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
@@ -717,6 +724,36 @@ dependencies = [
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cap-primitives"
|
||||
version = "4.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18"
|
||||
dependencies = [
|
||||
"ambient-authority",
|
||||
"fs-set-times",
|
||||
"io-extras",
|
||||
"io-lifetimes 3.0.1",
|
||||
"ipnet",
|
||||
"maybe-owned",
|
||||
"rustix 1.1.5",
|
||||
"rustix-linux-procfs",
|
||||
"windows-sys 0.61.2",
|
||||
"winx",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cap-std"
|
||||
version = "4.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1ec78e242cfa2cfe276807ac2ecc00315a6c97786977414bcd1c3963b6c91b8"
|
||||
dependencies = [
|
||||
"cap-primitives",
|
||||
"io-extras",
|
||||
"io-lifetimes 3.0.1",
|
||||
"rustix 1.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
@@ -1520,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"
|
||||
@@ -1537,12 +1580,29 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
|
||||
|
||||
[[package]]
|
||||
name = "fmv-macos-events"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
|
||||
dependencies = [
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -1599,6 +1659,17 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
||||
|
||||
[[package]]
|
||||
name = "fs-set-times"
|
||||
version = "0.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a"
|
||||
dependencies = [
|
||||
"io-lifetimes 2.0.4",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.34"
|
||||
@@ -1770,7 +1841,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
"hashbrown 0.17.1",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -1904,6 +1975,15 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
@@ -1912,7 +1992,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1921,7 +2001,7 @@ version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2160,6 +2240,34 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-extras"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f"
|
||||
dependencies = [
|
||||
"io-lifetimes 3.0.1",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-lifetimes"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983"
|
||||
|
||||
[[package]]
|
||||
name = "io-lifetimes"
|
||||
version = "3.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96"
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
@@ -2364,6 +2472,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "lebe"
|
||||
version = "0.5.3"
|
||||
@@ -2468,6 +2582,12 @@ dependencies = [
|
||||
"imgref",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maybe-owned"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4"
|
||||
|
||||
[[package]]
|
||||
name = "maybe-rayon"
|
||||
version = "0.1.1"
|
||||
@@ -2501,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"
|
||||
@@ -3091,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"
|
||||
@@ -3190,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"
|
||||
@@ -3235,11 +3386,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fmv-macos-events",
|
||||
"log",
|
||||
"open",
|
||||
"photocraft-automation",
|
||||
@@ -3252,6 +3404,7 @@ dependencies = [
|
||||
"photocraft-geom",
|
||||
"photocraft-io",
|
||||
"photocraft-raster",
|
||||
"photocraft-tablet",
|
||||
"photocraft-ui-egui",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
@@ -3260,7 +3413,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-algo"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3272,9 +3425,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-automation"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"cap-std",
|
||||
"getrandom 0.3.4",
|
||||
"photocraft-codecs",
|
||||
"photocraft-compose",
|
||||
@@ -3293,7 +3447,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cli"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-automation",
|
||||
"photocraft-codecs",
|
||||
@@ -3306,7 +3460,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cms"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"moxcms",
|
||||
"rayon",
|
||||
@@ -3314,7 +3468,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-codecs"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"exr",
|
||||
"flate2",
|
||||
@@ -3332,7 +3486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-color"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"serde",
|
||||
@@ -3340,7 +3494,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-compose"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"photocraft-color",
|
||||
@@ -3355,7 +3509,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-doc"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3366,7 +3520,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-engine"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"flate2",
|
||||
@@ -3381,18 +3535,22 @@ dependencies = [
|
||||
"photocraft-io",
|
||||
"photocraft-ops",
|
||||
"photocraft-paint",
|
||||
"photocraft-plugins",
|
||||
"photocraft-psd",
|
||||
"photocraft-raster",
|
||||
"photocraft-raw",
|
||||
"photocraft-text",
|
||||
"photocraft-vector",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"wat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-format"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"flate2",
|
||||
@@ -3410,14 +3568,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-geom"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-gpu"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"half",
|
||||
"log",
|
||||
@@ -3434,8 +3592,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-io"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"photocraft-algo",
|
||||
"photocraft-cms",
|
||||
"photocraft-codecs",
|
||||
"photocraft-color",
|
||||
@@ -3443,24 +3603,27 @@ dependencies = [
|
||||
"photocraft-doc",
|
||||
"photocraft-format",
|
||||
"photocraft-geom",
|
||||
"photocraft-paint",
|
||||
"photocraft-psd",
|
||||
"photocraft-raster",
|
||||
"photocraft-raw",
|
||||
"photocraft-text",
|
||||
"photocraft-vector",
|
||||
"proptest",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ops"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-doc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-paint"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3469,9 +3632,24 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-plugins"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
"photocraft-raster",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"wasmi",
|
||||
"wat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-psd"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"photocraft-psd",
|
||||
@@ -3482,7 +3660,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raster"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3491,17 +3669,40 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-testkit"
|
||||
version = "0.1.1"
|
||||
name = "photocraft-raw"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-raw",
|
||||
"png 0.18.1",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-tablet"
|
||||
version = "0.2.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.2.0"
|
||||
dependencies = [
|
||||
"memory-stats",
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
"photocraft-raster",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-text"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"parley",
|
||||
@@ -3515,8 +3716,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ui-egui"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
@@ -3532,8 +3734,11 @@ dependencies = [
|
||||
"photocraft-geom",
|
||||
"photocraft-gpu",
|
||||
"photocraft-io",
|
||||
"photocraft-plugins",
|
||||
"photocraft-raster",
|
||||
"photocraft-testkit",
|
||||
"photocraft-text",
|
||||
"photocraft-vector",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3541,7 +3746,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-vector"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-doc",
|
||||
@@ -3552,7 +3757,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4240,6 +4445,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix-linux-procfs"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -4575,6 +4790,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
|
||||
|
||||
[[package]]
|
||||
name = "spirv"
|
||||
version = "0.4.0+sdk-1.4.341.0"
|
||||
@@ -4605,6 +4826,16 @@ dependencies = [
|
||||
"float-cmp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string-interner"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -4911,6 +5142,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"
|
||||
@@ -5141,6 +5383,99 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.261.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser 0.261.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78693fcdd618e0fc34af59c6b8efa9ac5d58c68df940beff4bedddb6acfe7c27"
|
||||
dependencies = [
|
||||
"spin",
|
||||
"wasmi_collections",
|
||||
"wasmi_core",
|
||||
"wasmi_ir",
|
||||
"wasmparser 0.228.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi_collections"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a8be2aa467cf2d29e96ff759472c36eeb44a3c81c67fc9cb76c9a24c519c557"
|
||||
dependencies = [
|
||||
"string-interner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi_core"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69372d5fda3ea3d1e0aa6603c7888110e0187e88ea17cd8fc2e2df0a0e1f37fa"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi_ir"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f17b774caa13c618c7244f1ee51fe23c5e7b8538a471fa46d9949779758aed6"
|
||||
dependencies = [
|
||||
"wasmi_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.228.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.261.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wast"
|
||||
version = "261.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"leb128fmt",
|
||||
"memchr",
|
||||
"unicode-width",
|
||||
"wasm-encoder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wat"
|
||||
version = "1.261.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f"
|
||||
dependencies = [
|
||||
"wast",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.17"
|
||||
@@ -5839,12 +6174,40 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winx"
|
||||
version = "0.36.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
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"
|
||||
@@ -5922,9 +6285,11 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+34
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -19,6 +19,7 @@ photocraft-doc = { path = "crates/doc" }
|
||||
photocraft-ops = { path = "crates/ops" }
|
||||
photocraft-psd = { path = "crates/psd" }
|
||||
photocraft-codecs = { path = "crates/codecs" }
|
||||
photocraft-raw = { path = "crates/raw" }
|
||||
photocraft-compose = { path = "crates/compose" }
|
||||
photocraft-gpu = { path = "crates/gpu" }
|
||||
photocraft-paint = { path = "crates/paint" }
|
||||
@@ -28,9 +29,11 @@ photocraft-text = { path = "crates/text" }
|
||||
photocraft-vector = { path = "crates/vector" }
|
||||
photocraft-format = { path = "crates/format" }
|
||||
photocraft-automation = { path = "crates/automation" }
|
||||
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"
|
||||
@@ -66,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
|
||||
@@ -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.
|
||||
|
||||
@@ -257,10 +257,25 @@ cargo test --workspace # the test suite
|
||||
|
||||
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 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 +285,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
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use photocraft_automation::{Headless, PhotocraftMcp, files, security};
|
||||
use photocraft_automation::{AuthorizedWorkspace, Headless, PhotocraftMcp, files, security};
|
||||
use photocraft_io::ExportOptions;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -28,8 +28,10 @@ USAGE:
|
||||
photocraft-cli commands [--json] [--filter <text>]
|
||||
List the engine command registry.
|
||||
photocraft-cli mcp [--bridge <127.0.0.1:port>] [--control-token <64-hex> | --control-token-file <path>]
|
||||
[--automation-read-root <dir>] [--automation-write-root <dir>]
|
||||
Run the MCP server on stdio (headless engine, or bridge to a running `photocraft --control <port>`).
|
||||
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).
|
||||
@@ -54,6 +56,8 @@ const VALUE_FLAGS: &[&str] = &[
|
||||
"--port",
|
||||
"--control-token",
|
||||
"--control-token-file",
|
||||
"--automation-read-root",
|
||||
"--automation-write-root",
|
||||
];
|
||||
|
||||
fn parse(args: &[String]) -> Result<Args, String> {
|
||||
@@ -147,6 +151,11 @@ fn warn_all(err: &mut dyn Write, ws: &[String]) {
|
||||
}
|
||||
}
|
||||
|
||||
fn automation_workspace(args: &Args) -> Result<AuthorizedWorkspace, String> {
|
||||
AuthorizedWorkspace::new(args.get("--automation-read-root").map(Path::new), args.get("--automation-write-root").map(Path::new))
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
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());
|
||||
@@ -167,7 +176,7 @@ fn info(a: &Args, out: &mut dyn Write) -> R {
|
||||
let [file] = a.positional.as_slice() else {
|
||||
return Err("info needs <file>".into());
|
||||
};
|
||||
let mut h = Headless::new();
|
||||
let mut h = Headless::trusted_local();
|
||||
let opened = h.open(Path::new(file)).map_err(|e| e.to_string())?;
|
||||
let mut doc = h.inspect(None).map_err(|e| e.to_string())?;
|
||||
doc["warnings"] = opened["warnings"].clone();
|
||||
@@ -198,7 +207,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 mut h = Headless::new();
|
||||
let mut h = Headless::trusted_local();
|
||||
match (a.positional.as_slice(), a.get("--new")) {
|
||||
([file], None) => {
|
||||
let o = h.open(Path::new(file)).map_err(|e| e.to_string())?;
|
||||
@@ -267,7 +276,7 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
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 r = (|| -> Result<Vec<String>, String> {
|
||||
let mut h = Headless::new();
|
||||
let mut h = Headless::trusted_local();
|
||||
h.open(input).map_err(|e| e.to_string())?;
|
||||
for (id, p) in &actions {
|
||||
h.command_run(id, p.clone()).map_err(|e| format!("`{id}`: {e}"))?;
|
||||
@@ -337,7 +346,7 @@ fn commands(a: &Args, out: &mut dyn Write) -> R {
|
||||
|
||||
fn serve(a: &Args, err: &mut dyn Write) -> R {
|
||||
use std::sync::{Arc, Mutex};
|
||||
let h = Arc::new(Mutex::new(Headless::new()));
|
||||
let h = Arc::new(Mutex::new(Headless::with_workspace(automation_workspace(a)?)));
|
||||
match a.get("--port") {
|
||||
Some(port) => {
|
||||
let port: u16 = port.parse().map_err(|_| format!("bad --port `{port}`"))?;
|
||||
@@ -368,7 +377,7 @@ fn mcp(a: &Args) -> R {
|
||||
let token = security::client_token(supplied.as_deref(), token_file.as_deref()).map_err(|e| e.to_string())?;
|
||||
PhotocraftMcp::bridge(addr, &token).map_err(|e| e.to_string())?
|
||||
}
|
||||
None => PhotocraftMcp::headless(),
|
||||
None => PhotocraftMcp::headless_with_workspace(automation_workspace(a)?),
|
||||
};
|
||||
let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().map_err(|e| e.to_string())?;
|
||||
rt.block_on(server.serve_stdio()).map_err(|e| e.to_string())
|
||||
|
||||
@@ -29,4 +29,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent"] }
|
||||
@@ -4,7 +4,7 @@
|
||||
<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; }
|
||||
|
||||
@@ -11,8 +11,12 @@ use wasm_bindgen::JsCast as _;
|
||||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
const IMAGE_EXTS: &[&str] =
|
||||
&["psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm"];
|
||||
/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop
|
||||
/// brushes (.abr) and gradients (.grd), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm",
|
||||
"dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
|
||||
];
|
||||
const CANVAS_ID: &str = "photocraft_canvas";
|
||||
|
||||
pub fn start() {
|
||||
@@ -29,11 +33,13 @@ pub fn start() {
|
||||
let q = query();
|
||||
let force_cpu = q.contains("cpu");
|
||||
let mut options = eframe::WebOptions::default();
|
||||
photocraft_ui_egui::gpu_canvas::use_adapter_limits(&mut options.wgpu_options.wgpu_setup);
|
||||
if q.contains("webgl")
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
}
|
||||
let pen_target = canvas.clone();
|
||||
let result = eframe::WebRunner::new()
|
||||
.start(
|
||||
canvas,
|
||||
@@ -42,6 +48,7 @@ pub fn start() {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
|
||||
let inbox: Inbox = Arc::default();
|
||||
let mut app = PhotocraftApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
listen_pen(&pen_target, app.stylus.feed.clone());
|
||||
app.set_theme(&cc.egui_ctx, ThemeKind::Pro);
|
||||
if let Some(rs) = cc.wgpu_render_state.clone()
|
||||
&& !force_cpu
|
||||
@@ -62,6 +69,36 @@ pub fn start() {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
use photocraft_ui_egui::stylus::PenSample;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
for kind in ["pointerdown", "pointermove", "pointerup", "pointercancel", "pointerleave"] {
|
||||
let feed = feed.clone();
|
||||
let cb = Closure::<dyn FnMut(web_sys::PointerEvent)>::new(move |e: web_sys::PointerEvent| {
|
||||
let ty = e.type_();
|
||||
if ty == "pointerup" && e.pointer_type() == "pen" {
|
||||
return;
|
||||
}
|
||||
let pen = e.pointer_type() == "pen" && e.buttons() != 0 && ty != "pointercancel" && ty != "pointerleave";
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
@@ -101,19 +138,19 @@ impl eframe::App for WebShell {
|
||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
Services {
|
||||
import: Some(Box::new(|name: &str, bytes: &[u8]| photocraft_io::import(name, bytes).map(|r| r.document).map_err(|e| e.to_string()))),
|
||||
import: Some(Box::new(|name: &str, bytes: &[u8]| photocraft_io::import(name, bytes).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()))),
|
||||
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
|
||||
let mut opts = photocraft_io::ExportOptions::default();
|
||||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
photocraft_io::export(doc, path, &opts).map(|r| r.bytes).map_err(|e| e.to_string())
|
||||
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
|
||||
})),
|
||||
pick_open: Some(Box::new(move || {
|
||||
let inbox = open_inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(file) = rfd::AsyncFileDialog::new().add_filter("Images", IMAGE_EXTS).pick_file().await else {
|
||||
let Some(file) = rfd::AsyncFileDialog::new().add_filter("All Formats", OPEN_EXTS).pick_file().await else {
|
||||
return;
|
||||
};
|
||||
let bytes = file.read().await;
|
||||
|
||||
@@ -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,9 +31,15 @@ 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]
|
||||
# Finder double-click / Open With / Dock drops arrive as Apple events; this small audited crate
|
||||
# registers the handler (Objective-C interop needs unsafe, which our workspace forbids) behind a
|
||||
# safe API. Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for photocraft.exe; build.rs only uses it when targeting Windows.
|
||||
winresource = "0.1"
|
||||
@@ -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,45 @@
|
||||
//! macOS open-documents and quit Apple events.
|
||||
//!
|
||||
//! Finder double-clicks, Open With, drops on the Dock icon and `open -a PhotoCraft file.psd` don't
|
||||
//! pass paths on the command line: LaunchServices sends the running (or just-launched) app a
|
||||
//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the
|
||||
//! `NSApplicationDelegate`, and handling it ourselves needs Objective-C class declarations, i.e.
|
||||
//! `unsafe`, which this workspace forbids. The audited `fmv-macos-events` crate wraps exactly that
|
||||
//! (an `NSAppleEventManager` handler registered before Finder's launch event, leaving winit's
|
||||
//! delegate alone) behind a safe main-thread API; this module adapts it to [`OsEvent`]s that the UI
|
||||
//! polls every frame.
|
||||
|
||||
use fmv_macos_events::{Event, Inbox, Registration};
|
||||
use photocraft_ui_egui::OsEvent;
|
||||
|
||||
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
|
||||
pub struct AppleEvents {
|
||||
_registration: Registration,
|
||||
inbox: Inbox,
|
||||
}
|
||||
|
||||
impl AppleEvents {
|
||||
/// Register the handlers. Call on the main thread before the event loop starts, so the event
|
||||
/// that launched the app (Finder double-click) is caught too.
|
||||
pub fn install() -> Self {
|
||||
let (registration, inbox) = Registration::install();
|
||||
Self { _registration: registration, inbox }
|
||||
}
|
||||
|
||||
/// The queue the UI drains (see `Services::os_events`); events arriving later wake `ctx`.
|
||||
pub fn connect(&self, ctx: &egui::Context) -> photocraft_ui_egui::OsEventsFn {
|
||||
let ctx = ctx.clone();
|
||||
self.inbox.set_wake(move || ctx.request_repaint());
|
||||
let inbox = self.inbox.clone();
|
||||
Box::new(move || {
|
||||
inbox
|
||||
.drain()
|
||||
.into_iter()
|
||||
.map(|e| match e {
|
||||
Event::Open(paths) => OsEvent::Open(paths.into_iter().map(|p| p.to_string_lossy().into_owned()).collect()),
|
||||
Event::Quit => OsEvent::Quit,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use photocraft_automation::budgets::write_reply;
|
||||
use photocraft_automation::security::{
|
||||
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line,
|
||||
};
|
||||
@@ -62,7 +63,7 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
|
||||
"ok": false,
|
||||
"error": format!("request exceeds {MAX_REQUEST_BYTES} bytes"),
|
||||
});
|
||||
let _ = writeln!(out, "{reply}");
|
||||
let _ = write_reply(&mut out, &reply);
|
||||
let _ = out.flush();
|
||||
break;
|
||||
}
|
||||
@@ -72,7 +73,7 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
|
||||
if !authenticated {
|
||||
let (reply, ok) = authentication_reply(&line, token);
|
||||
authenticated = ok;
|
||||
if writeln!(out, "{reply}").is_err() || out.flush().is_err() || !authenticated {
|
||||
if write_reply(&mut out, &reply).is_err() || out.flush().is_err() || !authenticated {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
@@ -95,7 +96,7 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
if write_reply(&mut out, &reply).is_err() {
|
||||
break;
|
||||
}
|
||||
if out.flush().is_err() {
|
||||
@@ -103,3 +104,54 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::BufRead;
|
||||
|
||||
#[test]
|
||||
fn oversized_reply_preserves_framing_id_and_the_next_control_request() {
|
||||
use photocraft_automation::budgets::MAX_RESPONSE_BYTES;
|
||||
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
serve(stream, TOKEN, tx, egui::Context::default());
|
||||
});
|
||||
let handler = std::thread::spawn(move || {
|
||||
let first = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert_eq!(first.method, "test.large");
|
||||
first.reply.send(json!({"ok": true, "result": "x".repeat(MAX_RESPONSE_BYTES)})).unwrap();
|
||||
let second = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert_eq!(second.method, "test.small");
|
||||
second.reply.send(json!({"ok": true, "result": "still serving"})).unwrap();
|
||||
});
|
||||
let mut stream = TcpStream::connect(addr).unwrap();
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
writeln!(stream, "{}", json!({"id": 1, "method": "auth", "params": {"token": TOKEN}})).unwrap();
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["ok"], true);
|
||||
writeln!(stream, "{}", json!({"id": 2, "method": "test.large"})).unwrap();
|
||||
line.clear();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
let rejected: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(rejected["id"], 2);
|
||||
assert_eq!(rejected["ok"], false);
|
||||
assert!(rejected["error"].as_str().unwrap().contains("operation may have completed"));
|
||||
writeln!(stream, "{}", json!({"id": 3, "method": "test.small"})).unwrap();
|
||||
line.clear();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
let accepted: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(accepted["id"], 3);
|
||||
assert_eq!(accepted["result"], "still serving");
|
||||
drop(reader);
|
||||
drop(stream);
|
||||
handler.join().unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
//! 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);
|
||||
let (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);
|
||||
}
|
||||
}
|
||||
+154
-36
@@ -1,7 +1,12 @@
|
||||
//! Photocraft desktop app.
|
||||
//!
|
||||
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
|
||||
//! --control-token-file <path>] [files…]`
|
||||
//! --control-token-file <path>] [--automation-read-root <dir>]
|
||||
//! [--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.
|
||||
@@ -12,9 +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;
|
||||
@@ -22,28 +39,24 @@ 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()
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
crash_guard::install_hook();
|
||||
let mut control_port: Option<u16> = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut control_token = None;
|
||||
let mut control_token_file = None;
|
||||
let mut automation_read_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from);
|
||||
let mut automation_write_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from);
|
||||
let mut files = Vec::new();
|
||||
let mut safe_gpu = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--control-token" => control_token = args.next(),
|
||||
"--control-token-file" => control_token_file = args.next().map(std::path::PathBuf::from),
|
||||
"--automation-read-root" => automation_read_root = args.next().map(std::path::PathBuf::from),
|
||||
"--automation-write-root" => automation_write_root = args.next().map(std::path::PathBuf::from),
|
||||
"--safe-gpu" => safe_gpu = true,
|
||||
"--version" => {
|
||||
println!("photocraft {}", photocraft_engine::build_info::long_version());
|
||||
return Ok(());
|
||||
@@ -54,6 +67,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()) {
|
||||
@@ -70,14 +91,38 @@ fn main() -> eframe::Result {
|
||||
} else {
|
||||
eprintln!("photocraft: using supplied control token");
|
||||
}
|
||||
Some((port, token))
|
||||
let workspace = match photocraft_automation::AuthorizedWorkspace::new(automation_read_root.as_deref(), automation_write_root.as_deref()) {
|
||||
Ok(workspace) => workspace,
|
||||
Err(error) => {
|
||||
eprintln!("photocraft: cannot configure automation workspace: {error}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
Some((port, token, workspace))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Finder / Dock / Open With deliver files as Apple events, not arguments; catch the one that
|
||||
// launched us as well as later ones. Lives until the event loop returns.
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = apple_events::AppleEvents::install();
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = &apple_events;
|
||||
|
||||
let options = eframe::NativeOptions {
|
||||
// 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_icon(app_icon::window_icon())
|
||||
.with_app_id(APP_ID)
|
||||
.with_title("PhotoCraft")
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
@@ -88,37 +133,110 @@ fn main() -> eframe::Result {
|
||||
.with_title_shown(false),
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
// 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.
|
||||
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| {
|
||||
let mut app = PhotocraftApp::new(Session::new(), services::native());
|
||||
let automation = control.as_ref().map(|(_, _, workspace)| workspace.clone());
|
||||
let mut services = services::native(automation);
|
||||
services.preset_store = presets;
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
// 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);
|
||||
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));
|
||||
}
|
||||
if let Some((port, token)) = control {
|
||||
// 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);
|
||||
}
|
||||
for f in files {
|
||||
match std::fs::read(&f) {
|
||||
Ok(bytes) => {
|
||||
let name = std::path::Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(f.clone());
|
||||
if let Err(e) = app.open_bytes(&name, &bytes) {
|
||||
eprintln!("photocraft: {f}: {e}");
|
||||
} else if let Some(st) = app.session.active_mut() {
|
||||
st.path = Some(f.clone());
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("photocraft: {f}: {e}"),
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! The main display's ICC profile, for the colour-managed canvas (Edit › Color Settings ›
|
||||
//! Monitor Profile = `auto`).
|
||||
//!
|
||||
//! macOS: `NSScreen.mainScreen.colorSpace.ICCProfileData`, a documented AppKit API, read through
|
||||
//! `osascript` (AppleScriptObjC) so the app needs no `unsafe` FFI. The query runs on a background
|
||||
//! thread at launch (about 0.4 s) and the profile is applied when it arrives. Other platforms
|
||||
//! return `None` (sRGB, or the profile chosen in Color Settings).
|
||||
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
/// Starts reading the main display's profile in the background.
|
||||
pub fn detect_async() -> Receiver<Option<Vec<u8>>> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(detect());
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn detect() -> Option<Vec<u8>> {
|
||||
let out = std::process::Command::new("/usr/bin/osascript")
|
||||
.args([
|
||||
"-e",
|
||||
"use framework \"AppKit\"",
|
||||
"-e",
|
||||
"return ((current application's NSScreen's mainScreen()'s colorSpace()'s ICCProfileData()'s base64EncodedStringWithOptions:0) as text)",
|
||||
])
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let bytes = base64_decode(std::str::from_utf8(&out.stdout).ok()?.trim())?;
|
||||
// An ICC profile starts with its size and carries `acsp` at offset 36.
|
||||
(bytes.len() >= 132 && bytes.get(36..40) == Some(b"acsp")).then_some(bytes)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn detect() -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Standard base64 (RFC 4648, with padding) → bytes; `None` on any invalid character.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn base64_decode(s: &str) -> Option<Vec<u8>> {
|
||||
let val = |c: u8| -> Option<u32> {
|
||||
Some(match c {
|
||||
b'A'..=b'Z' => c - b'A',
|
||||
b'a'..=b'z' => c - b'a' + 26,
|
||||
b'0'..=b'9' => c - b'0' + 52,
|
||||
b'+' => 62,
|
||||
b'/' => 63,
|
||||
_ => return None,
|
||||
} as u32)
|
||||
};
|
||||
let s = s.trim_end_matches('=').as_bytes();
|
||||
let mut out = Vec::with_capacity(s.len() * 3 / 4);
|
||||
for chunk in s.chunks(4) {
|
||||
let mut acc = 0u32;
|
||||
for (i, c) in chunk.iter().enumerate() {
|
||||
acc |= val(*c)? << (18 - 6 * i);
|
||||
}
|
||||
let n = match chunk.len() {
|
||||
4 => 3,
|
||||
3 => 2,
|
||||
2 => 1,
|
||||
_ => return None,
|
||||
};
|
||||
out.extend_from_slice(&acc.to_be_bytes()[1..1 + n]);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base64() {
|
||||
assert_eq!(base64_decode("aGVsbG8=").as_deref(), Some(&b"hello"[..]));
|
||||
assert_eq!(base64_decode("aGVsbG8h").as_deref(), Some(&b"hello!"[..]));
|
||||
assert_eq!(base64_decode("aGk=").as_deref(), Some(&b"hi"[..]));
|
||||
assert_eq!(base64_decode("").as_deref(), Some(&b""[..]));
|
||||
assert!(base64_decode("a").is_none());
|
||||
assert!(base64_decode("a$==").is_none());
|
||||
}
|
||||
|
||||
/// The detected profile (when there is one) parses as an RGB profile.
|
||||
#[test]
|
||||
fn detected_profile_parses() {
|
||||
if let Some(bytes) = detect() {
|
||||
let p = photocraft_engine::color_cmds::profile_from_bytes(&std::sync::Arc::new(bytes)).expect("parses");
|
||||
assert_eq!(format!("{:?}", p.color_space), "Rgb", "{}", p.description);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,91 +12,137 @@ use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
const IMAGE_EXTS: &[&str] =
|
||||
&["psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm"];
|
||||
/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop
|
||||
/// brushes (.abr) and gradients (.grd), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm",
|
||||
"dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
|
||||
];
|
||||
|
||||
/// 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`).
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
if let Some(d) = std::env::var_os("PHOTOCRAFT_CONFIG_DIR") {
|
||||
return Some(PathBuf::from(d));
|
||||
/// 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"])];
|
||||
|
||||
/// [`SAVE_FILTERS`] with the one for `suggested`'s extension first.
|
||||
fn save_filters(suggested: &str) -> Vec<(&'static str, &'static [&'static str])> {
|
||||
let ext = Path::new(suggested).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
let mut v = SAVE_FILTERS.to_vec();
|
||||
if let Some(i) = v.iter().position(|(_, exts)| exts.contains(&ext.as_str())) {
|
||||
let f = v.remove(i);
|
||||
v.insert(0, f);
|
||||
}
|
||||
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"))
|
||||
v
|
||||
}
|
||||
|
||||
fn prefs_file() -> Option<PathBuf> {
|
||||
/// 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> {
|
||||
crate::app_dirs::config_dir()
|
||||
}
|
||||
|
||||
pub fn prefs_file() -> Option<PathBuf> {
|
||||
config_dir().map(|d| d.join("preferences.json"))
|
||||
}
|
||||
|
||||
/// The brush preset store (one file per preset group plus tip bitmaps; see
|
||||
/// `photocraft_engine::preset_store`).
|
||||
pub fn presets_dir() -> Option<PathBuf> {
|
||||
config_dir().map(|d| d.join("Presets"))
|
||||
}
|
||||
|
||||
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())?;
|
||||
}
|
||||
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())
|
||||
photocraft_format::atomic_write(path, bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn native() -> Services {
|
||||
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())?;
|
||||
let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path).to_string();
|
||||
Ok((name, bytes))
|
||||
}) as photocraft_ui_egui::AutomationReadFn
|
||||
});
|
||||
let automation_write = automation.clone().map(|workspace| {
|
||||
Box::new(move |path: &str, bytes: &[u8]| workspace.write(path, bytes).map_err(|error| error.to_string())) as photocraft_ui_egui::AutomationWriteFn
|
||||
});
|
||||
let automation_command = automation.map(|_| {
|
||||
Box::new(|id: &str, params: &serde_json::Value| {
|
||||
photocraft_automation::workspace::authorize_desktop_engine_command(id, params).map_err(|error| error.to_string())
|
||||
}) as photocraft_ui_egui::AutomationCommandFn
|
||||
});
|
||||
Services {
|
||||
import: Some(Box::new(|name: &str, bytes: &[u8]| {
|
||||
crate::crash_guard::guard("Open", || photocraft_io::import(name, bytes).map(|r| r.document).map_err(|e| e.to_string()))
|
||||
crate::crash_guard::guard("Open", || photocraft_io::import(name, bytes).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()))
|
||||
})),
|
||||
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
|
||||
let mut opts = photocraft_io::ExportOptions::default();
|
||||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
crate::crash_guard::guard("Export", || photocraft_io::export(doc, path, &opts).map(|r| r.bytes).map_err(|e| e.to_string()))
|
||||
crate::crash_guard::guard("Export", || photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()))
|
||||
})),
|
||||
pick_open: Some(Box::new(|| {
|
||||
let path = rfd::FileDialog::new().add_filter("Images", IMAGE_EXTS).pick_file()?;
|
||||
let path = rfd::FileDialog::new().add_filter("All Formats", OPEN_EXTS).add_filter("PhotoCraft", &["pcraft"]).pick_file()?;
|
||||
let bytes = std::fs::read(&path).ok()?;
|
||||
Some((path.to_string_lossy().to_string(), bytes))
|
||||
})),
|
||||
pick_save: Some(Box::new(|suggested: &str| {
|
||||
let p = std::path::Path::new(suggested);
|
||||
let mut d = rfd::FileDialog::new()
|
||||
.add_filter("Photoshop", &["psd", "psb"])
|
||||
.add_filter("PNG", &["png"])
|
||||
.add_filter("JPEG", &["jpg"])
|
||||
.add_filter("TIFF", &["tif"])
|
||||
.add_filter("OpenEXR", &["exr"]);
|
||||
let mut d = rfd::FileDialog::new();
|
||||
for (name, exts) in save_filters(suggested) {
|
||||
d = d.add_filter(name, exts);
|
||||
}
|
||||
if let Some(name) = p.file_name() {
|
||||
d = d.set_file_name(name.to_string_lossy());
|
||||
}
|
||||
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,
|
||||
encode_png: Some(Box::new(|w, h, rgba| {
|
||||
let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec()).map_err(|e| e.to_string())?;
|
||||
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string())
|
||||
})),
|
||||
inbox: None,
|
||||
open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))),
|
||||
clipboard_set_image: Some(Box::new(|w: u32, h: u32, px: &[u8]| {
|
||||
let mut cb = arboard::Clipboard::new().map_err(|e| e.to_string())?;
|
||||
cb.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(px) }).map_err(|e| e.to_string())
|
||||
})),
|
||||
clipboard_get_image: Some(Box::new(|| {
|
||||
let img = arboard::Clipboard::new().ok()?.get_image().ok()?;
|
||||
Some((img.width as u32, img.height as u32, img.bytes.into_owned()))
|
||||
})),
|
||||
clipboard_set_image: Some({
|
||||
let clip = clip.clone();
|
||||
Box::new(move |w: u32, h: u32, px: &[u8]| {
|
||||
let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?;
|
||||
let cb = match slot.as_mut() {
|
||||
Some(c) => c,
|
||||
None => slot.insert(arboard::Clipboard::new().map_err(|e| e.to_string())?),
|
||||
};
|
||||
cb.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(px) }).map_err(|e| e.to_string())
|
||||
})
|
||||
}),
|
||||
clipboard_get_image: Some({
|
||||
let clip = clip.clone();
|
||||
Box::new(move || {
|
||||
let mut slot = clip.try_borrow_mut().ok()?;
|
||||
let cb = match slot.as_mut() {
|
||||
Some(c) => c,
|
||||
None => slot.insert(arboard::Clipboard::new().ok()?),
|
||||
};
|
||||
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.
|
||||
@@ -129,6 +175,10 @@ pub fn native() -> Services {
|
||||
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path).map_err(|e| e.to_string())?;
|
||||
f.write_all(text.as_bytes()).map_err(|e| e.to_string())
|
||||
})),
|
||||
// Set by main once the Apple-event handlers are connected (macOS).
|
||||
os_events: None,
|
||||
// Set by main, which starts loading the store before the window opens.
|
||||
preset_store: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79).
|
||||
//!
|
||||
//! winit 0.30 drops tablet data on both, so `photocraft-tablet` reads it beside winit (an AppKit
|
||||
//! local event monitor; XInput2 raw events on a second X connection) and this module writes each
|
||||
//! sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
|
||||
//! Pointer Events and automation's simulated pen. Windows needs nothing here: winit forwards
|
||||
//! `WM_POINTER` pressure as touch force. Wayland has no tablet input yet (see the crate docs).
|
||||
|
||||
use photocraft_tablet::Sample;
|
||||
use photocraft_ui_egui::stylus::{PenSample, StylusFeed};
|
||||
|
||||
/// A tablet sample as the UI's pen sample.
|
||||
pub fn pen_sample(s: Sample) -> PenSample {
|
||||
PenSample { pressure: s.pressure, tilt_x: s.tilt_x, tilt_y: s.tilt_y, rotation: s.rotation, eraser: s.eraser }.sanitized()
|
||||
}
|
||||
|
||||
/// Writes tablet samples into `feed` (`None` = a mouse).
|
||||
pub fn sink(feed: StylusFeed) -> impl Fn(Option<Sample>) + Send + 'static {
|
||||
move |s| feed.set(s.map(pen_sample))
|
||||
}
|
||||
|
||||
/// Install the AppKit tablet monitor (main thread, before the event loop). Keep the result until
|
||||
/// the event loop returns. On failure this logs why: pens then paint like a mouse.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn install_macos(feed: &StylusFeed) -> Option<photocraft_tablet::macos::Monitor> {
|
||||
photocraft_tablet::macos::Monitor::install(sink(feed.clone())).map_err(|e| log::warn!("{e}")).ok()
|
||||
}
|
||||
|
||||
/// Start the XInput2 reader when eframe runs on X11. On Wayland, `$DISPLAY` is Xwayland, which
|
||||
/// sees none of this window's input (and a pen sample from another X app would outlive a mouse
|
||||
/// stroke here), so nothing starts.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn spawn_x11(feed: &StylusFeed, display: Option<DisplayKind>) {
|
||||
if display != Some(DisplayKind::X11) {
|
||||
log::info!("tablet pressure: not available on this display server ({display:?}); Wayland is a follow-up to #79");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = photocraft_tablet::x11::spawn(None, sink(feed.clone())) {
|
||||
log::warn!("{e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The windowing system eframe ended up on.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DisplayKind {
|
||||
X11,
|
||||
Wayland,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl DisplayKind {
|
||||
/// From the creation context's display handle.
|
||||
pub fn of(cc: &eframe::CreationContext<'_>) -> Option<Self> {
|
||||
use eframe::wgpu::rwh::{HasDisplayHandle, RawDisplayHandle};
|
||||
Some(match cc.display_handle().ok()?.as_raw() {
|
||||
RawDisplayHandle::Xlib(_) | RawDisplayHandle::Xcb(_) => DisplayKind::X11,
|
||||
RawDisplayHandle::Wayland(_) => DisplayKind::Wayland,
|
||||
_ => DisplayKind::Other,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_tablet::{Update, appkit, xi};
|
||||
use photocraft_ui_egui::PhotocraftApp;
|
||||
use photocraft_ui_egui::canvas::{ToolEvent, tool_event};
|
||||
use photocraft_ui_egui::state::Tool;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[test]
|
||||
fn samples_map_and_sanitize() {
|
||||
let p = pen_sample(Sample { pressure: 0.25, tilt_x: 30.0, tilt_y: -10.0, rotation: 90.0, eraser: true });
|
||||
assert_eq!(p, PenSample { pressure: 0.25, tilt_x: 30.0, tilt_y: -10.0, rotation: 90.0, eraser: true });
|
||||
let p = pen_sample(Sample { pressure: f32::NAN, tilt_x: 500.0, tilt_y: f32::NEG_INFINITY, rotation: -45.0, eraser: false });
|
||||
assert_eq!(p, PenSample { pressure: 1.0, tilt_x: 90.0, tilt_y: 0.0, rotation: 315.0, eraser: false });
|
||||
let feed = StylusFeed::default();
|
||||
let f = sink(feed.clone());
|
||||
f(Some(Sample { pressure: 0.5, ..Default::default() }));
|
||||
assert_eq!(feed.get().map(|p| p.pressure), Some(0.5));
|
||||
f(None);
|
||||
assert_eq!(feed.get(), None);
|
||||
}
|
||||
|
||||
/// The platform path end to end, minus the OS: raw AppKit / XInput2 values → the crate's
|
||||
/// mapping → this module's sink → the stylus feed → the canvas's tool events → `paint.stroke`.
|
||||
/// A light-to-heavy pen stroke must paint thin and faint at the light end and wide and opaque
|
||||
/// at the heavy end (Size and Opacity on Pen Pressure); a mouse paints full strength.
|
||||
fn pressure_stroke(mut next: impl FnMut(f32) -> Option<Sample>) -> (f64, f64, f64, f64) {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
let feed = app.stylus.feed.clone();
|
||||
let sink = sink(feed);
|
||||
app.session.execute("file.new", json!({"width": 240, "height": 80, "background": "white"})).unwrap();
|
||||
app.session.execute("tools.setColors", json!({"foreground": [0.0, 0.0, 0.0, 1.0]})).unwrap();
|
||||
let brush = json!({"size": 30, "hardness": 1.0, "spacing": 0.05, "pressureSize": true, "pressureOpacity": true, "smoothing": {"amount": 0}});
|
||||
app.session.execute("tools.setBrush", json!({ "brush": brush })).unwrap();
|
||||
app.ui.tool = Tool::Brush;
|
||||
let m = egui::Modifiers::NONE;
|
||||
let n = 20;
|
||||
for i in 0..=n {
|
||||
let t = i as f32 / n as f32;
|
||||
sink(next(0.1 + 0.9 * t));
|
||||
let (x, y) = (20.0 + 200.0 * f64::from(t), 40.0);
|
||||
let pressure = app.stylus.pressure();
|
||||
let ev = if i == 0 { ToolEvent::Down { x, y, pressure } } else { ToolEvent::Move { x, y, pressure } };
|
||||
tool_event(&mut app, ev, m);
|
||||
}
|
||||
tool_event(&mut app, ToolEvent::Up { x: 220.0, y: 40.0 }, m);
|
||||
sink(None);
|
||||
// Darkness (1 - luma) along x = 50 (light end) and x = 200 (heavy end), and the painted
|
||||
// half-width there.
|
||||
let mut dark = |x: i32, y: i32| -> f64 {
|
||||
let px: Value = app.session.execute("document.pixel", json!({"x": x, "y": y})).unwrap();
|
||||
1.0 - px[0].as_f64().unwrap()
|
||||
};
|
||||
let (light, heavy) = (dark(50, 40), dark(200, 40));
|
||||
let light_w = (0..40).take_while(|d| dark(50, 40 + d) > 0.02).count() as f64;
|
||||
let heavy_w = (0..40).take_while(|d| dark(200, 40 + d) > 0.02).count() as f64;
|
||||
(light, light_w, heavy, heavy_w)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appkit_tablet_pressure_drives_stroke_width_and_opacity() {
|
||||
let mut st = appkit::State::default();
|
||||
let (light_dark, light_w, heavy_dark, heavy_w) = pressure_stroke(|p| {
|
||||
let e =
|
||||
appkit::RawEvent { kind: appkit::event_type::LEFT_MOUSE_DRAGGED, subtype: appkit::subtype::TABLET_POINT, pressure: p, ..Default::default() };
|
||||
match st.handle(&e) {
|
||||
Update::Set(s) => s,
|
||||
Update::Keep => None,
|
||||
}
|
||||
});
|
||||
assert!(heavy_w > light_w * 2.0, "width responds: {light_w} → {heavy_w}");
|
||||
assert!(heavy_dark > light_dark + 0.3, "opacity responds: {light_dark} → {heavy_dark}");
|
||||
assert!(heavy_dark > 0.9, "full pressure paints (nearly) opaque: {heavy_dark}");
|
||||
|
||||
// A mouse (non-tablet subtype) through the same path: full size and opacity throughout.
|
||||
let mut st = appkit::State::default();
|
||||
let (m_dark, m_w, _, _) = pressure_stroke(|p| {
|
||||
let e = appkit::RawEvent { kind: appkit::event_type::LEFT_MOUSE_DRAGGED, subtype: appkit::subtype::MOUSE, pressure: p, ..Default::default() };
|
||||
match st.handle(&e) {
|
||||
Update::Set(s) => s,
|
||||
Update::Keep => None,
|
||||
}
|
||||
});
|
||||
assert!(m_dark > 0.9 && m_w >= heavy_w - 1.0, "mouse = pressure 1: {m_dark}, {m_w}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xinput_tablet_pressure_drives_stroke_width_and_opacity() {
|
||||
let mut st = xi::State::default();
|
||||
st.set_devices([xi::Device::new(9, "Wacom Intuos Pen stylus", [(2, "Abs Pressure".to_string(), 0.0, 65535.0)])]);
|
||||
let (light_dark, light_w, heavy_dark, heavy_w) = pressure_stroke(|p| match st.handle(9, [(2, f64::from(p) * 65535.0)]) {
|
||||
Update::Set(s) => s,
|
||||
Update::Keep => None,
|
||||
});
|
||||
assert!(heavy_w > light_w * 2.0, "width responds: {light_w} → {heavy_w}");
|
||||
assert!(heavy_dark > light_dark + 0.3, "opacity responds: {light_dark} → {heavy_dark}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_tablet_pressure_off_paints_like_a_mouse() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("prefs.set", json!({"path": "tools.useTabletPressure", "value": false})).unwrap();
|
||||
// The canvas copies the preference each frame; do what it does.
|
||||
app.stylus.use_pressure = app.session.prefs().tools.use_tablet_pressure;
|
||||
sink(app.stylus.feed.clone())(Some(Sample { pressure: 0.2, tilt_x: 40.0, ..Default::default() }));
|
||||
assert_eq!(app.stylus.pressure(), 1.0);
|
||||
assert_eq!(app.stylus.sample(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eraser_end_switches_to_the_eraser_and_back() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
let sink = sink(app.stylus.feed.clone());
|
||||
let mut st = appkit::State::default();
|
||||
let mut send = |e: appkit::RawEvent| {
|
||||
if let Update::Set(s) = st.handle(&e) {
|
||||
sink(s);
|
||||
}
|
||||
};
|
||||
let prox = |device, entering| appkit::RawEvent { kind: appkit::event_type::TABLET_PROXIMITY, device, entering, ..Default::default() };
|
||||
app.ui.tool = Tool::Brush;
|
||||
send(prox(appkit::device::PEN, true));
|
||||
assert!(!photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
|
||||
assert_eq!(app.ui.tool, Tool::Brush, "the tip changes nothing");
|
||||
send(prox(appkit::device::PEN, false));
|
||||
send(prox(appkit::device::ERASER, true));
|
||||
assert!(photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
|
||||
assert_eq!(app.ui.tool, Tool::Eraser);
|
||||
// Mouse moves in between change nothing; the tip coming back restores the Brush.
|
||||
send(appkit::RawEvent { kind: appkit::event_type::MOUSE_MOVED, ..Default::default() });
|
||||
assert!(!photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
|
||||
send(prox(appkit::device::PEN, true));
|
||||
assert!(photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
|
||||
assert_eq!(app.ui.tool, Tool::Brush);
|
||||
}
|
||||
}
|
||||
@@ -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,18 @@
|
||||
<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 21H8a2 2 0 0 1-1.42-.587l-3.994-3.999a2 2 0 0 1 0-2.828l10-10a2 2 0 0 1 2.829 0l5.999 6a2 2 0 0 1 0 2.828L12.834 21" />
|
||||
<path d="m5.082 11.09 8.828 8.828" />
|
||||
<path d="M2 2h1" />
|
||||
<path d="M6 2h1" />
|
||||
<path d="M2 6h1" />
|
||||
<path d="M4 4h1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 471 B |
@@ -0,0 +1,16 @@
|
||||
<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 21H8a2 2 0 0 1-1.42-.587l-3.994-3.999a2 2 0 0 1 0-2.828l10-10a2 2 0 0 1 2.829 0l5.999 6a2 2 0 0 1 0 2.828L12.834 21" />
|
||||
<path d="m5.082 11.09 8.828 8.828" />
|
||||
<path d="M5 1v5" />
|
||||
<path d="M7.5 3.5h-5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 432 B |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||
<path d="m15 5 4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 377 B |
@@ -16,4 +16,11 @@ Headless file operations are implemented in `crates/automation/src/files.rs`; RP
|
||||
|
||||
The control and headless TCP listeners are restricted to loopback addresses. Stdio MCP avoids a listening network socket, but the MCP process still acts with the filesystem permissions of the user who launched it.
|
||||
|
||||
Loopback is not authentication. TCP transports now require a bearer token and enforce request-byte, batch-step, and active-connection limits, but they do not implement capability scopes, allowed filesystem roots, client identity, encryption, or operation budgets. See the [Automation security model](../automation/security-model.md) and [Automation security](../security/automation-security.md) before exposing or extending these surfaces.
|
||||
Loopback is not authentication. TCP transports require a bearer token and enforce request-byte,
|
||||
batch-step, and active-connection limits. Remote file methods use an `AuthorizedWorkspace` with
|
||||
independent read and write directory capabilities; paths are relative to a configured root and
|
||||
fail closed when the applicable root is absent. General per-method scopes, client identity,
|
||||
encryption, and operation budgets are not implemented. See the
|
||||
[Automation security model](../automation/security-model.md) and
|
||||
[Automation security](../security/automation-security.md) before exposing or extending these
|
||||
surfaces.
|
||||
@@ -17,8 +17,23 @@ Implementation is split across:
|
||||
|
||||
## Current security posture
|
||||
|
||||
**Implemented:** the desktop server binds to IPv4 loopback; every TCP connection authenticates before dispatch; the bridge accepts only loopback-style addresses and authenticates on connection; encoded request lines are limited to 1 MiB; active connections are limited to 16; socket I/O and handler waits use timeouts; MCP and headless batches are limited to 256 steps.
|
||||
**Implemented:** the desktop server binds to IPv4 loopback; every TCP connection authenticates
|
||||
before dispatch; the bridge accepts only loopback-style addresses and authenticates on
|
||||
connection; encoded request lines are limited to 1 MiB; active connections are limited to 16;
|
||||
socket I/O and handler waits use timeouts; MCP and headless batches are limited to 256 steps.
|
||||
JSON-lines replies and encoded MCP tool results are capped at 8 MiB, with aggregate batch reply
|
||||
budgets. Headless previews have source-pixel, requested-edge, and encoded-PNG ceilings. The
|
||||
bridge bounds incoming reply frames and screenshot decoding.
|
||||
Automation file methods require separately configured read and write roots. Request paths are
|
||||
relative, and absolute, traversal, alternate-separator, device-name, and link-escape paths are
|
||||
rejected before file effects.
|
||||
|
||||
**Known limitations:** the token is a bearer credential and grants the complete exposed control surface. There is no encryption, client identity, per-method capability check, allowed filesystem root, JSON-depth limit, or operation/resource budget. The listener creates one thread per authenticated active connection, within the connection cap.
|
||||
**Known limitations:** the token is a bearer credential and grants the complete non-filesystem
|
||||
control surface. There is no encryption, client identity, general per-method capability check,
|
||||
explicit JSON-depth policy, aggregate session-memory accounting, or command-duration/cancellation
|
||||
budget. Desktop capture/encoding and import/export still need their own operation budgets.
|
||||
The listener creates one thread per authenticated
|
||||
active connection, within the connection cap. Trusted one-shot CLI operations and interactive
|
||||
desktop file pickers retain the launching user's normal filesystem authority.
|
||||
|
||||
Use a private token file, enable `--control` only for a trusted local automation session, and never proxy the unencrypted protocol beyond loopback. See [Automation security](../security/automation-security.md) for the remaining gateway work.
|
||||
@@ -8,7 +8,7 @@
|
||||
Start the headless server with:
|
||||
|
||||
```sh
|
||||
photocraft-cli mcp
|
||||
photocraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project
|
||||
```
|
||||
|
||||
Bridge to a running desktop application with the same private token file used by the app:
|
||||
@@ -21,8 +21,12 @@ Tools cover session/document operations, command discovery and execution, batchi
|
||||
|
||||
## Security notes
|
||||
|
||||
Stdio MCP does not open a network listener. Its client can still invoke file operations and engine commands using the MCP process's operating-system permissions. Bridge mode authenticates its loopback control connection with a bearer token before invoking any control method.
|
||||
Stdio MCP does not open a network listener. Its client receives only the read and write roots
|
||||
explicitly granted at launch; omitting a root omits that authority. Bridge mode authenticates its
|
||||
loopback control connection with a bearer token before invoking any control method and uses the
|
||||
roots configured on the desktop process.
|
||||
|
||||
The current server does not issue scoped sessions or distinguish trusted tools from high-impact tools. Authentication proves possession of the token; it does not restrict an authenticated client to document-only access. Do not expose either transport through an untrusted broker, shell, proxy, or remote service without adding an external policy boundary.
|
||||
|
||||
Capability-scoped MCP access is [proposed](../security/automation-security.md), not implemented.
|
||||
Filesystem capabilities are implemented. General tool and method capabilities remain
|
||||
[proposed](../security/automation-security.md).
|
||||
@@ -8,15 +8,17 @@ Automation requests are untrusted input with potential effects beyond the active
|
||||
| Stdio transport | **Implemented** for MCP and headless JSON lines |
|
||||
| Command-level error handling | **Implemented**, with command-specific tests and `panic_hunt` coverage |
|
||||
| Control-channel authentication | **Implemented:** 256-bit bearer token required before TCP method dispatch |
|
||||
| MCP capability scopes | **Known limitation:** absent |
|
||||
| Allowed read/write roots | **Known limitation:** absent |
|
||||
| Symlink-safe capability filesystem | **Known limitation:** absent |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Connection/worker limit | **Implemented:** 16 active TCP connections; one worker thread per accepted active connection |
|
||||
| Security audit events | **Proposed** |
|
||||
|
||||
## Proposed gateway
|
||||
## Gateway status and remaining work
|
||||
|
||||
```text
|
||||
MCP or control client
|
||||
@@ -24,8 +26,8 @@ MCP or control client
|
||||
authenticated session
|
||||
v
|
||||
security gateway
|
||||
- capabilities
|
||||
- path handles/roots
|
||||
- general method capabilities (proposed)
|
||||
- path handles/read-write roots (implemented)
|
||||
- request budgets
|
||||
- command policy
|
||||
- audit events
|
||||
@@ -34,4 +36,8 @@ MCP or control client
|
||||
command engine / UI shell
|
||||
```
|
||||
|
||||
The current token proves possession of a secret but does not limit what that client can do. The remaining design combines the authenticated transport with explicit capabilities, filesystem handles, resource budgets, and command policy. Until those controls land, private token handling, process isolation, and least-privileged execution are practical containment mechanisms.
|
||||
The current token proves possession of a secret but does not provide general method authorization.
|
||||
Filesystem handles and a defensive command-path policy are implemented; explicit non-filesystem
|
||||
capabilities, session-memory/command-duration budgets, and audit events remain. Preview and
|
||||
reply ceilings do not bound all document operations or desktop screenshot capture. Private token handling, process
|
||||
isolation, and least-privileged execution are still practical containment measures.
|
||||
@@ -8,32 +8,56 @@ Automation is a privilege boundary because requests can cause filesystem access,
|
||||
- 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.
|
||||
- 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.
|
||||
- Headless previews reject requested edges above 2048 pixels and source documents above
|
||||
67,108,864 pixels before compositing. Full-size requests must fit the edge ceiling. Rendered
|
||||
PNGs are limited to 5 MiB before base64 encoding or file writes. Trusted-local CLI rendering
|
||||
retains its existing behavior.
|
||||
- The MCP bridge bounds incoming replies and does not retry an operation after an oversized
|
||||
reply. Screenshot decoding has separate dimension, pixel, and allocation ceilings.
|
||||
- MCP normally uses stdio and can optionally bridge to loopback control TCP.
|
||||
- The bridge and UI request paths use timeouts.
|
||||
- Engine commands are expected to reject invalid parameters without panicking.
|
||||
- Remote filesystem methods use separately granted read and write directory capabilities.
|
||||
- Automation paths must be relative beneath the applicable root. Absolute paths, parent
|
||||
traversal, alternate separators, Windows device names, malformed components, and link escapes
|
||||
are rejected before file effects.
|
||||
- Engine commands that still use ambient paths fail closed at the automation boundary. Synthetic
|
||||
UI input reaches the same command policy, and automation-triggered file hooks do not run
|
||||
user-configured script-event paths.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- no per-client or per-tool capabilities;
|
||||
- no filesystem read/write root restriction;
|
||||
- no explicit JSON-depth, response-size, render, document-memory, or command-duration budget;
|
||||
- no general per-client or per-tool capability model beyond filesystem read/write authority;
|
||||
- no explicit JSON-depth policy, aggregate document/session-memory accounting, compositor
|
||||
scratch-space accounting, or command-duration/cancellation budget;
|
||||
- one thread per accepted TCP connection;
|
||||
- no structured security audit event stream;
|
||||
- full-size renders and expensive engine commands are not charged to a session budget.
|
||||
- desktop screenshot capture/encoding and document import/export have no automation-specific
|
||||
operation budgets; the desktop transport limits replies after their creation;
|
||||
- expensive engine commands are not charged to a session budget.
|
||||
|
||||
Reply rejection can happen after a command has changed state. Such errors report that the
|
||||
operation may have completed. Exhausting a batch reply budget stops subsequent steps, including
|
||||
when normal command errors would allow the batch to continue; it does not roll back prior edits.
|
||||
See [Control protocol](../automation/control-protocol.md) for the authoritative protocol reference.
|
||||
|
||||
## Proposed session model
|
||||
|
||||
The transport now establishes an authenticated connection from a cryptographically random credential before method discovery. A future security gateway should turn that authenticated connection into a short-lived, capability-scoped session.
|
||||
|
||||
Capabilities should be explicit and composable, for example:
|
||||
Remaining capabilities should be explicit and composable, for example:
|
||||
|
||||
```text
|
||||
DocumentRead
|
||||
DocumentWrite
|
||||
UiInspect
|
||||
UiControl
|
||||
FilesystemRead
|
||||
FilesystemWrite
|
||||
FilesystemRead (implemented as a root capability)
|
||||
FilesystemWrite (implemented as a separate root capability)
|
||||
PreferencesWrite
|
||||
ApplicationControl
|
||||
```
|
||||
@@ -55,4 +79,7 @@ Limits should fail with stable errors, be applied before expensive work, and be
|
||||
|
||||
## Deployment guidance today
|
||||
|
||||
Prefer stdio, start control TCP only when needed, use a private token file, keep the listener on loopback, use a least-privileged OS account, and isolate untrusted agents from sensitive files. Do not commit or log bearer tokens. Do not tunnel or proxy the unencrypted loopback protocol to another host.
|
||||
Prefer stdio, start control TCP only when needed, grant the narrowest read and write roots, use a
|
||||
private token file, keep the listener on loopback, use a least-privileged OS account, and isolate
|
||||
untrusted agents from sensitive files. Do not commit or log bearer tokens. Do not tunnel or proxy
|
||||
the unencrypted loopback protocol to another host.
|
||||
@@ -10,8 +10,8 @@ PhotoCraft processes complex attacker-controlled files and exposes automation th
|
||||
| Raster codecs | Default dimension/pixel/allocation limits; decoder-specific budgets; malformed/property tests; fuzz targets | Continuous fuzzing and more format-specific semantic cases |
|
||||
| `.pcraft` | Manifest/blob/total-byte limits; bounded decompression; CRC/hash and version checks; fuzz target | Tighter complexity/entry limits and directory-bundle path-race analysis |
|
||||
| Engine commands | Error-return contract, focused bad-parameter tests, adversarial `panic_hunt` test | Continue eliminating input-derived panic and exhaustion paths |
|
||||
| Control TCP | Loopback binding; 256-bit bearer-token authentication; 1 MiB request lines; 16 active connections; I/O timeouts | No capabilities, filesystem policy, encryption, client identity, JSON-depth/operation budgets, or audit trail |
|
||||
| MCP | Stdio transport; typed tool schemas; headless/bridge separation; authenticated bridge; 256-step batch limit | No authorization scopes or filesystem policy |
|
||||
| Control TCP | Loopback binding; bearer-token authentication; rooted read/write authority; 1 MiB request lines; 8 MiB replies; 16 active connections; I/O timeouts | No general method capabilities, encryption, client identity, explicit JSON-depth policy, aggregate session-memory/command-duration budgets, or audit trail |
|
||||
| MCP | Stdio; typed tools; authenticated and bounded bridge; rooted read/write authority; 256-step batches with aggregate reply budgets; headless preview/output ceilings | No general tool authorization scopes, aggregate session-memory accounting, or command cancellation; transport reply caps do not bound desktop screenshot capture |
|
||||
| Dependencies | Cargo lockfile and normal CI build/test/clippy | No `cargo-audit` or `cargo-deny` job/configuration confirmed in current tree |
|
||||
| Releases | Checksums and platform signing/notarization paths when secrets are configured | Signing can be absent; no SBOM generation confirmed in current tree |
|
||||
|
||||
@@ -20,8 +20,9 @@ The table is a snapshot of the current repository, not a guarantee about deploye
|
||||
## Security priorities
|
||||
|
||||
1. Add capability-scoped automation sessions on top of the authenticated transport.
|
||||
2. Restrict automation file access through explicit workspace handles.
|
||||
3. Add JSON-complexity, response-size, render, document-memory, and command-duration budgets.
|
||||
2. Maintain the implemented workspace-handle boundary as file-bearing commands evolve.
|
||||
3. Extend the preview/reply budgets to JSON complexity, aggregate document/session memory,
|
||||
desktop capture/import/export, and command duration/cancellation.
|
||||
4. Expand PSD/PSB, codec, and `.pcraft` fuzzing with regression corpora.
|
||||
5. Add dependency advisories/license policy checks and release SBOMs.
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Secure file handling
|
||||
|
||||
PhotoCraft reads and writes documents in the desktop app, CLI, headless RPC server, and MCP server. Current automation accepts caller-supplied strings and converts them directly to `PathBuf`; render-to-file uses `std::fs::write`, and shared save code may create parent directories.
|
||||
PhotoCraft reads and writes documents in the desktop app, CLI, headless RPC server, and MCP server.
|
||||
Remote automation paths are untrusted relative names resolved beneath explicitly granted roots.
|
||||
|
||||
## Implemented
|
||||
|
||||
@@ -8,18 +9,26 @@ PhotoCraft reads and writes documents in the desktop app, CLI, headless RPC serv
|
||||
- `.pcraft` ZIP loading validates archive offsets, entry sizes, CRCs, content hashes, and configured decompressed-size budgets.
|
||||
- Raster and PSD loaders apply parser/decode limits before major pixel allocation.
|
||||
- Native `.pcraft` saves use temporary-file/rename patterns in relevant store paths.
|
||||
|
||||
These are file-content protections, not path authorization.
|
||||
- `AuthorizedWorkspace` holds independent read and write directory capabilities for remote
|
||||
automation.
|
||||
- Absolute paths, parent traversal, alternate separators, drive/device/stream prefixes, reserved
|
||||
Windows device names, empty or dot components, and link escapes are rejected before file
|
||||
effects.
|
||||
- Non-existent output files are supported when their parent already exists beneath the write
|
||||
root. Automation does not create caller-selected directory trees.
|
||||
- Filesystem-bearing engine commands that have not migrated to capability I/O fail closed for
|
||||
automation. Trusted local CLI subcommands and user-driven desktop file pickers keep their
|
||||
intended ambient authority.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Automation has no configured read or write root.
|
||||
- Absolute paths and parent traversal are not rejected by a central policy.
|
||||
- Canonicalization and symlink/junction handling are not enforced as an authorization boundary.
|
||||
- Directory `.pcraft` bundles have not been documented as resistant to link replacement or time-of-check/time-of-use races.
|
||||
- The same automation session can combine document access and general filesystem effects.
|
||||
- Root grants are process launch configuration, not per-document grants.
|
||||
- General method capabilities and structured filesystem audit events are not implemented.
|
||||
- Existing hard links inside a granted root are trusted root contents; the path policy cannot
|
||||
infer how they were created.
|
||||
|
||||
## Proposed capability-based design
|
||||
## Capability-based design
|
||||
|
||||
Prefer an opened directory capability or workspace handle over repeated string sanitization:
|
||||
|
||||
@@ -30,7 +39,10 @@ AuthorizedWorkspace
|
||||
- export_render(relative_path)
|
||||
```
|
||||
|
||||
The handle should be created from an explicit user grant, preserve separate read/write permissions, reject absolute and escaping paths, define link behavior, and perform operations relative to the held directory capability. New files require a parent-directory handle because a non-existent target cannot be canonicalized safely.
|
||||
The handle is created from an explicit launch-time grant, preserves separate read/write
|
||||
permissions, rejects absolute and escaping paths, and performs operations relative to the held
|
||||
directory capability. New files require an existing in-root parent because a non-existent target
|
||||
cannot be canonicalized safely.
|
||||
|
||||
## Design requirements
|
||||
|
||||
@@ -41,4 +53,5 @@ The handle should be created from an explicit user grant, preserve separate read
|
||||
- Logs should avoid file contents and should minimize disclosure of full private paths.
|
||||
- Tests should cover `..`, absolute paths, mixed separators, case behavior, symlinks/junctions, non-existent targets, and replacement races on supported platforms.
|
||||
|
||||
Until this design is implemented, run untrusted automation inside an OS account, VM, container, or sandbox whose filesystem access already matches the intended workspace.
|
||||
OS-account, VM, container, or sandbox isolation remains appropriate defense in depth for
|
||||
untrusted automation and for controls not yet covered by general method capabilities.
|
||||
@@ -40,7 +40,8 @@ TRUSTED INTERNAL STATE
|
||||
| Path to filesystem effect | `crates/automation/src/files.rs`, app services | authorized root/handle, link policy, create/replace semantics |
|
||||
| Document to GPU work | `crates/gpu`, UI planner | texture limits, buffer sizes, supported-plan validation |
|
||||
|
||||
The filesystem and automation rows describe where policy belongs; the workspace-root, symlink, authentication, and capability controls are not yet implemented.
|
||||
Authentication and filesystem read/write capabilities are implemented at these boundaries.
|
||||
General method capabilities, structured audit events, and broader operation budgets remain open.
|
||||
|
||||
## Agent guidance
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Magic Eraser: erase a wand [`Region`] to transparency (or, on a layer with locked transparency,
|
||||
//! to a colour), tile by tile.
|
||||
//!
|
||||
//! Works in the surface's own channels at any depth and in any colour model: only the alpha
|
||||
//! channel changes (or, under a transparency lock, the colour channels move toward `lock_color`).
|
||||
|
||||
use photocraft_color::{read_sample, write_sample};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
use crate::selection::Region;
|
||||
|
||||
/// Erases `region` from `target` at `amount` (0–1, the tool's opacity), weighted by the selection's
|
||||
/// coverage. With `lock_color` (native colour channels, e.g. the background colour converted to the
|
||||
/// surface's model) the pixels are painted toward that colour instead and alpha is kept, as
|
||||
/// Photoshop does on layers with locked transparency. Returns the damaged rectangle.
|
||||
pub fn magic_erase(target: &mut Surface, region: &Region, amount: f32, selection: Option<&Surface>, lock_color: Option<&[f32]>) -> Rect {
|
||||
let amount = if amount.is_finite() { amount.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let bbox = region.bbox;
|
||||
let bw = bbox.width() as usize;
|
||||
if bbox.is_empty() || amount <= 0.0 || region.mask.len() != bw * bbox.height() as usize {
|
||||
return Rect::EMPTY;
|
||||
}
|
||||
let fmt = target.format();
|
||||
let nc = fmt.mode.color_channels();
|
||||
let alpha = fmt.alpha.then_some(nc);
|
||||
// Without alpha and without a lock colour there is nothing an eraser can do.
|
||||
if alpha.is_none() && lock_color.is_none() {
|
||||
return Rect::EMPTY;
|
||||
}
|
||||
let lock: Option<Vec<f32>> = lock_color.map(|c| (0..nc).map(|i| c.get(i).copied().unwrap_or(0.0)).collect());
|
||||
let rects: Vec<Rect> = bbox.tiles().map(|tc| tc.rect().intersect(&bbox)).filter(|r| !r.is_empty()).collect();
|
||||
let src: &Surface = target;
|
||||
let (sample, bpp) = (fmt.sample, fmt.bytes_per_pixel());
|
||||
// Each tile is edited as encoded bytes (only the touched samples are decoded), in parallel;
|
||||
// the results are copied back serially.
|
||||
let work = |r: Rect| -> Option<(Rect, Vec<u8>)> {
|
||||
let w = r.width() as usize;
|
||||
let cov = |x: i32, y: i32| region.mask.get((y - bbox.y0) as usize * bw + (x - bbox.x0) as usize).copied().unwrap_or(0);
|
||||
// Skip tiles the region doesn't touch.
|
||||
if !(r.y0..r.y1).any(|y| (r.x0..r.x1).any(|x| cov(x, y) != 0)) {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = src.to_interleaved(r);
|
||||
let sel = selection.map(|s| (s.channels(), s.read_region(r)));
|
||||
for y in r.y0..r.y1 {
|
||||
for x in r.x0..r.x1 {
|
||||
let c = cov(x, y);
|
||||
if c == 0 {
|
||||
continue;
|
||||
}
|
||||
let i = (y - r.y0) as usize * w + (x - r.x0) as usize;
|
||||
let s = sel.as_ref().map_or(1.0, |(sc, v)| v.get(i * sc).copied().unwrap_or(0.0));
|
||||
let k = f32::from(c) / 255.0 * amount * s;
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let Some(px) = bytes.get_mut(i * bpp..(i + 1) * bpp) else { continue };
|
||||
match (&lock, alpha) {
|
||||
(Some(col), _) => {
|
||||
for (ch, t) in col.iter().enumerate() {
|
||||
let v = read_sample(px, sample, ch);
|
||||
write_sample(px, sample, ch, v + (t - v) * k);
|
||||
}
|
||||
}
|
||||
(None, Some(a)) => {
|
||||
let v = read_sample(px, sample, a) * (1.0 - k);
|
||||
if k >= 1.0 || v <= 0.0 {
|
||||
// Fully erased pixels become the empty pixel, so tiles can be pruned.
|
||||
px.fill(0);
|
||||
} else {
|
||||
write_sample(px, sample, a, v);
|
||||
}
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((r, bytes))
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let done: Vec<(Rect, Vec<u8>)> = {
|
||||
use rayon::prelude::*;
|
||||
rects.into_par_iter().filter_map(work).collect()
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let done: Vec<(Rect, Vec<u8>)> = rects.into_iter().filter_map(work).collect();
|
||||
let mut dmg = Rect::EMPTY;
|
||||
for (r, bytes) in done {
|
||||
target.write_interleaved(r, &bytes);
|
||||
dmg = dmg.union(&r);
|
||||
}
|
||||
if lock.is_none() {
|
||||
target.prune();
|
||||
}
|
||||
dmg
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_raster::from_rgba;
|
||||
|
||||
fn region(bbox: Rect, v: u8) -> Region {
|
||||
Region { bbox, mask: vec![v; bbox.width() as usize * bbox.height() as usize] }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erases_alpha_in_every_format() {
|
||||
for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Grayscale, ColorMode::Lab] {
|
||||
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let fmt = PixelFormat::new(mode, sample, true);
|
||||
let mut s = Surface::new(fmt);
|
||||
s.fill_rect(Rect::new(0, 0, 600, 300), &from_rgba(&fmt, [0.2, 0.5, 0.7, 1.0]));
|
||||
let before = s.pixel(10, 10);
|
||||
let d = magic_erase(&mut s, ®ion(Rect::new(0, 0, 300, 300), 255), 1.0, None, None);
|
||||
assert_eq!(d, Rect::new(0, 0, 300, 300));
|
||||
assert_eq!(s.rgba(10, 10)[3], 0.0, "{fmt:?}");
|
||||
assert_eq!(s.pixel(400, 10), before, "outside the region is untouched {fmt:?}");
|
||||
// Half opacity halves alpha and keeps colour.
|
||||
let mut h = Surface::new(fmt);
|
||||
h.fill_rect(Rect::new(0, 0, 10, 10), &from_rgba(&fmt, [0.2, 0.5, 0.7, 1.0]));
|
||||
magic_erase(&mut h, ®ion(Rect::new(0, 0, 10, 10), 255), 0.5, None, None);
|
||||
let p = h.pixel(5, 5);
|
||||
let a = fmt.mode.color_channels();
|
||||
assert!((p[a] - 0.5).abs() < 0.01, "{fmt:?} {p:?}");
|
||||
assert!((p[0] - before[0]).abs() < 1e-6, "colour kept {fmt:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respects_selection_and_lock() {
|
||||
let fmt = PixelFormat::RGBA8;
|
||||
let mut s = Surface::new(fmt);
|
||||
s.fill_rect(Rect::new(0, 0, 20, 20), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let mut sel = Surface::new(PixelFormat::GRAY8);
|
||||
sel.fill_rect(Rect::new(0, 0, 10, 20), &[1.0]);
|
||||
magic_erase(&mut s, ®ion(Rect::new(0, 0, 20, 20), 255), 1.0, Some(&sel), None);
|
||||
assert_eq!(s.rgba(5, 5)[3], 0.0);
|
||||
assert_eq!(s.rgba(15, 5)[3], 1.0);
|
||||
// Locked transparency paints the lock colour, alpha kept.
|
||||
let mut l = Surface::new(fmt);
|
||||
l.fill_rect(Rect::new(0, 0, 20, 20), &[1.0, 0.0, 0.0, 1.0]);
|
||||
magic_erase(&mut l, ®ion(Rect::new(0, 0, 20, 20), 255), 1.0, None, Some(&[1.0, 1.0, 1.0]));
|
||||
assert_eq!(l.rgba(5, 5), [1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_inputs_do_nothing() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
s.fill_rect(Rect::new(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let orig = s.clone();
|
||||
// Mask length mismatch, zero/NaN amount, empty box.
|
||||
assert!(magic_erase(&mut s, &Region { bbox: Rect::new(0, 0, 4, 4), mask: vec![255; 3] }, 1.0, None, None).is_empty());
|
||||
assert!(magic_erase(&mut s, ®ion(Rect::new(0, 0, 4, 4), 255), f32::NAN, None, None).is_empty());
|
||||
assert!(magic_erase(&mut s, ®ion(Rect::EMPTY, 255), 1.0, None, None).is_empty());
|
||||
assert_eq!(s, orig);
|
||||
// No alpha channel and no lock colour: nothing to erase.
|
||||
let mut g = Surface::new(PixelFormat::GRAY8);
|
||||
assert!(magic_erase(&mut g, ®ion(Rect::new(0, 0, 4, 4), 255), 1.0, None, None).is_empty());
|
||||
}
|
||||
}
|
||||
+23
-7
@@ -27,6 +27,7 @@ pub mod content_aware;
|
||||
mod denoise;
|
||||
mod distort;
|
||||
mod distort2;
|
||||
pub mod erase;
|
||||
pub mod exif;
|
||||
pub mod features;
|
||||
mod fxutil;
|
||||
@@ -58,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;
|
||||
@@ -931,20 +933,34 @@ pub fn apply_tiled(
|
||||
}
|
||||
(*t, data)
|
||||
};
|
||||
// Tiles are filtered in groups of about RESULT_BUDGET bytes (at least one per core) and each
|
||||
// group is written before the next starts, so a huge layer never holds all its results as
|
||||
// floats at once.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let results: Vec<(Rect, Vec<f32>)> = {
|
||||
use rayon::prelude::*;
|
||||
tiles.par_iter().map(run).collect()
|
||||
};
|
||||
let threads = rayon::current_num_threads();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let results: Vec<(Rect, Vec<f32>)> = tiles.iter().map(run).collect();
|
||||
for (t, data) in results {
|
||||
out.write_region(t, &data);
|
||||
let threads = 1;
|
||||
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) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let results: Vec<(Rect, Vec<f32>)> = {
|
||||
use rayon::prelude::*;
|
||||
chunk.par_iter().map(run).collect()
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let results: Vec<(Rect, Vec<f32>)> = chunk.iter().map(run).collect();
|
||||
for (t, data) in results {
|
||||
out.write_region(t, &data);
|
||||
}
|
||||
}
|
||||
out.prune();
|
||||
out
|
||||
}
|
||||
|
||||
/// Bytes of float filter results held at once by [`apply_tiled`].
|
||||
const RESULT_BUDGET: usize = 256 << 20;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+331
-87
@@ -4,10 +4,12 @@
|
||||
//! and combining with an existing selection.
|
||||
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
|
||||
use photocraft_raster::Surface;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::photo_util::par_rows;
|
||||
|
||||
/// How a new selection combines with the current one.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -36,6 +38,32 @@ pub fn mask_from_surface(s: Option<&Surface>, area: Rect) -> Vec<f32> {
|
||||
let n = area.width() as usize * area.height() as usize;
|
||||
match s {
|
||||
None => vec![0.0; n],
|
||||
// Selections are GRAY8: copy tile bytes through a table, tile rows in parallel.
|
||||
Some(s) if s.format() == PixelFormat::GRAY8 && !area.is_empty() => {
|
||||
let w = area.width() as usize;
|
||||
let lut: [f32; 256] = std::array::from_fn(|i| i as f32 / 255.0);
|
||||
let dp = s.default_pixel().first().copied().unwrap_or(0.0);
|
||||
let mut out = vec![0.0f32; n];
|
||||
par_rows(&mut out, w, 1, |yy, row| {
|
||||
let y = area.y0 + yy as i32;
|
||||
let mut x = area.x0;
|
||||
while x < area.x1 {
|
||||
let tc = TileCoord::containing(x, y);
|
||||
let x1 = (tc.rect().x1).min(area.x1);
|
||||
let dst = row.get_mut((x - area.x0) as usize..(x1 - area.x0) as usize).unwrap_or_default();
|
||||
let src = s.tile(tc).and_then(|t| {
|
||||
let base = (y - tc.ty * TILE_SIZE) as usize * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize;
|
||||
t.bytes().get(base..base + dst.len())
|
||||
});
|
||||
match src {
|
||||
Some(src) => dst.iter_mut().zip(src).for_each(|(d, b)| *d = lut[*b as usize]),
|
||||
None => dst.fill(dp),
|
||||
}
|
||||
x = x1;
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
Some(s) => {
|
||||
let mut v = Vec::new();
|
||||
s.read_region_into(area, &mut v);
|
||||
@@ -45,35 +73,81 @@ pub fn mask_from_surface(s: Option<&Surface>, area: Rect) -> Vec<f32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 8-bit selection value of a coverage (clamped and rounded, as GRAY8 surfaces store it; NaN → 0).
|
||||
#[inline]
|
||||
fn coverage8(v: f32) -> u8 {
|
||||
(v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8
|
||||
}
|
||||
|
||||
/// Builds a GRAY8 selection surface from 8-bit coverage over `area`, allocating only the tiles
|
||||
/// that hold something (as [`Surface::prune`] would leave them).
|
||||
fn surface_from_coverage8(bytes: &[u8], area: Rect) -> Surface {
|
||||
let mut s = Surface::new(PixelFormat::GRAY8);
|
||||
let w = area.width() as usize;
|
||||
let ts = TILE_SIZE as usize;
|
||||
for tc in area.tiles() {
|
||||
let tr = tc.rect().intersect(&area);
|
||||
let (lx, span) = ((tr.x0 - area.x0) as usize, tr.width() as usize);
|
||||
let row = |y: i32| bytes.get((y - area.y0) as usize * w + lx..(y - area.y0) as usize * w + lx + span);
|
||||
if !(tr.y0..tr.y1).any(|y| row(y).is_some_and(|r| r.iter().any(|b| *b != 0))) {
|
||||
continue;
|
||||
}
|
||||
let data = s.tile_mut(tc).bytes_mut();
|
||||
for y in tr.y0..tr.y1 {
|
||||
let o = (y - tc.ty * TILE_SIZE) as usize * ts + (tr.x0 - tc.tx * TILE_SIZE) as usize;
|
||||
if let (Some(src), Some(dst)) = (row(y), data.get_mut(o..o + span)) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// 8-bit coverage of `m` (missing samples are 0), computed in parallel.
|
||||
fn to_coverage8(m: &[f32], area: Rect, f: impl Fn(usize, f32) -> f32 + Sync + Send) -> Vec<u8> {
|
||||
let w = area.width() as usize;
|
||||
let mut bytes = vec![0u8; w * area.height() as usize];
|
||||
par_rows(&mut bytes, w, 1, |y, row| {
|
||||
for (x, b) in row.iter_mut().enumerate() {
|
||||
let i = y * w + x;
|
||||
*b = coverage8(f(i, m.get(i).copied().unwrap_or(0.0)));
|
||||
}
|
||||
});
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Builds a GRAY8 selection surface from a mask over `area`.
|
||||
pub fn mask_to_surface(m: &[f32], area: Rect) -> Surface {
|
||||
let mut s = Surface::new(PixelFormat::GRAY8);
|
||||
if !area.is_empty() {
|
||||
s.write_region(area, m);
|
||||
if area.is_empty() {
|
||||
return Surface::new(PixelFormat::GRAY8);
|
||||
}
|
||||
s.prune();
|
||||
s
|
||||
surface_from_coverage8(&to_coverage8(m, area, |_, v| v), area)
|
||||
}
|
||||
|
||||
/// Combines `new` with `old` by `mode`. Returns `None` when nothing is selected.
|
||||
pub fn combine(old: Option<&Surface>, new: &[f32], area: Rect, mode: SelectionMode) -> Option<Surface> {
|
||||
let prev = mask_from_surface(old, area);
|
||||
// Quantize to the 8-bit grid selections are stored on, so combining a
|
||||
// mask with itself is exact.
|
||||
let q: Vec<f32> = new.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() / 255.0).collect();
|
||||
let new = &q[..];
|
||||
let out: Vec<f32> = match mode {
|
||||
SelectionMode::Replace => new.to_vec(),
|
||||
SelectionMode::Add => prev.iter().zip(new).map(|(a, b)| a.max(*b)).collect(),
|
||||
SelectionMode::Subtract => prev.iter().zip(new).map(|(a, b)| (a - b).max(0.0)).collect(),
|
||||
SelectionMode::Intersect => prev.iter().zip(new).map(|(a, b)| a.min(*b)).collect(),
|
||||
};
|
||||
// Below half an 8-bit step is nothing (selections are stored as 8-bit).
|
||||
let out: Vec<f32> = out.into_iter().map(|v| if v < 0.5 / 255.0 { 0.0 } else { v }).collect();
|
||||
if out.iter().all(|v| *v <= 0.0) {
|
||||
if area.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(mask_to_surface(&out, area))
|
||||
// Quantize to the 8-bit grid selections are stored on, so combining a mask with itself is
|
||||
// exact. Below half an 8-bit step is nothing.
|
||||
let q = |v: f32| f32::from(coverage8(v)) / 255.0;
|
||||
let bytes = match mode {
|
||||
SelectionMode::Replace => to_coverage8(new, area, |_, v| v),
|
||||
_ => {
|
||||
let prev = mask_from_surface(old, area);
|
||||
let at = |i: usize| prev.get(i).copied().unwrap_or(0.0);
|
||||
match mode {
|
||||
SelectionMode::Add => to_coverage8(new, area, |i, v| at(i).max(q(v))),
|
||||
SelectionMode::Subtract => to_coverage8(new, area, |i, v| (at(i) - q(v)).max(0.0)),
|
||||
_ => to_coverage8(new, area, |i, v| at(i).min(q(v))),
|
||||
}
|
||||
}
|
||||
};
|
||||
if bytes.iter().all(|b| *b == 0) {
|
||||
return None;
|
||||
}
|
||||
Some(surface_from_coverage8(&bytes, area))
|
||||
}
|
||||
|
||||
fn close(a: [f32; 4], b: [f32; 4], tol: f32) -> bool {
|
||||
@@ -370,6 +444,100 @@ pub fn color_range(px: &[[f32; 4]], color: [f32; 3], fuzziness: f32) -> Vec<f32>
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One Color Range sample (Sampled Colors): an eyedropper colour, and where it was picked
|
||||
/// (needed for Localized Color Clusters; area-local pixel coordinates).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct RangeSample {
|
||||
pub color: [f32; 3],
|
||||
pub at: Option<(f32, f32)>,
|
||||
}
|
||||
|
||||
/// Color Range › Sampled Colors with several samples (the dialog's "Add to Sample" eyedropper):
|
||||
/// each pixel takes its best match over the samples, each judged like [`color_range`]. With
|
||||
/// `localized` (Localized Color Clusters) a sample also fades with distance from where it was
|
||||
/// picked, reaching 0 at `localized` pixels, so only nearby matching colours are selected;
|
||||
/// samples without a position aren't localized. `w` is the row width of `px`.
|
||||
pub fn color_range_samples(px: &[[f32; 4]], w: usize, samples: &[RangeSample], fuzziness: f32, localized: Option<f32>) -> Vec<f32> {
|
||||
let f = fuzziness.max(0.0) + 1.0;
|
||||
px.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &[r, g, b, a])| {
|
||||
if a <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let (x, y) = ((i % w.max(1)) as f32, (i / w.max(1)) as f32);
|
||||
samples.iter().fold(0.0f32, |best, s| {
|
||||
let d = [r, g, b].iter().zip(&s.color).map(|(v, c)| (v - c).abs() * 255.0).fold(0.0f32, f32::max);
|
||||
let mut k = (1.0 - d / f).clamp(0.0, 1.0);
|
||||
if let (Some(radius), Some((sx, sy))) = (localized, s.at) {
|
||||
let dist = ((x - sx).powi(2) + (y - sy).powi(2)).sqrt();
|
||||
k *= (1.0 - dist / radius.max(1.0)).clamp(0.0, 1.0);
|
||||
}
|
||||
best.max(k)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Hue (degrees, 0..360) and chroma (max − min) of an RGB colour.
|
||||
fn hue_chroma([r, g, b]: [f32; 3]) -> (f32, f32) {
|
||||
let mx = r.max(g).max(b);
|
||||
let mn = r.min(g).min(b);
|
||||
let d = mx - mn;
|
||||
if d <= 0.0 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let h = if mx == r {
|
||||
((g - b) / d).rem_euclid(6.0)
|
||||
} else if mx == g {
|
||||
(b - r) / d + 2.0
|
||||
} else {
|
||||
(r - g) / d + 4.0
|
||||
};
|
||||
(h * 60.0, d)
|
||||
}
|
||||
|
||||
/// Color Range › Select: Reds, Yellows, Greens, Cyans, Blues or Magentas (`center` = 0, 60,
|
||||
/// 120, 180, 240, 300°). A pixel belongs to a family by how close its hue is (full at the
|
||||
/// family's hue, none 60° away, so an orange is half red, half yellow) times its chroma, so
|
||||
/// neutrals are never selected and muted colours only partly.
|
||||
pub fn hue_range(px: &[[f32; 4]], center: f32) -> Vec<f32> {
|
||||
px.iter()
|
||||
.map(|&[r, g, b, a]| {
|
||||
if a <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let (h, chroma) = hue_chroma([r, g, b].map(|v| v.clamp(0.0, 1.0)));
|
||||
let dh = (h - center).rem_euclid(360.0);
|
||||
let dh = dh.min(360.0 - dh);
|
||||
((1.0 - dh / 60.0).clamp(0.0, 1.0) * chroma).clamp(0.0, 1.0)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Color Range › Select: Highlights, Midtones or Shadows. Pixels whose luminance (Rec. 601
|
||||
/// weights, 0..255) lies in `lo..=hi` are fully selected; outside, selection falls off linearly
|
||||
/// to 0 over `falloff` levels (the dialog's Fuzziness).
|
||||
pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec<f32> {
|
||||
let ramp = falloff.max(1e-3);
|
||||
px.iter()
|
||||
.map(|&[r, g, b, a]| {
|
||||
if a <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let l = (0.299 * r + 0.587 * g + 0.114 * b).clamp(0.0, 1.0) * 255.0;
|
||||
let out = if l < lo {
|
||||
lo - l
|
||||
} else if l > hi {
|
||||
l - hi
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(1.0 - out / ramp).clamp(0.0, 1.0)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 1D squared distance transform (Felzenszwalb & Huttenlocher).
|
||||
fn dt1(f: &[f32], out: &mut [f32], v: &mut [usize], z: &mut [f32]) {
|
||||
let n = f.len();
|
||||
@@ -454,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
|
||||
@@ -610,6 +719,69 @@ mod tests {
|
||||
assert_eq!(m[3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_range_several_samples_and_localized() {
|
||||
let red = [1.0, 0.0, 0.0, 1.0];
|
||||
let blue = [0.0, 0.0, 1.0, 1.0];
|
||||
let green = [0.0, 1.0, 0.0, 1.0];
|
||||
// 4 × 1: red, blue, green, red.
|
||||
let px = vec![red, blue, green, red];
|
||||
let s = |c: [f32; 4], at: Option<(f32, f32)>| RangeSample { color: [c[0], c[1], c[2]], at };
|
||||
let m = color_range_samples(&px, 4, &[s(red, None), s(blue, None)], 10.0, None);
|
||||
assert_eq!(m, vec![1.0, 1.0, 0.0, 1.0]);
|
||||
// One sample is exactly the single-colour Color Range.
|
||||
assert_eq!(color_range_samples(&px, 4, &[s(red, None)], 40.0, None), color_range(&px, [1.0, 0.0, 0.0], 40.0));
|
||||
// Localized at x = 0 with a 2 px range: the far red pixel (x = 3) drops out, x = 1 would be half.
|
||||
let m = color_range_samples(&px, 4, &[s(red, Some((0.0, 0.0)))], 10.0, Some(2.0));
|
||||
assert_eq!(m, vec![1.0, 0.0, 0.0, 0.0]);
|
||||
let reds = vec![red; 4];
|
||||
let m = color_range_samples(&reds, 4, &[s(red, Some((0.0, 0.0)))], 10.0, Some(2.0));
|
||||
assert_eq!(m, vec![1.0, 0.5, 0.0, 0.0]);
|
||||
// No samples: nothing selected.
|
||||
assert!(color_range_samples(&px, 4, &[], 10.0, None).iter().all(|v| *v == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_families() {
|
||||
let px = vec![
|
||||
[1.0, 0.0, 0.0, 1.0], // red
|
||||
[1.0, 0.5, 0.0, 1.0], // orange: between red and yellow
|
||||
[0.5, 0.5, 0.5, 1.0], // grey
|
||||
[1.0, 0.5, 0.5, 1.0], // pink: a muted red
|
||||
[0.0, 0.0, 1.0, 1.0], // blue
|
||||
[1.0, 0.0, 0.0, 0.0], // transparent
|
||||
];
|
||||
let reds = hue_range(&px, 0.0);
|
||||
assert_eq!(reds[0], 1.0);
|
||||
assert!((reds[1] - 0.5).abs() < 1e-5);
|
||||
assert_eq!(reds[2], 0.0);
|
||||
assert!((reds[3] - 0.5).abs() < 1e-5);
|
||||
assert_eq!(reds[4], 0.0);
|
||||
assert_eq!(reds[5], 0.0);
|
||||
let yellows = hue_range(&px, 60.0);
|
||||
assert!((yellows[1] - 0.5).abs() < 1e-5);
|
||||
assert_eq!(hue_range(&px, 240.0)[4], 1.0);
|
||||
// Magentas wrap around 360°.
|
||||
assert_eq!(hue_range(&[[1.0, 0.0, 1.0, 1.0]], 300.0)[0], 1.0);
|
||||
assert!((hue_range(&[[1.0, 0.0, 0.5, 1.0]], 300.0)[0] - 0.5).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tonal_ranges() {
|
||||
let g = |v: f32| [v / 255.0, v / 255.0, v / 255.0, 1.0];
|
||||
let px = vec![g(0.0), g(60.0), g(75.0), g(128.0), g(200.0), g(255.0)];
|
||||
let near = |a: &[f32], b: &[f32]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4);
|
||||
// Shadows up to 65, fading over 20 levels.
|
||||
let sh = tone_range(&px, 0.0, 65.0, 20.0);
|
||||
assert!(near(&sh, &[1.0, 1.0, 0.5, 0.0, 0.0, 0.0]), "{sh:?}");
|
||||
// Highlights from 190.
|
||||
let h = tone_range(&px, 190.0, 255.0, 20.0);
|
||||
assert!(near(&h, &[0.0, 0.0, 0.0, 0.0, 1.0, 1.0]), "{h:?}");
|
||||
// Midtones 105..150.
|
||||
let m = tone_range(&px, 105.0, 150.0, 20.0);
|
||||
assert!(near(&m, &[0.0, 0.0, 0.0, 1.0, 0.0, 0.0]), "{m:?}");
|
||||
}
|
||||
|
||||
fn square(w: usize, h: usize) -> Vec<f32> {
|
||||
(0..w * h).map(|i| if (5..15).contains(&(i % w)) && (5..15).contains(&(i / w)) { 1.0 } else { 0.0 }).collect()
|
||||
}
|
||||
@@ -671,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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -25,6 +25,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "syn
|
||||
base64 = "0.22"
|
||||
schemars = "1"
|
||||
getrandom = { workspace = true }
|
||||
cap-std = "4"
|
||||
|
||||
[dev-dependencies]
|
||||
rmcp = { version = "3.5", default-features = false, features = ["client", "server", "macros", "transport-io"] }
|
||||
|
||||
@@ -6,11 +6,12 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::AutomationError;
|
||||
use crate::budgets::MAX_RESPONSE_BYTES;
|
||||
use crate::security::{AUTH_METHOD, MAX_REQUEST_BYTES, validate_token};
|
||||
|
||||
type Conn = (BufReader<tokio::net::tcp::OwnedReadHalf>, tokio::net::tcp::OwnedWriteHalf);
|
||||
@@ -75,6 +76,12 @@ impl BridgeClient {
|
||||
return Err(AutomationError::Bridge(format!("not connected to {}", self.addr)));
|
||||
};
|
||||
match tokio::time::timeout(self.timeout, exchange(conn, id, method, ¶ms)).await {
|
||||
Ok(Ok(Err(error @ AutomationError::BadRequest(_)))) => {
|
||||
// An oversized frame leaves unread bytes. Drop this connection and
|
||||
// report the budget failure without retrying a possibly completed edit.
|
||||
*guard = None;
|
||||
return Err(error);
|
||||
}
|
||||
Ok(Ok(v)) => return v,
|
||||
Ok(Err(e)) if attempt == 0 => {
|
||||
*guard = None;
|
||||
@@ -104,14 +111,19 @@ async fn exchange(conn: &mut Conn, id: u64, method: &str, params: &Value) -> Res
|
||||
let io = |e: std::io::Error| AutomationError::Bridge(e.to_string());
|
||||
conn.1.write_all(line.as_bytes()).await.map_err(io)?;
|
||||
conn.1.flush().await.map_err(io)?;
|
||||
let mut buf = String::new();
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
buf.clear();
|
||||
let n = conn.0.read_line(&mut buf).await.map_err(io)?;
|
||||
// Read raw bytes so truncation inside a UTF-8 character still reports the
|
||||
// budget error instead of a retryable decoding/transport error.
|
||||
let n = (&mut conn.0).take((MAX_RESPONSE_BYTES + 1) as u64).read_until(b'\n', &mut buf).await.map_err(io)?;
|
||||
if n > MAX_RESPONSE_BYTES {
|
||||
return Ok(Err(AutomationError::BadRequest(format!("bridge response exceeds {MAX_RESPONSE_BYTES} bytes; operation may have completed"))));
|
||||
}
|
||||
if n == 0 {
|
||||
return Err(AutomationError::Bridge("connection closed by the app".into()));
|
||||
}
|
||||
let Ok(v) = serde_json::from_str::<Value>(buf.trim()) else {
|
||||
let Ok(v) = serde_json::from_slice::<Value>(&buf) else {
|
||||
continue;
|
||||
};
|
||||
if v.get("id").and_then(Value::as_u64) != Some(id) {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Fixed ceilings for automation previews and outbound JSON. These bound individual
|
||||
//! outputs, not total document memory or the duration of an engine command.
|
||||
|
||||
use std::io::{self, Write};
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::AutomationError;
|
||||
use crate::security::MAX_REQUEST_BYTES;
|
||||
|
||||
/// Maximum encoded JSON reply, including its newline for JSON-lines transports.
|
||||
pub const MAX_RESPONSE_BYTES: usize = 8 << 20;
|
||||
/// Maximum PNG bytes before base64 expansion (leaves room for JSON/MCP metadata).
|
||||
pub const MAX_PNG_BYTES: usize = 5 << 20;
|
||||
/// Maximum requested preview edge. Zero still requests full size, within this ceiling.
|
||||
pub const MAX_PREVIEW_SIDE: u32 = 2048;
|
||||
/// Maximum source pixels processed by a headless automation preview.
|
||||
pub const MAX_RENDER_SOURCE_PIXELS: u64 = 64 << 20;
|
||||
|
||||
fn bad(message: impl Into<String>) -> AutomationError {
|
||||
AutomationError::BadRequest(message.into())
|
||||
}
|
||||
|
||||
/// Check before the compositor runs. Oversized requests fail rather than silently downscale.
|
||||
pub fn check_preview(width: u32, height: u32, max_side: u32) -> Result<(), AutomationError> {
|
||||
if width == 0 || height == 0 {
|
||||
return Err(bad("automation preview requires nonzero document dimensions"));
|
||||
}
|
||||
if u64::from(width) * u64::from(height) > MAX_RENDER_SOURCE_PIXELS {
|
||||
return Err(bad(format!("automation preview source exceeds {MAX_RENDER_SOURCE_PIXELS} pixels")));
|
||||
}
|
||||
let side = if max_side == 0 { width.max(height) } else { max_side };
|
||||
if side > MAX_PREVIEW_SIDE {
|
||||
return Err(bad(format!("automation preview side exceeds {MAX_PREVIEW_SIDE} pixels")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check before base64 encoding or writing a rendered PNG.
|
||||
pub fn check_png(bytes: usize) -> Result<(), AutomationError> {
|
||||
if bytes > MAX_PNG_BYTES {
|
||||
return Err(bad(format!("automation PNG exceeds {MAX_PNG_BYTES} bytes")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct LimitedWriter {
|
||||
bytes: Vec<u8>,
|
||||
maximum: usize,
|
||||
}
|
||||
|
||||
impl Write for LimitedWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
if buf.len() > self.maximum.saturating_sub(self.bytes.len()) {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, format!("response exceeds {} bytes", self.maximum)));
|
||||
}
|
||||
self.bytes.try_reserve(buf.len()).map_err(|error| io::Error::other(format!("response allocation failed: {error}")))?;
|
||||
self.bytes.extend_from_slice(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize into a bounded buffer. No partial response is sent to the transport.
|
||||
pub fn json_bytes(value: &impl Serialize) -> Result<Vec<u8>, AutomationError> {
|
||||
encode_with_limit(value, MAX_RESPONSE_BYTES - 1)
|
||||
}
|
||||
|
||||
fn encode_with_limit(value: &impl Serialize, maximum: usize) -> Result<Vec<u8>, AutomationError> {
|
||||
let mut writer = LimitedWriter { bytes: Vec::new(), maximum };
|
||||
serde_json::to_writer(&mut writer, value).map_err(|error| bad(format!("response encoding failed: {error}")))?;
|
||||
Ok(writer.bytes)
|
||||
}
|
||||
|
||||
/// Encode the entire envelope before writing. A rejected reply is replaced by a small
|
||||
/// error with the original ID; the operation may already have completed.
|
||||
pub fn write_reply(out: &mut impl Write, reply: &Value) -> io::Result<()> {
|
||||
let encoded = match json_bytes(reply) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(_) => {
|
||||
let error = json!({
|
||||
"id": reply.get("id").cloned().unwrap_or(Value::Null),
|
||||
"ok": false,
|
||||
"error": format!("response exceeds {MAX_RESPONSE_BYTES} bytes; operation may have completed"),
|
||||
});
|
||||
// Request IDs arriving over the wire fit in MAX_REQUEST_BYTES. Bound even
|
||||
// callers that construct a reply directly instead of using a transport.
|
||||
match json_bytes(&error) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(_) => b"{\"id\":null,\"ok\":false,\"error\":\"response budget exceeded\"}".to_vec(),
|
||||
}
|
||||
}
|
||||
};
|
||||
out.write_all(&encoded)?;
|
||||
out.write_all(b"\n")
|
||||
}
|
||||
|
||||
/// Reserve envelope/ID space and bound retained batch results incrementally, so
|
||||
/// many individually valid replies cannot accumulate into an enormous batch.
|
||||
pub struct BatchReplyBudget {
|
||||
remaining: usize,
|
||||
}
|
||||
|
||||
impl Default for BatchReplyBudget {
|
||||
fn default() -> Self {
|
||||
Self { remaining: MAX_RESPONSE_BYTES - MAX_REQUEST_BYTES - 4096 }
|
||||
}
|
||||
}
|
||||
|
||||
impl BatchReplyBudget {
|
||||
/// 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);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn preview_checks_full_size_requested_edge_and_source_before_rendering() {
|
||||
assert!(check_preview(64, 32, 0).is_ok());
|
||||
assert!(check_preview(MAX_PREVIEW_SIDE, MAX_PREVIEW_SIDE, 0).is_ok());
|
||||
assert!(check_preview(MAX_PREVIEW_SIDE + 1, 1, 0).is_err());
|
||||
assert!(check_preview(64, 32, MAX_PREVIEW_SIDE + 1).is_err());
|
||||
assert!(check_preview(8192, 8192, 1024).is_ok());
|
||||
assert!(check_preview(8193, 8192, 1024).is_err());
|
||||
assert!(check_preview(u32::MAX, u32::MAX, 1).is_err());
|
||||
assert!(check_preview(0, 32, 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_bytes_include_json_escaping_and_accept_exact_boundary() {
|
||||
assert_eq!(encode_with_limit(&json!("abc"), 5).unwrap(), b"\"abc\"");
|
||||
assert!(encode_with_limit(&json!("abc"), 4).is_err());
|
||||
assert!(encode_with_limit(&json!("\n\n"), 5).is_err());
|
||||
assert!(encode_with_limit(&Value::Null, 0).is_err());
|
||||
assert!(check_png(MAX_PNG_BYTES).is_ok());
|
||||
assert!(check_png(MAX_PNG_BYTES + 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_reply_is_one_complete_error_with_matching_id() {
|
||||
let reply = json!({"id": 7, "ok": true, "result": "x".repeat(MAX_RESPONSE_BYTES)});
|
||||
let mut out = Vec::new();
|
||||
write_reply(&mut out, &reply).unwrap();
|
||||
assert!(out.len() < 1024);
|
||||
assert_eq!(out.iter().filter(|&&byte| byte == b'\n').count(), 1);
|
||||
let error: Value = serde_json::from_slice(&out).unwrap();
|
||||
assert_eq!(error["id"], 7);
|
||||
assert_eq!(error["ok"], false);
|
||||
assert!(error["error"].as_str().unwrap().contains("operation may have completed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_cannot_retain_more_than_the_aggregate_reply_budget() {
|
||||
let mut budget = BatchReplyBudget { remaining: 10 };
|
||||
budget.charge(&json!("abc")).unwrap();
|
||||
assert!(budget.charge(&json!("abc")).is_err());
|
||||
assert_eq!(budget.remaining, 4);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,15 @@ pub struct Opened {
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Decode a document that was read through a filesystem capability.
|
||||
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<Opened, AutomationError> {
|
||||
if Path::new(name).extension().is_some_and(|extension| extension.eq_ignore_ascii_case(photocraft_format::EXTENSION)) {
|
||||
return Ok(Opened { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new() });
|
||||
}
|
||||
let r = photocraft_io::import(name, bytes)?;
|
||||
Ok(Opened { document: r.document, warnings: r.warnings })
|
||||
}
|
||||
|
||||
/// Open a document from disk. Directory bundles and `.pcraft` ZIPs load
|
||||
/// natively; everything else goes through `photocraft-io` (PSD, PNG, …).
|
||||
pub fn open(path: &Path) -> Result<Opened, AutomationError> {
|
||||
@@ -23,8 +32,7 @@ pub fn open(path: &Path) -> Result<Opened, AutomationError> {
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let r = photocraft_io::import(&name, &bytes)?;
|
||||
Ok(Opened { document: r.document, warnings: r.warnings })
|
||||
open_bytes(&name, &bytes)
|
||||
}
|
||||
|
||||
/// Previews for a `.pcraft` bundle.
|
||||
@@ -53,13 +61,25 @@ 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)
|
||||
}
|
||||
|
||||
/// Encode a document for a capability-scoped write. Unlike [`save`], this
|
||||
/// never obtains ambient filesystem authority and emits `.pcraft` as a ZIP.
|
||||
pub fn save_bytes(doc: &Document, name: &str, format_override: Option<&str>, opts: &ExportOptions) -> Result<(Vec<u8>, Vec<String>), AutomationError> {
|
||||
let ext = format_override
|
||||
.map(|format| format.trim_start_matches('.').to_ascii_lowercase())
|
||||
.or_else(|| Path::new(name).extension().map(|extension| extension.to_string_lossy().to_ascii_lowercase()))
|
||||
.ok_or_else(|| AutomationError::BadRequest(format!("cannot tell the format of `{name}`; pass a format")))?;
|
||||
if ext == photocraft_format::EXTENSION {
|
||||
return Ok((photocraft_format::save_to_bytes(doc, &previews(doc))?, Vec::new()));
|
||||
}
|
||||
let result = photocraft_io::export(doc, &ext, opts)?;
|
||||
Ok((result.bytes, result.warnings))
|
||||
}
|
||||
|
||||
/// Flattened document as PNG, scaled to fit `max_side` (0 = full size).
|
||||
pub fn render_png(doc: &Document, max_side: u32) -> Result<Vec<u8>, AutomationError> {
|
||||
let side = if max_side == 0 { doc.size.width.max(doc.size.height) } else { max_side };
|
||||
|
||||
@@ -9,21 +9,46 @@ use photocraft_format::PcraftWriter;
|
||||
use photocraft_io::ExportOptions;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{AutomationError, files};
|
||||
use crate::workspace::authorize_engine_command;
|
||||
use crate::{AuthorizedWorkspace, AutomationError, files};
|
||||
|
||||
enum Filesystem {
|
||||
Denied,
|
||||
TrustedLocal,
|
||||
Workspace(AuthorizedWorkspace),
|
||||
}
|
||||
|
||||
/// A headless editing session.
|
||||
#[derive(Default)]
|
||||
pub struct Headless {
|
||||
pub session: Session,
|
||||
/// Incremental `.pcraft` writers per document id.
|
||||
writers: HashMap<u64, PcraftWriter>,
|
||||
filesystem: Filesystem,
|
||||
}
|
||||
|
||||
impl Default for Headless {
|
||||
fn default() -> Self {
|
||||
Self { session: Session::new(), writers: HashMap::new(), filesystem: Filesystem::Denied }
|
||||
}
|
||||
}
|
||||
|
||||
impl Headless {
|
||||
/// Create an automation session with no filesystem authority.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Create a session for an explicit local CLI invocation. The CLI caller,
|
||||
/// not a remote automation client, supplies these host paths.
|
||||
pub fn trusted_local() -> Self {
|
||||
Self { session: Session::new(), writers: HashMap::new(), filesystem: Filesystem::TrustedLocal }
|
||||
}
|
||||
|
||||
/// Create an automation session with capability-scoped file access.
|
||||
pub fn with_workspace(workspace: AuthorizedWorkspace) -> Self {
|
||||
Self { session: Session::new(), writers: HashMap::new(), filesystem: Filesystem::Workspace(workspace) }
|
||||
}
|
||||
|
||||
fn doc_index(&self, index: Option<usize>) -> Result<usize, AutomationError> {
|
||||
match index {
|
||||
Some(i) if i < self.session.documents().len() => Ok(i),
|
||||
@@ -34,8 +59,17 @@ impl Headless {
|
||||
|
||||
/// Open a file and make it the active document.
|
||||
pub fn open(&mut self, path: &Path) -> Result<Value, AutomationError> {
|
||||
let o = files::open(path)?;
|
||||
let index = self.session.add_document(o.document, Some(path.to_string_lossy().into_owned()));
|
||||
let requested = path.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?;
|
||||
let o = match &self.filesystem {
|
||||
Filesystem::Denied => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())),
|
||||
Filesystem::TrustedLocal => files::open(path)?,
|
||||
Filesystem::Workspace(workspace) => {
|
||||
let bytes = workspace.read(requested)?;
|
||||
let name = path.file_name().and_then(|name| name.to_str()).unwrap_or(requested);
|
||||
files::open_bytes(name, &bytes)?
|
||||
}
|
||||
};
|
||||
let index = self.session.add_document(o.document, Some(requested.to_string()));
|
||||
let d = &self.session.documents()[index];
|
||||
Ok(json!({
|
||||
"index": index,
|
||||
@@ -51,17 +85,30 @@ impl Headless {
|
||||
/// saves to the document's own path.
|
||||
pub fn save(&mut self, index: Option<usize>, path: Option<&Path>, format: Option<&str>, opts: &ExportOptions) -> Result<Value, AutomationError> {
|
||||
let i = self.doc_index(index)?;
|
||||
let st = &self.session.documents()[i];
|
||||
let target: PathBuf = match (path, &st.path) {
|
||||
let (stored_path, doc) = {
|
||||
let state = &self.session.documents()[i];
|
||||
(state.path.clone(), state.doc.clone())
|
||||
};
|
||||
let target: PathBuf = match (path, stored_path.as_deref()) {
|
||||
(Some(p), _) => p.to_path_buf(),
|
||||
(None, Some(p)) => PathBuf::from(p),
|
||||
(None, None) => {
|
||||
return Err(AutomationError::BadRequest("document has no path; pass `path`".into()));
|
||||
}
|
||||
};
|
||||
let doc = st.doc.clone();
|
||||
let writer = self.writers.entry(doc.id.0).or_default();
|
||||
let warnings = files::save(&doc, &target, format, opts, Some(writer))?;
|
||||
let target_text = target.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?;
|
||||
let warnings = match &self.filesystem {
|
||||
Filesystem::Denied => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
|
||||
Filesystem::TrustedLocal => {
|
||||
let writer = self.writers.entry(doc.id.0).or_default();
|
||||
files::save(&doc, &target, format, opts, Some(writer))?
|
||||
}
|
||||
Filesystem::Workspace(workspace) => {
|
||||
let (bytes, warnings) = files::save_bytes(&doc, target_text, format, opts)?;
|
||||
workspace.write(target_text, &bytes)?;
|
||||
warnings
|
||||
}
|
||||
};
|
||||
let is_native = format
|
||||
.map(|f| f.trim_start_matches('.').eq_ignore_ascii_case("pcraft"))
|
||||
.unwrap_or_else(|| target.extension().is_some_and(|e| e.eq_ignore_ascii_case("pcraft")));
|
||||
@@ -75,6 +122,16 @@ impl Headless {
|
||||
Ok(json!({ "path": target.to_string_lossy(), "warnings": warnings }))
|
||||
}
|
||||
|
||||
/// Write rendered bytes through the configured write authority.
|
||||
pub fn write_render(&self, path: &Path, bytes: &[u8]) -> Result<(), AutomationError> {
|
||||
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 => photocraft_format::atomic_write(path, bytes).map_err(|error| AutomationError::Io(error.to_string())),
|
||||
Filesystem::Workspace(workspace) => workspace.write(requested, bytes),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inspect(&self, index: Option<usize>) -> Result<Value, AutomationError> {
|
||||
let i = self.doc_index(index)?;
|
||||
Ok(photocraft_engine::inspect::document(&self.session.documents()[i]))
|
||||
@@ -82,7 +139,15 @@ impl Headless {
|
||||
|
||||
pub fn render_png(&self, index: Option<usize>, max_side: u32) -> Result<Vec<u8>, AutomationError> {
|
||||
let i = self.doc_index(index)?;
|
||||
files::render_png(&self.session.documents()[i].doc, max_side)
|
||||
let doc = &self.session.documents()[i].doc;
|
||||
if !matches!(&self.filesystem, Filesystem::TrustedLocal) {
|
||||
crate::budgets::check_preview(doc.size.width, doc.size.height, max_side)?;
|
||||
}
|
||||
let png = files::render_png(doc, max_side)?;
|
||||
if !matches!(&self.filesystem, Filesystem::TrustedLocal) {
|
||||
crate::budgets::check_png(png.len())?;
|
||||
}
|
||||
Ok(png)
|
||||
}
|
||||
|
||||
pub fn session_list(&self) -> Value {
|
||||
@@ -121,6 +186,9 @@ impl Headless {
|
||||
|
||||
pub fn command_run(&mut self, id: &str, params: Value) -> 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)?)
|
||||
}
|
||||
}
|
||||
@@ -17,15 +17,18 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod bridge;
|
||||
pub mod budgets;
|
||||
pub mod files;
|
||||
pub mod headless;
|
||||
pub mod rpc;
|
||||
pub mod security;
|
||||
pub mod server;
|
||||
pub mod workspace;
|
||||
|
||||
pub use bridge::BridgeClient;
|
||||
pub use headless::Headless;
|
||||
pub use server::{Backend, PhotocraftMcp};
|
||||
pub use workspace::AuthorizedWorkspace;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AutomationError {
|
||||
|
||||
+106
-18
@@ -21,6 +21,7 @@ use std::sync::{Arc, Mutex, PoisonError};
|
||||
use base64::Engine as _;
|
||||
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,
|
||||
};
|
||||
@@ -96,11 +97,16 @@ impl Headless {
|
||||
}
|
||||
"doc.inspect" => self.inspect(index_of(&p)),
|
||||
"doc.render" => {
|
||||
let max = p.get("maxSide").and_then(Value::as_u64).unwrap_or(1024) as u32;
|
||||
let max = match p.get("maxSide") {
|
||||
None => 1024,
|
||||
Some(value) => {
|
||||
value.as_u64().and_then(|value| u32::try_from(value).ok()).ok_or_else(|| bad("maxSide must be an unsigned 32-bit integer"))?
|
||||
}
|
||||
};
|
||||
let png = self.render_png(index_of(&p), max)?;
|
||||
match str_of(&p, "path") {
|
||||
Some(path) => {
|
||||
std::fs::write(path, &png).map_err(|e| AutomationError::Io(e.to_string()))?;
|
||||
self.write_render(&PathBuf::from(path), &png)?;
|
||||
Ok(json!({"path": path, "bytes": png.len()}))
|
||||
}
|
||||
None => Ok(json!({
|
||||
@@ -131,6 +137,7 @@ 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") {
|
||||
@@ -140,14 +147,21 @@ impl Headless {
|
||||
} else {
|
||||
Err(bad(format!("step {i} needs `command` or `method`")))
|
||||
};
|
||||
match r {
|
||||
Ok(v) => results.push(json!({"ok": true, "result": v})),
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
results.push(json!({"ok": false, "error": e.to_string()}));
|
||||
if stop {
|
||||
break;
|
||||
}
|
||||
let was_error = r.is_err();
|
||||
let result = match r {
|
||||
Ok(v) => json!({"ok": true, "result": v}),
|
||||
Err(e) => json!({"ok": false, "error": e.to_string()}),
|
||||
};
|
||||
if let Err(error) = reply_budget.charge(&result) {
|
||||
failed += 1;
|
||||
results.push(json!({"ok": false, "error": format!("batch response budget exceeded; current step may have completed: {error}")}));
|
||||
break;
|
||||
}
|
||||
results.push(result);
|
||||
if was_error {
|
||||
failed += 1;
|
||||
if stop {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,17 +193,25 @@ pub fn respond(h: &Mutex<Headless>, line: &str) -> Value {
|
||||
}
|
||||
|
||||
/// Serve JSON lines from `r` to `w` until EOF. Blank lines are ignored.
|
||||
pub fn serve_lines(h: &Mutex<Headless>, r: impl BufRead, mut w: impl Write) -> std::io::Result<()> {
|
||||
for line in r.lines() {
|
||||
let line = line?;
|
||||
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);
|
||||
writeln!(w, "{reply}")?;
|
||||
write_reply(&mut w, &reply)?;
|
||||
w.flush()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token: &str) -> std::io::Result<()> {
|
||||
@@ -208,7 +230,7 @@ fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token:
|
||||
"ok": false,
|
||||
"error": format!("request exceeds {MAX_REQUEST_BYTES} bytes"),
|
||||
});
|
||||
writeln!(out, "{reply}")?;
|
||||
write_reply(&mut out, &reply)?;
|
||||
out.flush()?;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -222,7 +244,7 @@ fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token:
|
||||
authenticated = ok;
|
||||
reply
|
||||
};
|
||||
writeln!(out, "{reply}")?;
|
||||
write_reply(&mut out, &reply)?;
|
||||
out.flush()?;
|
||||
if !authenticated {
|
||||
return Ok(());
|
||||
@@ -263,7 +285,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn session() -> Mutex<Headless> {
|
||||
Mutex::new(Headless::new())
|
||||
Mutex::new(Headless::trusted_local())
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -356,6 +378,72 @@ mod tests {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_preview_rejects_oversized_full_size_and_numeric_wraparound() {
|
||||
let mut h = Headless::new();
|
||||
h.handle("doc.new", json!({"width": 2049, "height": 1})).unwrap();
|
||||
for max in [json!(0), json!(2049), json!(u64::from(u32::MAX) + 1), json!(-1), json!(1.5)] {
|
||||
assert!(h.handle("doc.render", json!({"maxSide": max})).is_err());
|
||||
}
|
||||
// A smaller preview still works, and rejection leaves the source document intact.
|
||||
let rendered = h.handle("doc.render", json!({"maxSide": 32})).unwrap();
|
||||
let png = base64::engine::general_purpose::STANDARD.decode(rendered["base64"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(photocraft_codecs::decode(&png).unwrap().dimensions(), (32, 1));
|
||||
assert_eq!(h.session.active().unwrap().doc.size.width, 2049);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdio_rejects_oversized_frames_before_dispatch() {
|
||||
let h = Mutex::new(Headless::new());
|
||||
let input = " ".repeat(MAX_REQUEST_BYTES + 1);
|
||||
let mut out = Vec::new();
|
||||
serve_lines(&h, input.as_bytes(), &mut out).unwrap();
|
||||
let reply: Value = serde_json::from_slice(&out).unwrap();
|
||||
assert_eq!(reply["ok"], false);
|
||||
assert!(reply["error"].as_str().unwrap().contains("request exceeds"));
|
||||
assert!(h.lock().unwrap().session.documents().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_rejection_precedes_output_file_creation() {
|
||||
let root = std::env::temp_dir().join(format!("pc-preview-budget-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let workspace = crate::AuthorizedWorkspace::new(None, Some(&root)).unwrap();
|
||||
let mut h = Headless::with_workspace(workspace);
|
||||
h.handle("doc.new", json!({"width": 16, "height": 8})).unwrap();
|
||||
let result = h.handle("doc.render", json!({"maxSide": 2049, "path": "rejected.png"}));
|
||||
assert!(result.unwrap_err().to_string().contains("preview side exceeds"));
|
||||
assert!(!root.join("rejected.png").exists());
|
||||
assert!(h.handle("doc.render", json!({"maxSide": 16, "path": "accepted.png"})).is_ok());
|
||||
assert!(root.join("accepted.png").exists());
|
||||
// Release directory capabilities before cleanup on Windows.
|
||||
drop(h);
|
||||
std::fs::remove_file(root.join("accepted.png")).unwrap();
|
||||
std::fs::remove_dir(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_local_render_keeps_full_size_behavior() {
|
||||
let mut h = Headless::trusted_local();
|
||||
h.handle("doc.new", json!({"width": 2049, "height": 1})).unwrap();
|
||||
let rendered = h.handle("doc.render", json!({"maxSide": 0})).unwrap();
|
||||
let png = base64::engine::general_purpose::STANDARD.decode(rendered["base64"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(photocraft_codecs::decode(&png).unwrap().dimensions(), (2049, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_response_budget_stops_later_steps_even_when_stop_on_error_is_false() {
|
||||
let mut h = Headless::new();
|
||||
let mut steps = vec![json!({"command": "command.list"}); MAX_BATCH_STEPS - 1];
|
||||
steps.push(json!({"command": "file.new", "params": {"width": 8, "height": 8}}));
|
||||
let result = h.batch(&json!({"steps": steps, "stopOnError": false})).unwrap();
|
||||
assert_eq!(result["failed"], 1);
|
||||
assert!(result["results"].as_array().unwrap().last().unwrap()["error"].as_str().unwrap().contains("batch response budget exceeded"));
|
||||
assert!(h.session.documents().is_empty(), "later edit must not execute after the quota is exhausted");
|
||||
assert!(crate::budgets::json_bytes(&result).is_ok());
|
||||
assert!(h.handle("methods", Value::Null).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_serves_loopback() {
|
||||
use std::io::{BufReader, Write as _};
|
||||
|
||||
+101
-32
@@ -14,9 +14,10 @@ use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::bridge::BridgeClient;
|
||||
use crate::budgets::{BatchReplyBudget, MAX_PREVIEW_SIDE, check_png, json_bytes};
|
||||
use crate::headless::Headless;
|
||||
use crate::security::MAX_BATCH_STEPS;
|
||||
use crate::{AutomationError, files};
|
||||
use crate::{AuthorizedWorkspace, AutomationError, files};
|
||||
|
||||
/// Where tools are executed.
|
||||
pub enum Backend {
|
||||
@@ -45,7 +46,7 @@ pub struct DocIndex {
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct OpenParams {
|
||||
/// Absolute path of the file to open (.pcraft, .psd, .png, .jpg, .tif, .exr, …).
|
||||
/// Forward-slash relative path beneath the configured automation read root.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
@@ -70,7 +71,8 @@ pub struct NewParams {
|
||||
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct SaveParams {
|
||||
/// Target path; the extension selects the format. Omit to save to the document's own path.
|
||||
/// 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.
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
/// Format override as an extension (pcraft, psd, png, jpg, tif, webp, exr, …).
|
||||
@@ -87,7 +89,8 @@ pub struct SaveParams {
|
||||
pub struct PreviewParams {
|
||||
#[serde(default)]
|
||||
pub index: Option<usize>,
|
||||
/// Longest side of the returned PNG in pixels (default 1024, 0 = full size).
|
||||
/// Longest side of a headless preview (default 1024, maximum 2048).
|
||||
/// Zero requests full size within that ceiling. Bridge mode returns the window screenshot.
|
||||
#[serde(default)]
|
||||
pub max_side: Option<u32>,
|
||||
}
|
||||
@@ -159,11 +162,26 @@ pub struct ControlParams {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn ok_json(v: &Value) -> CallToolResult {
|
||||
CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(v).unwrap_or_default())])
|
||||
let text = match json_bytes(v).and_then(|bytes| String::from_utf8(bytes).map_err(|error| AutomationError::Other(error.to_string()))) {
|
||||
Ok(text) => text,
|
||||
Err(error) => return fail(format!("{error}; operation may have completed")),
|
||||
};
|
||||
bounded_tool_result(CallToolResult::success(vec![Content::text(text)]))
|
||||
}
|
||||
|
||||
fn bounded_tool_result(result: CallToolResult) -> CallToolResult {
|
||||
match json_bytes(&result) {
|
||||
Ok(_) => result,
|
||||
Err(error) => fail(format!("{error}; operation may have completed")),
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(e: impl std::fmt::Display) -> CallToolResult {
|
||||
CallToolResult::error(vec![Content::text(e.to_string())])
|
||||
let result = CallToolResult::error(vec![Content::text(e.to_string())]);
|
||||
if json_bytes(&result).is_err() {
|
||||
return CallToolResult::error(vec![Content::text("response budget exceeded; operation may have completed")]);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Neither backend took the call. The backend is always headless or a bridge, so this only
|
||||
@@ -173,8 +191,11 @@ fn no_backend() -> CallToolResult {
|
||||
}
|
||||
|
||||
fn png_result(png: &[u8], note: String) -> CallToolResult {
|
||||
if let Err(error) = check_png(png.len()) {
|
||||
return fail(error);
|
||||
}
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(png);
|
||||
CallToolResult::success(vec![Content::image(b64, "image/png"), Content::text(note)])
|
||||
bounded_tool_result(CallToolResult::success(vec![Content::image(b64, "image/png"), Content::text(note)]))
|
||||
}
|
||||
|
||||
fn to_result(r: Result<Value, AutomationError>) -> Result<CallToolResult, McpError> {
|
||||
@@ -197,6 +218,10 @@ impl PhotocraftMcp {
|
||||
Self::with_backend(Backend::Headless(Arc::new(Mutex::new(Headless::new()))))
|
||||
}
|
||||
|
||||
pub fn headless_with_workspace(workspace: AuthorizedWorkspace) -> Self {
|
||||
Self::with_backend(Backend::Headless(Arc::new(Mutex::new(Headless::with_workspace(workspace)))))
|
||||
}
|
||||
|
||||
pub fn bridge(addr: &str, token: &str) -> Result<Self, AutomationError> {
|
||||
Ok(Self::with_backend(Backend::Bridge(Arc::new(BridgeClient::new(addr, token)?))))
|
||||
}
|
||||
@@ -242,28 +267,40 @@ impl PhotocraftMcp {
|
||||
}
|
||||
|
||||
async fn screenshot(&self, b: &BridgeClient, max_side: Option<u32>) -> Result<CallToolResult, McpError> {
|
||||
let path = std::env::temp_dir().join(format!("photocraft-mcp-shot-{}.png", std::process::id()));
|
||||
if let Err(e) = b.call("ui.screenshot", json!({"path": path.to_string_lossy()})).await {
|
||||
return Ok(fail(e));
|
||||
if max_side.is_some_and(|side| side > MAX_PREVIEW_SIDE) {
|
||||
return Ok(fail(format!("automation preview side exceeds {MAX_PREVIEW_SIDE} pixels")));
|
||||
}
|
||||
let bytes = match std::fs::read(&path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return Ok(fail(format!("screenshot file: {e}"))),
|
||||
let response = match b.call("ui.screenshot", json!({})).await {
|
||||
Ok(response) => response,
|
||||
Err(error) => return Ok(fail(error)),
|
||||
};
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let bytes = match max_side {
|
||||
Some(m) if m > 0 => downscale_png(&bytes, m).unwrap_or(bytes),
|
||||
_ => bytes,
|
||||
let Some(encoded) = response.get("base64").and_then(Value::as_str) else {
|
||||
return Ok(fail("app screenshot response did not contain PNG data"));
|
||||
};
|
||||
let bytes = match base64::engine::general_purpose::STANDARD.decode(encoded) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => return Ok(fail(format!("app screenshot data: {error}"))),
|
||||
};
|
||||
if let Err(error) = check_png(bytes.len()) {
|
||||
return Ok(fail(error));
|
||||
}
|
||||
let bytes = match downscale_png(&bytes, max_side.unwrap_or(0)) {
|
||||
Ok(Some(small)) => small,
|
||||
Ok(None) => bytes,
|
||||
Err(error) => return Ok(fail(error)),
|
||||
};
|
||||
Ok(png_result(&bytes, "screenshot of the live app window".into()))
|
||||
}
|
||||
}
|
||||
|
||||
fn downscale_png(png: &[u8], max_side: u32) -> Option<Vec<u8>> {
|
||||
let img = photocraft_codecs::decode(png).ok()?;
|
||||
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 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 w.max(h) <= max_side {
|
||||
return None;
|
||||
if max_side == 0 || w.max(h) <= max_side {
|
||||
return Ok(None);
|
||||
}
|
||||
let s = max_side as f32 / w.max(h) as f32;
|
||||
let (nw, nh) = (((w as f32 * s) as u32).max(1), ((h as f32 * s) as u32).max(1));
|
||||
@@ -278,8 +315,11 @@ fn downscale_png(png: &[u8], max_side: u32) -> Option<Vec<u8>> {
|
||||
out[di..di + 4].copy_from_slice(&src[si..si + 4]);
|
||||
}
|
||||
}
|
||||
let small = photocraft_codecs::Image::from_u8(nw, nh, photocraft_codecs::ChannelLayout::Rgba, out).ok()?;
|
||||
photocraft_codecs::encode(&small, photocraft_codecs::Format::Png, &Default::default()).ok()
|
||||
let small =
|
||||
photocraft_codecs::Image::from_u8(nw, nh, photocraft_codecs::ChannelLayout::Rgba, out).map_err(|error| AutomationError::Other(error.to_string()))?;
|
||||
let bytes =
|
||||
photocraft_codecs::encode(&small, photocraft_codecs::Format::Png, &Default::default()).map_err(|error| AutomationError::Other(error.to_string()))?;
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -357,7 +397,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).")]
|
||||
@@ -443,15 +484,24 @@ impl PhotocraftMcp {
|
||||
};
|
||||
let mut results = Vec::new();
|
||||
let mut failed = 0;
|
||||
let mut reply_budget = BatchReplyBudget::default();
|
||||
for s in &steps {
|
||||
match b.call("engine.execute", s.clone()).await {
|
||||
Ok(v) => results.push(json!({"ok": true, "result": v})),
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
results.push(json!({"ok": false, "error": e.to_string()}));
|
||||
if stop {
|
||||
break;
|
||||
}
|
||||
let response = b.call("engine.execute", s.clone()).await;
|
||||
let was_error = response.is_err();
|
||||
let result = match response {
|
||||
Ok(value) => json!({"ok": true, "result": value}),
|
||||
Err(error) => json!({"ok": false, "error": error.to_string()}),
|
||||
};
|
||||
if let Err(error) = reply_budget.charge(&result) {
|
||||
failed += 1;
|
||||
results.push(json!({"ok": false, "error": format!("batch response budget exceeded; current step may have completed: {error}")}));
|
||||
break;
|
||||
}
|
||||
results.push(result);
|
||||
if was_error {
|
||||
failed += 1;
|
||||
if stop {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -567,6 +617,25 @@ pub fn render_document_png(doc: &photocraft_doc::Document, max_side: u32) -> Res
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mcp_text_checks_the_encoded_tool_envelope() {
|
||||
// JSON escaping in the outer text-content envelope also consumes the budget.
|
||||
let result = ok_json(&json!("\n".repeat(crate::budgets::MAX_RESPONSE_BYTES / 3)));
|
||||
assert_eq!(result.is_error, Some(true));
|
||||
assert!(json_bytes(&result).is_ok());
|
||||
let error = fail("x".repeat(crate::budgets::MAX_RESPONSE_BYTES));
|
||||
assert_eq!(error.is_error, Some(true));
|
||||
assert!(json_bytes(&error).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_screenshot_decode_limits_apply_even_without_downscaling() {
|
||||
let image = photocraft_codecs::Image::from_u8(8193, 1, photocraft_codecs::ChannelLayout::Rgba, vec![0; 8193 * 4]).unwrap();
|
||||
let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
assert!(downscale_png(&png, 0).is_err());
|
||||
assert!(downscale_png(&png, 1024).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_panicking_command_does_not_wedge_the_session() {
|
||||
let mcp = PhotocraftMcp::headless();
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
//! Capability-based filesystem policy for untrusted automation paths.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cap_std::ambient_authority;
|
||||
use cap_std::fs::{Dir, OpenOptions};
|
||||
use photocraft_format::atomic::RenameRetry;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::AutomationError;
|
||||
|
||||
const DENIED: &str = "automation filesystem access is not granted";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RootCapability {
|
||||
dir: Arc<Dir>,
|
||||
}
|
||||
|
||||
/// Separate directory capabilities for automation reads and writes.
|
||||
///
|
||||
/// Root paths are trusted launch-time configuration. Request paths are always
|
||||
/// untrusted, forward-slash relative paths resolved by `cap-std` beneath the
|
||||
/// held directory handle. Absolute paths, parent traversal, alternate
|
||||
/// separators, Windows prefixes and empty components are rejected before I/O.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct AuthorizedWorkspace {
|
||||
read: Option<RootCapability>,
|
||||
write: Option<RootCapability>,
|
||||
}
|
||||
|
||||
impl AuthorizedWorkspace {
|
||||
/// Open the configured roots as capabilities. Either authority may be
|
||||
/// omitted; omitted authority fails closed.
|
||||
pub fn new(read_root: Option<&Path>, write_root: Option<&Path>) -> Result<Self, AutomationError> {
|
||||
Ok(Self { read: open_root(read_root, "read")?, write: open_root(write_root, "write")? })
|
||||
}
|
||||
|
||||
/// Read one regular file below the configured read root.
|
||||
pub fn read(&self, path: &str) -> Result<Vec<u8>, AutomationError> {
|
||||
let relative = relative_path(path)?;
|
||||
let root = self.read.as_ref().ok_or_else(|| AutomationError::BadRequest(format!("{DENIED}: read authority is absent")))?;
|
||||
let mut file = root.dir.open(&relative).map_err(|e| file_error("read", path, e))?;
|
||||
let metadata = file.metadata().map_err(|e| file_error("read", path, e))?;
|
||||
if !metadata.is_file() {
|
||||
return Err(AutomationError::BadRequest(format!("automation read path is not a regular file: `{path}`")));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes).map_err(|e| file_error("read", path, e))?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// a non-existent final target is supported without ambient path access.
|
||||
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_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(())
|
||||
}
|
||||
}
|
||||
|
||||
fn open_root(path: Option<&Path>, authority: &str) -> Result<Option<RootCapability>, AutomationError> {
|
||||
let Some(path) = path else { return Ok(None) };
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err(AutomationError::BadRequest(format!("automation {authority} root is empty")));
|
||||
}
|
||||
let dir = Dir::open_ambient_dir(path, ambient_authority())
|
||||
.map_err(|e| AutomationError::Io(format!("cannot open automation {authority} root `{}`: {e}", path.display())))?;
|
||||
Ok(Some(RootCapability { dir: Arc::new(dir) }))
|
||||
}
|
||||
|
||||
fn relative_path(raw: &str) -> Result<PathBuf, AutomationError> {
|
||||
if raw.is_empty() {
|
||||
return Err(path_error(raw, "path is empty"));
|
||||
}
|
||||
if raw.contains('\\') {
|
||||
return Err(path_error(raw, "alternate separators are not allowed; use `/`"));
|
||||
}
|
||||
if raw.starts_with('/') {
|
||||
return Err(path_error(raw, "absolute paths are not allowed"));
|
||||
}
|
||||
if raw.contains(':') {
|
||||
return Err(path_error(raw, "drive, device and stream prefixes are not allowed"));
|
||||
}
|
||||
for component in raw.split('/') {
|
||||
if component.is_empty() {
|
||||
return Err(path_error(raw, "empty path components are not allowed"));
|
||||
}
|
||||
if component == "." {
|
||||
return Err(path_error(raw, "`.` path components are not allowed"));
|
||||
}
|
||||
if component == ".." {
|
||||
return Err(path_error(raw, "parent traversal is not allowed"));
|
||||
}
|
||||
if component.ends_with(['.', ' ']) {
|
||||
return Err(path_error(raw, "path components ending in a dot or space are not allowed"));
|
||||
}
|
||||
if is_windows_device_name(component) {
|
||||
return Err(path_error(raw, "reserved device names are not allowed"));
|
||||
}
|
||||
}
|
||||
Ok(PathBuf::from(raw))
|
||||
}
|
||||
|
||||
fn is_windows_device_name(component: &str) -> bool {
|
||||
let stem = component.split('.').next().unwrap_or(component).to_ascii_uppercase();
|
||||
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$")
|
||||
|| stem.strip_prefix("COM").is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"))
|
||||
|| stem.strip_prefix("LPT").is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"))
|
||||
}
|
||||
|
||||
fn path_error(path: &str, reason: &str) -> AutomationError {
|
||||
AutomationError::BadRequest(format!("automation path rejected: {reason}: `{path}`"))
|
||||
}
|
||||
|
||||
fn file_error(operation: &str, path: &str, error: std::io::Error) -> AutomationError {
|
||||
let kind = error.kind();
|
||||
AutomationError::Io(format!("automation {operation} `{path}` failed ({kind:?}): {error}"))
|
||||
}
|
||||
|
||||
/// Filesystem-bearing engine commands have not yet been converted to consume
|
||||
/// directory capabilities. Automation must use `doc.open`, `doc.save` and
|
||||
/// `doc.render` for file effects until those commands are migrated.
|
||||
pub fn authorize_engine_command(id: &str, params: &Value) -> Result<(), AutomationError> {
|
||||
let safe_file_command = matches!(
|
||||
id,
|
||||
"file.new"
|
||||
| "file.close"
|
||||
| "file.closeAll"
|
||||
| "file.closeOthers"
|
||||
| "file.fileInfo"
|
||||
| "file.automate.fitImage"
|
||||
| "file.automate.conditionalModeChange"
|
||||
| "file.scripts.flattenAllLayerEffects"
|
||||
| "file.scripts.flattenAllMasks"
|
||||
| "file.scripts.deleteAllEmptyLayers"
|
||||
| "file.export.exportPreferences"
|
||||
);
|
||||
if id.starts_with("file.") && !safe_file_command {
|
||||
return Err(command_error(id));
|
||||
}
|
||||
if (id.starts_with("layer.smartObjects.") && id != "layer.smartObjects.convertToSmartObject")
|
||||
|| matches!(
|
||||
id,
|
||||
"pattern.import"
|
||||
| "pattern.export"
|
||||
| "edit.presets.migratePresets"
|
||||
| "measurementLog.export"
|
||||
| "layer.videoLayers.newVideoLayerFromFile"
|
||||
| "layer.videoLayers.replaceFootage"
|
||||
| "layer.videoLayers.reloadFrame"
|
||||
| "image.applyDataSet"
|
||||
)
|
||||
|| profile_command_may_read_ambient(id, params)
|
||||
|| preferences_may_grant_ambient_paths(id, params)
|
||||
|| params_contain_ambient_path(id, params)
|
||||
{
|
||||
return Err(command_error(id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Desktop sessions may already contain user-configured ambient colour-profile
|
||||
/// paths. Commands which implicitly resolve those settings are denied even
|
||||
/// when their request parameters contain no path. Fresh headless sessions
|
||||
/// cannot acquire such settings through the automation interface.
|
||||
pub fn authorize_desktop_engine_command(id: &str, params: &Value) -> Result<(), AutomationError> {
|
||||
authorize_engine_command(id, params)?;
|
||||
if id.starts_with("image.mode.") {
|
||||
return Err(command_error(id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn params_contain_ambient_path(id: &str, params: &Value) -> bool {
|
||||
let keys: &[&str] = match id {
|
||||
"image.adjustments.colorLookup" | "layer.newAdjustmentLayer.colorLookup" | "layer.setAdjustment" => &["file"],
|
||||
"filter.distort.displace" => &["mapPath"],
|
||||
"layer.quickExportAsPng" | "layer.exportAs" => &["path"],
|
||||
"edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo" | "view.proofSetup" | "view.gamutWarning" => &["profile"],
|
||||
"edit.colorSettings" => &["workingRgb", "workingCmyk", "workingGray"],
|
||||
_ => &[],
|
||||
};
|
||||
keys.iter().any(|key| {
|
||||
params
|
||||
.get(*key)
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| if matches!(*key, "file" | "mapPath" | "path") { !value.is_empty() } else { looks_like_path(value) })
|
||||
})
|
||||
}
|
||||
|
||||
fn profile_command_may_read_ambient(id: &str, params: &Value) -> bool {
|
||||
if matches!(id, "color.profileMismatch" | "edit.colorSettings" | "view.proofSetup") {
|
||||
return true;
|
||||
}
|
||||
matches!(id, "edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo")
|
||||
&& params.get("profile").and_then(Value::as_str).is_some_and(|profile| {
|
||||
matches!(profile, "working" | "default" | "working-cmyk" | "workingCmyk" | "working-rgb" | "workingRgb" | "working-gray" | "workingGray")
|
||||
})
|
||||
}
|
||||
|
||||
fn preferences_may_grant_ambient_paths(id: &str, params: &Value) -> bool {
|
||||
if id != "prefs.set" {
|
||||
return false;
|
||||
}
|
||||
let direct = params
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|path| path == "colorSettings" || path.starts_with("colorSettings.") || path == "scriptEvents" || path.starts_with("scriptEvents."));
|
||||
let batch = params.get("values").and_then(Value::as_object).is_some_and(|values| {
|
||||
values.keys().any(|path| path == "colorSettings" || path.starts_with("colorSettings.") || path == "scriptEvents" || path.starts_with("scriptEvents."))
|
||||
});
|
||||
direct || batch
|
||||
}
|
||||
|
||||
fn looks_like_path(value: &str) -> bool {
|
||||
value.contains('/')
|
||||
|| value.contains('\\')
|
||||
|| value.contains(':')
|
||||
|| value.to_ascii_lowercase().ends_with(".icc")
|
||||
|| value.to_ascii_lowercase().ends_with(".icm")
|
||||
}
|
||||
|
||||
fn command_error(id: &str) -> AutomationError {
|
||||
AutomationError::BadRequest(format!("automation command `{id}` uses ambient filesystem paths and is disabled; use capability-scoped document methods"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn roots(name: &str) -> (PathBuf, PathBuf, AuthorizedWorkspace) {
|
||||
let base = std::env::temp_dir().join(format!("photocraft-workspace-{}-{name}", std::process::id()));
|
||||
let inside = base.join("inside");
|
||||
let outside = base.join("outside");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let workspace = AuthorizedWorkspace::new(Some(&inside), Some(&inside)).unwrap();
|
||||
(inside, outside, workspace)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_read_and_nonexistent_output_write_stay_in_root() {
|
||||
let (inside, _, workspace) = roots("valid");
|
||||
std::fs::write(inside.join("read.txt"), b"canary").unwrap();
|
||||
assert_eq!(workspace.read("read.txt").unwrap(), b"canary");
|
||||
workspace.write("new-output.txt", b"created").unwrap();
|
||||
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");
|
||||
for path in [
|
||||
"",
|
||||
"/absolute",
|
||||
"../escape",
|
||||
"a/../escape",
|
||||
"a\\..\\escape",
|
||||
"a\\b",
|
||||
"C:/escape",
|
||||
"a//b",
|
||||
"./a",
|
||||
"name:stream",
|
||||
"NUL",
|
||||
"con.txt",
|
||||
"folder/COM1.log",
|
||||
] {
|
||||
assert!(workspace.read(path).is_err(), "read accepted {path:?}");
|
||||
assert!(workspace.write(path, b"x").is_err(), "write accepted {path:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_and_write_authority_are_separate() {
|
||||
let (inside, _, _) = roots("separate");
|
||||
std::fs::write(inside.join("read.txt"), b"canary").unwrap();
|
||||
let read_only = AuthorizedWorkspace::new(Some(&inside), None).unwrap();
|
||||
assert_eq!(read_only.read("read.txt").unwrap(), b"canary");
|
||||
assert!(read_only.write("blocked.txt", b"x").is_err());
|
||||
let write_only = AuthorizedWorkspace::new(None, Some(&inside)).unwrap();
|
||||
assert!(write_only.read("read.txt").is_err());
|
||||
write_only.write("written.txt", b"ok").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_parent_is_a_stable_error_before_any_file_is_created() {
|
||||
let (inside, _, workspace) = roots("missing-parent");
|
||||
let error = workspace.write("missing/output.txt", b"x").unwrap_err().to_string();
|
||||
assert!(error.contains("automation write"), "{error}");
|
||||
assert!(!inside.join("missing").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlink_escape_is_rejected() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let (inside, outside, workspace) = roots("symlink");
|
||||
std::fs::write(outside.join("secret.txt"), b"outside").unwrap();
|
||||
symlink(&outside, inside.join("link")).unwrap();
|
||||
assert!(workspace.read("link/secret.txt").is_err());
|
||||
assert!(workspace.write("link/new.txt", b"blocked").is_err());
|
||||
assert!(!outside.join("new.txt").exists());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_symlink_escape_is_rejected_when_supported() {
|
||||
use std::os::windows::fs::symlink_dir;
|
||||
|
||||
let (inside, outside, workspace) = roots("windows-link");
|
||||
std::fs::write(outside.join("secret.txt"), b"outside").unwrap();
|
||||
if symlink_dir(&outside, inside.join("link")).is_err() {
|
||||
return;
|
||||
}
|
||||
assert!(workspace.read("link/secret.txt").is_err());
|
||||
assert!(workspace.write("link/new.txt", b"blocked").is_err());
|
||||
assert!(!outside.join("new.txt").exists());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_junction_escape_is_rejected_when_supported() {
|
||||
use std::process::Command;
|
||||
|
||||
let (inside, outside, workspace) = roots("windows-junction");
|
||||
std::fs::write(outside.join("secret.txt"), b"outside").unwrap();
|
||||
let link = inside.join("junction");
|
||||
let status = Command::new("cmd.exe").args(["/D", "/C", "mklink", "/J"]).arg(&link).arg(&outside).status();
|
||||
if !status.is_ok_and(|status| status.success()) {
|
||||
return;
|
||||
}
|
||||
assert!(workspace.read("junction/secret.txt").is_err());
|
||||
assert!(workspace.write("junction/new.txt", b"blocked").is_err());
|
||||
assert!(!outside.join("new.txt").exists());
|
||||
let _ = std::fs::remove_dir(link);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filesystem_commands_fail_closed() {
|
||||
for id in [
|
||||
"file.openAs",
|
||||
"file.export.saveForWebLegacy",
|
||||
"pattern.import",
|
||||
"layer.smartObjects.exportContents",
|
||||
"measurementLog.export",
|
||||
"layer.videoLayers.reloadFrame",
|
||||
"image.applyDataSet",
|
||||
"edit.colorSettings",
|
||||
] {
|
||||
assert!(authorize_engine_command(id, &serde_json::json!({})).is_err());
|
||||
}
|
||||
assert!(authorize_engine_command("file.new", &serde_json::json!({})).is_ok());
|
||||
assert!(authorize_engine_command("image.mode.cmyk", &serde_json::json!({})).is_ok());
|
||||
assert!(authorize_desktop_engine_command("image.mode.cmyk", &serde_json::json!({})).is_err());
|
||||
assert!(authorize_engine_command("filter.distort.displace", &serde_json::json!({"mapPath": "outside.png"})).is_err());
|
||||
assert!(authorize_engine_command("layer.setAdjustment", &serde_json::json!({"file": "outside.cube"})).is_err());
|
||||
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "colorSettings.workingRgb", "value": "outside.icc"})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use photocraft_automation::PhotocraftMcp;
|
||||
use photocraft_automation::{AuthorizedWorkspace, PhotocraftMcp};
|
||||
use rmcp::model::{CallToolRequestParams, CallToolResult, ClientConfig};
|
||||
use rmcp::service::RunningService;
|
||||
use rmcp::{ClientHandler, RoleClient, ServiceExt};
|
||||
@@ -21,10 +21,11 @@ impl ClientHandler for Client {
|
||||
|
||||
type Conn = RunningService<RoleClient, Client>;
|
||||
|
||||
async fn connect() -> Conn {
|
||||
async fn connect(root: &std::path::Path) -> Conn {
|
||||
let (s, c) = tokio::io::duplex(1 << 20);
|
||||
let workspace = AuthorizedWorkspace::new(Some(root), Some(root)).expect("test workspace");
|
||||
tokio::spawn(async move {
|
||||
if let Ok(running) = PhotocraftMcp::headless().serve(s).await {
|
||||
if let Ok(running) = PhotocraftMcp::headless_with_workspace(workspace).serve(s).await {
|
||||
let _ = running.waiting().await;
|
||||
}
|
||||
});
|
||||
@@ -73,7 +74,7 @@ fn tmp(name: &str) -> std::path::PathBuf {
|
||||
}
|
||||
|
||||
/// A 64×48 RGB gradient PNG (red ramps across, green down).
|
||||
fn gradient_png(dir: &std::path::Path) -> String {
|
||||
fn gradient_png(dir: &std::path::Path) -> &'static str {
|
||||
let (w, h) = (64u32, 48u32);
|
||||
let mut px = Vec::with_capacity((w * h * 3) as usize);
|
||||
for y in 0..h {
|
||||
@@ -84,7 +85,7 @@ fn gradient_png(dir: &std::path::Path) -> String {
|
||||
let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgb, px).unwrap();
|
||||
let path = dir.join("gradient.png");
|
||||
std::fs::File::create(&path).unwrap().write_all(&photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
"gradient.png"
|
||||
}
|
||||
|
||||
fn close(a: &[f64], b: &[f64], tol: f64) -> bool {
|
||||
@@ -95,7 +96,7 @@ fn close(a: &[f64], b: &[f64], tol: f64) -> bool {
|
||||
async fn agent_completes_ten_scripted_tasks() {
|
||||
let dir = tmp("tasks");
|
||||
let png = gradient_png(&dir);
|
||||
let c = connect().await;
|
||||
let c = connect(&dir).await;
|
||||
|
||||
// 1. Make a title card: type layer with a drop shadow, exported as PNG.
|
||||
tool(&c, "doc_new", json!({"width": 200, "height": 120, "background": "white"})).await;
|
||||
@@ -107,7 +108,7 @@ async fn agent_completes_ten_scripted_tasks() {
|
||||
assert_eq!(t["text"]["text"], "Hello", "task 1: {t}");
|
||||
assert_eq!(t["effects"]["items"][0]["kind"], "Drop Shadow", "task 1: {t}");
|
||||
let out = dir.join("card.png");
|
||||
tool(&c, "doc_export", json!({"path": out.to_string_lossy()})).await;
|
||||
tool(&c, "doc_export", json!({"path": "card.png"})).await;
|
||||
let card = photocraft_codecs::decode(&std::fs::read(&out).unwrap()).unwrap();
|
||||
assert_eq!(card.dimensions(), (200, 120), "task 1");
|
||||
tool(&c, "doc_close", json!({})).await;
|
||||
@@ -187,12 +188,12 @@ async fn agent_completes_ten_scripted_tasks() {
|
||||
.collect();
|
||||
assert_eq!(xs, [10, 10, 10], "task 7: {}", doc["layers"]);
|
||||
|
||||
// 8. Export every layer to its own file.
|
||||
// 8. Export through the capability-scoped document API.
|
||||
let layers_dir = dir.join("layers");
|
||||
std::fs::create_dir_all(&layers_dir).unwrap();
|
||||
run(&c, "file.export.layersToFiles", json!({"dir": layers_dir.to_string_lossy(), "format": "png", "prefix": "swatch"})).await;
|
||||
tool(&c, "doc_export", json!({"path": "layers/swatch.png"})).await;
|
||||
let n = std::fs::read_dir(&layers_dir).unwrap().filter(|e| e.as_ref().is_ok_and(|e| e.path().extension().is_some_and(|x| x == "png"))).count();
|
||||
assert_eq!(n, 4, "task 8: three swatches plus the background");
|
||||
assert_eq!(n, 1, "task 8: capability-scoped export");
|
||||
|
||||
// 9. Resize, then crop to a square.
|
||||
run(&c, "image.imageSize", json!({"width": 60, "height": 45})).await;
|
||||
@@ -203,10 +204,9 @@ async fn agent_completes_ten_scripted_tasks() {
|
||||
// 10. Prepare for print: convert to CMYK and save natively, then reopen.
|
||||
run(&c, "image.mode.cmyk", json!({"intent": "perceptual"})).await;
|
||||
assert_eq!(inspect(&c).await["mode"], "Cmyk", "task 10");
|
||||
let saved = dir.join("print.pcraft");
|
||||
tool(&c, "doc_save", json!({"path": saved.to_string_lossy()})).await;
|
||||
tool(&c, "doc_save", json!({"path": "print.pcraft"})).await;
|
||||
tool(&c, "doc_close", json!({})).await;
|
||||
tool(&c, "doc_open", json!({"path": saved.to_string_lossy()})).await;
|
||||
tool(&c, "doc_open", json!({"path": "print.pcraft"})).await;
|
||||
let doc = inspect(&c).await;
|
||||
assert_eq!((doc["mode"].as_str(), doc["width"].as_u64()), (Some("Cmyk"), Some(45)), "task 10");
|
||||
assert_eq!(doc["layers"].as_array().unwrap().len(), 4, "task 10");
|
||||
|
||||
+118
-16
@@ -3,7 +3,7 @@
|
||||
use std::io::Write;
|
||||
|
||||
use base64::Engine as _;
|
||||
use photocraft_automation::PhotocraftMcp;
|
||||
use photocraft_automation::{AuthorizedWorkspace, PhotocraftMcp};
|
||||
use rmcp::model::{CallToolRequestParams, CallToolResult, ClientConfig};
|
||||
use rmcp::service::RunningService;
|
||||
use rmcp::{ClientHandler, RoleClient, ServiceExt};
|
||||
@@ -54,6 +54,22 @@ fn tmp(name: &str) -> std::path::PathBuf {
|
||||
d
|
||||
}
|
||||
|
||||
fn cleanup(path: &std::path::Path) {
|
||||
for _ in 0..20 {
|
||||
match std::fs::remove_dir_all(path) {
|
||||
Ok(()) => return,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
|
||||
Err(_) => std::thread::sleep(std::time::Duration::from_millis(10)),
|
||||
}
|
||||
}
|
||||
std::fs::remove_dir_all(path).expect("remove test workspace after server shutdown");
|
||||
}
|
||||
|
||||
fn headless_in(root: &std::path::Path) -> PhotocraftMcp {
|
||||
let workspace = AuthorizedWorkspace::new(Some(root), Some(root)).expect("test workspace");
|
||||
PhotocraftMcp::headless_with_workspace(workspace)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn lists_expected_tools() {
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
@@ -94,7 +110,7 @@ async fn lists_expected_tools() {
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn headless_edit_render_save_roundtrip() {
|
||||
let dir = tmp("edit");
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
let client = connect(headless_in(&dir)).await;
|
||||
|
||||
let r = call(&client, "doc_new", json!({"width": 64, "height": 48, "background": "white", "name": "Agent"})).await;
|
||||
assert_ne!(r.is_error, Some(true), "{}", text(&r));
|
||||
@@ -116,18 +132,17 @@ async fn headless_edit_render_save_roundtrip() {
|
||||
let decoded = photocraft_codecs::decode(&png).unwrap();
|
||||
assert_eq!(decoded.dimensions(), (32, 24));
|
||||
|
||||
let pc = dir.join("agent.pcraft");
|
||||
let r = json_of(&call(&client, "doc_save", json!({"path": pc.to_string_lossy()})).await);
|
||||
assert_eq!(r["path"], pc.to_string_lossy().as_ref());
|
||||
let r = json_of(&call(&client, "doc_save", json!({"path": "agent.pcraft"})).await);
|
||||
assert_eq!(r["path"], "agent.pcraft");
|
||||
let png_path = dir.join("agent.png");
|
||||
json_of(&call(&client, "doc_export", json!({"path": png_path.to_string_lossy()})).await);
|
||||
json_of(&call(&client, "doc_export", json!({"path": "agent.png"})).await);
|
||||
let jpg_path = dir.join("agent.jpg");
|
||||
json_of(&call(&client, "doc_export", json!({"path": jpg_path.to_string_lossy(), "quality": 70})).await);
|
||||
json_of(&call(&client, "doc_export", json!({"path": "agent.jpg", "quality": 70})).await);
|
||||
assert!(photocraft_codecs::decode(&std::fs::read(&png_path).unwrap()).is_ok());
|
||||
assert!(std::fs::metadata(&jpg_path).unwrap().len() > 100);
|
||||
|
||||
// Re-open the native file: identical layer tree.
|
||||
let o = json_of(&call(&client, "doc_open", json!({"path": pc.to_string_lossy()})).await);
|
||||
let o = json_of(&call(&client, "doc_open", json!({"path": "agent.pcraft"})).await);
|
||||
assert_eq!(o["index"], 1);
|
||||
let doc2 = json_of(&call(&client, "doc_inspect", json!({"index": 1})).await);
|
||||
assert_eq!(doc2["layers"].as_array().unwrap().len(), 2);
|
||||
@@ -136,7 +151,7 @@ async fn headless_edit_render_save_roundtrip() {
|
||||
json_of(&call(&client, "doc_close", json!({"index": 1})).await);
|
||||
json_of(&call(&client, "doc_select", json!({"index": 0})).await);
|
||||
client.cancel().await.unwrap();
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
cleanup(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -174,19 +189,103 @@ async fn errors_are_tool_errors_not_crashes() {
|
||||
client.cancel().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn preview_budget_failure_preserves_the_mcp_session() {
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
json_of(&call(&client, "doc_new", json!({"width": 2049, "height": 1})).await);
|
||||
for side in [0, 2049] {
|
||||
let reply = call(&client, "doc_render_preview", json!({"max_side": side})).await;
|
||||
assert_eq!(reply.is_error, Some(true));
|
||||
assert!(text(&reply).contains("preview side exceeds"));
|
||||
}
|
||||
let preview = call(&client, "doc_render_preview", json!({"max_side": 32})).await;
|
||||
assert!(preview.content.iter().any(|content| content.as_image().is_some()));
|
||||
let inspected = json_of(&call(&client, "doc_inspect", json!({})).await);
|
||||
assert_eq!(inspected["width"], 2049);
|
||||
client.cancel().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_response_budget_drops_connection_without_retrying_the_operation() {
|
||||
use photocraft_automation::{BridgeClient, budgets::MAX_RESPONSE_BYTES};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let app = tokio::spawn(async move {
|
||||
let (sock, _) = listener.accept().await.unwrap();
|
||||
let (read, mut write) = sock.into_split();
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).await.unwrap();
|
||||
let auth: Value = serde_json::from_str(&line).unwrap();
|
||||
let reply = json!({"id": auth["id"], "ok": true, "result": {"authenticated": true}});
|
||||
write.write_all(format!("{reply}\n").as_bytes()).await.unwrap();
|
||||
line.clear();
|
||||
reader.read_line(&mut line).await.unwrap();
|
||||
let request: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(request["method"], "engine.execute");
|
||||
let mut oversized = vec![b'x'; MAX_RESPONSE_BYTES];
|
||||
// The read limit cuts this multi-byte character in half. Classification must
|
||||
// still be a non-retryable budget error, not a UTF-8 transport failure.
|
||||
oversized.extend_from_slice("é".as_bytes());
|
||||
write.write_all(&oversized).await.unwrap();
|
||||
// A retry would connect to this still-live listener. The budget error must return
|
||||
// directly instead, without waiting for another authentication exchange.
|
||||
let next = tokio::time::timeout(std::time::Duration::from_millis(250), listener.accept()).await;
|
||||
assert!(next.is_err(), "oversized reply must not retry the operation");
|
||||
});
|
||||
let bridge = BridgeClient::new(&addr, CONTROL_TOKEN).unwrap();
|
||||
let result = tokio::time::timeout(std::time::Duration::from_secs(5), bridge.call("engine.execute", json!({"command": "command.list"}))).await.unwrap();
|
||||
assert!(result.unwrap_err().to_string().contains("bridge response exceeds"));
|
||||
app.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn open_png_and_inspect() {
|
||||
let dir = tmp("open");
|
||||
let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![200; 96]).unwrap();
|
||||
let path = dir.join("in.png");
|
||||
std::fs::File::create(&path).unwrap().write_all(&photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
let o = json_of(&call(&client, "doc_open", json!({"path": path.to_string_lossy()})).await);
|
||||
let client = connect(headless_in(&dir)).await;
|
||||
let o = json_of(&call(&client, "doc_open", json!({"path": "in.png"})).await);
|
||||
assert_eq!((o["width"].as_u64(), o["height"].as_u64()), (Some(8), Some(4)));
|
||||
let px = json_of(&call(&client, "command_run", json!({"id": "document.pixel", "params": {"x": 1, "y": 1}})).await);
|
||||
assert!(px.to_string().contains("0.78"), "{px}");
|
||||
client.cancel().await.unwrap();
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
cleanup(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn filesystem_policy_rejects_absolute_and_escaping_paths_before_effects() {
|
||||
let base = tmp("filesystem-policy");
|
||||
let root = base.join("workspace");
|
||||
let outside = base.join("outside");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
|
||||
let img = photocraft_codecs::Image::from_u8(4, 3, photocraft_codecs::ChannelLayout::Rgb, vec![42; 36]).unwrap();
|
||||
let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
std::fs::write(root.join("inside.png"), bytes).unwrap();
|
||||
let client = connect(headless_in(&root)).await;
|
||||
|
||||
assert_ne!(call(&client, "doc_open", json!({"path": "inside.png"})).await.is_error, Some(true));
|
||||
let absolute = outside.join("absolute.png");
|
||||
let response = call(&client, "doc_save", json!({"path": absolute.to_string_lossy()})).await;
|
||||
assert_eq!(response.is_error, Some(true));
|
||||
assert!(text(&response).contains("automation path rejected"), "{}", text(&response));
|
||||
assert!(!absolute.exists());
|
||||
|
||||
for path in ["../outside/traversal.png", "..\\outside\\mixed.png", "C:/outside/prefix.png", ""] {
|
||||
let response = call(&client, "doc_save", json!({"path": path})).await;
|
||||
assert_eq!(response.is_error, Some(true), "path {path:?}: {}", text(&response));
|
||||
}
|
||||
assert!(!outside.join("traversal.png").exists());
|
||||
assert!(!outside.join("mixed.png").exists());
|
||||
|
||||
let response = call(&client, "command_run", json!({"id": "layer.smartObjects.exportContents", "params": {"path": "outside.bin"}})).await;
|
||||
assert_eq!(response.is_error, Some(true));
|
||||
assert!(text(&response).contains("ambient filesystem paths"), "{}", text(&response));
|
||||
client.cancel().await.unwrap();
|
||||
cleanup(&base);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -218,16 +317,16 @@ 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"]}})
|
||||
}
|
||||
"engine.commands" => {
|
||||
json!({"id": id, "ok": true, "result": [{"id": "file.new", "label": "New…", "enabled": true}]})
|
||||
}
|
||||
"ui.screenshot" => {
|
||||
let path = req["params"]["path"].as_str().unwrap().to_owned();
|
||||
let img = photocraft_codecs::Image::from_u8(40, 20, photocraft_codecs::ChannelLayout::Rgba, vec![9; 3200]).unwrap();
|
||||
std::fs::write(&path, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
|
||||
json!({"id": id, "ok": true, "result": {"path": path}})
|
||||
let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
let png = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
json!({"id": id, "ok": true, "result": {"mimeType": "image/png", "base64": png}})
|
||||
}
|
||||
_ => json!({"id": id, "ok": false, "error": format!("unknown tool `{method}`")}),
|
||||
};
|
||||
@@ -250,6 +349,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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -60,6 +60,10 @@ the same.
|
||||
|
||||
## Documented asymmetries and limitations
|
||||
|
||||
* **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused
|
||||
with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes
|
||||
and develops them, and `photocraft-io` routes them there.
|
||||
|
||||
* **AVIF (the only asymmetric format).** Encoding uses `ravif`, which is pure Rust. Decoding
|
||||
needs `dav1d`, which is C. AVIF is therefore read-unsupported, and write support is gated
|
||||
behind the non-default `avif` feature. In a default build it is neither readable nor writable,
|
||||
@@ -101,7 +105,7 @@ the same.
|
||||
`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^28 pixels and 2 GiB.
|
||||
defaults are 262144 px per side, 2^30 pixels and 8 GiB (2 GiB on 32-bit targets such as wasm).
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
}
|
||||
|
||||
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
let img = src.convert(plan.layout, plan.sample);
|
||||
let img = src.converted(plan.layout, plan.sample);
|
||||
let (w, h) = (img.width() as usize, img.height() as usize);
|
||||
let layout = img.layout();
|
||||
let names: &[&str] = match layout {
|
||||
|
||||
@@ -153,7 +153,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
}
|
||||
|
||||
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
let img = src.convert(plan.layout, plan.sample);
|
||||
let img = src.converted(plan.layout, plan.sample);
|
||||
let (w, h) = img.dimensions();
|
||||
let (w16, h16) = match (u16::try_from(w), u16::try_from(h)) {
|
||||
(Ok(a), Ok(b)) => (a, b),
|
||||
|
||||
@@ -97,7 +97,7 @@ fn is_latin1_keyword(k: &str) -> bool {
|
||||
}
|
||||
|
||||
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
let img = src.convert(plan.layout, plan.sample);
|
||||
let img = src.converted(plan.layout, plan.sample);
|
||||
let (w, h) = img.dimensions();
|
||||
let color = match img.layout() {
|
||||
ChannelLayout::Gray => png::ColorType::Grayscale,
|
||||
@@ -112,9 +112,9 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
s => return Err(CodecError::encode(F, format!("unsupported sample {s:?}"))),
|
||||
};
|
||||
// Big-endian sample bytes.
|
||||
let mut data = img.data().to_vec();
|
||||
let mut data = std::borrow::Cow::Borrowed(img.data());
|
||||
if depth == png::BitDepth::Sixteen {
|
||||
for c in data.as_chunks_mut::<2>().0 {
|
||||
for c in data.to_mut().as_chunks_mut::<2>().0 {
|
||||
let v = u16::from_ne_bytes([c[0], c[1]]);
|
||||
c.copy_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
|
||||
@@ -241,7 +241,7 @@ fn decode_pam(r: &mut Reader<'_>, limits: &Limits) -> Result<Image, CodecError>
|
||||
}
|
||||
|
||||
pub(crate) fn encode(src: &Image, plan: Plan, _opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
let img = src.convert(plan.layout, plan.sample);
|
||||
let img = src.converted(plan.layout, plan.sample);
|
||||
let (w, h) = img.dimensions();
|
||||
let layout = img.layout();
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -135,7 +135,7 @@ fn camera_raw(b: &[u8]) -> bool {
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
if camera_raw(bytes) {
|
||||
return Err(CodecError::unsupported(F, "camera raw files (such as CR2, NEF, ARW or DNG) are not supported"));
|
||||
return Err(CodecError::unsupported(F, "camera raw files (such as CR2, NEF, ARW or DNG) are not flat images; decode them with photocraft-raw"));
|
||||
}
|
||||
let tl = {
|
||||
let mut l = tiff::decoder::Limits::default();
|
||||
@@ -360,7 +360,7 @@ where
|
||||
}
|
||||
|
||||
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
let img = src.convert(plan.layout, plan.sample);
|
||||
let img = src.converted(plan.layout, plan.sample);
|
||||
let (w, h) = img.dimensions();
|
||||
let compression = match opts.tiff_compression {
|
||||
TiffCompression::None => tiff::encoder::Compression::Uncompressed,
|
||||
|
||||
@@ -99,7 +99,7 @@ pub(crate) fn encode(f: Format, src: &Image, plan: Plan, opts: &EncodeOptions) -
|
||||
{
|
||||
return Err(CodecError::encode(f, format!("dimensions exceed {mw}x{mh}")));
|
||||
}
|
||||
let img = src.convert(plan.layout, plan.sample);
|
||||
let img = src.converted(plan.layout, plan.sample);
|
||||
let dynimg = to_dynamic(&img)?;
|
||||
let mut cursor = Cursor::new(Vec::new());
|
||||
match f {
|
||||
|
||||
@@ -37,7 +37,7 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
if !opts.webp_lossless {
|
||||
return Err(CodecError::unsupported(F, "lossy WebP encoding needs libwebp (C); only lossless is available"));
|
||||
}
|
||||
let img = src.convert(plan.layout, plan.sample);
|
||||
let img = src.converted(plan.layout, plan.sample);
|
||||
let ct = match img.layout() {
|
||||
ChannelLayout::Gray => image_webp::ColorType::L8,
|
||||
ChannelLayout::GrayA => image_webp::ColorType::La8,
|
||||
|
||||
@@ -327,6 +327,12 @@ impl Image {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Image::convert`], borrowing `self` when it already has that layout and sample type
|
||||
/// (encoders use it so a full-size image isn't copied for nothing).
|
||||
pub fn converted(&self, layout: ChannelLayout, sample: SampleType) -> std::borrow::Cow<'_, Image> {
|
||||
if layout == self.layout && sample == self.sample { std::borrow::Cow::Borrowed(self) } else { std::borrow::Cow::Owned(self.convert(layout, sample)) }
|
||||
}
|
||||
|
||||
/// Interleaved RGBA, 8 bits per sample.
|
||||
pub fn to_rgba8(&self) -> Vec<u8> {
|
||||
self.convert(ChannelLayout::Rgba, SampleType::U8).data
|
||||
|
||||
@@ -18,7 +18,10 @@ pub struct Limits {
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Limits { max_width: 1 << 18, max_height: 1 << 18, max_pixels: 1 << 28, max_alloc: 2 << 30 }
|
||||
// 2^30 pixels (e.g. 32768²) and, on 64-bit targets, 8 GiB: enough for the large
|
||||
// documents Photoshop users open (a 20000² RGBA 16-bit image is 3.2 GB).
|
||||
let max_alloc = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 };
|
||||
Limits { max_width: 1 << 18, max_height: 1 << 18, max_pixels: 1 << 30, max_alloc }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
+157
-14
@@ -3,13 +3,91 @@
|
||||
//! CMYK ↔ RGB goes through the ICC colour management module (`photocraft-cms`) with the
|
||||
//! built-in coated CMYK profile and sRGB (relative colorimetric with black point
|
||||
//! compensation, Photoshop's default), so CMYK pixels display and accept painted colours the
|
||||
//! way a colour-managed editor does. Document-specific profiles are applied by the engine's
|
||||
//! mode/profile conversions; these per-pixel helpers always assume the built-in profiles.
|
||||
//! way a colour-managed editor does. A document's embedded CMYK profile replaces the built-in
|
||||
//! one while its [`CmykSpace`] is active on the thread ([`with_cmyk_space`]: the compositor,
|
||||
//! the GPU upload and composite exports enter it); otherwise the built-in profiles apply.
|
||||
//! Lab uses the exact D50 formulas; gray is treated as sGray (gray `v` = sRGB `(v, v, v)`).
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::cell::RefCell;
|
||||
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||
|
||||
use photocraft_cms::{Builtin, Intent, Transform};
|
||||
use photocraft_cms::{Builtin, ColorSpace, Intent, Profile, Transform};
|
||||
|
||||
/// A document's own CMYK profile, linked to sRGB both ways (relative colorimetric + BPC, like
|
||||
/// the built-in defaults). While a space is active on a thread ([`with_cmyk_space`]),
|
||||
/// [`cmyk_to_rgb`] and [`rgb_to_cmyk`] (and so `Surface::rgba`, the compositor's reads of CMYK
|
||||
/// layers and CMYK fill colours) use it instead of the built-in coated CMYK.
|
||||
#[derive(Debug)]
|
||||
pub struct CmykSpace {
|
||||
to_srgb: Transform,
|
||||
from_srgb: Transform,
|
||||
/// Content hash of the profile (cache keys of converted pixels).
|
||||
pub id: u64,
|
||||
}
|
||||
|
||||
impl CmykSpace {
|
||||
/// The space of a document's embedded profile bytes: `None` when there are none, they do
|
||||
/// not parse as a CMYK profile, or they are the built-in coated CMYK (the default path).
|
||||
/// Cached by allocation, so calling this per render is cheap.
|
||||
pub fn for_profile(icc: Option<&Arc<Vec<u8>>>) -> Option<Arc<CmykSpace>> {
|
||||
type Cache = Mutex<Vec<(Weak<Vec<u8>>, Option<Arc<CmykSpace>>)>>;
|
||||
static CACHE: OnceLock<Cache> = OnceLock::new();
|
||||
let bytes = icc?;
|
||||
let cache = CACHE.get_or_init(Default::default);
|
||||
{
|
||||
let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
c.retain(|(w, _)| w.strong_count() > 0);
|
||||
if let Some((_, s)) = c.iter().find(|(w, _)| w.as_ptr() == Arc::as_ptr(bytes)) {
|
||||
return s.clone();
|
||||
}
|
||||
}
|
||||
let space = Self::build(bytes);
|
||||
cache.lock().unwrap_or_else(|e| e.into_inner()).push((Arc::downgrade(bytes), space.clone()));
|
||||
space
|
||||
}
|
||||
|
||||
fn build(bytes: &[u8]) -> Option<Arc<CmykSpace>> {
|
||||
let p = Profile::parse(bytes).ok().filter(|p| p.color_space == ColorSpace::Cmyk)?;
|
||||
let coated = Builtin::CoatedCmyk.profile();
|
||||
if p.content_hash() == coated.content_hash() {
|
||||
return None;
|
||||
}
|
||||
let srgb = Builtin::Srgb.profile();
|
||||
let to_srgb = Transform::new(&p, srgb, Intent::RelativeColorimetric, true).ok()?;
|
||||
let from_srgb = Transform::new(srgb, &p, Intent::RelativeColorimetric, true).ok()?;
|
||||
Some(Arc::new(CmykSpace { to_srgb, from_srgb, id: p.content_hash() }))
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static ACTIVE_CMYK: RefCell<Option<Arc<CmykSpace>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Restores the previously active CMYK space when dropped (also on unwind).
|
||||
struct Restore(Option<Arc<CmykSpace>>);
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
let prev = self.0.take();
|
||||
ACTIVE_CMYK.with(|a| *a.borrow_mut() = prev);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f` with `space` as this thread's CMYK profile (`None`: the built-in coated CMYK).
|
||||
/// Thread-local: parallel workers must enter the scope themselves.
|
||||
pub fn with_cmyk_space<R>(space: Option<&Arc<CmykSpace>>, f: impl FnOnce() -> R) -> R {
|
||||
if space.is_none() && ACTIVE_CMYK.with(|a| a.borrow().is_none()) {
|
||||
return f();
|
||||
}
|
||||
let prev = ACTIVE_CMYK.with(|a| std::mem::replace(&mut *a.borrow_mut(), space.cloned()));
|
||||
let _restore = Restore(prev);
|
||||
f()
|
||||
}
|
||||
|
||||
/// The CMYK space active on this thread (see [`with_cmyk_space`]).
|
||||
pub fn active_cmyk_space() -> Option<Arc<CmykSpace>> {
|
||||
ACTIVE_CMYK.with(|a| a.borrow().clone())
|
||||
}
|
||||
|
||||
// The built-in profiles always link (the tests use these transforms); `None` only guards
|
||||
// against a cms regression, and then the conversions fall back to the naive formulas.
|
||||
@@ -23,25 +101,47 @@ fn srgb_to_cmyk_transform() -> Option<&'static Transform> {
|
||||
T.get_or_init(|| Transform::new(Builtin::Srgb.profile(), Builtin::CoatedCmyk.profile(), Intent::RelativeColorimetric, true).ok()).as_ref()
|
||||
}
|
||||
|
||||
/// Colour-managed CMYK → sRGB (built-in coated CMYK profile, relative colorimetric + BPC).
|
||||
/// Colour-managed CMYK → sRGB (the active document CMYK profile, else the built-in coated
|
||||
/// CMYK; relative colorimetric + BPC).
|
||||
#[inline]
|
||||
pub fn cmyk_to_rgb(c: [f32; 4]) -> [f32; 3] {
|
||||
let Some(t) = cmyk_to_srgb_transform() else {
|
||||
return cmyk_to_rgb_naive(c);
|
||||
};
|
||||
let mut o = [0.0f32; 3];
|
||||
t.eval_fast(&c, &mut o);
|
||||
// The active document's embedded CMYK profile, if one is in scope.
|
||||
let active = ACTIVE_CMYK.with(|a| match &*a.borrow() {
|
||||
Some(s) => {
|
||||
s.to_srgb.eval_fast(&c, &mut o);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
});
|
||||
if !active {
|
||||
let Some(t) = cmyk_to_srgb_transform() else {
|
||||
return cmyk_to_rgb_naive(c);
|
||||
};
|
||||
t.eval_fast(&c, &mut o);
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// Colour-managed sRGB → CMYK (built-in coated CMYK profile, relative colorimetric + BPC).
|
||||
/// Colour-managed sRGB → CMYK (the active document CMYK profile, else the built-in coated
|
||||
/// CMYK; relative colorimetric + BPC).
|
||||
#[inline]
|
||||
pub fn rgb_to_cmyk(rgb: [f32; 3]) -> [f32; 4] {
|
||||
let Some(t) = srgb_to_cmyk_transform() else {
|
||||
return rgb_to_cmyk_naive(rgb);
|
||||
};
|
||||
let v = [rgb[0].clamp(0.0, 1.0), rgb[1].clamp(0.0, 1.0), rgb[2].clamp(0.0, 1.0)];
|
||||
let mut o = [0.0f32; 4];
|
||||
t.eval_fast(&[rgb[0].clamp(0.0, 1.0), rgb[1].clamp(0.0, 1.0), rgb[2].clamp(0.0, 1.0)], &mut o);
|
||||
let active = ACTIVE_CMYK.with(|a| match &*a.borrow() {
|
||||
Some(s) => {
|
||||
s.from_srgb.eval_fast(&v, &mut o);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
});
|
||||
if !active {
|
||||
let Some(t) = srgb_to_cmyk_transform() else {
|
||||
return rgb_to_cmyk_naive(rgb);
|
||||
};
|
||||
t.eval_fast(&v, &mut o);
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
@@ -149,6 +249,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_cmyk_space_scope() {
|
||||
// A synthetic uncoated-like profile (heavier dot gain than the built-in coated one).
|
||||
let params = photocraft_cms::synth::CmykParams {
|
||||
description: "Test Uncoated".into(),
|
||||
tvi: [0.26, 0.26, 0.26, 0.3],
|
||||
grid_a2b: 5,
|
||||
grid_b2a: 9,
|
||||
..Default::default()
|
||||
};
|
||||
let p = photocraft_cms::synth::cmyk_profile(¶ms);
|
||||
let bytes = p.to_bytes();
|
||||
let space = CmykSpace::for_profile(Some(&bytes)).expect("a CMYK profile other than the default");
|
||||
// Cached by allocation.
|
||||
assert!(Arc::ptr_eq(&space, &CmykSpace::for_profile(Some(&bytes)).unwrap()));
|
||||
// The built-in profile, absent or non-CMYK bytes keep the default path.
|
||||
assert!(CmykSpace::for_profile(Some(&Builtin::CoatedCmyk.profile().to_bytes())).is_none());
|
||||
assert!(CmykSpace::for_profile(Some(&Builtin::Srgb.profile().to_bytes())).is_none());
|
||||
assert!(CmykSpace::for_profile(Some(&Arc::new(vec![1, 2, 3]))).is_none());
|
||||
assert!(CmykSpace::for_profile(None).is_none());
|
||||
let ink = [0.0, 0.5, 0.5, 0.0];
|
||||
let default = cmyk_to_rgb(ink);
|
||||
let mut expect = [0.0f32; 3];
|
||||
Transform::new(&p, Builtin::Srgb.profile(), Intent::RelativeColorimetric, true).unwrap().eval(&ink, &mut expect);
|
||||
let inside = with_cmyk_space(Some(&space), || {
|
||||
assert!(active_cmyk_space().is_some());
|
||||
cmyk_to_rgb(ink)
|
||||
});
|
||||
for i in 0..3 {
|
||||
assert!((inside[i] - expect[i]).abs() < 0.01, "{inside:?} vs {expect:?}");
|
||||
}
|
||||
assert!((0..3).any(|i| (inside[i] - default[i]).abs() > 0.02), "the document profile changes the colour: {inside:?} {default:?}");
|
||||
// Restored afterwards (and nested scopes restore the outer one).
|
||||
assert!(active_cmyk_space().is_none());
|
||||
assert_eq!(cmyk_to_rgb(ink), default);
|
||||
with_cmyk_space(Some(&space), || {
|
||||
with_cmyk_space(None, || assert!(active_cmyk_space().is_none()));
|
||||
assert!(active_cmyk_space().is_some());
|
||||
let back = cmyk_to_rgb(rgb_to_cmyk([0.6, 0.4, 0.3]));
|
||||
assert!((back[0] - 0.6).abs() < 0.04 && (back[2] - 0.3).abs() < 0.04, "{back:?}");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_roundtrip_and_anchors() {
|
||||
let white = srgb_to_lab([1.0, 1.0, 1.0]);
|
||||
|
||||
@@ -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.
|
||||
|
||||
+284
-54
@@ -5,7 +5,7 @@
|
||||
|
||||
use photocraft_color::convert::rgb_to_gray;
|
||||
use photocraft_doc::Adjustment;
|
||||
use photocraft_doc::adjust::{CurvePoint, LevelsChannel};
|
||||
use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel, ToneSpace};
|
||||
|
||||
use crate::Buffer;
|
||||
|
||||
@@ -30,6 +30,15 @@ impl Transfer {
|
||||
_ => 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 +60,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 +105,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);
|
||||
@@ -96,39 +113,26 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
|
||||
})
|
||||
})
|
||||
}
|
||||
Adjustment::Levels { master, per_channel } => {
|
||||
let luts: [Vec<f32>; 3] =
|
||||
std::array::from_fn(|i| (0..LUT_SIZE).map(|k| levels(&per_channel[i], levels(master, k as f32 / (LUT_SIZE - 1) as f32))).collect());
|
||||
map_rgb(buf, |c| std::array::from_fn(|i| lut(&luts[i], c[i])))
|
||||
Adjustment::Levels { space, .. } | Adjustment::Curves { space, .. } => {
|
||||
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)),
|
||||
}
|
||||
}
|
||||
Adjustment::Curves { master, per_channel } => {
|
||||
// Photoshop applies each channel's curve first, then the
|
||||
// composite (RGB) curve.
|
||||
let m = curve_lut(master);
|
||||
let luts: [Vec<f32>; 3] = std::array::from_fn(|i| {
|
||||
let ch = curve_lut(&per_channel[i]);
|
||||
ch.iter().map(|&v| lut(&m, v)).collect()
|
||||
});
|
||||
map_rgb(buf, |c| std::array::from_fn(|i| lut(&luts[i], c[i])))
|
||||
}
|
||||
Adjustment::HueSaturation { hue, saturation, lightness, colorize } => {
|
||||
let (h, s, l) = (*hue, *saturation / 100.0, *lightness / 100.0);
|
||||
Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => {
|
||||
let table = (!*colorize && ranges.iter().any(|r| !r.is_neutral())).then(|| hue_range_tables(ranges));
|
||||
map_rgb(buf, |c| {
|
||||
let (mut hh, mut ss, ll) = rgb_to_hsl(c);
|
||||
if *colorize {
|
||||
hh = h.rem_euclid(360.0) / 360.0;
|
||||
ss = s.abs().max(0.25);
|
||||
} else {
|
||||
hh = (hh + h / 360.0).rem_euclid(1.0);
|
||||
ss = (ss * (1.0 + s)).clamp(0.0, 1.0);
|
||||
}
|
||||
let mut rgb = hsl_to_rgb(hh, ss, ll);
|
||||
if l > 0.0 {
|
||||
rgb = rgb.map(|v| v + (1.0 - v) * l);
|
||||
} else if l < 0.0 {
|
||||
rgb = rgb.map(|v| v * (1.0 + l));
|
||||
}
|
||||
rgb
|
||||
let (dh, ds, dl) = match &table {
|
||||
Some(t) => {
|
||||
// Range edits follow the pixel's original hue, faded out towards grey (greys have no hue).
|
||||
let h0 = rgb_to_hsl(c).0;
|
||||
let chroma = ((c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])) * 4.0).min(1.0);
|
||||
(lut(&t[0], h0) * chroma, lut(&t[1], h0) * chroma, lut(&t[2], h0) * chroma)
|
||||
}
|
||||
None => (0.0, 0.0, 0.0),
|
||||
};
|
||||
hue_saturation(c, *hue + dh, (*saturation / 100.0 + ds).clamp(-1.0, 1.0), (*lightness / 100.0 + dl).clamp(-1.0, 1.0), *colorize)
|
||||
})
|
||||
}
|
||||
Adjustment::Vibrance { vibrance, saturation } => {
|
||||
@@ -154,28 +158,30 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
|
||||
}
|
||||
}),
|
||||
Adjustment::BlackWhite { weights, tint } => map_rgb(buf, |c| {
|
||||
// weights: reds, yellows, greens, cyans, blues, magentas in percent (PS defaults 40,60,40,60,20,80)
|
||||
let (h, s, _l) = rgb_to_hsl(c);
|
||||
let base = rgb_to_gray(c);
|
||||
let sector = h * 6.0;
|
||||
let i0 = sector.floor() as usize % 6;
|
||||
let i1 = (i0 + 1) % 6;
|
||||
let f = sector.fract();
|
||||
let defaults = [40.0, 60.0, 40.0, 60.0, 20.0, 80.0];
|
||||
let w = (weights[i0] - defaults[i0]) * (1.0 - f) + (weights[i1] - defaults[i1]) * f;
|
||||
let g = (base + s * w / 100.0 * 0.5).clamp(0.0, 1.0);
|
||||
let g = black_white_gray(c, weights);
|
||||
match tint {
|
||||
Some(t) => std::array::from_fn(|i| (g * t[i] * 2.0).clamp(0.0, 1.0) * 0.5 + g * 0.5),
|
||||
None => [g; 3],
|
||||
}
|
||||
}),
|
||||
Adjustment::GradientMap { stops, reverse } => map_rgb(buf, |c| {
|
||||
let mut t = rgb_to_gray(c);
|
||||
if *reverse {
|
||||
t = 1.0 - t;
|
||||
Adjustment::GradientMap { stops, reverse, dither } => {
|
||||
let (w, x0, y0) = (buf.rect.width().max(1) as usize, buf.rect.x0, buf.rect.y0);
|
||||
for (i, p) in buf.px.iter_mut().enumerate() {
|
||||
if p[3] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut t = rgb_to_gray([p[0], p[1], p[2]]);
|
||||
if *reverse {
|
||||
t = 1.0 - t;
|
||||
}
|
||||
let mut o = gradient(stops, t);
|
||||
if *dither {
|
||||
let d = bayer4(x0 + (i % w) as i32, y0 + (i / w) as i32) / 255.0;
|
||||
o = o.map(|v| (v + d).clamp(0.0, 1.0));
|
||||
}
|
||||
p[..3].copy_from_slice(&o);
|
||||
}
|
||||
gradient(stops, t)
|
||||
}),
|
||||
}
|
||||
Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => map_rgb(buf, |c| {
|
||||
let l = rgb_to_gray(c);
|
||||
let ws = (1.0 - l * 2.0).clamp(0.0, 1.0);
|
||||
@@ -209,6 +215,123 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-channel LUTs of a Levels or Curves adjustment, each channel's record composed with the
|
||||
/// master (Photoshop applies the channel curve first, then the composite). Four rows: the three
|
||||
/// `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_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 } => {
|
||||
let m = if *space == ToneSpace::Lab { curve_lut(&[]) } else { curve_lut(master) };
|
||||
let row = |c: &[CurvePoint]| curve_lut(c).iter().map(|&v| lut(&m, v)).collect();
|
||||
[row(&per_channel[0]), row(&per_channel[1]), row(&per_channel[2]), row(if *space == ToneSpace::Cmyk { black } else { &[] })]
|
||||
}
|
||||
_ => std::array::from_fn(|_| (0..LUT_SIZE).map(x).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies channel LUTs to a display-RGB colour in CMYK (ink brightness, 1 - ink) or Lab space,
|
||||
/// through the conversions the document's surfaces use. The change is added as a difference of
|
||||
/// two round trips, so channels a curve leaves alone (and out-of-gamut colours) stay exact.
|
||||
fn tone_in_space(space: ToneSpace, luts: &[Vec<f32>; 4], c: [f32; 3]) -> [f32; 3] {
|
||||
use photocraft_color::convert::{cmyk_to_rgb, lab_to_srgb, rgb_to_cmyk, srgb_to_lab};
|
||||
let (before, after) = match space {
|
||||
ToneSpace::Cmyk => {
|
||||
let ink = rgb_to_cmyk(c);
|
||||
let out: [f32; 4] = std::array::from_fn(|i| 1.0 - lut(&luts[i], 1.0 - ink[i]));
|
||||
if out == ink {
|
||||
return c;
|
||||
}
|
||||
(cmyk_to_rgb(ink), cmyk_to_rgb(out))
|
||||
}
|
||||
ToneSpace::Lab => {
|
||||
let l = srgb_to_lab(c);
|
||||
let n = [l[0] / 100.0, (l[1] + 128.0) / 255.0, (l[2] + 128.0) / 255.0];
|
||||
let o: [f32; 3] = std::array::from_fn(|i| lut(&luts[i], n[i]));
|
||||
if o == n {
|
||||
return c;
|
||||
}
|
||||
let back = |v: [f32; 3]| lab_to_srgb([v[0] * 100.0, v[1] * 255.0 - 128.0, v[2] * 255.0 - 128.0]);
|
||||
(back(n), back(o))
|
||||
}
|
||||
ToneSpace::Rgb => return std::array::from_fn(|i| lut(&luts[i], c[i])),
|
||||
};
|
||||
std::array::from_fn(|i| (c[i] + after[i] - before[i]).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// Hue/Saturation's range edits as three hue-indexed tables (hue shift in degrees, saturation and
|
||||
/// lightness as fractions), sampled at hue `k / (len - 1)` turns. Ranges add up where they overlap.
|
||||
pub fn hue_range_tables(ranges: &[HueRange; 6]) -> [Vec<f32>; 3] {
|
||||
let at = |k: usize| {
|
||||
let deg = k as f32 / (LUT_SIZE - 1) as f32 * 360.0;
|
||||
ranges.iter().filter(|r| !r.is_neutral()).fold([0.0f32; 3], |acc, r| {
|
||||
let w = r.weight(deg);
|
||||
[acc[0] + w * r.hue, acc[1] + w * r.saturation / 100.0, acc[2] + w * r.lightness / 100.0]
|
||||
})
|
||||
};
|
||||
let rows: Vec<[f32; 3]> = (0..LUT_SIZE).map(at).collect();
|
||||
std::array::from_fn(|i| rows.iter().map(|r| r[i]).collect())
|
||||
}
|
||||
|
||||
/// One Hue/Saturation evaluation: hue shift in degrees, saturation and lightness in -1..=1.
|
||||
/// Colorize sets the hue and saturation instead of shifting them.
|
||||
pub fn hue_saturation(c: [f32; 3], hue: f32, s: f32, l: f32, colorize: bool) -> [f32; 3] {
|
||||
let (mut hh, mut ss, ll) = rgb_to_hsl(c);
|
||||
if colorize {
|
||||
hh = hue.rem_euclid(360.0) / 360.0;
|
||||
ss = s.abs().max(0.25);
|
||||
} else {
|
||||
hh = (hh + hue / 360.0).rem_euclid(1.0);
|
||||
ss = (ss * (1.0 + s)).clamp(0.0, 1.0);
|
||||
}
|
||||
let mut rgb = hsl_to_rgb(hh, ss, ll);
|
||||
if l > 0.0 {
|
||||
rgb = rgb.map(|v| v + (1.0 - v) * l);
|
||||
} else if l < 0.0 {
|
||||
rgb = rgb.map(|v| v * (1.0 + l));
|
||||
}
|
||||
rgb
|
||||
}
|
||||
|
||||
/// Black & White grey value. Each colour is the sum of a grey part (its smallest channel), a
|
||||
/// secondary-colour part (yellow, cyan or magenta: middle − smallest) and a primary part (red,
|
||||
/// green or blue: largest − middle); the sliders (percent, Photoshop defaults 40 60 40 60 20 80 for
|
||||
/// reds, yellows, greens, cyans, blues, magentas) weight the two colour parts.
|
||||
pub fn black_white_gray(c: [f32; 3], weights: &[f32; 6]) -> f32 {
|
||||
let (r, g, b) = (c[0], c[1], c[2]);
|
||||
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
|
||||
let mid = r + g + b - max - min;
|
||||
// Primary: the largest channel; secondary: the two largest channels together.
|
||||
let primary = if r >= g && r >= b {
|
||||
0
|
||||
} else if g >= b {
|
||||
2
|
||||
} else {
|
||||
4
|
||||
};
|
||||
let secondary = if b <= r && b <= g {
|
||||
1 // red + green = yellow
|
||||
} else if r <= g {
|
||||
3 // green + blue = cyan
|
||||
} else {
|
||||
5 // red + blue = magenta
|
||||
};
|
||||
(min + (mid - min) * weights[secondary] / 100.0 + (max - mid) * weights[primary] / 100.0).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Ordered-dither offset in -0.5..0.5 (4×4 Bayer matrix) for document pixel (x, y).
|
||||
pub fn bayer4(x: i32, y: i32) -> f32 {
|
||||
const M: [f32; 16] = [0.0, 8.0, 2.0, 10.0, 12.0, 4.0, 14.0, 6.0, 3.0, 11.0, 1.0, 9.0, 15.0, 7.0, 13.0, 5.0];
|
||||
@@ -358,20 +481,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 {
|
||||
@@ -541,3 +681,93 @@ mod lookup_tests {
|
||||
assert_eq!(bayer4(-4, -4), bayer4(0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tone_tests {
|
||||
use super::*;
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
fn ramp() -> Buffer {
|
||||
let rect = Rect::new(0, 0, 8, 8);
|
||||
let px = (0..64).map(|i| [((i % 8) as f32) / 7.0, ((i / 8) as f32) / 7.0, 0.3, 1.0]).collect();
|
||||
Buffer { rect, px }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_tone_in_every_space_is_identity() {
|
||||
for space in [ToneSpace::Rgb, ToneSpace::Cmyk, ToneSpace::Lab] {
|
||||
let mut a = Adjustment::identity_curves();
|
||||
if let Adjustment::Curves { space: s, .. } = &mut a {
|
||||
*s = space;
|
||||
}
|
||||
let mut b = ramp();
|
||||
apply(&a, &mut b);
|
||||
for (x, y) in b.px.iter().zip(ramp().px.iter()) {
|
||||
for k in 0..3 {
|
||||
assert!((x[k] - y[k]).abs() < 0.02, "{space:?} {x:?} {y:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_black_curve_darkens_and_lab_lightness_lifts() {
|
||||
let line = |a: f32, b: f32| vec![CurvePoint { input: 0.0, output: a }, CurvePoint { input: 1.0, output: b }];
|
||||
// Black brightness 1 -> 0.5: more black ink everywhere.
|
||||
let a = Adjustment::Curves {
|
||||
master: line(0.0, 1.0),
|
||||
per_channel: [line(0.0, 1.0), line(0.0, 1.0), line(0.0, 1.0)],
|
||||
space: ToneSpace::Cmyk,
|
||||
black: line(0.0, 0.5),
|
||||
};
|
||||
let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[0.8, 0.8, 0.8, 1.0]] };
|
||||
apply(&a, &mut b);
|
||||
assert!(b.px[0][0] < 0.7, "{:?}", b.px[0]);
|
||||
// Lab lightness 0..1 -> 0.2..1 lifts a dark grey; a/b untouched keeps it grey.
|
||||
let a = Adjustment::Curves {
|
||||
master: line(1.0, 0.0), // ignored in Lab
|
||||
per_channel: [line(0.2, 1.0), line(0.0, 1.0), line(0.0, 1.0)],
|
||||
space: ToneSpace::Lab,
|
||||
black: Vec::new(),
|
||||
};
|
||||
let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[0.2, 0.2, 0.2, 1.0]] };
|
||||
apply(&a, &mut b);
|
||||
let p = b.px[0];
|
||||
assert!(p[0] > 0.3 && (p[0] - p[2]).abs() < 0.02, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_ranges_only_touch_their_colours() {
|
||||
let mut ranges = HueRange::defaults();
|
||||
ranges[0].saturation = -100.0; // desaturate reds
|
||||
let a = Adjustment::HueSaturation { hue: 0.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges };
|
||||
let mut b = Buffer { rect: Rect::new(0, 0, 3, 1), px: vec![[0.9, 0.1, 0.1, 1.0], [0.1, 0.1, 0.9, 1.0], [0.5, 0.5, 0.5, 1.0]] };
|
||||
apply(&a, &mut b);
|
||||
let red = b.px[0];
|
||||
assert!((red[0] - red[1]).abs() < 0.05, "red desaturated {red:?}");
|
||||
assert!((b.px[1][2] - 0.9).abs() < 1e-4 && (b.px[1][0] - 0.1).abs() < 1e-4, "blue untouched {:?}", b.px[1]);
|
||||
assert_eq!(b.px[2], [0.5, 0.5, 0.5, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_white_weights() {
|
||||
let d = [40.0, 60.0, 40.0, 60.0, 20.0, 80.0];
|
||||
assert!((black_white_gray([0.5; 3], &d) - 0.5).abs() < 1e-6, "greys keep their value");
|
||||
assert!((black_white_gray([1.0, 0.0, 0.0], &d) - 0.4).abs() < 1e-6);
|
||||
assert!((black_white_gray([1.0, 1.0, 0.0], &d) - 0.6).abs() < 1e-6);
|
||||
let mut w = d;
|
||||
w[0] = 100.0;
|
||||
assert!(black_white_gray([1.0, 0.0, 0.0], &w) > black_white_gray([1.0, 0.0, 0.0], &d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_map_dither_stays_close() {
|
||||
let stops = vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])];
|
||||
let mut a = ramp();
|
||||
let mut b = ramp();
|
||||
apply(&Adjustment::GradientMap { stops: stops.clone(), reverse: false, dither: false }, &mut a);
|
||||
apply(&Adjustment::GradientMap { stops, reverse: false, dither: true }, &mut b);
|
||||
assert!(a.px.iter().zip(&b.px).all(|(x, y)| (x[0] - y[0]).abs() <= 0.5 / 255.0 + 1e-6));
|
||||
assert!(a.px.iter().zip(&b.px).any(|(x, y)| x[0] != y[0]));
|
||||
}
|
||||
}
|
||||
+361
-79
@@ -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;
|
||||
@@ -25,7 +27,7 @@ use photocraft_doc::{
|
||||
};
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use crate::pattern::{Placement, Tile};
|
||||
use crate::pattern::{PREPARED_PATTERN_BYTES, Placement, PreparedPatterns};
|
||||
use crate::{Buffer, psblend};
|
||||
|
||||
/// `true` if the layer has at least one enabled effect and the master switch is on.
|
||||
@@ -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.
|
||||
@@ -446,9 +465,25 @@ pub fn gradient_t(style: GradientStyle, angle: f32, scale: f32, reverse: bool, o
|
||||
|
||||
/// Samples colour and opacity stops at `t`.
|
||||
pub fn sample_gradient(g: &Gradient, t: f32) -> [f32; 4] {
|
||||
let color = sample_stops(&g.stops.iter().map(|(p, c)| (*p, rgb(c))).collect::<Vec<_>>(), t);
|
||||
let alpha = if g.opacity_stops.is_empty() { 1.0 } else { sample_stops(&g.opacity_stops.iter().map(|(p, a)| (*p, [*a; 3])).collect::<Vec<_>>(), t)[0] };
|
||||
[color[0], color[1], color[2], alpha]
|
||||
PreparedGradient::new(g).sample(t)
|
||||
}
|
||||
|
||||
// Prepared only for one paint call: CMYK conversions must use that call's active profile.
|
||||
struct PreparedGradient {
|
||||
color: Vec<(f32, [f32; 3])>,
|
||||
opacity: Vec<(f32, [f32; 3])>,
|
||||
}
|
||||
|
||||
impl PreparedGradient {
|
||||
fn new(g: &Gradient) -> Self {
|
||||
Self { color: g.stops.iter().map(|(p, c)| (*p, rgb(c))).collect(), opacity: g.opacity_stops.iter().map(|(p, a)| (*p, [*a; 3])).collect() }
|
||||
}
|
||||
|
||||
fn sample(&self, t: f32) -> [f32; 4] {
|
||||
let color = sample_stops(&self.color, t);
|
||||
let alpha = if self.opacity.is_empty() { 1.0 } else { sample_stops(&self.opacity, t)[0] };
|
||||
[color[0], color[1], color[2], alpha]
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_stops(stops: &[(f32, [f32; 3])], t: f32) -> [f32; 3] {
|
||||
@@ -557,8 +592,18 @@ fn glow_map(shape: &Map, g: &Glow, inner: bool) -> Map {
|
||||
|
||||
/// Paints a pattern (looked up in `patterns`) through coverage `m`; missing patterns paint nothing.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paint_pattern(dst: &mut Buffer, m: &Map, patterns: &[Pattern], name: &str, id: &str, place: Placement, big: Rect, blend: BlendMode, opacity: f32) {
|
||||
let Some(tile) = photocraft_doc::pattern::find(patterns, id, name).and_then(Tile::new) else { return };
|
||||
fn paint_pattern(
|
||||
dst: &mut Buffer,
|
||||
m: &Map,
|
||||
patterns: &PreparedPatterns<'_>,
|
||||
name: &str,
|
||||
id: &str,
|
||||
place: Placement,
|
||||
big: Rect,
|
||||
blend: BlendMode,
|
||||
opacity: f32,
|
||||
) {
|
||||
let Some(tile) = patterns.get(id, name) else { return };
|
||||
let w = big.width() as usize;
|
||||
paint(
|
||||
dst,
|
||||
@@ -574,17 +619,29 @@ fn paint_pattern(dst: &mut Buffer, m: &Map, patterns: &[Pattern], name: &str, id
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paint_fx(dst: &mut Buffer, m: &Map, p: &FxPaint, shape_bounds: Rect, anchor: (f64, f64), big: Rect, blend: BlendMode, opacity: f32, patterns: &[Pattern]) {
|
||||
fn paint_fx(
|
||||
dst: &mut Buffer,
|
||||
m: &Map,
|
||||
p: &FxPaint,
|
||||
shape_bounds: Rect,
|
||||
anchor: (f64, f64),
|
||||
big: Rect,
|
||||
blend: BlendMode,
|
||||
opacity: f32,
|
||||
patterns: &PreparedPatterns<'_>,
|
||||
) {
|
||||
match p {
|
||||
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);
|
||||
sample_gradient(g, 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,
|
||||
@@ -667,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;
|
||||
@@ -774,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.
|
||||
@@ -793,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 {
|
||||
@@ -808,7 +869,7 @@ pub fn bevel_geom(b: &Bevel) -> BevelGeom {
|
||||
/// Bevel height map in `0..=1`. Smooth: the shape blurred by two box passes of the bevel
|
||||
/// width. Chisel: linear ramps of the exact distance to the edge (size wide; emboss styles
|
||||
/// straddle the edge), slightly blurred for Chisel Soft. Soften blurs the result.
|
||||
fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx) -> Map {
|
||||
fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, patterns: &PreparedPatterns<'_>) -> Map {
|
||||
let size = b.size.max(1.0);
|
||||
let mut h = if b.technique == BevelTechnique::Smooth {
|
||||
let mut h = shape.clone();
|
||||
@@ -832,7 +893,7 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx) -> Map
|
||||
// Texture element: the pattern's luminance as extra height, scaled so a full-contrast step
|
||||
// at 100 % depth slopes like the bevel at its depth.
|
||||
if let Some(t) = &b.texture
|
||||
&& let Some(tile) = photocraft_doc::pattern::find(tex.patterns, &t.id, &t.name).and_then(Tile::new)
|
||||
&& let Some(tile) = patterns.get(&t.id, &t.name)
|
||||
{
|
||||
let unit = if b.depth.abs() > 1e-6 { (g.depth / b.depth).abs().max(1e-3) } else { g.width.max(1.0) };
|
||||
let place = Placement::anchored(tex.anchor, t.link, t.phase, t.scale, 0.0);
|
||||
@@ -878,14 +939,23 @@ pub fn bevel_chisel_h(paint: BevelPaint, size: f32, din: f32, dout: f32) -> f32
|
||||
|
||||
/// Bevel highlight and shadow maps: `[hi, sh]` (inner or outer) or `[hi, sh, hi_out, sh_out]`
|
||||
/// (emboss styles).
|
||||
fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx) -> (Vec<Map>, BevelPaint) {
|
||||
fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, patterns: &PreparedPatterns<'_>) -> (Vec<Map>, BevelPaint) {
|
||||
let g = bevel_geom(b);
|
||||
let hmap = bevel_height(shape, b, &g, tex);
|
||||
let hmap = bevel_height(shape, b, &g, tex, patterns);
|
||||
let (angle, altitude) = if b.use_global_light { (light.angle, light.altitude) } else { (b.angle, b.altitude) };
|
||||
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 {
|
||||
@@ -895,7 +965,7 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx) ->
|
||||
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;
|
||||
@@ -908,13 +978,21 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx) ->
|
||||
};
|
||||
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)
|
||||
@@ -933,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 {
|
||||
@@ -986,6 +1075,18 @@ fn satin_map(shape: &Map, s: &photocraft_doc::effects::Satin) -> Map {
|
||||
|
||||
/// Build every effect map for `layer` from its alpha `shape` over `rect`.
|
||||
pub fn build_maps(layer: &Layer, shape: Vec<f32>, rect: Rect, light: &GlobalLight, tex: &TextureCtx) -> FxMaps {
|
||||
let prepared = PreparedPatterns::new(tex.patterns, PREPARED_PATTERN_BYTES);
|
||||
build_maps_prepared(layer, shape, rect, light, tex, &prepared)
|
||||
}
|
||||
|
||||
pub(crate) fn build_maps_prepared(
|
||||
layer: &Layer,
|
||||
shape: Vec<f32>,
|
||||
rect: Rect,
|
||||
light: &GlobalLight,
|
||||
tex: &TextureCtx,
|
||||
patterns: &PreparedPatterns<'_>,
|
||||
) -> FxMaps {
|
||||
let (w, h) = (rect.width() as usize, rect.height() as usize);
|
||||
let shape = Map { w, h, v: shape };
|
||||
let items: Vec<&Effect> = layer.effects.items.iter().filter(|e| e.enabled()).collect();
|
||||
@@ -1000,7 +1101,7 @@ pub fn build_maps(layer: &Layer, shape: Vec<f32>, rect: Rect, light: &GlobalLigh
|
||||
Effect::InnerGlow(g) => vec![glow_map(&shape, g, true)],
|
||||
Effect::Satin(s) => vec![satin_map(&shape, s)],
|
||||
Effect::BevelEmboss(b) => {
|
||||
let (maps, paint) = bevel_maps(&shape, b, light, tex);
|
||||
let (maps, paint) = bevel_maps(&shape, b, light, tex, patterns);
|
||||
bevel_paint[i] = paint;
|
||||
maps
|
||||
}
|
||||
@@ -1010,8 +1111,21 @@ pub fn build_maps(layer: &Layer, shape: Vec<f32>, rect: Rect, light: &GlobalLigh
|
||||
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).
|
||||
@@ -1020,9 +1134,32 @@ const FAR: f32 = 1.0e9;
|
||||
/// Composites `content` (the layer's own pixels over `big`, alpha already
|
||||
/// 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, None);
|
||||
}
|
||||
|
||||
pub(crate) fn composite_with_effects_prepared(
|
||||
layer: &Layer,
|
||||
content: &Buffer,
|
||||
backdrop: &mut Buffer,
|
||||
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).
|
||||
@@ -1071,7 +1208,18 @@ pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Bu
|
||||
// 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 }
|
||||
@@ -1110,6 +1258,15 @@ pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Bu
|
||||
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();
|
||||
@@ -1117,11 +1274,9 @@ pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Bu
|
||||
(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 {
|
||||
@@ -1131,11 +1286,11 @@ pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Bu
|
||||
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);
|
||||
@@ -1161,19 +1316,27 @@ pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Bu
|
||||
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 {
|
||||
@@ -1196,10 +1359,9 @@ pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Bu
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -1213,6 +1375,17 @@ pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Bu
|
||||
*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 {
|
||||
@@ -1225,6 +1398,55 @@ pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Bu
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -1244,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).
|
||||
@@ -1265,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),
|
||||
}
|
||||
@@ -1313,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);
|
||||
@@ -1326,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);
|
||||
@@ -1341,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);
|
||||
@@ -1409,7 +1657,13 @@ mod tests {
|
||||
#[test]
|
||||
fn smooth_inner_bevel_lights_the_top_edge_inside_only() {
|
||||
let shape = square(40, 10, 30);
|
||||
let (maps, paint) = bevel_maps(&shape, &bevel_of(BevelStyle::InnerBevel, BevelTechnique::Smooth), &GlobalLight::default(), &no_tex());
|
||||
let (maps, paint) = bevel_maps(
|
||||
&shape,
|
||||
&bevel_of(BevelStyle::InnerBevel, BevelTechnique::Smooth),
|
||||
&GlobalLight::default(),
|
||||
&no_tex(),
|
||||
&PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES),
|
||||
);
|
||||
assert_eq!(paint, BevelPaint::Inner);
|
||||
let at = |m: &Map, x: usize, y: usize| m.v[y * 40 + x];
|
||||
// Light from the top: highlight along the top edge, shadow along the bottom, flat middle.
|
||||
@@ -1425,19 +1679,47 @@ mod tests {
|
||||
fn emboss_paints_both_sides_and_pillow_flips_the_outside() {
|
||||
let shape = square(40, 10, 30);
|
||||
let l = GlobalLight::default();
|
||||
let (e, paint) = bevel_maps(&shape, &bevel_of(BevelStyle::Emboss, BevelTechnique::Smooth), &l, &no_tex());
|
||||
assert_eq!((paint, e.len()), (BevelPaint::Both, 4));
|
||||
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, 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);
|
||||
let (p, _) = bevel_maps(&shape, &bevel_of(BevelStyle::PillowEmboss, BevelTechnique::Smooth), &l, &no_tex());
|
||||
assert!(at(&p[0], 20, 10) > 0.3 && at(&p[3], 20, 9) > 0.3, "pillow: outside top edge in shadow");
|
||||
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[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]
|
||||
fn chisel_hard_has_flat_facets() {
|
||||
let shape = square(48, 10, 38);
|
||||
let (m, _) = bevel_maps(&shape, &bevel_of(BevelStyle::InnerBevel, BevelTechnique::ChiselHard), &GlobalLight::default(), &no_tex());
|
||||
let (m, _) = bevel_maps(
|
||||
&shape,
|
||||
&bevel_of(BevelStyle::InnerBevel, BevelTechnique::ChiselHard),
|
||||
&GlobalLight::default(),
|
||||
&no_tex(),
|
||||
&PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES),
|
||||
);
|
||||
let at = |x: usize, y: usize| m[0].v[y * 48 + x];
|
||||
// A constant slope: equal highlight along the top facet.
|
||||
assert!(at(24, 12) > 0.1 && (at(24, 12) - at(24, 13)).abs() < 1e-3);
|
||||
@@ -1448,7 +1730,7 @@ mod tests {
|
||||
let shape = square(40, 6, 34);
|
||||
let l = GlobalLight::default();
|
||||
let plain = bevel_of(BevelStyle::InnerBevel, BevelTechnique::Smooth);
|
||||
let (a, _) = bevel_maps(&shape, &plain, &l, &no_tex());
|
||||
let (a, _) = bevel_maps(&shape, &plain, &l, &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES));
|
||||
// A contour reshapes the profile (here a ramp folded at its middle).
|
||||
let mut c = plain.clone();
|
||||
let fold = vec![
|
||||
@@ -1457,7 +1739,7 @@ mod tests {
|
||||
photocraft_doc::adjust::CurvePoint { input: 1.0, output: 0.0 },
|
||||
];
|
||||
c.contour = Some(photocraft_doc::BevelContour { contour: Contour::Custom { name: "fold".into(), points: fold }, range: 1.0, anti_alias: false });
|
||||
let (b, _) = bevel_maps(&shape, &c, &l, &no_tex());
|
||||
let (b, _) = bevel_maps(&shape, &c, &l, &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES));
|
||||
let diff: f32 = a[1].v.iter().zip(&b[1].v).map(|(x, y)| (x - y).abs()).sum();
|
||||
assert!(diff > 5.0, "{diff}");
|
||||
// A striped texture lights the flat middle.
|
||||
@@ -1476,7 +1758,7 @@ mod tests {
|
||||
phase: (0.0, 0.0),
|
||||
});
|
||||
let tex = TextureCtx { rect: Rect::new(0, 0, 40, 40), patterns: &pats, anchor: (0.0, 0.0) };
|
||||
let (m, _) = bevel_maps(&shape, &t, &l, &tex);
|
||||
let (m, _) = bevel_maps(&shape, &t, &l, &tex, &PreparedPatterns::new(&pats, PREPARED_PATTERN_BYTES));
|
||||
let mid: f32 = (16..24).map(|y| m[0].v[y * 40 + 20] + m[1].v[y * 40 + 20]).sum();
|
||||
let flat: f32 = (16..24).map(|y| a[0].v[y * 40 + 20] + a[1].v[y * 40 + 20]).sum();
|
||||
assert!(mid > flat + 0.5, "{mid} vs {flat}");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+674
-188
File diff suppressed because it is too large.
Load diff
+82
-17
@@ -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;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,76 @@
|
||||
//! wrap-around on premultiplied colour, so integer placements at 100 % reproduce the tile
|
||||
//! exactly and scaled ones stay seamless.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use photocraft_doc::{Pattern, Rect};
|
||||
|
||||
pub(crate) const PREPARED_PATTERN_BYTES: usize = 64 << 20;
|
||||
const MAX_PREPARED_PATTERNS: usize = 64;
|
||||
type PreparedSlot = Arc<OnceLock<Option<Arc<Tile>>>>;
|
||||
|
||||
struct PreparedState {
|
||||
slots: HashMap<usize, PreparedSlot>,
|
||||
reserved_bytes: usize,
|
||||
}
|
||||
|
||||
/// Demand-prepared pixels owned by one rendering call, never by the document.
|
||||
pub(crate) struct PreparedPatterns<'a> {
|
||||
patterns: &'a [Pattern],
|
||||
budget: usize,
|
||||
state: Mutex<PreparedState>,
|
||||
}
|
||||
|
||||
impl<'a> PreparedPatterns<'a> {
|
||||
pub(crate) fn new(patterns: &'a [Pattern], budget: usize) -> Self {
|
||||
Self { patterns, budget, state: Mutex::new(PreparedState { slots: HashMap::new(), reserved_bytes: 0 }) }
|
||||
}
|
||||
|
||||
pub(crate) fn source(&self) -> &'a [Pattern] {
|
||||
self.patterns
|
||||
}
|
||||
|
||||
pub(crate) fn get(&self, id: &str, name: &str) -> Option<Arc<Tile>> {
|
||||
let p = photocraft_doc::pattern::find(self.patterns, id, name)?;
|
||||
if p.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let bytes = usize::try_from(p.width)
|
||||
.ok()
|
||||
.and_then(|w| usize::try_from(p.height).ok().and_then(|h| w.checked_mul(h)))
|
||||
.and_then(|pixels| pixels.checked_mul(std::mem::size_of::<[f32; 4]>()));
|
||||
// The borrowed immutable slice pins these addresses for the complete call.
|
||||
let key = std::ptr::from_ref(p) as usize;
|
||||
let slot = {
|
||||
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(slot) = state.slots.get(&key) {
|
||||
Some(Arc::clone(slot))
|
||||
} else if let Some(bytes) = bytes
|
||||
&& bytes <= self.budget.saturating_sub(state.reserved_bytes)
|
||||
&& state.slots.len() < MAX_PREPARED_PATTERNS
|
||||
{
|
||||
let slot: PreparedSlot = Arc::new(OnceLock::new());
|
||||
// Reserve before initialization: concurrent pending buffers consume the budget too.
|
||||
state.reserved_bytes += bytes;
|
||||
state.slots.insert(key, Arc::clone(&slot));
|
||||
Some(slot)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
match slot {
|
||||
Some(slot) => {
|
||||
// Conversion must stay serial: Rayon work stealing can re-enter this slot
|
||||
// while its initializer is running inside an effect-map OnceLock.
|
||||
// Run on the requesting worker to preserve its active CMYK profile.
|
||||
slot.get_or_init(|| Tile::new(p).map(Arc::new)).clone()
|
||||
}
|
||||
None => Tile::new(p).map(Arc::new),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pattern converted once to straight-alpha RGBA for sampling.
|
||||
pub struct Tile {
|
||||
w: usize,
|
||||
@@ -140,9 +208,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn identity_placement_tiles_exactly() {
|
||||
let t = Tile::new(&checker()).unwrap();
|
||||
let patterns = [checker()];
|
||||
let prepared = PreparedPatterns::new(&patterns, PREPARED_PATTERN_BYTES);
|
||||
let t = prepared.get(&patterns[0].id, "").unwrap();
|
||||
let again = prepared.get(&patterns[0].id, "").unwrap();
|
||||
assert!(Arc::ptr_eq(&t, &again));
|
||||
let uncached = PreparedPatterns::new(&patterns, 0);
|
||||
let fallback = uncached.get(&patterns[0].id, "").unwrap();
|
||||
let p = Placement::new(Rect::new(0, 0, 10, 10), false, (0.0, 0.0), 1.0, 0.0);
|
||||
let px = render(&t, &p, Rect::new(-4, 0, 8, 1));
|
||||
assert_eq!(px, render(&fallback, &p, Rect::new(-4, 0, 8, 1)));
|
||||
assert_eq!(px[0], [0.0, 0.0, 1.0, 1.0]); // x = -4 wraps to 0
|
||||
assert_eq!(px[6], [1.0, 0.0, 0.0, 1.0]); // x = 2
|
||||
assert_eq!(px[8], [0.0, 0.0, 1.0, 1.0]); // x = 4
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Downsampled proxy documents: every pixel surface scaled down by an integer factor `k`, so a
|
||||
//! composite of the proxy approximates the document's composite at 1/k resolution for ~1/k² of
|
||||
//! the work. Used for interactive previews and for thumbnails of large documents (navigator,
|
||||
//! channel thumbnails, histograms, file previews), where a full-resolution composite of a 200 MP
|
||||
//! document would cost seconds and gigabytes.
|
||||
|
||||
use photocraft_doc::{Document, Layer, LayerContent, Size};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
/// Nearest-neighbour downsample of a surface by integer factor `k` (document coordinates / k).
|
||||
pub fn downsample(s: &Surface, k: u32) -> Surface {
|
||||
let mut out = Surface::with_default(s.format(), &s.default_pixel());
|
||||
let b = s.content_bounds();
|
||||
if b.is_empty() || k <= 1 {
|
||||
return if k <= 1 { s.clone() } else { out };
|
||||
}
|
||||
// Surfaces span at most ±2^30 px; larger factors reduce everything to one pixel anyway.
|
||||
let k = k.min(1 << 20) as i32;
|
||||
let bpp = s.format().bytes_per_pixel();
|
||||
let (x0, x1) = (b.x0.div_euclid(k), b.x1.div_euclid(k) + i32::from(b.x1.rem_euclid(k) != 0));
|
||||
let (y0, y1) = (b.y0.div_euclid(k), b.y1.div_euclid(k) + i32::from(b.y1.rem_euclid(k) != 0));
|
||||
let w = (x1 - x0).max(0) as usize;
|
||||
let mut row = vec![0u8; w * bpp];
|
||||
for oy in y0..y1 {
|
||||
let src = s.to_interleaved(Rect::new(x0 * k, oy * k, x1 * k, oy * k + 1));
|
||||
for (i, px) in row.chunks_exact_mut(bpp).enumerate() {
|
||||
px.copy_from_slice(&src[i * k as usize * bpp..(i * k as usize + 1) * bpp]);
|
||||
}
|
||||
out.write_interleaved(Rect::new(x0, oy, x1, oy + 1), &row);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn shrink_layer(l: &mut Layer, k: u32) {
|
||||
if let Some(m) = &mut l.mask {
|
||||
m.surface = downsample(&m.surface, k);
|
||||
}
|
||||
if let Some(fc) = &mut l.fill_cache {
|
||||
fc.surface = downsample(&fc.surface, k);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
LayerContent::Text(t) => t.cache = t.cache.as_ref().map(|s| downsample(s, k)),
|
||||
LayerContent::Shape(sh) => sh.cache = sh.cache.as_ref().map(|s| downsample(s, k)),
|
||||
LayerContent::Smart(so) => so.cache = so.cache.as_ref().map(|s| downsample(s, k)),
|
||||
LayerContent::Adjustment(_) | LayerContent::Fill(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy of `doc` scaled down by `k` (same layer ids, so adjustments can be swapped in).
|
||||
pub fn proxy_document(doc: &Document, k: u32) -> Document {
|
||||
let mut p = doc.clone();
|
||||
if k <= 1 {
|
||||
return p;
|
||||
}
|
||||
p.size = Size::new(doc.size.width.div_ceil(k), doc.size.height.div_ceil(k));
|
||||
for l in &mut p.layers {
|
||||
shrink_layer(l, k);
|
||||
}
|
||||
// Spot channels are printed under Multichannel composites.
|
||||
for c in &mut p.channels {
|
||||
c.surface = downsample(&c.surface, k);
|
||||
}
|
||||
p.selection = None;
|
||||
p
|
||||
}
|
||||
|
||||
/// Whether a proxy composite is a faithful reduction of `doc`: layer effects (whose sizes are in
|
||||
/// document pixels) and vector masks (paths in document coordinates) would not scale with it.
|
||||
pub fn proxy_faithful(doc: &Document) -> bool {
|
||||
doc.walk().iter().all(|(_, _, l)| !crate::effects::has_effects(l) && l.vector_mask.as_ref().is_none_or(|v| !v.enabled))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
|
||||
|
||||
#[test]
|
||||
fn downsample_picks_every_kth_pixel() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
s.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 0.0, 0.0, 1.0]);
|
||||
s.write_pixel(4, 4, &[0.0, 0.0, 1.0, 1.0]);
|
||||
let d = downsample(&s, 4);
|
||||
assert_eq!(d.pixel(0, 0), vec![1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(d.pixel(1, 1), vec![0.0, 0.0, 1.0, 1.0]);
|
||||
assert_eq!(d.pixel(2, 2)[3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downsample_handles_negative_coordinates() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
s.fill_rect(Rect::new(-8, -8, 0, 0), &[0.0, 1.0, 0.0, 1.0]);
|
||||
let d = downsample(&s, 4);
|
||||
assert_eq!(d.pixel(-1, -1), vec![0.0, 1.0, 0.0, 1.0]);
|
||||
assert_eq!(d.pixel(0, 0)[3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_keeps_structure_and_ids() {
|
||||
let doc = Document::with_background("d", Size::new(4000, 3000), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||||
let p = proxy_document(&doc, 4);
|
||||
assert_eq!((p.size.width, p.size.height), (1000, 750));
|
||||
assert_eq!(p.layers[0].id, doc.layers[0].id);
|
||||
assert_eq!(p.layers[0].surface().unwrap().pixel(999, 749), vec![1.0; 4]);
|
||||
assert!(proxy_faithful(&doc));
|
||||
}
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
+407
-17
@@ -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);
|
||||
@@ -173,6 +208,16 @@ fn pass_through_group_opacity_mixes() {
|
||||
g.opacity = 0.25;
|
||||
d.layers.push(g);
|
||||
assert!((px(&d, 0, 0)[0] - 0.75).abs() <= E);
|
||||
|
||||
// Opaque backdrops hide straight-alpha interpolation errors.
|
||||
let mut transparent = Document::new("coverage", Size::new(2, 2), ColorMode::Rgb, SampleType::F32);
|
||||
let mut red = Layer::raster("HDR red", transparent.pixel_format());
|
||||
red.surface_mut().unwrap().fill_rect(transparent.bounds(), &[2.0, 0.0, 0.0, 1.0]);
|
||||
let mut group = Layer::group("half", vec![red]);
|
||||
group.opacity = 0.5;
|
||||
transparent.layers.push(group);
|
||||
let actual = px(&transparent, 0, 0);
|
||||
assert!(close4(actual, [2.0, 0.0, 0.0, 0.5]), "{actual:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -246,11 +291,17 @@ fn hue_saturation_roundtrips_and_desaturates() {
|
||||
assert!((back[i] - c[i]).abs() < 1e-5);
|
||||
}
|
||||
let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [0.8, 0.3, 0.1, 1.0]);
|
||||
adjust::apply(&Adjustment::HueSaturation { hue: 0.0, saturation: -100.0, lightness: 0.0, colorize: false }, &mut buf);
|
||||
adjust::apply(
|
||||
&Adjustment::HueSaturation { hue: 0.0, saturation: -100.0, lightness: 0.0, colorize: false, ranges: photocraft_doc::adjust::HueRange::defaults() },
|
||||
&mut buf,
|
||||
);
|
||||
let p = buf.px[0];
|
||||
assert!((p[0] - p[1]).abs() < 1e-5 && (p[1] - p[2]).abs() < 1e-5);
|
||||
let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [1.0, 0.0, 0.0, 1.0]);
|
||||
adjust::apply(&Adjustment::HueSaturation { hue: 120.0, saturation: 0.0, lightness: 0.0, colorize: false }, &mut buf);
|
||||
adjust::apply(
|
||||
&Adjustment::HueSaturation { hue: 120.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges: photocraft_doc::adjust::HueRange::defaults() },
|
||||
&mut buf,
|
||||
);
|
||||
assert!(close4(buf.px[0], [0.0, 1.0, 0.0, 1.0]));
|
||||
}
|
||||
|
||||
@@ -260,16 +311,11 @@ 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 buf = render_fill(&g, Rect::new(0, 0, 10, 1), Rect::new(0, 0, 10, 1), &[]);
|
||||
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
|
||||
let one = render_fill(&g, Rect::new(9, 0, 10, 1), Rect::new(0, 0, 10, 1), &[]);
|
||||
let one = render_fill(&g, Rect::new(9, 0, 10, 1), Rect::new(0, 0, 10, 1), &patterns);
|
||||
assert_eq!(one.px[0], buf.px[9]);
|
||||
assert!(buf.px[0][0] < buf.px[9][0], "left dark, right light");
|
||||
}
|
||||
@@ -584,11 +630,19 @@ fn group_effects_apply_to_group_shape() {
|
||||
|
||||
#[test]
|
||||
fn effects_render_identically_in_tiles() {
|
||||
let d = fx_doc(vec![Effect::DropShadow(Shadow { spread: 0.0, size: 5.0, ..shadow(4.0, 120.0) }), stroke(2.0, StrokePosition::Outside)]);
|
||||
let full = flatten(&d);
|
||||
for (x, y) in [(33, 33), (8, 20), (31, 12)] {
|
||||
let t = render(&d, Rect::from_xywh(x, y, 1, 1)).px[0];
|
||||
assert!(close4(t, full.get(x, y)), "({x},{y})");
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let mut d = fx_doc(vec![Effect::DropShadow(Shadow { spread: 0.0, size: 5.0, ..shadow(4.0, 120.0) }), stroke(2.0, StrokePosition::Outside)]);
|
||||
d.depth = depth;
|
||||
for layer in &mut d.layers {
|
||||
if let Some(surface) = layer.surface_mut() {
|
||||
*surface = surface.convert(photocraft_color::PixelFormat { sample: depth, ..surface.format() });
|
||||
}
|
||||
}
|
||||
let full = flatten(&d);
|
||||
// Compare every boundary pixel: a few sampled points miss application-halo regressions.
|
||||
for tile in [1, 7, 33] {
|
||||
assert_eq!(render_tiled(&d, d.bounds(), tile), full, "{depth:?}, tile {tile}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,13 +715,16 @@ fn effect_maps_are_cached_and_invalidated_by_pixel_changes() {
|
||||
let mut l = solid_layer("fx", Rect::new(16, 16, 48, 48), [1.0, 0.0, 0.0, 1.0]);
|
||||
l.effects.items.push(photocraft_doc::Effect::default_drop_shadow());
|
||||
doc.layers.push(l);
|
||||
let cx = Ctx { canvas: doc.bounds(), transfer: adjust::Transfer::Srgb, light: doc.global_light, patterns: &doc.patterns, mode: doc.mode, depth: doc.depth };
|
||||
let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES);
|
||||
let cx = Ctx::for_doc(&doc, &patterns);
|
||||
let a = effect_maps(&doc.layers[1], &cx);
|
||||
let b = effect_maps(&doc.layers[1], &cx);
|
||||
assert!(std::sync::Arc::ptr_eq(&a, &b), "second request hits the cache");
|
||||
let first = flatten(&doc);
|
||||
// Editing the layer's pixels changes its tiles, so the maps are rebuilt.
|
||||
doc.layers[1].surface_mut().unwrap().fill_rect(Rect::new(8, 8, 20, 20), &[0.0, 0.0, 1.0, 1.0]);
|
||||
let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES);
|
||||
let cx = Ctx::for_doc(&doc, &patterns);
|
||||
let c = effect_maps(&doc.layers[1], &cx);
|
||||
assert!(!std::sync::Arc::ptr_eq(&a, &c), "pixel edit invalidates");
|
||||
// Cached rendering equals a fresh build (tiled and full renders agree too).
|
||||
@@ -678,6 +735,17 @@ fn effect_maps_are_cached_and_invalidated_by_pixel_changes() {
|
||||
assert!(close4(*p, *q));
|
||||
}
|
||||
assert_ne!(first.px, again.px);
|
||||
|
||||
// Tileless masks differ only in their defaults; reusing their effect maps leaves a ghost shadow.
|
||||
let mut masked = fx_doc(vec![Effect::DropShadow(shadow(5.0, 90.0))]);
|
||||
masked.layers[1].mask = Some(photocraft_doc::LayerMask::reveal_all());
|
||||
assert!(close4(px(&masked, 20, 32), [0.0, 0.0, 0.0, 1.0]));
|
||||
masked.layers[1].mask = Some(photocraft_doc::LayerMask::hide_all());
|
||||
let warm = flatten(&masked);
|
||||
let mut cold = masked.clone();
|
||||
cold.layers[1].id = Layer::raster("uncached", cold.pixel_format()).id;
|
||||
assert!(warm.px == flatten(&cold).px, "default-only mask changes must invalidate cached effects");
|
||||
assert!(close4(px(&masked, 20, 32), [1.0; 4]));
|
||||
}
|
||||
|
||||
// ---------- PSD-fidelity effect semantics (fitted on Photoshop composites) ----------
|
||||
@@ -709,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.
|
||||
@@ -1002,3 +1117,278 @@ fn levels_matches_photoshop() {
|
||||
assert!((adjust::levels(&ident(2.5), 1.0) - 1.0).abs() < 5e-4);
|
||||
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));
|
||||
}
|
||||
|
||||
// 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);
|
||||
let mut l = Layer::raster("grad", PixelFormat::RGBA8);
|
||||
let s = l.surface_mut().unwrap();
|
||||
for y in 0..h as i32 {
|
||||
s.fill_rect(Rect::new(0, y, w as i32 / 2, y + 1), &[y as f32 / h as f32, 0.3, 1.0 - y as f32 / h as f32, 1.0]);
|
||||
}
|
||||
s.fill_rect(Rect::new(w as i32 / 2, 0, w as i32, h as i32 / 3), &[0.9, 0.1, 0.1, 0.4]);
|
||||
d.layers.push(l);
|
||||
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_tiled_in_place_matches_one_tile_with_many_bands() {
|
||||
// Enough bands for the band-parallel path, and few for the tile-parallel one.
|
||||
let d = tall_doc(37, 900);
|
||||
let whole = render_tiled(&d, d.bounds(), 10_000);
|
||||
for tile in [5, 64, 300] {
|
||||
assert_eq!(render_tiled(&d, d.bounds(), tile), whole, "tile {tile}");
|
||||
}
|
||||
let r = Rect::new(3, 17, 30, 811);
|
||||
assert_eq!(render_tiled(&d, r, 4).px, render_tiled(&d, r, 10_000).px);
|
||||
assert!(render_tiled(&d, Rect::new(5, 5, 5, 900), 4).px.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_bands_cover_the_rect_in_order() {
|
||||
let d = tall_doc(20, 700);
|
||||
let r = Rect::new(2, 10, 18, 690);
|
||||
let mut rows = Vec::new();
|
||||
let mut px = Vec::new();
|
||||
render_bands(&d, r, 100, |b| -> Result<(), ()> {
|
||||
assert_eq!((b.rect.x0, b.rect.x1), (r.x0, r.x1));
|
||||
rows.push((b.rect.y0, b.rect.y1));
|
||||
px.extend(b.px);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
// 100 rows round up to one 256-row tile band.
|
||||
assert_eq!(rows, vec![(10, 266), (266, 522), (522, 690)]);
|
||||
assert_eq!(px, render(&d, r).px);
|
||||
// The sink's error stops the render.
|
||||
let mut n = 0;
|
||||
assert_eq!(
|
||||
render_bands(&d, r, 1, |_| {
|
||||
n += 1;
|
||||
Err("stop")
|
||||
}),
|
||||
Err("stop")
|
||||
);
|
||||
assert_eq!(n, 1);
|
||||
assert_eq!(band_rows_for(14_000, 0) % RENDER_TILE, 0);
|
||||
assert!(band_rows_for(1, 0) > 0 && band_rows_for(0, -5) > 0);
|
||||
}
|
||||
|
||||
/// The previous thumbnail: flatten, then area-average (premultiplied).
|
||||
fn reference_reduce(doc: &Document, w: usize, h: usize) -> Vec<[f32; 4]> {
|
||||
let full = flatten(doc);
|
||||
let (fw, fh) = (full.rect.width() as usize, full.rect.height() as usize);
|
||||
let mut out = Vec::new();
|
||||
for ty in 0..h {
|
||||
let (y0, y1) = (ty * fh / h, ((ty + 1) * fh / h).max(ty * fh / h + 1).min(fh));
|
||||
for tx in 0..w {
|
||||
let (x0, x1) = (tx * fw / w, ((tx + 1) * fw / w).max(tx * fw / w + 1).min(fw));
|
||||
let mut acc = [0.0f32; 4];
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let p = full.px[y * fw + x];
|
||||
for c in 0..3 {
|
||||
acc[c] += p[c] * p[3];
|
||||
}
|
||||
acc[3] += p[3];
|
||||
}
|
||||
}
|
||||
let n = ((y1 - y0) * (x1 - x0)).max(1) as f32;
|
||||
out.push(if acc[3] > 0.0 { [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3] / n] } else { [0.0; 4] });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduced_render_matches_the_full_composite_averaged() {
|
||||
let d = tall_doc(53, 1000);
|
||||
for (w, h, band) in [(10, 190, 0), (7, 33, 256), (53, 999, 512), (1, 1, 256)] {
|
||||
let got = render_reduced_in_bands(&d, w, h, None, band);
|
||||
assert_eq!((got.rect.width(), got.rect.height()), (w, h));
|
||||
assert_eq!(got.px, reference_reduce(&d, w as usize, h as usize), "{w}x{h} band {band}");
|
||||
}
|
||||
// Full size is the composite itself; an empty document reduces to transparency.
|
||||
assert_eq!(render_reduced(&d, 53, 1000).px, flatten(&d).px);
|
||||
let empty = Document::new("e", Size::new(0, 0), ColorMode::Rgb, SampleType::U8);
|
||||
assert_eq!(render_reduced(&empty, 4, 4).px, vec![[0.0; 4]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduced_damage_matches_the_whole_reduction() {
|
||||
// A reduced canvas texture updates only what a stroke touched: those pixels must equal the
|
||||
// whole reduction's, for uneven factors, damage on span edges and bands smaller than the area.
|
||||
let d = tall_doc(53, 1000);
|
||||
for (w, h, band) in [(10, 190, 0), (7, 33, 256), (53, 999, 64), (53, 1000, 0), (1, 1, 256)] {
|
||||
let all = render_reduced_in_bands(&d, w, h, None, band);
|
||||
for dmg in [Rect::new(0, 0, 1, 1), Rect::new(5, 17, 6, 18), Rect::new(12, 300, 40, 701), Rect::new(-9, 990, 80, 2000), Rect::new(0, 0, 53, 1000)] {
|
||||
let part = render_reduced_in_bands(&d, w, h, Some(dmg), band);
|
||||
let r = part.rect;
|
||||
assert!(!r.is_empty() && r.x0 >= 0 && r.y0 >= 0 && r.x1 as u32 <= w && r.y1 as u32 <= h, "{w}x{h} {dmg:?} -> {r:?}");
|
||||
for y in r.y0..r.y1 {
|
||||
for x in r.x0..r.x1 {
|
||||
assert_eq!(part.get(x, y), all.get(x, y), "{w}x{h} band {band} {dmg:?} at {x},{y}");
|
||||
}
|
||||
}
|
||||
// Exactly the output pixels whose source area meets the damage.
|
||||
let span = |t: u32, f: u32, n: u32| (t * f / n, ((t + 1) * f / n).max(t * f / n + 1).min(f));
|
||||
let d = dmg.intersect(&Rect::new(0, 0, 53, 1000));
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let ((x0, x1), (y0, y1)) = (span(x, 53, w), span(y, 1000, h));
|
||||
let meets = !Rect::new(x0 as i32, y0 as i32, x1 as i32, y1 as i32).intersect(&d).is_empty();
|
||||
assert_eq!(r.contains(x as i32, y as i32), meets, "{w}x{h} {dmg:?} at {x},{y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(render_reduced_in_bands(&d, w, h, Some(Rect::new(60, 0, 70, 10)), band).rect.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_documents_thumbnail_from_a_proxy() {
|
||||
// 4200 × 4200 (17.6 MP) is above PROXY_THUMBNAIL_PIXELS: the proxy path must still give a
|
||||
// thumbnail of the right size whose colours match the exact reduction.
|
||||
let mut d = doc_white(4200, 4200);
|
||||
d.layers.push(solid_layer("red", Rect::new(0, 0, 2100, 4200), [1.0, 0.0, 0.0, 1.0]));
|
||||
let t = thumbnail(&d, 100);
|
||||
assert_eq!((t.width, t.height), (100, 100));
|
||||
let at = |x: usize, y: usize| t.pixels[(y * 100 + x) * 4..(y * 100 + x) * 4 + 4].to_vec();
|
||||
assert_eq!(at(10, 50), vec![255, 0, 0, 255]);
|
||||
assert_eq!(at(90, 50), vec![255, 255, 255, 255]);
|
||||
// Effects don't scale with a proxy: such documents reduce the exact composite.
|
||||
assert!(proxy::proxy_faithful(&d));
|
||||
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]
|
||||
|
||||
+150
-5
@@ -9,6 +9,14 @@ pub struct CurvePoint {
|
||||
pub output: f32,
|
||||
}
|
||||
|
||||
/// Whether `points` is the straight 0→0, 1→1 line (or too short to be a curve).
|
||||
pub fn is_identity_curve(points: &[CurvePoint]) -> bool {
|
||||
match points {
|
||||
[a, b] => a.input == 0.0 && a.output == 0.0 && b.input == 1.0 && b.output == 1.0,
|
||||
p => p.len() < 2,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LevelsChannel {
|
||||
pub in_black: f32,
|
||||
@@ -24,6 +32,93 @@ impl Default for LevelsChannel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which document channels the per-channel records of Levels and Curves address. Compositing
|
||||
/// happens in display RGB; `Cmyk` and `Lab` records are applied to the pixel converted into that
|
||||
/// space (the same conversions the document's surfaces use), so ink and Lab channel edits behave
|
||||
/// like Photoshop's in CMYK and Lab documents. Grayscale documents use `Rgb` with the master record.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ToneSpace {
|
||||
/// `per_channel` = red, green, blue.
|
||||
#[default]
|
||||
Rgb,
|
||||
/// `per_channel` = cyan, magenta, yellow plus `black`; values are channel brightness
|
||||
/// (1 - ink), and the master record applies to all four inks.
|
||||
Cmyk,
|
||||
/// `per_channel` = lightness, a, b (stored 0..=1 like Lab surfaces); there is no composite
|
||||
/// record in Lab, so the master is ignored.
|
||||
Lab,
|
||||
}
|
||||
|
||||
/// One of Hue/Saturation's six colour ranges (reds, yellows, greens, cyans, blues, magentas).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct HueRange {
|
||||
/// Hue shift in degrees (-180..=180).
|
||||
pub hue: f32,
|
||||
/// Saturation change in percent (-100..=100).
|
||||
pub saturation: f32,
|
||||
/// Lightness change in percent (-100..=100).
|
||||
pub lightness: f32,
|
||||
/// Hue degrees where the range starts fading in, is fully on, starts fading out and ends
|
||||
/// (Photoshop's four range sliders). May wrap past 0/360.
|
||||
pub bounds: [f32; 4],
|
||||
}
|
||||
|
||||
impl HueRange {
|
||||
/// The neutral range `i` (0 = reds … 5 = magentas) with Photoshop's default extent: fully on
|
||||
/// for 30° around its centre, fading over 30° each side.
|
||||
pub fn neutral(i: usize) -> Self {
|
||||
let c = 60.0 * i as f32;
|
||||
Self { hue: 0.0, saturation: 0.0, lightness: 0.0, bounds: [c - 45.0, c - 15.0, c + 15.0, c + 45.0] }
|
||||
}
|
||||
|
||||
pub fn defaults() -> [HueRange; 6] {
|
||||
std::array::from_fn(Self::neutral)
|
||||
}
|
||||
|
||||
/// The canonical form of four range sliders: increasing, within one turn, centre in
|
||||
/// -30..330 (so the default reds are -45, -15, 15, 45 whichever way they were written).
|
||||
pub fn canonical_bounds(b: [f32; 4]) -> [f32; 4] {
|
||||
if b.iter().any(|v| !v.is_finite()) {
|
||||
return [-45.0, -15.0, 15.0, 45.0];
|
||||
}
|
||||
let mut out = [b[0]; 4];
|
||||
for k in 1..4 {
|
||||
out[k] = (out[k - 1] + (b[k] - out[k - 1]).rem_euclid(360.0)).min(out[0] + 359.0);
|
||||
}
|
||||
let centre = (out[1] + out[2]) / 2.0;
|
||||
let shift = (centre + 30.0).rem_euclid(360.0) - 30.0 - centre;
|
||||
out.map(|v| v + shift)
|
||||
}
|
||||
|
||||
pub fn is_neutral(&self) -> bool {
|
||||
self.hue == 0.0 && self.saturation == 0.0 && self.lightness == 0.0
|
||||
}
|
||||
|
||||
/// How much a pixel of hue `deg` (degrees) belongs to this range, 0..=1.
|
||||
pub fn weight(&self, deg: f32) -> f32 {
|
||||
let [a, b, c, d] = self.bounds;
|
||||
// Measure from the range's start so wrap-around ranges (reds) work.
|
||||
let rel = |x: f32| if x.is_finite() { (x - a).rem_euclid(360.0) } else { 0.0 };
|
||||
let b = rel(b);
|
||||
let c = rel(c).max(b);
|
||||
let d = rel(d).max(c);
|
||||
let h = rel(deg);
|
||||
if h <= b {
|
||||
if b <= 0.0 { 1.0 } else { h / b }
|
||||
} else if h <= c {
|
||||
1.0
|
||||
} else if h <= d {
|
||||
if d <= c { 1.0 } else { (d - h) / (d - c) }
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_hue_ranges() -> [HueRange; 6] {
|
||||
HueRange::defaults()
|
||||
}
|
||||
|
||||
/// Photoshop adjustment layers (Layer → New Adjustment Layer).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Adjustment {
|
||||
@@ -32,15 +127,25 @@ pub enum Adjustment {
|
||||
contrast: f32,
|
||||
legacy: bool,
|
||||
},
|
||||
/// Composite channel first, then R, G, B.
|
||||
/// Composite channel first, then R, G, B (or the channels `space` names).
|
||||
Levels {
|
||||
master: LevelsChannel,
|
||||
per_channel: [LevelsChannel; 3],
|
||||
#[serde(default)]
|
||||
space: ToneSpace,
|
||||
/// The black ink channel when `space` is [`ToneSpace::Cmyk`].
|
||||
#[serde(default)]
|
||||
black: LevelsChannel,
|
||||
},
|
||||
/// Master curve then R, G, B curves.
|
||||
/// Master curve then R, G, B curves (or the channels `space` names).
|
||||
Curves {
|
||||
master: Vec<CurvePoint>,
|
||||
per_channel: [Vec<CurvePoint>; 3],
|
||||
#[serde(default)]
|
||||
space: ToneSpace,
|
||||
/// The black ink curve when `space` is [`ToneSpace::Cmyk`] (empty = identity).
|
||||
#[serde(default)]
|
||||
black: Vec<CurvePoint>,
|
||||
},
|
||||
Exposure {
|
||||
exposure: f32,
|
||||
@@ -51,11 +156,14 @@ pub enum Adjustment {
|
||||
vibrance: f32,
|
||||
saturation: f32,
|
||||
},
|
||||
/// Master hue / saturation / lightness (or the Colorize colour) plus six hue ranges.
|
||||
HueSaturation {
|
||||
hue: f32,
|
||||
saturation: f32,
|
||||
lightness: f32,
|
||||
colorize: bool,
|
||||
#[serde(default = "default_hue_ranges")]
|
||||
ranges: [HueRange; 6],
|
||||
},
|
||||
ColorBalance {
|
||||
shadows: [f32; 3],
|
||||
@@ -99,6 +207,9 @@ pub enum Adjustment {
|
||||
GradientMap {
|
||||
stops: Vec<(f32, [f32; 3])>,
|
||||
reverse: bool,
|
||||
/// Ordered dither of ±½ an 8-bit step to hide banding.
|
||||
#[serde(default)]
|
||||
dither: bool,
|
||||
},
|
||||
/// Per range (reds, yellows, greens, cyans, blues, magentas, whites, neutrals, blacks) the
|
||||
/// cyan, magenta, yellow and black change in percent (-100..=100), as in Photoshop.
|
||||
@@ -137,15 +248,49 @@ impl Adjustment {
|
||||
}
|
||||
|
||||
pub fn default_hue_saturation() -> Self {
|
||||
Adjustment::HueSaturation { hue: 0.0, saturation: 0.0, lightness: 0.0, colorize: false }
|
||||
Adjustment::HueSaturation { hue: 0.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges: HueRange::defaults() }
|
||||
}
|
||||
|
||||
pub fn identity_curves() -> Self {
|
||||
let line = || vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }];
|
||||
Adjustment::Curves { master: line(), per_channel: [line(), line(), line()] }
|
||||
Adjustment::Curves { master: line(), per_channel: [line(), line(), line()], space: ToneSpace::Rgb, black: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn identity_levels() -> Self {
|
||||
Adjustment::Levels { master: LevelsChannel::default(), per_channel: Default::default() }
|
||||
Adjustment::Levels { master: LevelsChannel::default(), per_channel: Default::default(), space: ToneSpace::Rgb, black: LevelsChannel::default() }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hue_range_weights() {
|
||||
let reds = HueRange::neutral(0);
|
||||
assert_eq!(reds.weight(0.0), 1.0);
|
||||
assert_eq!(reds.weight(350.0), 1.0);
|
||||
assert!((reds.weight(30.0) - 0.5).abs() < 1e-5);
|
||||
assert!((reds.weight(330.0) - 0.5).abs() < 1e-5);
|
||||
assert_eq!(reds.weight(60.0), 0.0);
|
||||
assert_eq!(reds.weight(180.0), 0.0);
|
||||
let blues = HueRange::neutral(4);
|
||||
assert_eq!(blues.weight(240.0), 1.0);
|
||||
assert_eq!(blues.weight(120.0), 0.0);
|
||||
// Degenerate bounds never divide by zero.
|
||||
let odd = HueRange { bounds: [10.0, 10.0, 10.0, 10.0], ..HueRange::neutral(0) };
|
||||
assert!(odd.weight(10.0).is_finite() && odd.weight(200.0).is_finite());
|
||||
let nan = HueRange { bounds: [f32::NAN; 4], ..HueRange::neutral(0) };
|
||||
assert!(nan.weight(10.0).is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_files_get_neutral_new_fields() {
|
||||
let a: Adjustment = serde_json::from_str(r#"{"HueSaturation":{"hue":1.0,"saturation":2.0,"lightness":3.0,"colorize":false}}"#).unwrap();
|
||||
assert!(matches!(a, Adjustment::HueSaturation { ranges, .. } if ranges == HueRange::defaults()));
|
||||
let c: Adjustment = serde_json::from_str(r#"{"Curves":{"master":[],"per_channel":[[],[],[]]}}"#).unwrap();
|
||||
assert!(matches!(c, Adjustment::Curves { space: ToneSpace::Rgb, .. }));
|
||||
let g: Adjustment = serde_json::from_str(r#"{"GradientMap":{"stops":[],"reverse":true}}"#).unwrap();
|
||||
assert!(matches!(g, Adjustment::GradientMap { dither: false, .. }));
|
||||
}
|
||||
}
|
||||
+27
-1
@@ -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,
|
||||
|
||||
@@ -92,7 +92,7 @@ impl Duotone {
|
||||
let t = i as f32 / 32.0;
|
||||
(t, self.render(t))
|
||||
});
|
||||
crate::Adjustment::GradientMap { stops: stops.collect(), reverse: false }
|
||||
crate::Adjustment::GradientMap { stops: stops.collect(), reverse: false, dither: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
@@ -24,7 +28,15 @@ photocraft-algo = { workspace = true }
|
||||
photocraft-text = { workspace = true }
|
||||
photocraft-vector = { workspace = true }
|
||||
photocraft-io = { workspace = true }
|
||||
photocraft-psd = { workspace = true }
|
||||
photocraft-format = { workspace = true }
|
||||
photocraft-plugins = { workspace = true }
|
||||
blake3 = { version = "1.8", default-features = false, features = ["pure"] }
|
||||
photocraft-codecs = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Synthetic camera raw files for the open tests.
|
||||
photocraft-raw = { workspace = true, features = ["testgen"] }
|
||||
# WebAssembly text -> binary for plug-in test modules (Apache-2.0 WITH LLVM-exception).
|
||||
wat = "1.228"
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Colour-managed canvas display cost on a 36 MP document (#46).
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run --release -p photocraft-engine --example bench_display -- [--size 7360x4912] [--reps 5]
|
||||
//! ```
|
||||
//!
|
||||
//! Times, per profile: the composite (shared by every path), the CPU canvas conversion before
|
||||
//! (`Buffer::to_rgba8`, no colour management) and after (`CanvasDisplay::to_rgba8`), the
|
||||
//! per-frame display lookup (`display_signature`, what the GPU canvas checks each frame) and
|
||||
//! building the 33³ GPU display LUT (once per profile/monitor change).
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_cms::Builtin;
|
||||
use photocraft_color::{Color, ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Size};
|
||||
use photocraft_engine::Session;
|
||||
|
||||
fn arg(args: &[String], name: &str) -> Option<String> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned())
|
||||
}
|
||||
|
||||
fn median(mut v: Vec<f64>) -> f64 {
|
||||
v.sort_by(|a, b| a.total_cmp(b));
|
||||
v[v.len() / 2]
|
||||
}
|
||||
|
||||
fn time(reps: usize, mut f: impl FnMut()) -> f64 {
|
||||
median(
|
||||
(0..reps)
|
||||
.map(|_| {
|
||||
let t = Instant::now();
|
||||
f();
|
||||
t.elapsed().as_secs_f64() * 1000.0
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let (w, h) =
|
||||
arg(&args, "--size").and_then(|s| s.split_once('x').map(|(a, b)| (a.parse().unwrap_or(7360), b.parse().unwrap_or(4912)))).unwrap_or((7360, 4912));
|
||||
let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(5).max(1);
|
||||
println!("document {w}×{h} ({:.1} MP), median of {reps}", (w * h) as f64 / 1e6);
|
||||
println!("{:<22} {:>10} {:>14} {:>14} {:>12} {:>10}", "profile", "composite", "CPU before", "CPU after", "per frame", "LUT build");
|
||||
for (name, profile, depth) in [
|
||||
("sRGB (untagged)", None, SampleType::U8),
|
||||
("Display P3", Some(Builtin::DisplayP3), SampleType::U8),
|
||||
("ProPhoto", Some(Builtin::ProPhotoCompat), SampleType::U16),
|
||||
("linear sRGB (EXR)", Some(Builtin::LinearSrgb), SampleType::F32),
|
||||
] {
|
||||
let mut d = Document::with_background("b", Size::new(w, h), ColorMode::Rgb, depth, Color::rgb(0.7, 0.4, 0.2));
|
||||
d.icc_profile = profile.map(|b| b.profile().to_bytes());
|
||||
let s = Session::new();
|
||||
let mut buf = None;
|
||||
let composite = time(reps, || buf = Some(photocraft_compose::flatten(&d)));
|
||||
let buf = buf.expect("composited");
|
||||
let before = time(reps, || {
|
||||
std::hint::black_box(buf.to_rgba8());
|
||||
});
|
||||
let display = s.color.canvas_display(&d).expect("display");
|
||||
let after = time(reps, || {
|
||||
let d = s.color.canvas_display(&d).expect("display");
|
||||
std::hint::black_box(if d.is_identity() { buf.to_rgba8() } else { d.to_rgba8(&buf) });
|
||||
});
|
||||
let frame = time(reps * 200, || {
|
||||
std::hint::black_box(s.color.display_signature(&d));
|
||||
});
|
||||
let lut = time(reps, || {
|
||||
std::hint::black_box(s.color.canvas_lut(&d, 33).expect("lut"));
|
||||
});
|
||||
let tag = if display.is_identity() { " (identity)" } else { "" };
|
||||
println!("{:<22} {composite:>8.1}ms {before:>12.1}ms {after:>12.1}ms {:>10.1}µs {lut:>8.1}ms{tag}", name, frame * 1000.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Timing: the example WebAssembly Invert plug-in vs the built-in Image › Adjustments › Invert,
|
||||
//! through the full command path, on a 6000 × 4000 (24 MP) layer.
|
||||
//! `cargo run --release -p photocraft-engine --example bench_plugin_invert [width] [height] [depth]`
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_geom::Rect;
|
||||
use serde_json::json;
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1).map(|v| v.parse::<u64>().unwrap_or(0));
|
||||
let w = args.next().filter(|v| *v > 0).unwrap_or(6000);
|
||||
let h = args.next().filter(|v| *v > 0).unwrap_or(4000);
|
||||
let depth = args.next().filter(|v| *v > 0).unwrap_or(8);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": w, "height": h, "depth": depth})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.edit("texture", |doc, active| {
|
||||
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||
let fmt = surf.format();
|
||||
let row: Vec<f32> = (0..w).flat_map(|x| photocraft_raster::from_rgba(&fmt, [(x % 256) as f32 / 255.0, 0.5, 0.25, 1.0])).collect();
|
||||
for y in 0..h as i32 {
|
||||
surf.write_region(Rect::new(0, y, w as i32, y + 1), &row);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let wasm = include_bytes!("../../plugins/tests/fixtures/invert.wasm");
|
||||
let data: String = {
|
||||
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
wasm.chunks(3)
|
||||
.flat_map(|c| {
|
||||
let n = (u32::from(c[0]) << 16) | (u32::from(*c.get(1).unwrap_or(&0)) << 8) | u32::from(*c.get(2).unwrap_or(&0));
|
||||
(0..4).map(move |i| if i <= c.len() { T[(n >> (18 - 6 * i)) as usize & 63] as char } else { '=' })
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
s.execute("plugin.install", json!({"data": data})).unwrap();
|
||||
println!("{w}x{h} ({:.1} MP), {depth}-bit RGBA layer", (w * h) as f64 / 1e6);
|
||||
for _ in 0..3 {
|
||||
let t = Instant::now();
|
||||
s.execute("image.adjustments.invert", json!({})).unwrap();
|
||||
let builtin = t.elapsed().as_secs_f64() * 1e3;
|
||||
let t = Instant::now();
|
||||
s.execute("plugin.run", json!({"id": "org.photocraft.example.invert"})).unwrap();
|
||||
let plugin = t.elapsed().as_secs_f64() * 1e3;
|
||||
println!("built-in invert {builtin:>7.0} ms plug-in invert {plugin:>7.0} ms");
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -0,0 +1,792 @@
|
||||
//! Full parameter sets for the per-pixel adjustment kinds, shared by the destructive
|
||||
//! `image.adjustments.*` commands, `layer.newAdjustmentLayer.*` and `layer.setAdjustment`.
|
||||
//!
|
||||
//! [`from_params`] reads params over a base adjustment (missing keys keep the base's value, so a
|
||||
//! partial update only changes what it names) and [`to_params`] writes the complete set back, so
|
||||
//! `from_params(kind, &to_params(a), None, mode) == a`. Values out of range are clamped like
|
||||
//! Photoshop's fields; a key of the wrong type is an error.
|
||||
//!
|
||||
//! Units: Levels and Curves in 0–255 levels; percentages for the rest (as in Photoshop's dialogs);
|
||||
//! colours as `"#rrggbb"` or `[r, g, b]` in 0–1.
|
||||
|
||||
use photocraft_color::ColorMode;
|
||||
use photocraft_doc::Adjustment;
|
||||
use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel, ToneSpace};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
/// Hue/Saturation and Black & White colour-range keys (storage order).
|
||||
pub const HUE_RANGES: [&str; 6] = ["reds", "yellows", "greens", "cyans", "blues", "magentas"];
|
||||
|
||||
/// Black & White default weights (percent) for [`HUE_RANGES`].
|
||||
pub const BW_DEFAULTS: [f32; 6] = [40.0, 60.0, 40.0, 60.0, 20.0, 80.0];
|
||||
|
||||
/// Photo Filter presets `(id, label, colour)`: common photographic filter colours.
|
||||
pub const PHOTO_FILTERS: &[(&str, &str, [u8; 3])] = &[
|
||||
("warming85", "Warming Filter (85)", [236, 138, 0]),
|
||||
("warmingLBA", "Warming Filter (LBA)", [250, 150, 0]),
|
||||
("warming81", "Warming Filter (81)", [235, 177, 19]),
|
||||
("cooling80", "Cooling Filter (80)", [0, 109, 255]),
|
||||
("coolingLBB", "Cooling Filter (LBB)", [0, 93, 255]),
|
||||
("cooling82", "Cooling Filter (82)", [0, 181, 255]),
|
||||
("red", "Red", [234, 26, 26]),
|
||||
("orange", "Orange", [243, 132, 23]),
|
||||
("yellow", "Yellow", [249, 227, 28]),
|
||||
("green", "Green", [25, 201, 25]),
|
||||
("cyan", "Cyan", [29, 203, 234]),
|
||||
("blue", "Blue", [29, 53, 234]),
|
||||
("violet", "Violet", [155, 29, 234]),
|
||||
("magenta", "Magenta", [227, 24, 227]),
|
||||
("sepia", "Sepia", [172, 122, 51]),
|
||||
("deepRed", "Deep Red", [255, 0, 0]),
|
||||
("deepBlue", "Deep Blue", [0, 34, 205]),
|
||||
("deepEmerald", "Deep Emerald", [0, 140, 0]),
|
||||
("deepYellow", "Deep Yellow", [255, 213, 0]),
|
||||
("underwater", "Underwater", [0, 194, 177]),
|
||||
];
|
||||
|
||||
/// Per-channel keys of Levels/Curves in each tone space (after the composite).
|
||||
pub fn channel_keys(space: ToneSpace) -> &'static [&'static str] {
|
||||
match space {
|
||||
ToneSpace::Rgb => &["red", "green", "blue"],
|
||||
ToneSpace::Cmyk => &["cyan", "magenta", "yellow", "black"],
|
||||
ToneSpace::Lab => &["lightness", "a", "b"],
|
||||
}
|
||||
}
|
||||
|
||||
/// The tone space new Levels/Curves get in a document of `mode`.
|
||||
pub fn default_space(mode: ColorMode) -> ToneSpace {
|
||||
match mode {
|
||||
ColorMode::Cmyk => ToneSpace::Cmyk,
|
||||
ColorMode::Lab => ToneSpace::Lab,
|
||||
_ => ToneSpace::Rgb,
|
||||
}
|
||||
}
|
||||
|
||||
/// The neutral adjustment of `kind` (what "Reset to defaults" restores).
|
||||
pub fn default_for(kind: &str, mode: ColorMode) -> Result<Adjustment> {
|
||||
from_params(kind, &json!({}), None, mode)
|
||||
}
|
||||
|
||||
/// Reads the adjustment of `kind` from `p` over `base` (or the kind's defaults when `base` is
|
||||
/// None or of another kind). `mode` picks the Levels/Curves channel space when neither the params
|
||||
/// nor the base name one.
|
||||
pub fn from_params(kind: &str, p: &Value, base: Option<&Adjustment>, mode: ColorMode) -> Result<Adjustment> {
|
||||
let cmd = format!("adjustment `{kind}`");
|
||||
if !p.is_object() && !p.is_null() {
|
||||
return Err(bad(&cmd, "params must be an object"));
|
||||
}
|
||||
let r = Reader { cmd: &cmd, p };
|
||||
Ok(match kind {
|
||||
"invert" => Adjustment::Invert,
|
||||
"brightnessContrast" => {
|
||||
let (b0, c0, l0) = match base {
|
||||
Some(Adjustment::BrightnessContrast { brightness, contrast, legacy }) => (*brightness, *contrast, *legacy),
|
||||
_ => (0.0, 0.0, false),
|
||||
};
|
||||
let legacy = r.boolean("legacy", l0)?;
|
||||
let (lo, hi) = if legacy { (-100.0, 100.0) } else { (-50.0, 100.0) };
|
||||
Adjustment::BrightnessContrast { brightness: r.num("brightness", b0, -150.0, 150.0)?, contrast: r.num("contrast", c0, lo, hi)?, legacy }
|
||||
}
|
||||
"threshold" => {
|
||||
let l0 = match base {
|
||||
Some(Adjustment::Threshold { level }) => level * 255.0,
|
||||
_ => 128.0,
|
||||
};
|
||||
Adjustment::Threshold { level: r.num("level", l0, 1.0, 255.0)?.round() / 255.0 }
|
||||
}
|
||||
"posterize" => {
|
||||
let l0 = match base {
|
||||
Some(Adjustment::Posterize { levels }) => *levels as f32,
|
||||
_ => 4.0,
|
||||
};
|
||||
Adjustment::Posterize { levels: r.num("levels", l0, 2.0, 255.0)?.round() as u32 }
|
||||
}
|
||||
"exposure" => {
|
||||
let (e, o, g) = match base {
|
||||
Some(Adjustment::Exposure { exposure, offset, gamma }) => (*exposure, *offset, *gamma),
|
||||
_ => (0.0, 0.0, 1.0),
|
||||
};
|
||||
Adjustment::Exposure { exposure: r.num("exposure", e, -20.0, 20.0)?, offset: r.num("offset", o, -0.5, 0.5)?, gamma: r.num("gamma", g, 0.01, 9.99)? }
|
||||
}
|
||||
"vibrance" => {
|
||||
let (v, s) = match base {
|
||||
Some(Adjustment::Vibrance { vibrance, saturation }) => (*vibrance, *saturation),
|
||||
_ => (0.0, 0.0),
|
||||
};
|
||||
Adjustment::Vibrance { vibrance: r.num("vibrance", v, -100.0, 100.0)?, saturation: r.num("saturation", s, -100.0, 100.0)? }
|
||||
}
|
||||
"hueSaturation" => hue_saturation(&r, base)?,
|
||||
"levels" => levels(&r, base, mode)?,
|
||||
"curves" => curves(&r, base, mode)?,
|
||||
"colorBalance" => {
|
||||
let (mut tones, preserve) = match base {
|
||||
Some(Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity }) => {
|
||||
([*shadows, *midtones, *highlights], *preserve_luminosity)
|
||||
}
|
||||
_ => ([[0.0; 3]; 3], true),
|
||||
};
|
||||
for (i, key) in ["shadows", "midtones", "highlights"].iter().enumerate() {
|
||||
if let Some(v) = r.numbers(key, 3)? {
|
||||
tones[i] = [v[0], v[1], v[2]].map(|x| x.clamp(-100.0, 100.0));
|
||||
}
|
||||
}
|
||||
Adjustment::ColorBalance {
|
||||
shadows: tones[0],
|
||||
midtones: tones[1],
|
||||
highlights: tones[2],
|
||||
preserve_luminosity: r.boolean("preserveLuminosity", preserve)?,
|
||||
}
|
||||
}
|
||||
"blackWhite" => {
|
||||
let (mut weights, tint0) = match base {
|
||||
Some(Adjustment::BlackWhite { weights, tint }) => (*weights, *tint),
|
||||
_ => (BW_DEFAULTS, None),
|
||||
};
|
||||
for (i, key) in HUE_RANGES.iter().enumerate() {
|
||||
weights[i] = r.num(key, weights[i], -200.0, 300.0)?;
|
||||
}
|
||||
// Photoshop's default tint colour is a light sepia.
|
||||
let color = r.color("tintColor", tint0.unwrap_or([0.882, 0.827, 0.702]))?;
|
||||
let tint = r.boolean("tint", tint0.is_some())?.then_some(color);
|
||||
Adjustment::BlackWhite { weights, tint }
|
||||
}
|
||||
"photoFilter" => {
|
||||
let (c0, d0, p0) = match base {
|
||||
Some(Adjustment::PhotoFilter { color, density, preserve_luminosity }) => (*color, *density, *preserve_luminosity),
|
||||
_ => ([236.0 / 255.0, 138.0 / 255.0, 0.0], 0.25, true),
|
||||
};
|
||||
let preset = match p.get("filter") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(id)) => {
|
||||
let f = PHOTO_FILTERS.iter().find(|f| f.0 == id).ok_or_else(|| bad(&cmd, format!("unknown filter `{id}`")))?;
|
||||
Some(f.2.map(|v| f32::from(v) / 255.0))
|
||||
}
|
||||
Some(_) => return Err(bad(&cmd, "`filter` must be a preset id")),
|
||||
};
|
||||
let color = r.color("color", preset.unwrap_or(c0))?;
|
||||
Adjustment::PhotoFilter {
|
||||
color,
|
||||
density: r.num("density", d0 * 100.0, 0.0, 100.0)? / 100.0,
|
||||
preserve_luminosity: r.boolean("preserveLuminosity", p0)?,
|
||||
}
|
||||
}
|
||||
"channelMixer" => {
|
||||
let (mut matrix, mono0) = match base {
|
||||
Some(Adjustment::ChannelMixer { matrix, monochrome }) => (*matrix, *monochrome),
|
||||
_ => ([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]], false),
|
||||
};
|
||||
let monochrome = r.boolean("monochrome", mono0)?;
|
||||
if monochrome && !mono0 && p.get("gray").is_none() {
|
||||
// Turning Monochrome on starts from Photoshop's grey mix.
|
||||
matrix[0] = [0.4, 0.4, 0.2, 0.0];
|
||||
}
|
||||
for (i, key) in ["red", "green", "blue"].iter().enumerate() {
|
||||
if let Some(v) = r.numbers(key, 4)? {
|
||||
matrix[i] = mixer_row(&v);
|
||||
}
|
||||
}
|
||||
if let Some(v) = r.numbers("gray", 4)? {
|
||||
matrix[0] = mixer_row(&v);
|
||||
}
|
||||
Adjustment::ChannelMixer { matrix, monochrome }
|
||||
}
|
||||
"gradientMap" => {
|
||||
let (stops0, rev0, dither0) = match base {
|
||||
Some(Adjustment::GradientMap { stops, reverse, dither }) => (stops.clone(), *reverse, *dither),
|
||||
_ => (vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])], false, false),
|
||||
};
|
||||
let stops = match p.get("stops") {
|
||||
None | Some(Value::Null) => stops0,
|
||||
Some(v) => gradient_stops(&cmd, v)?,
|
||||
};
|
||||
Adjustment::GradientMap { stops, reverse: r.boolean("reverse", rev0)?, dither: r.boolean("dither", dither0)? }
|
||||
}
|
||||
"selectiveColor" => crate::adjust_cmds::selective_from_params(p, base.filter(|b| matches!(b, Adjustment::SelectiveColor { .. }))),
|
||||
"colorLookup" => crate::adjust_cmds::lookup_from_params(p, base.filter(|b| matches!(b, Adjustment::ColorLookup { .. })))?,
|
||||
other => return Err(bad("adjustment", format!("unknown adjustment kind `{other}`"))),
|
||||
})
|
||||
}
|
||||
|
||||
/// The complete parameter set of `adj` (the inverse of [`from_params`]).
|
||||
pub fn to_params(adj: &Adjustment) -> Value {
|
||||
match adj {
|
||||
Adjustment::Invert | Adjustment::Unsupported { .. } => json!({}),
|
||||
Adjustment::BrightnessContrast { brightness, contrast, legacy } => json!({"brightness": brightness, "contrast": contrast, "legacy": legacy}),
|
||||
Adjustment::Threshold { level } => json!({"level": (level * 255.0).round()}),
|
||||
Adjustment::Posterize { levels } => json!({"levels": levels}),
|
||||
Adjustment::Exposure { exposure, offset, gamma } => json!({"exposure": exposure, "offset": offset, "gamma": gamma}),
|
||||
Adjustment::Vibrance { vibrance, saturation } => json!({"vibrance": vibrance, "saturation": saturation}),
|
||||
Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => {
|
||||
let mut v = json!({"hue": hue, "saturation": saturation, "lightness": lightness, "colorize": colorize});
|
||||
for (key, r) in HUE_RANGES.iter().zip(ranges.iter()) {
|
||||
v[*key] = json!({"hue": r.hue, "saturation": r.saturation, "lightness": r.lightness, "range": r.bounds});
|
||||
}
|
||||
v
|
||||
}
|
||||
Adjustment::Levels { master, per_channel, space, black } => {
|
||||
let mut v = levels_obj(master);
|
||||
for (key, c) in channel_keys(*space).iter().zip([&per_channel[0], &per_channel[1], &per_channel[2], black]) {
|
||||
v[*key] = levels_obj(c);
|
||||
}
|
||||
if *space == ToneSpace::Lab {
|
||||
// No composite in Lab: the top-level keys are the lightness channel.
|
||||
v = merge(levels_obj(&per_channel[0]), v);
|
||||
}
|
||||
v
|
||||
}
|
||||
Adjustment::Curves { master, per_channel, space, black } => {
|
||||
let mut v = json!({"points": curve_arr(master)});
|
||||
let ink = if black.len() >= 2 { black.clone() } else { identity_curve() };
|
||||
let chans = per_channel.iter().cloned().chain(std::iter::once(ink));
|
||||
for (key, c) in channel_keys(*space).iter().zip(chans) {
|
||||
v[*key] = curve_arr(&c);
|
||||
}
|
||||
if *space == ToneSpace::Lab {
|
||||
v["points"] = curve_arr(&per_channel[0]);
|
||||
}
|
||||
v
|
||||
}
|
||||
Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => {
|
||||
json!({"shadows": shadows, "midtones": midtones, "highlights": highlights, "preserveLuminosity": preserve_luminosity})
|
||||
}
|
||||
Adjustment::BlackWhite { weights, tint } => {
|
||||
let mut v = json!({"tint": tint.is_some(), "tintColor": hex(tint.unwrap_or([0.882, 0.827, 0.702]))});
|
||||
for (key, w) in HUE_RANGES.iter().zip(weights.iter()) {
|
||||
v[*key] = json!(w);
|
||||
}
|
||||
v
|
||||
}
|
||||
Adjustment::PhotoFilter { color, density, preserve_luminosity } => {
|
||||
json!({"color": hex(*color), "density": (density * 1000.0).round() / 10.0, "preserveLuminosity": preserve_luminosity})
|
||||
}
|
||||
Adjustment::ChannelMixer { matrix, monochrome } => {
|
||||
let row = |r: &[f32; 4]| json!(r.map(|x| (x * 1000.0).round() / 10.0));
|
||||
let mut v = json!({"red": row(&matrix[0]), "green": row(&matrix[1]), "blue": row(&matrix[2]), "monochrome": monochrome});
|
||||
if *monochrome {
|
||||
v["gray"] = row(&matrix[0]);
|
||||
}
|
||||
v
|
||||
}
|
||||
Adjustment::GradientMap { stops, reverse, dither } => {
|
||||
json!({"stops": stops.iter().map(|(t, c)| json!([t, hex(*c)])).collect::<Vec<_>>(), "reverse": reverse, "dither": dither})
|
||||
}
|
||||
Adjustment::SelectiveColor { relative, adjustments } => {
|
||||
let mut v = json!({"method": if *relative { "relative" } else { "absolute" }});
|
||||
for (i, key) in crate::adjust_cmds::RANGES.iter().enumerate() {
|
||||
v[*key] = json!(adjustments[i]);
|
||||
}
|
||||
v
|
||||
}
|
||||
Adjustment::ColorLookup { tetrahedral, dither, .. } => {
|
||||
json!({"interpolation": if *tetrahedral { "tetrahedral" } else { "trilinear" }, "dither": dither})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||||
}
|
||||
|
||||
/// Typed, clamped access to optional params.
|
||||
struct Reader<'a> {
|
||||
cmd: &'a str,
|
||||
p: &'a Value,
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
fn get(&self, key: &str) -> Option<&Value> {
|
||||
self.p.get(key).filter(|v| !v.is_null())
|
||||
}
|
||||
|
||||
fn num(&self, key: &str, default: f32, lo: f32, hi: f32) -> Result<f32> {
|
||||
match self.get(key) {
|
||||
None => Ok(default.clamp(lo, hi)),
|
||||
Some(v) => num_in(self.cmd, key, v, lo, hi),
|
||||
}
|
||||
}
|
||||
|
||||
fn boolean(&self, key: &str, default: bool) -> Result<bool> {
|
||||
match self.get(key) {
|
||||
None => Ok(default),
|
||||
Some(Value::Bool(b)) => Ok(*b),
|
||||
Some(_) => Err(bad(self.cmd, format!("`{key}` must be true or false"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// An array of exactly `n` numbers.
|
||||
fn numbers(&self, key: &str, n: usize) -> Result<Option<Vec<f32>>> {
|
||||
let Some(v) = self.get(key) else { return Ok(None) };
|
||||
let a = v.as_array().filter(|a| a.len() == n).ok_or_else(|| bad(self.cmd, format!("`{key}` must be an array of {n} numbers")))?;
|
||||
a.iter().map(|x| num_in(self.cmd, key, x, -1e6, 1e6)).collect::<Result<Vec<_>>>().map(Some)
|
||||
}
|
||||
|
||||
fn color(&self, key: &str, default: [f32; 3]) -> Result<[f32; 3]> {
|
||||
match self.get(key) {
|
||||
None => Ok(default),
|
||||
Some(v) => color_of(self.cmd, key, v),
|
||||
}
|
||||
}
|
||||
|
||||
/// A channel object (`{"inBlack": …}`) or None.
|
||||
fn object(&self, key: &str) -> Result<Option<Reader<'_>>> {
|
||||
match self.get(key) {
|
||||
None => Ok(None),
|
||||
Some(v) if v.is_object() => Ok(Some(Reader { cmd: self.cmd, p: v })),
|
||||
Some(_) => Err(bad(self.cmd, format!("`{key}` must be an object"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn num_in(cmd: &str, key: &str, v: &Value, lo: f32, hi: f32) -> Result<f32> {
|
||||
let x = v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?;
|
||||
Ok((x as f32).clamp(lo, hi))
|
||||
}
|
||||
|
||||
fn color_of(cmd: &str, key: &str, v: &Value) -> Result<[f32; 3]> {
|
||||
let err = || bad(cmd, format!("`{key}` must be \"#rrggbb\" or [r, g, b] in 0..1"));
|
||||
match v {
|
||||
Value::String(s) => {
|
||||
let h = s.trim_start_matches('#');
|
||||
let b = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|x| f32::from(x) / 255.0);
|
||||
match (h.len(), b(0), b(2), b(4)) {
|
||||
(6 | 8, Some(r), Some(g), Some(bb)) => Ok([r, g, bb]),
|
||||
_ => Err(err()),
|
||||
}
|
||||
}
|
||||
Value::Array(a) if a.len() >= 3 => {
|
||||
let c = |i: usize| a.get(i).and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| (x as f32).clamp(0.0, 1.0)).ok_or_else(err);
|
||||
Ok([c(0)?, c(1)?, c(2)?])
|
||||
}
|
||||
_ => Err(err()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hex(c: [f32; 3]) -> String {
|
||||
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
format!("#{:02x}{:02x}{:02x}", b(c[0]), b(c[1]), b(c[2]))
|
||||
}
|
||||
|
||||
fn merge(mut a: Value, b: Value) -> Value {
|
||||
if let (Some(ao), Value::Object(bo)) = (a.as_object_mut(), b) {
|
||||
for (k, v) in bo {
|
||||
ao.entry(k).or_insert(v);
|
||||
}
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
fn mixer_row(v: &[f32]) -> [f32; 4] {
|
||||
std::array::from_fn(|i| v.get(i).copied().unwrap_or(0.0).clamp(-200.0, 200.0) / 100.0)
|
||||
}
|
||||
|
||||
fn hue_saturation(r: &Reader<'_>, base: Option<&Adjustment>) -> Result<Adjustment> {
|
||||
let (h, s, l, c, mut ranges) = match base {
|
||||
Some(Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges }) => (*hue, *saturation, *lightness, *colorize, ranges.clone()),
|
||||
_ => (0.0, 0.0, 0.0, false, HueRange::defaults()),
|
||||
};
|
||||
let colorize = r.boolean("colorize", c)?;
|
||||
// Colorize hue is 0..360, a shift is -180..180.
|
||||
let hue = if colorize { r.num("hue", h.rem_euclid(360.0), 0.0, 360.0)? } else { r.num("hue", h, -180.0, 180.0)? };
|
||||
let (saturation, lightness) = (r.num("saturation", s, if colorize { 0.0 } else { -100.0 }, 100.0)?, r.num("lightness", l, -100.0, 100.0)?);
|
||||
for (i, key) in HUE_RANGES.iter().enumerate() {
|
||||
let Some(o) = r.object(key)? else { continue };
|
||||
let Some(range) = ranges.get_mut(i) else { continue };
|
||||
range.hue = o.num("hue", range.hue, -180.0, 180.0)?;
|
||||
range.saturation = o.num("saturation", range.saturation, -100.0, 100.0)?;
|
||||
range.lightness = o.num("lightness", range.lightness, -100.0, 100.0)?;
|
||||
if let Some(b) = o.numbers("range", 4)? {
|
||||
// Keep the four sliders ordered and within one turn of the first.
|
||||
range.bounds = HueRange::canonical_bounds([b[0], b[1], b[2], b[3]]);
|
||||
}
|
||||
}
|
||||
Ok(Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges })
|
||||
}
|
||||
|
||||
/// Which tone space the params address: any channel key of a space picks it.
|
||||
fn space_of(r: &Reader<'_>, base: Option<ToneSpace>, mode: ColorMode) -> ToneSpace {
|
||||
let has = |keys: &[&str]| keys.iter().any(|k| r.get(k).is_some());
|
||||
if has(&["cyan", "magenta", "yellow", "black"]) {
|
||||
ToneSpace::Cmyk
|
||||
} else if has(&["a", "b"]) || (has(&["lightness"]) && !has(&["red", "green", "blue"])) {
|
||||
ToneSpace::Lab
|
||||
} else if has(&["red", "green", "blue"]) {
|
||||
ToneSpace::Rgb
|
||||
} else {
|
||||
base.unwrap_or_else(|| default_space(mode))
|
||||
}
|
||||
}
|
||||
|
||||
fn levels(r: &Reader<'_>, base: Option<&Adjustment>, mode: ColorMode) -> Result<Adjustment> {
|
||||
let (mut master, mut chans, base_space) = match base {
|
||||
Some(Adjustment::Levels { master, per_channel, space, black }) => {
|
||||
(master.clone(), [per_channel[0].clone(), per_channel[1].clone(), per_channel[2].clone(), black.clone()], Some(*space))
|
||||
}
|
||||
_ => (LevelsChannel::default(), Default::default(), None),
|
||||
};
|
||||
let space = space_of(r, base_space, mode);
|
||||
if base_space.is_some_and(|b| b != space) {
|
||||
chans = Default::default();
|
||||
}
|
||||
let read = |o: &Reader<'_>, c: &LevelsChannel| -> Result<LevelsChannel> {
|
||||
let in_black = o.num("inBlack", c.in_black * 255.0, 0.0, 253.0)?;
|
||||
let in_white = o.num("inWhite", c.in_white * 255.0, 2.0, 255.0)?.max(in_black + 2.0);
|
||||
Ok(LevelsChannel {
|
||||
in_black: in_black / 255.0,
|
||||
in_white: in_white / 255.0,
|
||||
gamma: o.num("gamma", c.gamma, 0.01, 9.99)?,
|
||||
out_black: o.num("outBlack", c.out_black * 255.0, 0.0, 255.0)? / 255.0,
|
||||
out_white: o.num("outWhite", c.out_white * 255.0, 0.0, 255.0)? / 255.0,
|
||||
})
|
||||
};
|
||||
if space == ToneSpace::Lab {
|
||||
// Top-level keys (and "gray") address lightness: Lab has no composite.
|
||||
chans[0] = read(r, &chans[0])?;
|
||||
} else {
|
||||
master = read(r, &master)?;
|
||||
if let Some(o) = r.object("gray")? {
|
||||
master = read(&o, &master)?;
|
||||
}
|
||||
}
|
||||
for (i, key) in channel_keys(space).iter().enumerate() {
|
||||
if let (Some(o), Some(c)) = (r.object(key)?, chans.get(i).cloned()) {
|
||||
chans[i] = read(&o, &c)?;
|
||||
}
|
||||
}
|
||||
let [c0, c1, c2, black] = chans;
|
||||
Ok(Adjustment::Levels { master, per_channel: [c0, c1, c2], space, black: if space == ToneSpace::Cmyk { black } else { LevelsChannel::default() } })
|
||||
}
|
||||
|
||||
fn identity_curve() -> Vec<CurvePoint> {
|
||||
vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }]
|
||||
}
|
||||
|
||||
fn curve_arr(c: &[CurvePoint]) -> Value {
|
||||
let c = if c.len() >= 2 { c.to_vec() } else { identity_curve() };
|
||||
json!(c.iter().map(|p| [(p.input * 255.0 * 100.0).round() / 100.0, (p.output * 255.0 * 100.0).round() / 100.0]).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// Most points a curve may have (Photoshop allows 16; the PSD record holds 19).
|
||||
pub const MAX_CURVE_POINTS: usize = 19;
|
||||
|
||||
/// `[[in, out], …]` in 0..255: sorted by input, duplicates collapsed, 2..=19 points.
|
||||
pub fn parse_curve(cmd: &str, key: &str, v: &Value) -> Result<Vec<CurvePoint>> {
|
||||
let err = || bad(cmd, format!("`{key}` must be an array of 2..={MAX_CURVE_POINTS} [input, output] pairs in 0..255"));
|
||||
let a = v.as_array().ok_or_else(err)?;
|
||||
if a.len() < 2 || a.len() > 64 {
|
||||
return Err(err());
|
||||
}
|
||||
let mut pts = Vec::with_capacity(a.len());
|
||||
for q in a {
|
||||
let q = q.as_array().filter(|q| q.len() == 2).ok_or_else(err)?;
|
||||
let x = q.first().and_then(Value::as_f64).filter(|x| x.is_finite()).ok_or_else(err)?;
|
||||
let y = q.get(1).and_then(Value::as_f64).filter(|y| y.is_finite()).ok_or_else(err)?;
|
||||
pts.push(CurvePoint { input: (x as f32).clamp(0.0, 255.0) / 255.0, output: (y as f32).clamp(0.0, 255.0) / 255.0 });
|
||||
}
|
||||
pts.sort_by(|a, b| a.input.total_cmp(&b.input));
|
||||
pts.dedup_by(|later, earlier| (later.input - earlier.input).abs() < 0.5 / 255.0);
|
||||
if pts.len() < 2 || pts.len() > MAX_CURVE_POINTS {
|
||||
return Err(err());
|
||||
}
|
||||
Ok(pts)
|
||||
}
|
||||
|
||||
fn curves(r: &Reader<'_>, base: Option<&Adjustment>, mode: ColorMode) -> Result<Adjustment> {
|
||||
let (mut master, mut chans, base_space) = match base {
|
||||
Some(Adjustment::Curves { master, per_channel, space, black }) => {
|
||||
(master.clone(), [per_channel[0].clone(), per_channel[1].clone(), per_channel[2].clone(), black.clone()], Some(*space))
|
||||
}
|
||||
_ => (identity_curve(), std::array::from_fn(|_| identity_curve()), None),
|
||||
};
|
||||
let space = space_of(r, base_space, mode);
|
||||
if base_space.is_some_and(|b| b != space) {
|
||||
chans = std::array::from_fn(|_| identity_curve());
|
||||
}
|
||||
let composite = r.get("points").or_else(|| r.get("gray"));
|
||||
if let Some(v) = composite {
|
||||
let c = parse_curve(r.cmd, "points", v)?;
|
||||
if space == ToneSpace::Lab {
|
||||
chans[0] = c;
|
||||
} else {
|
||||
master = c;
|
||||
}
|
||||
}
|
||||
for (i, key) in channel_keys(space).iter().enumerate() {
|
||||
if let Some(v) = r.get(key)
|
||||
&& let Some(slot) = chans.get_mut(i)
|
||||
{
|
||||
*slot = parse_curve(r.cmd, key, v)?;
|
||||
}
|
||||
}
|
||||
let [c0, c1, c2, black] = chans;
|
||||
let black = if space == ToneSpace::Cmyk && !photocraft_doc::adjust::is_identity_curve(&black) { black } else { Vec::new() };
|
||||
Ok(Adjustment::Curves { master, per_channel: [c0, c1, c2], space, black })
|
||||
}
|
||||
|
||||
fn levels_obj(c: &LevelsChannel) -> Value {
|
||||
let q = |v: f32| (v * 255.0 * 100.0).round() / 100.0;
|
||||
json!({"inBlack": q(c.in_black), "gamma": (c.gamma * 1000.0).round() / 1000.0, "inWhite": q(c.in_white), "outBlack": q(c.out_black), "outWhite": q(c.out_white)})
|
||||
}
|
||||
|
||||
/// `[[location 0..1, colour], …]` (colour as for `color`), sorted by location.
|
||||
fn gradient_stops(cmd: &str, v: &Value) -> Result<Vec<(f32, [f32; 3])>> {
|
||||
let err = || bad(cmd, "`stops` must be an array of 2..=64 [location 0..1, colour] pairs");
|
||||
let a = v.as_array().filter(|a| (2..=64).contains(&a.len())).ok_or_else(err)?;
|
||||
let mut out = Vec::with_capacity(a.len());
|
||||
for s in a {
|
||||
let (t, c) = match s {
|
||||
Value::Array(p) if p.len() == 2 => (p.first(), p.get(1)),
|
||||
Value::Object(o) => (o.get("location"), o.get("color")),
|
||||
_ => return Err(err()),
|
||||
};
|
||||
let t = t.and_then(Value::as_f64).filter(|t| t.is_finite()).ok_or_else(err)?;
|
||||
let c = color_of(cmd, "stops", c.ok_or_else(err)?)?;
|
||||
out.push(((t as f32).clamp(0.0, 1.0), c));
|
||||
}
|
||||
out.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Params with the private keys (`layer`, `coalesce`, `__kind`) removed.
|
||||
pub fn user_keys(p: &Value) -> Map<String, Value> {
|
||||
p.as_object()
|
||||
.map(|o| {
|
||||
o.iter()
|
||||
.filter(|(k, _)| !matches!(k.as_str(), "layer" | "coalesce" | "channel" | "targetChannel") && !k.starts_with("__"))
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const KINDS: [&str; 15] = [
|
||||
"invert",
|
||||
"brightnessContrast",
|
||||
"threshold",
|
||||
"posterize",
|
||||
"exposure",
|
||||
"vibrance",
|
||||
"hueSaturation",
|
||||
"levels",
|
||||
"curves",
|
||||
"colorBalance",
|
||||
"blackWhite",
|
||||
"photoFilter",
|
||||
"channelMixer",
|
||||
"gradientMap",
|
||||
"selectiveColor",
|
||||
];
|
||||
|
||||
/// A non-default parameter set per kind (RGB channel keys for Levels/Curves).
|
||||
pub(crate) fn sample(kind: &str) -> Value {
|
||||
match kind {
|
||||
"brightnessContrast" => json!({"brightness": 20, "contrast": -10, "legacy": true}),
|
||||
"threshold" => json!({"level": 100}),
|
||||
"posterize" => json!({"levels": 7}),
|
||||
"exposure" => json!({"exposure": 1.5, "offset": 0.1, "gamma": 1.4}),
|
||||
"vibrance" => json!({"vibrance": 30, "saturation": -20}),
|
||||
"hueSaturation" => {
|
||||
json!({"hue": 25, "saturation": -20, "lightness": 10, "reds": {"hue": 10, "saturation": -30, "lightness": 5, "range": [300, 340, 20, 50]}})
|
||||
}
|
||||
"levels" => json!({"inBlack": 10, "inWhite": 240, "outBlack": 5, "outWhite": 250, "gamma": 1.2, "red": {"inBlack": 3, "gamma": 0.8}}),
|
||||
"curves" => json!({"points": [[0, 0], [128, 150], [255, 255]], "blue": [[0, 20], [255, 235]]}),
|
||||
"colorBalance" => json!({"shadows": [10, -5, 0], "midtones": [0, 20, -30], "highlights": [5, 5, 5], "preserveLuminosity": false}),
|
||||
"blackWhite" => json!({"reds": 120, "blues": -40, "tint": true, "tintColor": "#d0a070"}),
|
||||
"photoFilter" => json!({"color": "#3366cc", "density": 40, "preserveLuminosity": false}),
|
||||
"channelMixer" => json!({"red": [80, 30, -10, 5], "blue": [0, 10, 90, 0]}),
|
||||
"gradientMap" => json!({"stops": [[0, "#000000"], [0.5, "#ff0000"], [1, [1, 1, 1]]], "reverse": true, "dither": true}),
|
||||
"selectiveColor" => json!({"method": "absolute", "reds": [10, 0, -20, 5]}),
|
||||
_ => json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_kind_round_trips_through_params() {
|
||||
for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab, ColorMode::Grayscale] {
|
||||
for kind in KINDS {
|
||||
let mut p = sample(kind);
|
||||
if matches!(kind, "levels" | "curves") && default_space(mode) != ToneSpace::Rgb {
|
||||
// Off RGB the channel keys are the document's.
|
||||
p.as_object_mut().unwrap().retain(|k, _| !matches!(k.as_str(), "red" | "blue"));
|
||||
}
|
||||
let a = from_params(kind, &p, None, mode).unwrap_or_else(|e| panic!("{kind} {mode:?}: {e}"));
|
||||
assert_ne!(Some(&a), default_for(kind, mode).ok().as_ref().filter(|_| kind != "invert"), "{kind} params ignored");
|
||||
let back = from_params(kind, &to_params(&a), None, mode).unwrap();
|
||||
assert_eq!(to_params(&back), to_params(&a), "{kind} {mode:?}");
|
||||
// A partial update keeps everything it doesn't name.
|
||||
let again = from_params(kind, &json!({}), Some(&a), mode).unwrap();
|
||||
assert_eq!(again, a, "{kind} {mode:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_reach_the_model() {
|
||||
let a = from_params("colorBalance", &json!({"midtones": [50, 0, -200]}), None, ColorMode::Rgb).unwrap();
|
||||
assert_eq!(a, Adjustment::ColorBalance { shadows: [0.0; 3], midtones: [50.0, 0.0, -100.0], highlights: [0.0; 3], preserve_luminosity: true });
|
||||
let a = from_params("channelMixer", &json!({"monochrome": true}), None, ColorMode::Rgb).unwrap();
|
||||
assert!(matches!(a, Adjustment::ChannelMixer { matrix, monochrome: true } if matrix[0] == [0.4, 0.4, 0.2, 0.0]));
|
||||
let a = from_params("photoFilter", &json!({"filter": "cooling80", "density": 60}), None, ColorMode::Rgb).unwrap();
|
||||
assert!(matches!(a, Adjustment::PhotoFilter { color, density, .. } if color[2] == 1.0 && (density - 0.6).abs() < 1e-6));
|
||||
let a = from_params("blackWhite", &json!({"reds": 120, "tint": true}), None, ColorMode::Rgb).unwrap();
|
||||
assert!(matches!(a, Adjustment::BlackWhite { weights, tint: Some(_) } if weights[0] == 120.0 && weights[1] == 60.0));
|
||||
let a = from_params("hueSaturation", &json!({"blues": {"hue": 40}}), None, ColorMode::Rgb).unwrap();
|
||||
assert!(matches!(&a, Adjustment::HueSaturation { ranges, .. } if ranges[4].hue == 40.0 && ranges[0].is_neutral()));
|
||||
let a = from_params("gradientMap", &json!({"stops": [[1, "#ffffff"], [0, "#000000"]], "dither": true}), None, ColorMode::Rgb).unwrap();
|
||||
assert!(matches!(&a, Adjustment::GradientMap { stops, dither: true, .. } if stops[0].0 == 0.0));
|
||||
// CMYK curves: ink channels; Lab: lightness without a composite.
|
||||
let a = from_params("curves", &json!({"black": [[0, 0], [255, 200]]}), None, ColorMode::Rgb).unwrap();
|
||||
assert!(matches!(&a, Adjustment::Curves { space: ToneSpace::Cmyk, black, .. } if black.len() == 2));
|
||||
let a = from_params("curves", &json!({"points": [[0, 40], [255, 255]]}), None, ColorMode::Lab).unwrap();
|
||||
assert!(matches!(&a, Adjustment::Curves { space: ToneSpace::Lab, per_channel, .. } if per_channel[0][0].output > 0.1));
|
||||
let a = from_params("levels", &json!({"inBlack": 20}), None, ColorMode::Cmyk).unwrap();
|
||||
assert!(matches!(&a, Adjustment::Levels { space: ToneSpace::Cmyk, master, .. } if (master.in_black - 20.0 / 255.0).abs() < 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_are_errors_not_panics() {
|
||||
for (kind, p) in [
|
||||
("curves", json!({"points": "abc"})),
|
||||
("curves", json!({"points": [[0, 0]]})),
|
||||
("curves", json!({"red": [[0, 0], [1]]})),
|
||||
("curves", json!({"points": [[5, 5], [5, 9]]})),
|
||||
("levels", json!({"red": 3})),
|
||||
("levels", json!({"inBlack": "x"})),
|
||||
("hueSaturation", json!({"reds": {"range": [1, 2]}})),
|
||||
("hueSaturation", json!({"colorize": 1})),
|
||||
("colorBalance", json!({"shadows": [1, 2]})),
|
||||
("blackWhite", json!({"tintColor": "#zz0000"})),
|
||||
("photoFilter", json!({"filter": "nope"})),
|
||||
("photoFilter", json!({"color": [1]})),
|
||||
("channelMixer", json!({"red": [1, 2, 3]})),
|
||||
("gradientMap", json!({"stops": [[0, "#000"]]})),
|
||||
("gradientMap", json!({"stops": 7})),
|
||||
("nope", json!({})),
|
||||
("levels", json!([1, 2])),
|
||||
] {
|
||||
assert!(from_params(kind, &p, None, ColorMode::Rgb).is_err(), "{kind} {p}");
|
||||
}
|
||||
// Out-of-range numbers clamp.
|
||||
let a = from_params("hueSaturation", &json!({"hue": 1e9, "saturation": -1e9}), None, ColorMode::Rgb).unwrap();
|
||||
assert!(matches!(a, Adjustment::HueSaturation { hue: 180.0, saturation: -100.0, .. }));
|
||||
let a = from_params("levels", &json!({"inBlack": 250, "inWhite": 10}), None, ColorMode::Rgb).unwrap();
|
||||
assert!(matches!(a, Adjustment::Levels { master, .. } if master.in_white > master.in_black));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_range_sliders_stay_ordered() {
|
||||
let a = from_params("hueSaturation", &json!({"greens": {"range": [100, 90, 200, 150]}}), None, ColorMode::Rgb).unwrap();
|
||||
let Adjustment::HueSaturation { ranges, .. } = a else { panic!() };
|
||||
let b = ranges[2].bounds;
|
||||
assert!(b[0] <= b[1] && b[1] <= b[2] && b[2] <= b[3] && b[3] - b[0] < 360.0, "{b:?}");
|
||||
}
|
||||
|
||||
/// A colourful 16×16 document (`mode`, `depth`) for command tests.
|
||||
fn colourful(mode: &str, depth: u32) -> crate::Session {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", json!({"width": 16, "height": 16, "mode": mode, "depth": depth})).unwrap();
|
||||
for (i, c) in ["#c83c28", "#3c8c50", "#2850b4", "#d2b464"].iter().enumerate() {
|
||||
s.execute("select.rect", json!({"x": (i % 2) * 8, "y": (i / 2) * 8, "width": 8, "height": 8})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": c})).unwrap();
|
||||
}
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn pixels(s: &crate::Session) -> Vec<Vec<f32>> {
|
||||
let d = s.active().unwrap();
|
||||
let bg = d.doc.layers[0].surface().unwrap();
|
||||
[(2, 2), (10, 2), (2, 10), (10, 10)].iter().map(|&(x, y)| bg.pixel(x, y)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destructive_commands_apply_their_params_at_every_depth() {
|
||||
for depth in [8, 16, 32] {
|
||||
for kind in KINDS.iter().filter(|k| **k != "invert") {
|
||||
let mut s = colourful("rgb", depth);
|
||||
let before = pixels(&s);
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
s.execute(&format!("image.adjustments.{kind}"), sample(kind)).unwrap_or_else(|e| panic!("{kind} @{depth}: {e}"));
|
||||
assert_ne!(pixels(&s), before, "{kind} @{depth} changed nothing");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{kind}: one history step");
|
||||
// Identity params leave the pixels alone (within one 8-bit step).
|
||||
let mut s = colourful("rgb", depth);
|
||||
s.execute(&format!("image.adjustments.{kind}"), to_params(&default_for(kind, ColorMode::Rgb).unwrap())).unwrap();
|
||||
if !matches!(*kind, "threshold" | "posterize" | "blackWhite" | "gradientMap" | "photoFilter") {
|
||||
for (a, b) in pixels(&s).iter().zip(&before) {
|
||||
assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() <= 1.0 / 255.0 + 1e-4), "{kind} @{depth}: {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_and_lab_documents_use_their_channels() {
|
||||
let mut s = colourful("cmyk", 8);
|
||||
let before = pixels(&s);
|
||||
// More black ink darkens every patch.
|
||||
s.execute("image.adjustments.curves", json!({"black": [[0, 0], [255, 180]]})).unwrap();
|
||||
let after = pixels(&s);
|
||||
assert!(after.iter().zip(&before).all(|(a, b)| a[3] > b[3] + 0.05), "{after:?} vs {before:?}");
|
||||
let mut s = colourful("lab", 16);
|
||||
let before = pixels(&s);
|
||||
s.execute("image.adjustments.levels", json!({"lightness": {"outBlack": 60}})).unwrap();
|
||||
let after = pixels(&s);
|
||||
assert!(after.iter().zip(&before).all(|(a, b)| a[0] >= b[0]), "lightness lifted {after:?} vs {before:?}");
|
||||
// The adjustment layer of a CMYK document defaults to ink channels.
|
||||
let mut s = colourful("cmyk", 8);
|
||||
s.execute("layer.newAdjustmentLayer.curves", json!({})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
let l = s.active().unwrap().doc.layer(id).unwrap().clone();
|
||||
assert!(matches!(l.content, photocraft_doc::LayerContent::Adjustment(Adjustment::Curves { space: ToneSpace::Cmyk, .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_adjustment_merges_and_resets() {
|
||||
let mut s = colourful("rgb", 8);
|
||||
s.execute("layer.newAdjustmentLayer.colorBalance", json!({"midtones": [30, 0, 0]})).unwrap();
|
||||
s.execute("layer.setAdjustment", json!({"highlights": [0, 0, -20]})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
let adj = |s: &crate::Session| match &s.active().unwrap().doc.layer(id).unwrap().content {
|
||||
photocraft_doc::LayerContent::Adjustment(a) => a.clone(),
|
||||
_ => panic!(),
|
||||
};
|
||||
assert!(matches!(adj(&s), Adjustment::ColorBalance { midtones: [30.0, 0.0, 0.0], highlights: [0.0, 0.0, -20.0], .. }));
|
||||
s.execute("layer.setAdjustment", json!({"layer": id.0})).unwrap();
|
||||
assert_eq!(adj(&s), default_for("colorBalance", ColorMode::Rgb).unwrap());
|
||||
// Bad params are errors and leave the layer alone.
|
||||
s.execute("layer.setAdjustment", json!({"midtones": [10, 10, 10]})).unwrap();
|
||||
let kept = adj(&s);
|
||||
for bad in [json!({"midtones": "x"}), json!({"preserveLuminosity": 3}), json!({"shadows": [1]})] {
|
||||
assert!(s.execute("layer.setAdjustment", bad.clone()).is_err(), "{bad}");
|
||||
assert_eq!(adj(&s), kept);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_reject_bad_params_gracefully() {
|
||||
let mut s = colourful("rgb", 8);
|
||||
for (kind, p) in [
|
||||
("curves", json!({"points": [[0, 0]]})),
|
||||
("levels", json!({"red": "dark"})),
|
||||
("gradientMap", json!({"stops": [[0, 1, 2]]})),
|
||||
("photoFilter", json!({"filter": 3})),
|
||||
("channelMixer", json!({"gray": [1, 2]})),
|
||||
("hueSaturation", json!({"reds": 4})),
|
||||
("blackWhite", json!({"tint": "yes"})),
|
||||
] {
|
||||
for prefix in ["image.adjustments.", "layer.newAdjustmentLayer."] {
|
||||
let r = s.execute(&format!("{prefix}{kind}"), p.clone());
|
||||
assert!(matches!(r, Err(EngineError::BadParams { .. })), "{prefix}{kind} {p}: {r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,6 +181,7 @@ fn compound<R>(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Res
|
||||
match r {
|
||||
Ok(v) => {
|
||||
st.history.record(label, before);
|
||||
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
|
||||
@@ -291,7 +291,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 +307,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 +526,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
|
||||
),
|
||||
|
||||
+265
-32
@@ -10,7 +10,7 @@ use photocraft_doc::LayerContent;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_paint::mixer::{MixerSettings, apply_mixer_stroke};
|
||||
use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement};
|
||||
use photocraft_paint::{BrushPreset, BrushSettings, GrayTile, Stroke, StrokePoint, TipShape, render_stroke};
|
||||
use photocraft_paint::{BrushPreset, BrushSettings, GrayTile, Stroke, StrokePoint, StrokeRenderer, TipShape, render_stroke};
|
||||
use photocraft_raster::Surface;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -47,6 +47,9 @@ fn always(_: &Session) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Largest stroke coordinate accepted (a few times the largest document side, 300 000 px).
|
||||
const MAX_COORD: f64 = 1_000_000.0;
|
||||
|
||||
/// Parse `points`: arrays `[x, y, pressure?, tiltX?, tiltY?, rotation?, timeMs?, wheel?]` or
|
||||
/// objects `{"x":…, "y":…, "pressure":…, "tiltX":…, …, "time":…}`.
|
||||
pub fn parse_points(p: &Value, cmd: &str) -> Result<Vec<StrokePoint>> {
|
||||
@@ -71,12 +74,21 @@ pub fn parse_points(p: &Value, cmd: &str) -> Result<Vec<StrokePoint>> {
|
||||
if pts.is_empty() {
|
||||
return Err(bad(cmd, "`points` is empty"));
|
||||
}
|
||||
// A stroke runs dab by dab along its length: an absurd coordinate would mean billions of dabs.
|
||||
if pts.iter().any(|q| !(q.x.abs() <= MAX_COORD && q.y.abs() <= MAX_COORD)) {
|
||||
return Err(bad(cmd, format!("point coordinates must be finite and within ±{MAX_COORD}")));
|
||||
}
|
||||
Ok(pts)
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -93,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> {
|
||||
@@ -107,7 +146,7 @@ fn find_preset<'a>(s: &'a Session, name: &str, cmd: &str) -> Result<&'a BrushPre
|
||||
pub fn resolve_brush(s: &Session, p: &Value, cmd: &str) -> Result<BrushSettings> {
|
||||
let mut b = s.tools.brush.clone();
|
||||
if let Some(name) = p.get("preset").and_then(Value::as_str) {
|
||||
b = find_preset(s, name, cmd)?.brush.clone().with_protected_texture(&s.tools.brush);
|
||||
b = find_preset(s, name, cmd)?.brush.clone().picked_over(&s.tools.brush);
|
||||
}
|
||||
if let Some(patch) = p.get("brush").filter(|v| v.is_object()) {
|
||||
b = merge_brush(&b, patch, cmd)?;
|
||||
@@ -154,37 +193,66 @@ fn damage_json(s: &mut Session, dmg: Rect) -> Value {
|
||||
json!({ "damage": [dmg.x0, dmg.y0, dmg.width(), dmg.height()] })
|
||||
}
|
||||
|
||||
/// Stroke with a resolved brush onto the target layer (pixels or mask).
|
||||
fn stroke_with(s: &mut Session, p: &Value, label: &str, brush: BrushSettings, pts: Vec<StrokePoint>, auto_erase: bool) -> Result<Value> {
|
||||
/// The target layer (`None` for a channel), brush and zoom of a stroke. On a mask or channel the
|
||||
/// eraser paints the background colour (Photoshop).
|
||||
fn stroke_target(s: &Session, p: &Value, brush: BrushSettings) -> Result<(Option<photocraft_doc::LayerId>, BrushSettings, f32)> {
|
||||
let gray = is_mask_target(p) || crate::channel_cmds::is_channel_target(p);
|
||||
let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_id(s, p)?) };
|
||||
let zoom = num(p, "zoom").unwrap_or(1.0);
|
||||
let brush = if gray && brush.erase { BrushSettings { erase: false, color: s.tools.background, ..brush } } else { brush };
|
||||
Ok((id, brush, num(p, "zoom").unwrap_or(1.0)))
|
||||
}
|
||||
|
||||
/// Erasing a layer with locked transparency (e.g. the Background) can't remove opacity, so it
|
||||
/// paints the background colour instead (Photoshop).
|
||||
fn erase_locked(brush: &mut BrushSettings, lock: bool, bg: [f32; 4]) {
|
||||
if brush.erase && lock {
|
||||
brush.erase = false;
|
||||
brush.color = bg;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stroke with a resolved brush onto the target layer (pixels or mask).
|
||||
fn stroke_with(s: &mut Session, p: &Value, label: &str, brush: BrushSettings, pts: Vec<StrokePoint>, auto_erase: bool) -> Result<Value> {
|
||||
let bg = s.tools.background;
|
||||
let fg = brush.color;
|
||||
// On a mask or channel the eraser paints the background colour (Photoshop).
|
||||
let brush = if gray && brush.erase { BrushSettings { erase: false, color: bg, ..brush } } else { brush };
|
||||
let (id, brush, zoom) = stroke_target(s, p, brush)?;
|
||||
let dmg = s.edit(label, |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
|
||||
let mut brush = brush;
|
||||
// Erasing a layer with locked transparency (e.g. the Background) can't remove opacity, so
|
||||
// it paints the background colour instead (Photoshop). `gray` targets are handled above.
|
||||
if brush.erase && lock {
|
||||
brush.erase = false;
|
||||
brush.color = bg;
|
||||
}
|
||||
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 {
|
||||
@@ -204,13 +272,89 @@ pub fn paint_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
stroke_with(s, p, label, brush, pts, false)
|
||||
}
|
||||
|
||||
/// A `paint.stroke` rendered while it is drawn, onto a copy of the active document, so the canvas
|
||||
/// shows the real dabs before the stroke commits. Committing the same params and points with
|
||||
/// `"seed": live.seed` gives the same pixels.
|
||||
pub struct LiveStroke {
|
||||
/// The active document with the stroke so far.
|
||||
pub doc: std::sync::Arc<photocraft_doc::Document>,
|
||||
/// Jitter seed to pass to `paint.stroke`.
|
||||
pub seed: u64,
|
||||
renderer: StrokeRenderer,
|
||||
pre: Surface,
|
||||
sel: Option<Surface>,
|
||||
lock: bool,
|
||||
layer: Option<photocraft_doc::LayerId>,
|
||||
params: Value,
|
||||
/// Where the doc shows the stroke's end as finishing it would draw it (see `push`).
|
||||
tail: Rect,
|
||||
}
|
||||
|
||||
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, 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 };
|
||||
live.push(&pts)?;
|
||||
Ok(live)
|
||||
}
|
||||
|
||||
/// Everything the stroke has touched so far.
|
||||
pub fn bounds(&self) -> Rect {
|
||||
self.renderer.bounds().union(&self.tail)
|
||||
}
|
||||
|
||||
/// Render more points; returns the rectangle that changed. The doc shows the stroke as
|
||||
/// committing it now would: with smoothing, the brush lags behind the pointer and catches up
|
||||
/// when the stroke ends, so that catch-up tail is drawn too (and redrawn on every step), and
|
||||
/// nothing new appears on release.
|
||||
pub fn push(&mut self, pts: &[StrokePoint]) -> Result<Rect> {
|
||||
self.renderer.push(pts);
|
||||
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?;
|
||||
let mut dmg = Rect::EMPTY;
|
||||
let old = std::mem::replace(&mut self.tail, Rect::EMPTY);
|
||||
if !old.is_empty() {
|
||||
// Back to the stroke without the previous tail.
|
||||
surf.write_region(old, &self.pre.read_region(old));
|
||||
self.renderer.mark_dirty(old);
|
||||
dmg = old;
|
||||
}
|
||||
dmg = dmg.union(&self.renderer.composite(&self.pre, surf, self.sel.as_ref(), self.lock, false));
|
||||
if let Some(mut tail) = self.renderer.tail_preview() {
|
||||
self.tail = tail.composite(&self.pre, surf, self.sel.as_ref(), self.lock, false);
|
||||
dmg = dmg.union(&self.tail);
|
||||
}
|
||||
Ok(dmg)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -223,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() {
|
||||
@@ -318,6 +470,7 @@ fn upsert(s: &mut Session, preset: BrushPreset) {
|
||||
Some(x) => *x = preset,
|
||||
None => s.tools.presets.push(preset),
|
||||
}
|
||||
s.brush_presets_changed();
|
||||
}
|
||||
|
||||
fn presets_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -327,7 +480,7 @@ fn presets_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(patch) => merge_brush(&s.tools.brush, patch, cmd)?,
|
||||
None => s.tools.brush.clone(),
|
||||
};
|
||||
upsert(s, BrushPreset { name: name.clone(), brush, builtin: false });
|
||||
upsert(s, BrushPreset { name: name.clone(), brush, builtin: false, group: String::new() });
|
||||
Ok(json!({ "name": name, "count": s.tools.presets.len() }))
|
||||
}
|
||||
|
||||
@@ -339,6 +492,7 @@ fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.tools.presets.len() == before {
|
||||
return Err(bad(cmd, format!("no brush preset named `{name}`")));
|
||||
}
|
||||
s.brush_presets_changed();
|
||||
Ok(json!({ "count": s.tools.presets.len() }))
|
||||
}
|
||||
|
||||
@@ -402,7 +556,7 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let tip = GrayTile::from_f32(tw, th, &data);
|
||||
let brush = BrushSettings { tip: TipShape::Sampled(tip), size: tw.max(th) as f32, spacing: 0.25, pressure_size: false, ..BrushSettings::default() };
|
||||
upsert(s, BrushPreset { name: name.clone(), brush: brush.clone(), builtin: false });
|
||||
upsert(s, BrushPreset { name: name.clone(), brush: brush.clone(), builtin: false, group: String::new() });
|
||||
s.tools.brush = brush;
|
||||
Ok(json!({ "name": name, "width": tw, "height": th }))
|
||||
}
|
||||
@@ -411,7 +565,7 @@ fn set_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "tools.setBrush";
|
||||
let mut b = s.tools.brush.clone();
|
||||
if let Some(name) = p.get("preset").and_then(Value::as_str) {
|
||||
b = find_preset(s, name, cmd)?.brush.clone().with_protected_texture(&s.tools.brush);
|
||||
b = find_preset(s, name, cmd)?.brush.clone().picked_over(&s.tools.brush);
|
||||
}
|
||||
if flag(p, "reset", false) {
|
||||
b = BrushSettings::default();
|
||||
@@ -427,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 }
|
||||
@@ -451,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);
|
||||
@@ -247,3 +286,234 @@ fn eraser_on_background_layer_paints_the_background_colour() {
|
||||
let w = st.doc.layers[0].surface().unwrap().rgba(10, 2);
|
||||
assert!(w[0] > 0.9 && w[1] > 0.9 && w[2] > 0.9, "untouched stays white: {w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_stroke_matches_the_committed_stroke() {
|
||||
// The canvas previews strokes with `LiveStroke`; it must show what `paint.stroke` commits
|
||||
// (soft edge, scatter seed), not a stand-in.
|
||||
let mut s = session(200, 100);
|
||||
let pts = [[10.0, 50.0, 1.0], [60.0, 30.0, 0.7], [120.0, 60.0, 0.9], [180.0, 40.0, 1.0]];
|
||||
let p = json!({"points": [pts[0]], "preset": "Spatter", "brush": {"hardness": 0.0}, "smoothing": 0.0, "target": "pixels"});
|
||||
let mut live = LiveStroke::begin(&s, &p).unwrap();
|
||||
for c in pts[1..].chunks(2) {
|
||||
let sp: Vec<StrokePoint> = c.iter().map(|q| StrokePoint::new(q[0], q[1], q[2] as f32)).collect();
|
||||
assert!(!live.push(&sp).unwrap().is_empty());
|
||||
}
|
||||
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();
|
||||
assert!(same_pixels(&shown, &surface(&s), Rect::new(0, 0, 200, 100)));
|
||||
assert!((0..100).any(|y| (0..200).any(|x| (0.05..0.95).contains(&shown.rgba(x, y)[3]))), "soft edge previewed");
|
||||
// Bad input is an error, not a panic.
|
||||
assert!(LiveStroke::begin(&s, &json!({"points": []})).is_err());
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Canvas-wide geometry: Image › Image Rotation (90°, 180°, flips, arbitrary), Canvas Size, Crop,
|
||||
//! Trim and Image Size move everything in the document, not only pixels. Pixels are remapped by
|
||||
//! the callers; this module moves the rest: type, shape and smart-object transforms, vector masks,
|
||||
//! gradient fill angles, effect reference points, artboards, and document-level paths, guides,
|
||||
//! slices, notes and measurement marks. Layer effect angles are left alone (Photoshop keeps the
|
||||
//! Global Light when the canvas rotates).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_doc::{Document, Fill, Layer, LayerContent, TextLayer};
|
||||
use photocraft_geom::{Affine, Point, Rect};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
use crate::pixels::remap_surface;
|
||||
|
||||
const EPS: f64 = 1e-9;
|
||||
|
||||
fn map_pt(a: &Affine, p: [f64; 2]) -> [f64; 2] {
|
||||
let q = a.apply(Point::new(p[0], p[1]));
|
||||
[q.x, q.y]
|
||||
}
|
||||
|
||||
/// Bounding box `[x0, y0, x1, y1]` of the four mapped corners of `[x0, y0, x1, y1]`.
|
||||
fn map_box(a: &Affine, b: [f64; 4]) -> [f64; 4] {
|
||||
let c = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]].map(|p| map_pt(a, p));
|
||||
c.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p[0]), r[1].min(p[1]), r[2].max(p[0]), r[3].max(p[1])])
|
||||
}
|
||||
|
||||
fn map_rect(a: &Affine, r: Rect) -> Rect {
|
||||
if r.is_empty() {
|
||||
return r;
|
||||
}
|
||||
let b = map_box(a, [f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)]);
|
||||
let i = |v: f64| v.round().clamp(f64::from(i32::MIN / 2), f64::from(i32::MAX / 2)) as i32;
|
||||
Rect::new(i(b[0]), i(b[1]), i(b[2]), i(b[3]))
|
||||
}
|
||||
|
||||
/// A gradient angle (degrees, counter-clockwise on screen) carried through `a`'s linear part.
|
||||
fn map_angle(a: &Affine, deg: f32) -> f32 {
|
||||
let (s, c) = f64::from(deg).to_radians().sin_cos();
|
||||
// Direction in y-down document space.
|
||||
let (dx, dy) = (c, -s);
|
||||
let (nx, ny) = (a.m[0] * dx + a.m[2] * dy, a.m[1] * dx + a.m[3] * dy);
|
||||
if nx.abs() < EPS && ny.abs() < EPS {
|
||||
return deg;
|
||||
}
|
||||
let out = (-ny).atan2(nx).to_degrees();
|
||||
// Keep whole-degree angles whole (90° turns and flips are exact up to rounding noise).
|
||||
let out = if (out - out.round()).abs() < 1e-6 { out.round() } else { out };
|
||||
// (−180°, 180°]: −0 from atan2 would otherwise give −180°.
|
||||
(if out <= -180.0 { out + 360.0 } else { out }) as f32
|
||||
}
|
||||
|
||||
fn map_fill(a: &Affine, f: &mut Fill) {
|
||||
if let Fill::Gradient { angle, offset, .. } = f {
|
||||
*angle = map_angle(a, *angle);
|
||||
// The centre offset (a fraction of the frame) turns with the canvas: exact for 90° turns
|
||||
// and flips, where the frame's sides swap with the axes.
|
||||
let (ox, oy) = (f64::from(offset.0), f64::from(offset.1));
|
||||
*offset = ((a.m[0] * ox + a.m[2] * oy) as f32, (a.m[1] * ox + a.m[3] * oy) as f32);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuilds a type layer's PSD `TySh` block for its current transform, keeping its pixels.
|
||||
fn retag_text(dpi: f32, t: &mut TextLayer) {
|
||||
if t.psd_raw.is_none() {
|
||||
return;
|
||||
}
|
||||
let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let layout = eng.layout(t, dpi);
|
||||
t.psd_raw = Some(Arc::new(photocraft_text::psd::build_tysh(t, dpi, layout.bounds())));
|
||||
}
|
||||
|
||||
/// Applies `a` to one layer's (and its children's) non-pixel geometry. With `content` false only
|
||||
/// the parts Free Transform (`transform_layer`) doesn't move are touched: unlinked vector masks,
|
||||
/// gradient fill angles, effect reference points and artboards.
|
||||
pub(crate) fn transform_layer_geometry(l: &mut Layer, a: &Affine, content: bool, dpi: f32) {
|
||||
if let Some(vm) = l.vector_mask.as_mut()
|
||||
&& (content || !vm.linked)
|
||||
{
|
||||
vm.path = vm.path.transform(a);
|
||||
}
|
||||
if let Some((x, y)) = l.effects.reference {
|
||||
let p = map_pt(a, [x, y]);
|
||||
l.effects.reference = Some((p[0], p[1]));
|
||||
}
|
||||
if !content {
|
||||
// Free Transform doesn't warp fill caches: drop it, the fill re-renders from its model.
|
||||
l.fill_cache = None;
|
||||
} else if let Some(fc) = &mut l.fill_cache {
|
||||
map_fill(a, &mut fc.fill);
|
||||
}
|
||||
match &mut l.content {
|
||||
LayerContent::Fill(f) => map_fill(a, f),
|
||||
LayerContent::Shape(sh) => {
|
||||
if let Some(f) = &mut sh.fill {
|
||||
map_fill(a, f);
|
||||
}
|
||||
if content {
|
||||
crate::vector_cmds::transform_shape(sh, a);
|
||||
}
|
||||
}
|
||||
LayerContent::Text(t) if content => {
|
||||
t.transform = a.mul(&t.transform);
|
||||
retag_text(dpi, t);
|
||||
}
|
||||
LayerContent::Smart(sm) if content => sm.transform = crate::smart_cmds::snap_affine(a.mul(&sm.transform)),
|
||||
LayerContent::Group(g) => {
|
||||
if let Some(ab) = &mut g.artboard {
|
||||
ab.rect = map_rect(a, ab.rect);
|
||||
}
|
||||
for c in &mut g.children {
|
||||
transform_layer_geometry(c, a, content, dpi);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Document-level geometry: paths, notes, Count/Ruler marks, slices and guides. Guides survive
|
||||
/// axis-aligned maps (and swap orientation under 90° turns); any other map clears them.
|
||||
pub(crate) fn transform_doc_marks(doc: &mut Document, a: &Affine) {
|
||||
for p in &mut doc.paths {
|
||||
p.path = p.path.transform(a);
|
||||
}
|
||||
if let Some(wp) = &mut doc.work_path {
|
||||
*wp = wp.transform(a);
|
||||
}
|
||||
for n in &mut doc.notes {
|
||||
n.position = map_pt(a, n.position);
|
||||
n.popup = map_box(a, n.popup);
|
||||
}
|
||||
for g in &mut doc.measurement.count_groups {
|
||||
for p in &mut g.points {
|
||||
*p = map_pt(a, *p);
|
||||
}
|
||||
}
|
||||
if let Some(r) = &mut doc.measurement.ruler {
|
||||
r.start = map_pt(a, r.start);
|
||||
r.end = map_pt(a, r.end);
|
||||
if let Some(p) = &mut r.protractor {
|
||||
*p = map_pt(a, *p);
|
||||
}
|
||||
}
|
||||
for s in &mut doc.slices.list {
|
||||
s.rect = map_rect(a, s.rect);
|
||||
}
|
||||
let [m0, m1, m2, m3, tx, ty] = a.m;
|
||||
let g = &mut doc.guides;
|
||||
if m1.abs() < EPS && m2.abs() < EPS {
|
||||
g.horizontal.iter_mut().for_each(|y| *y = (m3 * f64::from(*y) + ty) as f32);
|
||||
g.vertical.iter_mut().for_each(|x| *x = (m0 * f64::from(*x) + tx) as f32);
|
||||
} else if m0.abs() < EPS && m3.abs() < EPS {
|
||||
// A horizontal guide at y becomes a vertical one at x' = m2·y + tx, and vice versa.
|
||||
let v: Vec<f32> = g.horizontal.iter().map(|y| (m2 * f64::from(*y) + tx) as f32).collect();
|
||||
let h: Vec<f32> = g.vertical.iter().map(|x| (m1 * f64::from(*x) + ty) as f32).collect();
|
||||
g.horizontal = h;
|
||||
g.vertical = v;
|
||||
} else {
|
||||
g.horizontal.clear();
|
||||
g.vertical.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies `a` to all non-pixel geometry of the document (layers and document marks).
|
||||
pub(crate) fn transform_geometry(doc: &mut Document, a: &Affine) {
|
||||
let dpi = doc.resolution_dpi;
|
||||
for l in &mut doc.layers {
|
||||
transform_layer_geometry(l, a, true, dpi);
|
||||
}
|
||||
transform_doc_marks(doc, a);
|
||||
}
|
||||
|
||||
/// What to re-render after the geometry moved.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Refresh {
|
||||
/// Shapes only (their pixels are clipped to the canvas, which may have grown).
|
||||
Shapes,
|
||||
/// Type, shapes and smart objects (after a resampling map). Smart objects whose source is
|
||||
/// unavailable keep their (already mapped) cache.
|
||||
All,
|
||||
}
|
||||
|
||||
pub(crate) fn refresh(doc: &mut Document, what: Refresh) {
|
||||
fn rec(snapshot: &Document, layers: &mut [Layer], what: Refresh) {
|
||||
for l in layers {
|
||||
match &mut l.content {
|
||||
LayerContent::Group(g) => rec(snapshot, &mut g.children, what),
|
||||
LayerContent::Shape(sh) => crate::vector_cmds::refresh_shape(snapshot, sh),
|
||||
LayerContent::Text(t) if what == Refresh::All => crate::type_cmds::refresh(snapshot, t),
|
||||
LayerContent::Smart(_) if what == Refresh::All => {
|
||||
let _ = crate::smart_cmds::refresh_layer(snapshot, l);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let snapshot = doc.clone();
|
||||
rec(&snapshot, &mut doc.layers, what);
|
||||
}
|
||||
|
||||
/// Image › Image Rotation by a right angle or a flip.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Turn {
|
||||
FlipHorizontal,
|
||||
FlipVertical,
|
||||
Rotate180,
|
||||
Cw90,
|
||||
Ccw90,
|
||||
}
|
||||
|
||||
impl Turn {
|
||||
/// Pixel map `(x, y) → (x', y')` (pixel indices) for a `w × h` canvas.
|
||||
pub(crate) fn pixel_map(self, w: i32, h: i32) -> impl Fn(i32, i32) -> (i32, i32) {
|
||||
move |x, y| match self {
|
||||
Turn::FlipHorizontal => (w - 1 - x, y),
|
||||
Turn::FlipVertical => (x, h - 1 - y),
|
||||
Turn::Rotate180 => (w - 1 - x, h - 1 - y),
|
||||
Turn::Cw90 => (h - 1 - y, x),
|
||||
Turn::Ccw90 => (y, w - 1 - x),
|
||||
}
|
||||
}
|
||||
/// The same map in continuous (pixel-edge) coordinates.
|
||||
pub(crate) fn affine(self, w: f64, h: f64) -> Affine {
|
||||
Affine {
|
||||
m: match self {
|
||||
Turn::FlipHorizontal => [-1.0, 0.0, 0.0, 1.0, w, 0.0],
|
||||
Turn::FlipVertical => [1.0, 0.0, 0.0, -1.0, 0.0, h],
|
||||
Turn::Rotate180 => [-1.0, 0.0, 0.0, -1.0, w, h],
|
||||
Turn::Cw90 => [0.0, 1.0, -1.0, 0.0, h, 0.0],
|
||||
Turn::Ccw90 => [0.0, -1.0, 1.0, 0.0, 0.0, w],
|
||||
},
|
||||
}
|
||||
}
|
||||
fn swaps(self) -> bool {
|
||||
matches!(self, Turn::Cw90 | Turn::Ccw90)
|
||||
}
|
||||
}
|
||||
|
||||
/// Every pixel surface a canvas turn moves: content, rendered caches (type, shape, smart object,
|
||||
/// fill), masks (layer, smart filter), alpha channels, Quick Mask and the selection.
|
||||
fn remap_pixels(doc: &mut Document, map: &dyn Fn(i32, i32) -> (i32, i32)) {
|
||||
fn rec(layers: &mut [Layer], map: &dyn Fn(i32, i32) -> (i32, i32)) {
|
||||
let re = |s: &mut Surface| *s = remap_surface(s, map);
|
||||
for l in layers {
|
||||
if let Some(m) = &mut l.mask {
|
||||
re(&mut m.surface);
|
||||
}
|
||||
if let Some(fc) = &mut l.fill_cache {
|
||||
re(&mut fc.surface);
|
||||
}
|
||||
match &mut l.content {
|
||||
LayerContent::Raster(s) => re(s),
|
||||
LayerContent::Text(t) => t.cache.iter_mut().for_each(re),
|
||||
LayerContent::Shape(sh) => sh.cache.iter_mut().for_each(re),
|
||||
LayerContent::Smart(sm) => {
|
||||
sm.cache.iter_mut().for_each(re);
|
||||
if let Some(m) = &mut sm.filter_mask {
|
||||
re(&mut m.surface);
|
||||
}
|
||||
}
|
||||
LayerContent::Group(g) => rec(&mut g.children, map),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
rec(&mut doc.layers, map);
|
||||
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
|
||||
ch.surface = remap_surface(&ch.surface, map);
|
||||
}
|
||||
if let Some(sel) = &doc.selection {
|
||||
doc.selection = Some(remap_surface(sel, map));
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns or flips the whole canvas. Pixels (and rendered caches) are remapped exactly, so nothing
|
||||
/// is re-rendered; vector geometry follows through the matching affine map.
|
||||
pub(crate) fn turn_canvas(doc: &mut Document, turn: Turn) {
|
||||
let (w, h) = (doc.size.width as i32, doc.size.height as i32);
|
||||
let map = turn.pixel_map(w, h);
|
||||
remap_pixels(doc, &map);
|
||||
let a = turn.affine(f64::from(doc.size.width), f64::from(doc.size.height));
|
||||
if turn.swaps() {
|
||||
doc.size = photocraft_doc::Size::new(doc.size.height, doc.size.width);
|
||||
}
|
||||
transform_geometry(doc, &a);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,287 @@
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
use photocraft_doc::vector::LiveShape;
|
||||
use serde_json::json;
|
||||
|
||||
const W: u32 = 64;
|
||||
const H: u32 = 40;
|
||||
|
||||
fn doc(s: &Session) -> &Document {
|
||||
&s.active().unwrap().doc
|
||||
}
|
||||
|
||||
/// Paints the active raster layer with `f(x, y)` (straight RGBA).
|
||||
fn paint(s: &mut Session, f: impl Fn(i32, i32) -> [f32; 4]) {
|
||||
s.edit("setup", |doc, active| {
|
||||
let b = doc.bounds();
|
||||
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||
let fmt = surf.format();
|
||||
let mut data = Vec::new();
|
||||
for y in b.y0..b.y1 {
|
||||
for x in b.x0..b.x1 {
|
||||
data.extend(photocraft_raster::from_rgba(&fmt, f(x, y)));
|
||||
}
|
||||
}
|
||||
surf.write_region(b, &data);
|
||||
surf.prune();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// An asymmetric document: a gradient Background, a type layer, a shape layer, a raster layer
|
||||
/// with a vector mask and a smart object, plus a guide, a saved path and a slice.
|
||||
fn mixed(depth: u64, mode: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": W, "height": H, "depth": depth, "mode": mode})).unwrap();
|
||||
paint(&mut s, |x, y| [x as f32 / W as f32, y as f32 / H as f32, 0.3, 1.0]);
|
||||
s.execute("type.create", json!({"x": 4, "y": 16, "text": "Fj", "size": 14, "color": "#102080"})).unwrap();
|
||||
s.execute("shape.create", json!({"kind": "rect", "rect": [40, 4, 18, 9], "fill": "#ff8800"})).unwrap();
|
||||
s.execute("shape.create", json!({"kind": "ellipse", "rect": [6, 24, 12, 10], "fill": "#00aa44"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
paint(&mut s, |x, y| if (28..60).contains(&x) && (18..38).contains(&y) { [0.9, 0.1, 0.6, 1.0] } else { [0.0; 4] });
|
||||
s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[30, 20], [58, 22], [36, 37]]}]}})).unwrap();
|
||||
s.execute("layer.vectorMask.currentPath", json!({})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
paint(&mut s, |x, y| if (20..27).contains(&x) && (2..12).contains(&y) { [0.1, 0.8, 0.9, 1.0] } else { [0.0; 4] });
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap();
|
||||
s.execute("view.newGuide", json!({"orientation": "horizontal", "position": 5})).unwrap();
|
||||
s.execute("path.set", json!({"name": "Saved", "path": {"subpaths": [{"knots": [[2, 2], [12, 2], [12, 8]]}]}})).unwrap();
|
||||
s.execute("slice.new", json!({"rect": [1, 2, 10, 6]})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn command(t: Turn) -> &'static str {
|
||||
match t {
|
||||
Turn::FlipHorizontal => "image.imageRotation.flipCanvasHorizontal",
|
||||
Turn::FlipVertical => "image.imageRotation.flipCanvasVertical",
|
||||
Turn::Rotate180 => "image.imageRotation.180",
|
||||
Turn::Cw90 => "image.imageRotation.90cw",
|
||||
Turn::Ccw90 => "image.imageRotation.90ccw",
|
||||
}
|
||||
}
|
||||
|
||||
const TURNS: [Turn; 5] = [Turn::FlipHorizontal, Turn::FlipVertical, Turn::Rotate180, Turn::Cw90, Turn::Ccw90];
|
||||
|
||||
/// The flattened original, remapped by the turn's pixel map.
|
||||
fn expected(before: &photocraft_compose::Buffer, t: Turn, nw: u32) -> Vec<[f32; 4]> {
|
||||
let map = t.pixel_map(W as i32, H as i32);
|
||||
let mut out = vec![[0.0f32; 4]; before.px.len()];
|
||||
for y in 0..H as i32 {
|
||||
for x in 0..W as i32 {
|
||||
let (nx, ny) = map(x, y);
|
||||
out[ny as usize * nw as usize + nx as usize] = before.px[y as usize * W as usize + x as usize];
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// (mean absolute difference, fraction of pixels differing by more than 0.2).
|
||||
fn diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> (f32, f32) {
|
||||
assert_eq!(a.len(), b.len());
|
||||
let mut sum = 0.0;
|
||||
let mut big = 0usize;
|
||||
for (p, q) in a.iter().zip(b) {
|
||||
let d = (0..4).map(|c| (p[c] * p[3] - q[c] * q[3]).abs()).fold(0.0f32, f32::max);
|
||||
sum += d;
|
||||
big += usize::from(d > 0.2);
|
||||
}
|
||||
(sum / a.len() as f32, big as f32 / a.len() as f32)
|
||||
}
|
||||
|
||||
fn check_turn(depth: u64, mode: &str, t: Turn) {
|
||||
let mut s = mixed(depth, mode);
|
||||
let before = photocraft_compose::flatten(doc(&s));
|
||||
s.execute(command(t), json!({})).unwrap();
|
||||
let nw = doc(&s).size.width;
|
||||
let want = expected(&before, t, nw);
|
||||
// Caches are remapped exactly; vector masks re-rasterize from their mapped path.
|
||||
let got = photocraft_compose::flatten(doc(&s));
|
||||
let (mean, big) = diff(&got.px, &want);
|
||||
assert!(mean < 1.0 / 255.0 && big < 0.01, "{t:?} {depth} {mode}: mean {mean} big {big}");
|
||||
// Re-rendering every type, shape and smart object from its *geometry* gives the same image,
|
||||
// so the transforms (not only the cached pixels) moved.
|
||||
let mut d = doc(&s).clone();
|
||||
refresh(&mut d, Refresh::All);
|
||||
let (mean, big) = diff(&photocraft_compose::flatten(&d).px, &want);
|
||||
assert!(mean < 2.0 / 255.0 && big < 0.01, "{t:?} {depth} {mode} re-rendered: mean {mean} big {big}");
|
||||
// Undo restores the original, bit-exactly.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let undone = photocraft_compose::flatten(doc(&s));
|
||||
assert_eq!(undone.px.len(), before.px.len(), "{t:?} {depth} {mode}: undo changed the canvas size");
|
||||
let (mut n, mut max, mut first) = (0usize, 0.0f32, None);
|
||||
for (i, (p, q)) in undone.px.iter().zip(&before.px).enumerate() {
|
||||
if p != q {
|
||||
n += 1;
|
||||
max = (0..4).map(|c| (p[c] - q[c]).abs()).fold(max, f32::max);
|
||||
first.get_or_insert((i % W as usize, i / W as usize, *p, *q));
|
||||
}
|
||||
}
|
||||
assert!(n == 0, "{t:?} {depth} {mode}: undo left {n} pixels different (max {max}); first (x, y, undone, before): {first:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_angle_turns_move_vectors_type_and_smart_objects() {
|
||||
for t in TURNS {
|
||||
check_turn(8, "rgb", t);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turns_at_every_depth_and_mode() {
|
||||
for (depth, mode) in [(16, "rgb"), (32, "rgb"), (8, "cmyk"), (16, "cmyk"), (8, "gray"), (16, "gray")] {
|
||||
check_turn(depth, mode, Turn::Cw90);
|
||||
}
|
||||
check_turn(32, "rgb", Turn::FlipHorizontal);
|
||||
check_turn(8, "cmyk", Turn::Ccw90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turns_map_guides_paths_slices_and_live_shapes() {
|
||||
let mut s = mixed(8, "rgb");
|
||||
s.execute("image.imageRotation.90cw", json!({})).unwrap();
|
||||
let d = doc(&s);
|
||||
assert_eq!((d.size.width, d.size.height), (H, W));
|
||||
// A vertical guide at x=10 becomes a horizontal one at y=10; a horizontal one at y=5 becomes
|
||||
// a vertical one at x = H − 5.
|
||||
assert_eq!(d.guides.horizontal, vec![10.0]);
|
||||
assert_eq!(d.guides.vertical, vec![(H - 5) as f32]);
|
||||
// Slice [1,2 10×6] → x' = H − y: [H−8, 1, 6×10].
|
||||
let sl = &d.slices.list[0];
|
||||
assert_eq!(sl.rect, Rect::new(H as i32 - 8, 1, H as i32 - 2, 11));
|
||||
let saved = d.paths.iter().find(|p| p.name == "Saved").unwrap();
|
||||
let k = saved.path.subpaths[0].knots[1].anchor;
|
||||
assert!((k.x - (f64::from(H) - 2.0)).abs() < 1e-9 && (k.y - 12.0).abs() < 1e-9, "{k:?}");
|
||||
// The rectangle stays a live rectangle with the rotated box.
|
||||
let live: Vec<&LiveShape> = d
|
||||
.walk()
|
||||
.into_iter()
|
||||
.filter_map(|(_, _, l)| match &l.content {
|
||||
LayerContent::Shape(sh) => sh.live.as_ref(),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
live.iter()
|
||||
.any(|l| matches!(l, LiveShape::Rect { rect, .. } if rect.iter().zip([f64::from(H) - 13.0, 40.0, 9.0, 18.0]).all(|(a, b)| (a - b).abs() < 1e-9))),
|
||||
"{live:?}"
|
||||
);
|
||||
assert!(live.iter().any(|l| matches!(l, LiveShape::Ellipse { .. })));
|
||||
// Flip twice is the identity for the geometry.
|
||||
let before = doc(&s).clone();
|
||||
s.execute("image.imageRotation.flipCanvasVertical", json!({})).unwrap();
|
||||
s.execute("image.imageRotation.flipCanvasVertical", json!({})).unwrap();
|
||||
let after = doc(&s);
|
||||
assert_eq!(after.guides, before.guides);
|
||||
assert_eq!(after.slices.list[0].rect, before.slices.list[0].rect);
|
||||
assert_eq!(photocraft_compose::flatten(after).px, photocraft_compose::flatten(&before).px);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_fill_angle_follows_the_canvas() {
|
||||
let a = Turn::Cw90.affine(10.0, 20.0);
|
||||
// Up (90°) turns clockwise to the right (0°); flips mirror.
|
||||
assert_eq!(map_angle(&a, 90.0), 0.0);
|
||||
assert_eq!(map_angle(&Turn::FlipHorizontal.affine(10.0, 20.0), 0.0), 180.0);
|
||||
assert_eq!(map_angle(&Turn::FlipVertical.affine(10.0, 20.0), 30.0), -30.0);
|
||||
assert_eq!(map_angle(&Turn::Ccw90.affine(10.0, 20.0), 0.0), 90.0);
|
||||
}
|
||||
|
||||
fn type_and_shape_origin(d: &Document) -> ([f64; 6], [f64; 2]) {
|
||||
let mut t = None;
|
||||
let mut k = None;
|
||||
for (_, _, l) in d.walk() {
|
||||
match &l.content {
|
||||
LayerContent::Text(x) => t = Some(x.transform.m),
|
||||
LayerContent::Shape(sh) if k.is_none() => k = Some([sh.path.subpaths[0].knots[0].anchor.x, sh.path.subpaths[0].knots[0].anchor.y]),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(t.unwrap(), k.unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_size_and_crop_move_vector_geometry() {
|
||||
let mut s = mixed(8, "rgb");
|
||||
let before = photocraft_compose::flatten(doc(&s));
|
||||
let (t0, k0) = type_and_shape_origin(doc(&s));
|
||||
s.execute("image.canvasSize", json!({"width": 20, "height": 10, "relative": true, "anchor": "bottomRight", "extensionColor": "transparent"})).unwrap();
|
||||
let d = doc(&s);
|
||||
let (t1, k1) = type_and_shape_origin(d);
|
||||
assert_eq!((t1[4] - t0[4], t1[5] - t0[5]), (20.0, 10.0));
|
||||
assert_eq!((k1[0] - k0[0], k1[1] - k0[1]), (20.0, 10.0));
|
||||
assert_eq!(d.guides.vertical, vec![30.0]);
|
||||
assert_eq!(d.slices.list[0].rect, Rect::new(21, 12, 31, 18));
|
||||
// The old canvas shows unchanged at its new offset, also after re-rendering from geometry.
|
||||
let mut r = d.clone();
|
||||
refresh(&mut r, Refresh::All);
|
||||
for img in [photocraft_compose::flatten(d), photocraft_compose::flatten(&r)] {
|
||||
let nw = img.rect.width() as usize;
|
||||
let shifted: Vec<[f32; 4]> = (0..H as usize).flat_map(|y| img.px[(y + 10) * nw + 20..(y + 10) * nw + 20 + W as usize].to_vec()).collect();
|
||||
let (mean, big) = diff(&shifted, &before.px);
|
||||
assert!(mean < 2.0 / 255.0 && big < 0.01, "mean {mean} big {big}");
|
||||
}
|
||||
// Crop back: everything returns.
|
||||
s.execute("image.crop", json!({"x": 20, "y": 10, "width": W, "height": H})).unwrap();
|
||||
let (t2, k2) = type_and_shape_origin(doc(&s));
|
||||
assert_eq!((t2, k2), (t0, k0));
|
||||
let (mean, _) = diff(&photocraft_compose::flatten(doc(&s)).px, &before.px);
|
||||
assert!(mean < 1.0 / 255.0, "{mean}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_size_scales_vector_geometry() {
|
||||
let mut s = mixed(8, "rgb");
|
||||
let (t0, k0) = type_and_shape_origin(doc(&s));
|
||||
s.execute("image.imageSize", json!({"width": W * 2, "height": H * 2})).unwrap();
|
||||
let d = doc(&s);
|
||||
let (t1, k1) = type_and_shape_origin(d);
|
||||
assert!((t1[0] - 2.0 * t0[0]).abs() < 1e-9 && (t1[4] - 2.0 * t0[4]).abs() < 1e-9, "{t0:?} {t1:?}");
|
||||
assert_eq!((k1[0], k1[1]), (2.0 * k0[0], 2.0 * k0[1]));
|
||||
assert_eq!(d.guides.vertical, vec![20.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arbitrary_rotation_moves_marks_and_unlinked_vector_masks() {
|
||||
let mut s = mixed(8, "rgb");
|
||||
// Unlink the vector mask: a canvas rotation still moves it.
|
||||
s.edit("unlink", |doc, _| {
|
||||
for l in doc.layers.iter_mut() {
|
||||
if let Some(vm) = &mut l.vector_mask {
|
||||
vm.linked = false;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = doc(&s).clone();
|
||||
s.execute("image.rotation.arbitrary", json!({"angle": 90, "direction": "cw"})).unwrap();
|
||||
let d = doc(&s);
|
||||
let a = Turn::Cw90.affine(f64::from(W), f64::from(H));
|
||||
let vm = |d: &Document| d.walk().into_iter().find_map(|(_, _, l)| l.vector_mask.as_ref().map(|v| v.path.subpaths[0].knots[0].anchor)).unwrap();
|
||||
let (p, q) = (a.apply(vm(&before)), vm(d));
|
||||
assert!((p.x - q.x).abs() < 1e-6 && (p.y - q.y).abs() < 1e-6, "{p:?} {q:?}");
|
||||
assert_eq!(d.slices.list[0].rect, map_rect(&a, before.slices.list[0].rect));
|
||||
// 90° arbitrary == 90° clockwise for the composite.
|
||||
let mut s2 = mixed(8, "rgb");
|
||||
s2.execute("image.imageRotation.90cw", json!({})).unwrap();
|
||||
let (mean, big) = diff(&photocraft_compose::flatten(d).px, &photocraft_compose::flatten(doc(&s2)).px);
|
||||
assert!(mean < 3.0 / 255.0 && big < 0.02, "mean {mean} big {big}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turns_on_empty_and_tiny_documents_never_panic() {
|
||||
for (w, h) in [(1, 1), (1, 7), (5, 1)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": w, "height": h})).unwrap();
|
||||
for t in TURNS {
|
||||
s.execute(command(t), json!({"junk": [1, 2]})).unwrap();
|
||||
}
|
||||
s.execute("image.rotation.arbitrary", json!({"angle": 33})).unwrap();
|
||||
}
|
||||
let mut s = Session::new();
|
||||
for t in TURNS {
|
||||
assert!(s.execute(command(t), json!({})).is_err(), "no document");
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,8 @@ pub struct ChannelView {
|
||||
/// Visible alpha channels (missing entries are hidden).
|
||||
pub alpha_visible: Vec<bool>,
|
||||
pub quick_mask_hidden: bool,
|
||||
/// ⌥-click view of the active layer's mask (#196); see [`crate::mask_view_cmds`].
|
||||
pub layer_mask: Option<crate::mask_view_cmds::LayerMaskView>,
|
||||
}
|
||||
|
||||
impl ChannelView {
|
||||
@@ -80,7 +82,15 @@ impl ChannelView {
|
||||
/// True when the canvas shows just the normal composite (nothing extra to draw).
|
||||
pub fn is_plain(&self, doc: &Document) -> bool {
|
||||
let colors = color_count(doc);
|
||||
self.visible_colors(colors) == colors && !(0..doc.channels.len()).any(|i| self.alpha_shown(i)) && (doc.quick_mask.is_none() || self.quick_mask_hidden)
|
||||
self.visible_colors(colors) == colors
|
||||
&& !(0..doc.channels.len()).any(|i| self.alpha_shown(i))
|
||||
&& (doc.quick_mask.is_none() || self.quick_mask_hidden)
|
||||
&& self.shown_layer_mask(doc).is_none()
|
||||
}
|
||||
/// The layer mask shown on the canvas (#196) and how, if its layer still has a mask.
|
||||
pub fn shown_layer_mask<'a>(&self, doc: &'a Document) -> Option<(&'a photocraft_doc::LayerMask, crate::mask_view_cmds::MaskViewMode)> {
|
||||
let v = self.layer_mask?;
|
||||
Some((doc.layer(v.layer)?.mask.as_ref()?, v.mode))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +125,7 @@ pub(crate) fn fix_view(st: &mut DocState) {
|
||||
if v.visible_colors(colors) == 0 && !(0..n).any(|i| v.alpha_shown(i)) {
|
||||
v.color_hidden.clear();
|
||||
}
|
||||
crate::mask_view_cmds::fix(st);
|
||||
}
|
||||
|
||||
/// Bump the revision for a view-only change without dirtying a clean document.
|
||||
@@ -212,6 +223,8 @@ pub enum ChanRef {
|
||||
Selection,
|
||||
Transparency,
|
||||
LayerMask,
|
||||
/// The layer's vector mask, rasterized (⌘-click its thumbnail, #196).
|
||||
VectorMask,
|
||||
}
|
||||
|
||||
pub fn parse_ref(v: &Value, doc: &Document) -> Option<ChanRef> {
|
||||
@@ -233,6 +246,7 @@ pub fn parse_ref(v: &Value, doc: &Document) -> Option<ChanRef> {
|
||||
"selection" => return Some(ChanRef::Selection),
|
||||
"transparency" => return Some(ChanRef::Transparency),
|
||||
"mask" | "layermask" => return Some(ChanRef::LayerMask),
|
||||
"vectormask" | "vector mask" => return Some(ChanRef::VectorMask),
|
||||
_ => {}
|
||||
}
|
||||
if l == composite_name(mode).to_ascii_lowercase() {
|
||||
@@ -335,6 +349,14 @@ fn ref_planes(doc: &Document, layer: Option<LayerId>, active: Option<LayerId>, r
|
||||
let m = l.mask.as_ref().ok_or_else(|| EngineError::Other(format!("layer \"{}\" has no mask", l.name)))?;
|
||||
vec![read_plane(&m.surface, area)]
|
||||
}
|
||||
ChanRef::VectorMask => {
|
||||
let id = layer.or(active).ok_or_else(|| EngineError::Other("vector mask needs a layer".into()))?;
|
||||
let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let m = l.vector_mask.as_ref().ok_or_else(|| EngineError::Other(format!("layer \"{}\" has no vector mask", l.name)))?;
|
||||
// The path's shape, whether or not the mask is enabled (as Photoshop loads it).
|
||||
let shape = photocraft_doc::VectorMask { enabled: true, density: 1.0, feather: 0.0, ..m.clone() };
|
||||
vec![photocraft_vector::vector_mask_values(&shape, area)]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -514,8 +536,23 @@ pub(crate) fn channel_surface_for_filter<'a>(doc: &'a mut Document, p: &Value) -
|
||||
/// Commands whose target follows the Channels panel.
|
||||
fn routed(id: &str) -> bool {
|
||||
id.starts_with("filter.")
|
||||
|| id == "plugin.run"
|
||||
|| id.starts_with("image.adjustments.")
|
||||
|| matches!(id, "paint.stroke" | "paint.pencil" | "paint.bucket" | "paint.gradient" | "paint.mixerBrush" | "edit.fill" | "image.applyImage")
|
||||
|| crate::fill_key_cmds::IDS.contains(&id)
|
||||
|| matches!(
|
||||
id,
|
||||
"paint.cloneStamp"
|
||||
| "paint.healingBrush"
|
||||
| "paint.spotHealing"
|
||||
| "paint.dodge"
|
||||
| "paint.burn"
|
||||
| "paint.sponge"
|
||||
| "paint.blur"
|
||||
| "paint.sharpen"
|
||||
| "paint.smudge"
|
||||
| "paint.historyBrush"
|
||||
)
|
||||
}
|
||||
|
||||
/// Fill in `"target"` from the targeted channel (or Quick Mask mode) when the caller gave none.
|
||||
@@ -527,6 +564,8 @@ pub(crate) fn inject_target(s: &Session, id: &str, params: Value) -> Value {
|
||||
let t = match st.channel_view.target {
|
||||
ChannelTarget::Alpha(i) if i < st.doc.channels.len() => json!({ "channel": i }),
|
||||
ChannelTarget::Composite if st.doc.quick_mask.is_some() => json!("quickMask"),
|
||||
// Viewing the active layer's mask (⌥-click): pixel commands edit the mask.
|
||||
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some() => json!("mask"),
|
||||
_ => return params,
|
||||
};
|
||||
match params {
|
||||
@@ -1415,6 +1454,7 @@ pub fn channels_json(st: &DocState) -> Value {
|
||||
"quickMask": doc.quick_mask.as_ref().map(|q| json!({ "visible": !v.quick_mask_hidden, "color": q.color.to_rgb(), "opacity": q.opacity, "indicates": q.indicates })),
|
||||
"target": v.target,
|
||||
"compositeVisible": v.visible_colors(colors) == colors,
|
||||
"layerMaskView": crate::mask_view_cmds::view_json(st),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1447,7 +1487,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Load Selection…",
|
||||
["Select"],
|
||||
None,
|
||||
r##"{"channel":index|name|"composite"|"red|green|blue|…"|"transparency"|"mask"|"quickMask"|"selection","layer":id?,"document":index?,"invert":bool=false,"operation":"new|add|subtract|intersect"="new"}"##,
|
||||
r##"{"channel":index|name|"composite"|"red|green|blue|…"|"transparency"|"mask"|"vectorMask"|"quickMask"|"selection","layer":id?,"document":index?,"invert":bool=false,"operation":"new|add|subtract|intersect"="new"}"##,
|
||||
has_doc,
|
||||
load_selection
|
||||
),
|
||||
|
||||
Loaded 100 of 500 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user