mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-19 13:44:11 -04:00
Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3ac5f672e | ||
|
|
cc418e0b08 | ||
|
|
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,10 +52,10 @@ 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
|
||||
|
||||
@@ -50,4 +50,13 @@ jobs:
|
||||
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
|
||||
@@ -201,6 +201,44 @@ 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
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
@@ -236,7 +274,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, web]
|
||||
needs: [version, macos, windows, linux, flatpak, 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,7 +11,7 @@ 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 |
|
||||
| `crates/<name>/README.md` (where present) | Public API of that crate |
|
||||
|
||||
@@ -24,7 +24,7 @@ crates/
|
||||
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
|
||||
@@ -67,6 +67,10 @@ 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.
|
||||
|
||||
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. `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.
|
||||
|
||||
+2
-1
@@ -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` (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/{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) |
|
||||
|
||||
Generated
+303
-27
@@ -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"
|
||||
@@ -717,6 +723,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"
|
||||
@@ -1537,12 +1573,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 +1652,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 +1834,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 +1968,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 +1985,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1921,7 +1994,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 +2233,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 +2465,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 +2575,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"
|
||||
@@ -3235,11 +3348,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fmv-macos-events",
|
||||
"log",
|
||||
"open",
|
||||
"photocraft-automation",
|
||||
@@ -3260,7 +3374,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-algo"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3272,9 +3386,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 +3408,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cli"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-automation",
|
||||
"photocraft-codecs",
|
||||
@@ -3306,7 +3421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cms"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"moxcms",
|
||||
"rayon",
|
||||
@@ -3314,7 +3429,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-codecs"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"exr",
|
||||
"flate2",
|
||||
@@ -3332,7 +3447,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-color"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"serde",
|
||||
@@ -3340,7 +3455,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-compose"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"photocraft-color",
|
||||
@@ -3355,7 +3470,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-doc"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3366,7 +3481,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-engine"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"flate2",
|
||||
@@ -3381,18 +3496,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 +3529,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,7 +3553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-io"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"photocraft-codecs",
|
||||
@@ -3443,8 +3562,10 @@ dependencies = [
|
||||
"photocraft-doc",
|
||||
"photocraft-format",
|
||||
"photocraft-geom",
|
||||
"photocraft-paint",
|
||||
"photocraft-psd",
|
||||
"photocraft-raster",
|
||||
"photocraft-raw",
|
||||
"photocraft-text",
|
||||
"photocraft-vector",
|
||||
"proptest",
|
||||
@@ -3453,14 +3574,14 @@ dependencies = [
|
||||
|
||||
[[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 +3590,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 +3618,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raster"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3490,9 +3626,18 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raw"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-raw",
|
||||
"png 0.18.1",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-testkit"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3501,7 +3646,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-text"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"parley",
|
||||
@@ -3515,8 +3660,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ui-egui"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
@@ -3532,6 +3678,7 @@ dependencies = [
|
||||
"photocraft-geom",
|
||||
"photocraft-gpu",
|
||||
"photocraft-io",
|
||||
"photocraft-plugins",
|
||||
"photocraft-raster",
|
||||
"photocraft-text",
|
||||
"rayon",
|
||||
@@ -3541,7 +3688,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-vector"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-doc",
|
||||
@@ -3552,7 +3699,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4240,6 +4387,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 +4732,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 +4768,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"
|
||||
@@ -5141,6 +5314,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,6 +6105,16 @@ 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"
|
||||
@@ -5922,7 +6198,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+3
-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,6 +29,7 @@ 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" }
|
||||
|
||||
@@ -257,10 +257,17 @@ 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 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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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,28 @@ pub fn start() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Pen pressure, tilt and twist 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";
|
||||
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 }));
|
||||
});
|
||||
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 +130,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;
|
||||
|
||||
@@ -32,6 +32,12 @@ rfd = { workspace = true }
|
||||
arboard = "3"
|
||||
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,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();
|
||||
}
|
||||
}
|
||||
+43
-17
@@ -1,7 +1,8 @@
|
||||
//! 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>] [files…]`
|
||||
//!
|
||||
//! `--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,8 +13,11 @@
|
||||
#![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)]
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod control_server;
|
||||
mod crash_guard;
|
||||
mod monitor_profile;
|
||||
mod services;
|
||||
|
||||
use photocraft_engine::Session;
|
||||
@@ -37,6 +41,8 @@ fn main() -> eframe::Result {
|
||||
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 args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
@@ -44,6 +50,8 @@ fn main() -> eframe::Result {
|
||||
"--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),
|
||||
"--version" => {
|
||||
println!("photocraft {}", photocraft_engine::build_info::long_version());
|
||||
return Ok(());
|
||||
@@ -70,12 +78,29 @@ 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 {
|
||||
// Read the main display's ICC profile while the window opens (colour-managed canvas).
|
||||
let monitor = monitor_profile::detect_async();
|
||||
// Brush presets load in the background; the app attaches them when they arrive.
|
||||
let presets = services::presets_dir().map(photocraft_engine::preset_store::open_dir_async);
|
||||
let mut options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_icon(app_icon())
|
||||
.with_app_id(APP_ID)
|
||||
@@ -88,12 +113,20 @@ fn main() -> eframe::Result {
|
||||
.with_title_shown(false),
|
||||
..Default::default()
|
||||
};
|
||||
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
|
||||
photocraft_ui_egui::gpu_canvas::use_adapter_limits(&mut options.wgpu_options.wgpu_setup);
|
||||
eframe::run_native(
|
||||
"Photocraft",
|
||||
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");
|
||||
if let Ok(Some(icc)) = monitor.recv_timeout(std::time::Duration::from_secs(2)) {
|
||||
app.session.color.monitor_profile = Some(std::sync::Arc::new(icc));
|
||||
}
|
||||
// Preferences › Performance › Use Graphics Processor.
|
||||
if let Some(rs) = cc.wgpu_render_state.clone()
|
||||
&& std::env::var_os("PHOTOCRAFT_CPU_CANVAS").is_none()
|
||||
@@ -101,23 +134,16 @@ fn main() -> eframe::Result {
|
||||
{
|
||||
app.set_wgpu(rs);
|
||||
}
|
||||
if let Some((port, token)) = control {
|
||||
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));
|
||||
}
|
||||
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
|
||||
app.open_paths(&files);
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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,8 +12,29 @@ 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",
|
||||
];
|
||||
|
||||
/// 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);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else the platform convention
|
||||
/// (macOS `~/Library/Application Support/Photocraft`, Windows `%APPDATA%\Photocraft`, Linux
|
||||
@@ -36,6 +57,12 @@ 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"))
|
||||
}
|
||||
@@ -50,39 +77,55 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
std::fs::rename(&tmp, path).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 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()))),
|
||||
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())
|
||||
@@ -129,6 +172,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,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 |
@@ -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());
|
||||
}
|
||||
}
|
||||
+22
-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;
|
||||
@@ -931,20 +932,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)]
|
||||
|
||||
@@ -370,6 +370,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();
|
||||
@@ -610,6 +704,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()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -60,6 +68,20 @@ pub fn save(
|
||||
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 => std::fs::write(path, bytes).map_err(|error| AutomationError::Io(format!("{}: {error}", path.display()))),
|
||||
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 _};
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -443,15 +483,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 +616,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,369 @@
|
||||
//! 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 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.
|
||||
///
|
||||
/// 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 mut options = OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true);
|
||||
let mut file = root.dir.open_with(&relative, &options).map_err(|e| file_error("write", path, e))?;
|
||||
file.write_all(bytes).map_err(|e| file_error("write", path, e))?;
|
||||
file.flush().map_err(|e| file_error("write", path, e))
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
#[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");
|
||||
|
||||
+114
-15
@@ -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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -224,10 +323,10 @@ async fn fake_app() -> (String, tokio::task::JoinHandle<Vec<Value>>) {
|
||||
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}`")}),
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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]);
|
||||
|
||||
+238
-48
@@ -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;
|
||||
|
||||
@@ -96,39 +96,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(adj);
|
||||
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 +141,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 +198,117 @@ 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] {
|
||||
let x = |k: usize| k as f32 / (LUT_SIZE - 1) as f32;
|
||||
match adj {
|
||||
Adjustment::Levels { master, per_channel, space, black } => {
|
||||
let ident = LevelsChannel::default();
|
||||
let m = if *space == ToneSpace::Lab { &ident } else { master };
|
||||
let row = |c: &LevelsChannel| (0..LUT_SIZE).map(|k| levels(c, levels(m, x(k)))).collect();
|
||||
[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];
|
||||
@@ -541,3 +641,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]));
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,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.
|
||||
@@ -446,9 +446,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 +573,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 +600,28 @@ 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 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, g.angle, g.scale, g.reverse, g.offset, shape_bounds, x, y))
|
||||
},
|
||||
blend,
|
||||
opacity,
|
||||
@@ -808,7 +845,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 +869,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,9 +915,9 @@ 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();
|
||||
@@ -986,6 +1023,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 +1049,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
|
||||
}
|
||||
@@ -1020,6 +1069,18 @@ 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);
|
||||
}
|
||||
|
||||
pub(crate) fn composite_with_effects_prepared(
|
||||
layer: &Layer,
|
||||
content: &Buffer,
|
||||
backdrop: &mut Buffer,
|
||||
maps: &FxMaps,
|
||||
layer_bounds: Rect,
|
||||
patterns: &PreparedPatterns<'_>,
|
||||
) {
|
||||
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() };
|
||||
@@ -1409,7 +1470,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 +1492,27 @@ 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());
|
||||
let (e, paint) =
|
||||
bevel_maps(&shape, &bevel_of(BevelStyle::Emboss, BevelTechnique::Smooth), &l, &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES));
|
||||
assert_eq!((paint, e.len()), (BevelPaint::Both, 4));
|
||||
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());
|
||||
let (p, _) =
|
||||
bevel_maps(&shape, &bevel_of(BevelStyle::PillowEmboss, BevelTechnique::Smooth), &l, &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES));
|
||||
assert!(at(&p[0], 20, 10) > 0.3 && at(&p[3], 20, 9) > 0.3, "pillow: outside top edge in shadow");
|
||||
}
|
||||
|
||||
#[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 +1523,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 +1532,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 +1551,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}");
|
||||
|
||||
+374
-120
@@ -10,7 +10,9 @@
|
||||
//! - fill layers, Dissolve
|
||||
//!
|
||||
//! This is the reference the GPU backend (milestone M5) must match within 1/255. Compositing
|
||||
//! currently happens in display RGB. Mode-native (CMYK/Lab) compositing arrives with ICC in M8.
|
||||
//! currently happens in display RGB. CMYK layers are read through the document's CMYK profile
|
||||
//! (the built-in coated CMYK when untagged) and composited in sRGB; mode-native (CMYK/Lab)
|
||||
//! compositing arrives with ICC in M8.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
@@ -20,6 +22,7 @@ pub mod effects;
|
||||
pub mod masks;
|
||||
pub mod multichannel;
|
||||
pub mod pattern;
|
||||
pub mod proxy;
|
||||
pub mod psblend;
|
||||
pub mod shape_split;
|
||||
|
||||
@@ -81,59 +84,160 @@ pub fn render(doc: &Document, rect: Rect) -> Buffer {
|
||||
|
||||
/// [`render`] with an explicit tile size (tests check tile independence).
|
||||
pub fn render_tiled(doc: &Document, rect: Rect, tile: i32) -> Buffer {
|
||||
let cx = Ctx {
|
||||
canvas: doc.bounds(),
|
||||
transfer: adjust::Transfer::for_mode(doc.mode),
|
||||
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);
|
||||
render_tiled_with(doc, rect, tile, &cx)
|
||||
}
|
||||
|
||||
fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer {
|
||||
let tile = tile.max(1);
|
||||
// Lab documents mix Normal blending in CIELAB, as Photoshop does (psblend::LAB_MIX).
|
||||
let lab = doc.mode == photocraft_color::ColorMode::Lab;
|
||||
if rect.width() as i32 <= tile && rect.height() as i32 <= tile {
|
||||
let mut buf = multichannel::backdrop(doc, rect);
|
||||
psblend::LAB_MIX.with(|l| l.set(lab));
|
||||
composite_stack(&doc.layers, &mut buf, &cx);
|
||||
psblend::LAB_MIX.with(|l| l.set(false));
|
||||
return buf;
|
||||
// CMYK layers are read through the document's own CMYK profile (thread-local scope).
|
||||
let cmyk = cmyk_space(doc);
|
||||
let cmyk = cmyk.as_ref();
|
||||
if rect.is_empty() {
|
||||
return Buffer::transparent(rect);
|
||||
}
|
||||
let mut tiles = Vec::new();
|
||||
let mut y = rect.y0;
|
||||
while y < rect.y1 {
|
||||
if rect.width() as i32 <= tile && rect.height() as i32 <= tile {
|
||||
return photocraft_color::convert::with_cmyk_space(cmyk, || {
|
||||
let mut buf = multichannel::backdrop(doc, rect);
|
||||
psblend::LAB_MIX.with(|l| l.set(lab));
|
||||
composite_stack(&doc.layers, &mut buf, cx);
|
||||
psblend::LAB_MIX.with(|l| l.set(false));
|
||||
buf
|
||||
});
|
||||
}
|
||||
let run = |t: Rect| {
|
||||
photocraft_color::convert::with_cmyk_space(cmyk, || {
|
||||
let mut b = multichannel::backdrop(doc, t);
|
||||
psblend::LAB_MIX.with(|l| l.set(lab));
|
||||
composite_stack(&doc.layers, &mut b, cx);
|
||||
psblend::LAB_MIX.with(|l| l.set(false));
|
||||
b
|
||||
})
|
||||
};
|
||||
// Tiles are written straight into the output, one row of tiles (a band) at a time, so the
|
||||
// peak is the output plus the tiles in flight, not a second full-size copy.
|
||||
let w = rect.width() as usize;
|
||||
let mut out = Buffer::transparent(rect);
|
||||
let band_tiles = |y0: i32| {
|
||||
let y1 = y0.saturating_add(tile).min(rect.y1);
|
||||
let mut v = Vec::new();
|
||||
let mut x = rect.x0;
|
||||
while x < rect.x1 {
|
||||
tiles.push(Rect::new(x, y, (x + tile).min(rect.x1), (y + tile).min(rect.y1)));
|
||||
x += tile;
|
||||
let x1 = x.saturating_add(tile).min(rect.x1);
|
||||
v.push(Rect::new(x, y0, x1, y1));
|
||||
x = x1;
|
||||
}
|
||||
y += tile;
|
||||
}
|
||||
let run = |t: &Rect| {
|
||||
let mut b = multichannel::backdrop(doc, *t);
|
||||
psblend::LAB_MIX.with(|l| l.set(lab));
|
||||
composite_stack(&doc.layers, &mut b, &cx);
|
||||
psblend::LAB_MIX.with(|l| l.set(false));
|
||||
b
|
||||
v
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let parts: Vec<Buffer> = {
|
||||
use rayon::prelude::*;
|
||||
tiles.par_iter().map(run).collect()
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let parts: Vec<Buffer> = tiles.iter().map(run).collect();
|
||||
let mut out = Buffer::transparent(rect);
|
||||
let w = rect.width() as usize;
|
||||
for part in parts {
|
||||
let put = |band: &mut [[f32; 4]], y0: i32, part: &Buffer| {
|
||||
let pw = part.rect.width() as usize;
|
||||
for (row, src) in part.px.chunks_exact(pw).enumerate() {
|
||||
let o = ((part.rect.y0 - rect.y0) as usize + row) * w + (part.rect.x0 - rect.x0) as usize;
|
||||
out.px[o..o + pw].copy_from_slice(src);
|
||||
let o = ((part.rect.y0 - y0) as usize + row) * w + (part.rect.x0 - rect.x0) as usize;
|
||||
band[o..o + pw].copy_from_slice(src);
|
||||
}
|
||||
};
|
||||
let band_len = w * tile as usize;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
let bands = rect.height().div_ceil(tile as u32) as usize;
|
||||
if bands >= 2 * rayon::current_num_threads() {
|
||||
// Enough bands to keep every core busy: one band per task, its tiles in turn.
|
||||
out.px.par_chunks_mut(band_len).enumerate().for_each(|(i, band)| {
|
||||
let y0 = rect.y0 + i as i32 * tile;
|
||||
for t in band_tiles(y0) {
|
||||
put(band, y0, &run(t));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Few, wide bands: the tiles of each band in parallel.
|
||||
for (i, band) in out.px.chunks_mut(band_len).enumerate() {
|
||||
let y0 = rect.y0 + i as i32 * tile;
|
||||
let parts: Vec<Buffer> = band_tiles(y0).into_par_iter().map(run).collect();
|
||||
for part in &parts {
|
||||
put(band, y0, part);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
for (i, band) in out.px.chunks_mut(band_len).enumerate() {
|
||||
let y0 = rect.y0 + i as i32 * tile;
|
||||
for t in band_tiles(y0) {
|
||||
put(band, y0, &run(t));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The document's own CMYK profile for reading its CMYK pixels (`None`: not a CMYK document,
|
||||
/// untagged, or the built-in coated CMYK). Enter it with `photocraft_color::convert::with_cmyk_space`
|
||||
/// around code that converts the document's CMYK pixels or colours to RGB.
|
||||
pub fn cmyk_space(doc: &Document) -> Option<std::sync::Arc<photocraft_color::convert::CmykSpace>> {
|
||||
if doc.mode != photocraft_color::ColorMode::Cmyk {
|
||||
return None;
|
||||
}
|
||||
photocraft_color::convert::CmykSpace::for_profile(doc.icc_profile.as_ref())
|
||||
}
|
||||
|
||||
/// Pixels per band of [`render_bands`] (a 14000 px wide band is ~600 rows, ~130 MB of f32).
|
||||
pub const BAND_PIXELS: u64 = 8 << 20;
|
||||
|
||||
/// Composite `rect` in horizontal bands, top to bottom, handing each band to `sink` (tiles inside
|
||||
/// a band render in parallel). Peak memory is one band instead of the whole composite, so huge
|
||||
/// documents can be exported or displayed without a full-size float copy. `band_rows` of 0 picks
|
||||
/// about [`BAND_PIXELS`] per band; bands are whole multiples of [`RENDER_TILE`] rows.
|
||||
pub fn render_bands<E>(doc: &Document, rect: Rect, band_rows: i32, mut sink: impl FnMut(Buffer) -> Result<(), E>) -> Result<(), E> {
|
||||
if rect.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// Keep prepared pixels and compiled vector masks across bands without retaining any rendered band.
|
||||
let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES);
|
||||
let cx = Ctx::for_doc(doc, &patterns);
|
||||
let rows = band_rows_for(rect.width(), band_rows);
|
||||
let mut y = rect.y0;
|
||||
while y < rect.y1 {
|
||||
let y1 = y.saturating_add(rows).min(rect.y1);
|
||||
sink(render_tiled_with(doc, Rect::new(rect.x0, y, rect.x1, y1), RENDER_TILE, &cx))?;
|
||||
y = y1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Band height for [`render_bands`]: `requested` rounded up to whole tiles, or ~[`BAND_PIXELS`].
|
||||
pub fn band_rows_for(width: u32, requested: i32) -> i32 {
|
||||
let want = if requested > 0 { i64::from(requested) } else { (BAND_PIXELS / u64::from(width.max(1))) as i64 };
|
||||
let tiles = (want + i64::from(RENDER_TILE) - 1) / i64::from(RENDER_TILE);
|
||||
(tiles.clamp(1, 1 << 16) * i64::from(RENDER_TILE)) as i32
|
||||
}
|
||||
|
||||
/// The document's composite as a pixel surface in `fmt` (straight RGBA converted to its model
|
||||
/// and depth), optionally flattened over an opaque `background`; rendered and written in bands,
|
||||
/// so no full-size float composite is held. Tiles equal to the default pixel are pruned.
|
||||
pub fn flatten_to_surface(doc: &Document, fmt: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface {
|
||||
let mut s = Surface::new(fmt);
|
||||
let n = fmt.channels();
|
||||
let mut data = Vec::new();
|
||||
let _ = render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> {
|
||||
let band = match background {
|
||||
Some(bg) => band.over_background(bg),
|
||||
None => band,
|
||||
};
|
||||
data.clear();
|
||||
data.resize(band.px.len() * n, 0.0);
|
||||
for (p, out) in band.px.iter().zip(data.chunks_exact_mut(n)) {
|
||||
photocraft_raster::from_rgba_into(&fmt, *p, out);
|
||||
}
|
||||
s.write_region(band.rect, &data);
|
||||
Ok(())
|
||||
});
|
||||
s.prune();
|
||||
s
|
||||
}
|
||||
|
||||
/// Composite the full canvas.
|
||||
pub fn flatten(doc: &Document) -> Buffer {
|
||||
render(doc, doc.bounds())
|
||||
@@ -142,6 +246,7 @@ pub fn flatten(doc: &Document) -> Buffer {
|
||||
/// Render an arbitrary subset: a single layer (e.g. for thumbnails), isolated.
|
||||
pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer {
|
||||
let mut buf = Buffer::transparent(rect);
|
||||
let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
|
||||
composite_stack(
|
||||
std::slice::from_ref(layer),
|
||||
&mut buf,
|
||||
@@ -149,53 +254,163 @@ pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer {
|
||||
canvas: rect,
|
||||
transfer: adjust::Transfer::Srgb,
|
||||
light: photocraft_doc::GlobalLight::default(),
|
||||
patterns: &[],
|
||||
patterns: &patterns,
|
||||
mode: photocraft_color::ColorMode::Rgb,
|
||||
depth: photocraft_color::SampleType::F32,
|
||||
vector_masks: RenderVectorMasks::default(),
|
||||
},
|
||||
);
|
||||
buf
|
||||
}
|
||||
|
||||
/// Downscaled RGBA8 render of the document (nearest-neighbour sampling) for thumbnails or navigators.
|
||||
/// Documents above this many pixels get thumbnails from a proxy (see [`proxy`]) when that is
|
||||
/// faithful; smaller ones are reduced from the exact composite.
|
||||
pub const PROXY_THUMBNAIL_PIXELS: u64 = 16 << 20;
|
||||
|
||||
/// Downscaled RGBA8 render of the document (area-averaged) for thumbnails, navigators and
|
||||
/// histograms, at most `max_side` pixels on its longer side. Large documents are reduced from a
|
||||
/// downsampled proxy (about twice the thumbnail's size), so a thumbnail of a 200 MP document
|
||||
/// costs milliseconds and no full-size composite; the rest stream the exact composite in bands.
|
||||
pub fn thumbnail(doc: &Document, max_side: u32) -> Rgba8Image {
|
||||
// Composite once (tile-parallel), then area-average down. Rendering per thumbnail pixel
|
||||
// would redo layer effects for every sample.
|
||||
thumbnail_buffer(doc, max_side).to_rgba8()
|
||||
}
|
||||
|
||||
/// [`thumbnail`] as straight-alpha floats (for callers that colour-convert it first).
|
||||
pub fn thumbnail_buffer(doc: &Document, max_side: u32) -> Buffer {
|
||||
let b = doc.bounds();
|
||||
let scale = (max_side as f32 / b.width().max(b.height()).max(1) as f32).min(1.0);
|
||||
let longest = b.width().max(b.height()).max(1);
|
||||
let scale = (max_side as f32 / longest as f32).min(1.0);
|
||||
let w = ((b.width() as f32 * scale).round() as u32).max(1);
|
||||
let h = ((b.height() as f32 * scale).round() as u32).max(1);
|
||||
let full = flatten(doc);
|
||||
let k = longest / max_side.max(1).saturating_mul(2);
|
||||
if k >= 2 && doc.size.area() > PROXY_THUMBNAIL_PIXELS && proxy::proxy_faithful(doc) {
|
||||
render_reduced(&proxy::proxy_document(doc, k), w, h)
|
||||
} else {
|
||||
render_reduced(doc, w, h)
|
||||
}
|
||||
}
|
||||
|
||||
/// The document's composite area-averaged (premultiplied) down to `w`×`h` (clamped to the
|
||||
/// document size), rendered in bands so no full-size composite is held.
|
||||
pub fn render_reduced(doc: &Document, w: u32, h: u32) -> Buffer {
|
||||
render_reduced_in_bands(doc, w, h, None, 0)
|
||||
}
|
||||
|
||||
/// The part of [`render_reduced`]`(doc, w, h)` that a change to `damage` (document pixels) can
|
||||
/// affect: the output pixels whose source areas meet it, rendered from just those areas, with the
|
||||
/// same values the whole reduction gives them. The buffer's rect is in output pixels (empty when
|
||||
/// `damage` misses the document), so a reduced canvas texture can update only what a stroke touched.
|
||||
pub fn render_reduced_damage(doc: &Document, w: u32, h: u32, damage: Rect) -> Buffer {
|
||||
render_reduced_in_bands(doc, w, h, Some(damage), 0)
|
||||
}
|
||||
|
||||
/// [`render_reduced`] (or, with `damage`, [`render_reduced_damage`]) with an explicit band height
|
||||
/// (see [`render_bands`]).
|
||||
fn render_reduced_in_bands(doc: &Document, w: u32, h: u32, damage: Option<Rect>, band_rows: i32) -> Buffer {
|
||||
let b = doc.bounds();
|
||||
let (fw, fh) = (b.width() as usize, b.height() as usize);
|
||||
let mut img = Rgba8Image::new(w, h);
|
||||
for ty in 0..h as usize {
|
||||
let (y0, y1) = (ty * fh / h as usize, ((ty + 1) * fh / h as usize).max(ty * fh / h as usize + 1).min(fh));
|
||||
for tx in 0..w as usize {
|
||||
let (x0, x1) = (tx * fw / w as usize, ((tx + 1) * fw / w as usize).max(tx * fw / w as usize + 1).min(fw));
|
||||
// Premultiplied average so transparent pixels don't darken edges.
|
||||
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];
|
||||
let (w, h) = (w.clamp(1, b.width().max(1)) as usize, h.clamp(1, b.height().max(1)) as usize);
|
||||
let full = Rect::from_xywh(0, 0, w as u32, h as u32);
|
||||
if fw == 0 || fh == 0 {
|
||||
return Buffer::transparent(if damage.is_some() { Rect::EMPTY } else { full });
|
||||
}
|
||||
// Output column / row of each document column / row: output pixel t covers [t·f/n, (t+1)·f/n).
|
||||
let span = |t: usize, f: usize, n: usize| (t * f / n, ((t + 1) * f / n).max(t * f / n + 1).min(f));
|
||||
// The output pixel whose span holds document column / row `x` (spans are contiguous: n <= f).
|
||||
let cell = |x: usize, f: usize, n: usize| ((x + 1) * n).saturating_sub(1) / f;
|
||||
// Output pixels to produce and the document area they cover.
|
||||
let out = match damage {
|
||||
None => full,
|
||||
Some(d) => {
|
||||
let d = d.intersect(&b);
|
||||
if d.is_empty() {
|
||||
return Buffer::transparent(Rect::EMPTY);
|
||||
}
|
||||
let (x0, x1) = ((d.x0 - b.x0) as usize, (d.x1 - b.x0) as usize);
|
||||
let (y0, y1) = ((d.y0 - b.y0) as usize, (d.y1 - b.y0) as usize);
|
||||
Rect::new(cell(x0, fw, w) as i32, cell(y0, fh, h) as i32, cell(x1 - 1, fw, w) as i32 + 1, cell(y1 - 1, fh, h) as i32 + 1)
|
||||
}
|
||||
};
|
||||
let (ox0, oy0, ow, oh) = (out.x0 as usize, out.y0 as usize, out.width() as usize, out.height() as usize);
|
||||
let (sx0, sx1) = (span(ox0, fw, w).0, span(ox0 + ow - 1, fw, w).1);
|
||||
let (sy0, sy1) = (span(oy0, fh, h).0, span(oy0 + oh - 1, fh, h).1);
|
||||
let src = Rect::new(b.x0 + sx0 as i32, b.y0 + sy0 as i32, b.x0 + sx1 as i32, b.y0 + sy1 as i32);
|
||||
if (w, h) == (fw, fh) {
|
||||
let mut px = render(doc, src);
|
||||
px.rect = out;
|
||||
return px;
|
||||
}
|
||||
let map = |f: usize, n: usize| {
|
||||
let mut m = vec![0u32; f];
|
||||
for t in 0..n {
|
||||
let (a, z) = span(t, f, n);
|
||||
m[a..z].fill(t as u32);
|
||||
}
|
||||
m
|
||||
};
|
||||
let (cols, rows) = (map(fw, w), map(fh, h));
|
||||
let cols = &cols[sx0..sx1];
|
||||
let mut acc = vec![[0.0f32; 4]; ow * oh];
|
||||
let ok: Result<(), ()> = render_bands(doc, src, band_rows, |band| {
|
||||
let (by0, by1) = ((band.rect.y0 - b.y0) as usize, (band.rect.y1 - b.y0) as usize);
|
||||
let (t0, t1) = (rows[by0] as usize, rows[by1 - 1] as usize + 1);
|
||||
let sw = sx1 - sx0;
|
||||
// Each output row sums its source rows in this band (in order, top to bottom).
|
||||
let sum_row = |t: usize, out: &mut [[f32; 4]]| {
|
||||
let (a, z) = span(t, fh, h);
|
||||
for y in a.max(by0)..z.min(by1) {
|
||||
let src = &band.px[(y - by0) * sw..(y - by0 + 1) * sw];
|
||||
for (p, &c) in src.iter().zip(cols) {
|
||||
let o = &mut out[c as usize - ox0];
|
||||
for i in 0..3 {
|
||||
o[i] += p[i] * p[3];
|
||||
}
|
||||
acc[3] += p[3];
|
||||
o[3] += p[3];
|
||||
}
|
||||
}
|
||||
let n = ((y1 - y0) * (x1 - x0)).max(1) as f32;
|
||||
let a = acc[3] / n;
|
||||
let px = if acc[3] > 0.0 { [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], a] } else { [0.0; 4] };
|
||||
let o = (ty * w as usize + tx) * 4;
|
||||
for (dst, v) in img.pixels[o..o + 4].iter_mut().zip(px) {
|
||||
*dst = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
||||
}
|
||||
};
|
||||
let part = &mut acc[(t0 - oy0) * ow..(t1 - oy0) * ow];
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
part.par_chunks_mut(ow).enumerate().for_each(|(i, out)| sum_row(t0 + i, out));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
for (i, out) in part.chunks_mut(ow).enumerate() {
|
||||
sum_row(t0 + i, out);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
debug_assert!(ok.is_ok());
|
||||
for (i, p) in acc.iter_mut().enumerate() {
|
||||
let ((x0, x1), (y0, y1)) = (span(ox0 + i % ow, fw, w), span(oy0 + i / ow, fh, h));
|
||||
let n = ((y1 - y0) * (x1 - x0)).max(1) as f32;
|
||||
let a = p[3];
|
||||
*p = if a > 0.0 { [p[0] / a, p[1] / a, p[2] / a, a / n] } else { [0.0; 4] };
|
||||
}
|
||||
Buffer { rect: out, px: acc }
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RenderVectorMasks {
|
||||
masks: std::sync::Mutex<std::collections::HashMap<usize, std::sync::Arc<std::sync::OnceLock<photocraft_vector::CompiledVectorMask>>>>,
|
||||
}
|
||||
|
||||
impl RenderVectorMasks {
|
||||
fn values(&self, mask: &photocraft_doc::VectorMask, rect: Rect) -> Vec<f32> {
|
||||
// Addresses identify immutable mask instances only for this render's lifetime.
|
||||
let key = std::ptr::from_ref(mask) as usize;
|
||||
let slot = {
|
||||
let mut masks = self.masks.lock().unwrap_or_else(|e| e.into_inner());
|
||||
masks.entry(key).or_default().clone()
|
||||
};
|
||||
// Compilation is sequential. Rendering can enter Rayon and must happen after
|
||||
// initialization, so waiting tiles cannot re-enter a slot still being built.
|
||||
let compiled = slot.get_or_init(|| photocraft_vector::CompiledVectorMask::new(mask));
|
||||
compiled.render(rect)
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// Composite a sibling list (bottom→top) onto `backdrop`.
|
||||
/// Rendering context shared down the tree.
|
||||
struct Ctx<'a> {
|
||||
/// Document canvas: fill layers and gradients are laid out relative to it, never to the render rect.
|
||||
@@ -204,13 +419,29 @@ struct Ctx<'a> {
|
||||
transfer: adjust::Transfer,
|
||||
/// Global light for layer effects.
|
||||
light: photocraft_doc::GlobalLight,
|
||||
/// The document's patterns (pattern fills and overlays).
|
||||
patterns: &'a [Pattern],
|
||||
/// Prepared document patterns, shared across all tiles and bands of this render call.
|
||||
patterns: &'a pattern::PreparedPatterns<'a>,
|
||||
/// The document's colour mode (channel restrictions name its channels).
|
||||
mode: photocraft_color::ColorMode,
|
||||
depth: photocraft_color::SampleType,
|
||||
vector_masks: RenderVectorMasks,
|
||||
}
|
||||
|
||||
impl<'a> Ctx<'a> {
|
||||
fn for_doc(doc: &Document, patterns: &'a pattern::PreparedPatterns<'a>) -> Self {
|
||||
Self {
|
||||
canvas: doc.bounds(),
|
||||
transfer: adjust::Transfer::for_mode(doc.mode),
|
||||
light: doc.global_light,
|
||||
patterns,
|
||||
mode: doc.mode,
|
||||
depth: doc.depth,
|
||||
vector_masks: RenderVectorMasks::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Composite a sibling list (bottom→top) onto `backdrop`.
|
||||
fn composite_stack(layers: &[Layer], backdrop: &mut Buffer, cx: &Ctx) {
|
||||
let mut i = 0;
|
||||
while i < layers.len() {
|
||||
@@ -296,8 +527,8 @@ pub fn paint_bounds(layer: &Layer) -> Option<Rect> {
|
||||
|
||||
/// The layer's effective mask over `rect` (row-major), read once per tile: the pixel mask
|
||||
/// times the rasterized vector mask.
|
||||
fn mask_vals(layer: &Layer, rect: Rect) -> Option<Vec<f32>> {
|
||||
let vector = layer.vector_mask.as_ref().map(|vm| photocraft_vector::vector_mask_values(vm, rect));
|
||||
fn mask_vals(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Vec<f32>> {
|
||||
let vector = layer.vector_mask.as_ref().map(|vm| cx.vector_masks.values(vm, rect));
|
||||
let Some(m) = layer.mask.as_ref() else { return vector };
|
||||
let mut v = Vec::new();
|
||||
m.values_into(rect, &mut v);
|
||||
@@ -334,7 +565,7 @@ fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Buffer> {
|
||||
None => Buffer::transparent(rect),
|
||||
},
|
||||
};
|
||||
if let Some(m) = mask_vals(layer, rect) {
|
||||
if let Some(m) = mask_vals(layer, rect, cx) {
|
||||
for (p, k) in buf.px.iter_mut().zip(&m) {
|
||||
p[3] *= k;
|
||||
}
|
||||
@@ -345,14 +576,8 @@ fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Buffer> {
|
||||
/// The alpha of `layer`'s own content over `rect` (masks applied, row-major): the shape its
|
||||
/// effect maps are built from (for the GPU compositor). Zero for adjustment layers.
|
||||
pub fn layer_shape(doc: &Document, layer: &Layer, rect: Rect) -> Vec<f32> {
|
||||
let cx = Ctx {
|
||||
canvas: doc.bounds(),
|
||||
transfer: adjust::Transfer::for_mode(doc.mode),
|
||||
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);
|
||||
render_content(layer, rect, &cx).map(|b| b.px.iter().map(|p| p[3]).collect()).unwrap_or_else(|| vec![0.0; rect.width() as usize * rect.height() as usize])
|
||||
}
|
||||
|
||||
@@ -392,56 +617,61 @@ pub fn fill_frame(layer: &Layer, canvas: Rect) -> Rect {
|
||||
/// A fill layer's content over `canvas`, laid out in the frame its masks give it (as the
|
||||
/// compositor does) but without applying the masks: the pixels a PSD fill layer stores.
|
||||
pub fn render_fill_content(layer: &Layer, f: &Fill, canvas: Rect, patterns: &[Pattern]) -> Buffer {
|
||||
render_fill(f, canvas, fill_frame(layer, canvas), patterns)
|
||||
let prepared = pattern::PreparedPatterns::new(patterns, pattern::PREPARED_PATTERN_BYTES);
|
||||
render_fill(f, canvas, fill_frame(layer, canvas), &prepared)
|
||||
}
|
||||
|
||||
fn render_fill(f: &Fill, rect: Rect, canvas: Rect, patterns: &[Pattern]) -> Buffer {
|
||||
fn render_fill(f: &Fill, rect: Rect, canvas: Rect, patterns: &pattern::PreparedPatterns<'_>) -> Buffer {
|
||||
match f {
|
||||
Fill::Solid(c) => {
|
||||
let rgb = c.to_rgb();
|
||||
Buffer::filled(rect, [rgb[0], rgb[1], rgb[2], c.alpha])
|
||||
}
|
||||
Fill::Gradient { stops, angle, scale, style, reverse } => {
|
||||
// Convert once per render call, while its document CMYK profile is active.
|
||||
let stops: Vec<_> = stops
|
||||
.iter()
|
||||
.map(|(p, c)| {
|
||||
let rgb = c.to_rgb();
|
||||
(*p, [rgb[0], rgb[1], rgb[2], c.alpha])
|
||||
})
|
||||
.collect();
|
||||
// Gradient geometry relative to the layer's frame, independent of the render rect.
|
||||
let mut b = Buffer::transparent(rect);
|
||||
for y in rect.y0..rect.y1 {
|
||||
for x in rect.x0..rect.x1 {
|
||||
let t = effects::gradient_t(*style, *angle, *scale, *reverse, (0.0, 0.0), canvas, x as f32 + 0.5, y as f32 + 0.5);
|
||||
let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize;
|
||||
b.px[i] = sample_stops(stops, t);
|
||||
b.px[i] = sample_stops(&stops, t);
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
// Laid out from the layer's frame when linked; transparent if the pattern is missing.
|
||||
Fill::Pattern { name, scale, id, angle, link, phase } => match photocraft_doc::pattern::find(patterns, id, name).and_then(pattern::Tile::new) {
|
||||
Fill::Pattern { name, scale, id, angle, link, phase } => match patterns.get(id, name) {
|
||||
Some(tile) => Buffer { rect, px: pattern::render(&tile, &pattern::Placement::new(canvas, *link, *phase, *scale, *angle), rect) },
|
||||
None => Buffer::transparent(rect),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_stops(stops: &[(f32, photocraft_color::Color)], t: f32) -> [f32; 4] {
|
||||
let conv = |c: &photocraft_color::Color| {
|
||||
let r = c.to_rgb();
|
||||
[r[0], r[1], r[2], c.alpha]
|
||||
};
|
||||
fn sample_stops(stops: &[(f32, [f32; 4])], t: f32) -> [f32; 4] {
|
||||
match stops {
|
||||
[] => [0.0; 4],
|
||||
[only] => conv(&only.1),
|
||||
[only] => only.1,
|
||||
_ => {
|
||||
if t <= stops[0].0 {
|
||||
return conv(&stops[0].1);
|
||||
return stops[0].1;
|
||||
}
|
||||
for w in stops.windows(2) {
|
||||
let (a, b) = (&w[0], &w[1]);
|
||||
if t <= b.0 {
|
||||
let k = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 };
|
||||
let (ca, cb) = (conv(&a.1), conv(&b.1));
|
||||
let (ca, cb) = (a.1, b.1);
|
||||
return std::array::from_fn(|i| ca[i] + (cb[i] - ca[i]) * k);
|
||||
}
|
||||
}
|
||||
conv(&stops[stops.len() - 1].1)
|
||||
stops[stops.len() - 1].1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -640,15 +870,26 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
&& layer.blend == BlendMode::PassThrough
|
||||
&& !effects::has_effects(layer)
|
||||
{
|
||||
let before = backdrop.clone();
|
||||
composite_stack(&g.children, backdrop, cx);
|
||||
let needs_mix = opacity < 1.0 || layer.mask.is_some() || layer.vector_mask.is_some();
|
||||
if needs_mix {
|
||||
let mv = mask_vals(layer, rect);
|
||||
let has_clipped = clipped.iter().any(|c| c.visible);
|
||||
// Children can draw directly unless mixing or clipping needs the original backdrop.
|
||||
let before = (needs_mix || has_clipped).then(|| backdrop.clone());
|
||||
composite_stack(&g.children, backdrop, cx);
|
||||
if needs_mix && let Some(before) = &before {
|
||||
let mv = mask_vals(layer, rect, cx);
|
||||
for (i, (p, a)) in backdrop.px.iter_mut().zip(&before.px).enumerate() {
|
||||
let k = opacity * mask_k(&mv, i);
|
||||
let b = *p;
|
||||
*p = std::array::from_fn(|c| a[c] + (b[c] - a[c]) * k);
|
||||
// Coverage mixes premultiplied colour; the stored buffer remains straight alpha.
|
||||
let wa = a[3] * (1.0 - k);
|
||||
let wb = b[3] * k;
|
||||
let alpha = wa + wb;
|
||||
*p = if alpha > 0.0 {
|
||||
let c = |c: usize| (a[c] * wa + b[c] * wb) / alpha;
|
||||
[c(0), c(1), c(2), alpha]
|
||||
} else {
|
||||
[0.0; 4]
|
||||
};
|
||||
}
|
||||
}
|
||||
// Layers clipped to a pass-through group sit atop the group's
|
||||
@@ -656,9 +897,7 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
// them, each placed over the original backdrop) is added to the
|
||||
// pass-through result. Exact when the children blend Normal, close
|
||||
// otherwise (matches psd-tools clipping-mask3/4/5).
|
||||
if clipped.iter().any(|c| c.visible)
|
||||
&& let Some(iso) = render_content(layer, rect, cx)
|
||||
{
|
||||
if has_clipped && let (Some(before), Some(iso)) = (before, render_content(layer, rect, cx)) {
|
||||
let mut clipped_iso = iso.clone();
|
||||
for c in clipped.iter().filter(|c| c.visible) {
|
||||
composite_atop(c, &mut clipped_iso, cx);
|
||||
@@ -684,14 +923,13 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
|
||||
// Adjustment layers transform the backdrop, then blend the result back in.
|
||||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||||
let before = backdrop.clone();
|
||||
let mut adjusted = before.clone();
|
||||
let mut adjusted = backdrop.clone();
|
||||
adjust::apply_with(adj, &mut adjusted, cx.transfer);
|
||||
// Clipped layers onto an adjustment are uncommon; they composite atop the adjusted result.
|
||||
for c in clipped.iter().filter(|c| c.visible) {
|
||||
composite_atop(c, &mut adjusted, cx);
|
||||
}
|
||||
let mv = mask_vals(layer, rect);
|
||||
let mv = mask_vals(layer, rect, cx);
|
||||
for y in rect.y0..rect.y1 {
|
||||
for x in rect.x0..rect.x1 {
|
||||
let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize;
|
||||
@@ -699,7 +937,8 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let b = before.px[i];
|
||||
// Only `adjusted` has changed; this pixel's original backdrop is still in place.
|
||||
let b = backdrop.px[i];
|
||||
let a = adjusted.px[i];
|
||||
let blended = blend::blend_rgb(layer.blend, [b[0], b[1], b[2]], [a[0], a[1], a[2]]);
|
||||
backdrop.px[i] = [b[0] + (blended[0] - b[0]) * k, b[1] + (blended[1] - b[1]) * k, b[2] + (blended[2] - b[2]) * k, b[3]];
|
||||
@@ -709,14 +948,21 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
}
|
||||
|
||||
if effects::has_effects(layer) {
|
||||
// Effects reach beyond the render rect: render the layer larger.
|
||||
let big = rect.inflate(effects::margin(layer));
|
||||
let Some(mut content) = render_content(layer, big, cx) else { return };
|
||||
// Neighbourhoods are already captured by the full-region effect maps;
|
||||
// content and effect application only need the output rectangle.
|
||||
let Some(mut content) = render_content(layer, rect, cx) else { return };
|
||||
for c in clipped.iter().filter(|c| c.visible) {
|
||||
composite_atop(c, &mut content, cx);
|
||||
}
|
||||
let maps = effect_maps(layer, cx);
|
||||
effects::composite_with_effects(layer, &content, backdrop, &maps, paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)), cx.patterns);
|
||||
effects::composite_with_effects_prepared(
|
||||
layer,
|
||||
&content,
|
||||
backdrop,
|
||||
&maps,
|
||||
paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)),
|
||||
cx.patterns,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Some((mut content, stroke)) = shape_parts(layer, clipped, rect, cx) {
|
||||
@@ -753,7 +999,7 @@ fn shape_parts(layer: &Layer, clipped: &[Layer], rect: Rect, cx: &Ctx) -> Option
|
||||
let (fs, ss) = shape_split::split(sh, cx.canvas)?;
|
||||
let mut f = surface_to_buffer(&fs, rect);
|
||||
let mut s = surface_to_buffer(&ss, rect);
|
||||
if let Some(m) = mask_vals(layer, rect) {
|
||||
if let Some(m) = mask_vals(layer, rect, cx) {
|
||||
for ((a, b), k) in f.px.iter_mut().zip(s.px.iter_mut()).zip(&m) {
|
||||
a[3] *= k;
|
||||
b[3] *= k;
|
||||
@@ -771,11 +1017,19 @@ fn shape_parts(layer: &Layer, clipped: &[Layer], rect: Rect, cx: &Ctx) -> Option
|
||||
fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHasher) {
|
||||
use std::hash::{Hash, Hasher};
|
||||
fn surface_fp(s: &Surface) -> u64 {
|
||||
s.tiles().fold(s.tile_count() as u64, |acc, (c, t)| {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
s.format().hash(&mut h);
|
||||
// Missing tiles read the default pixel, so tile identity alone is not pixel identity.
|
||||
for sample in s.default_pixel() {
|
||||
sample.to_bits().hash(&mut h);
|
||||
}
|
||||
let tiles = s.tiles().fold(s.tile_count() as u64, |acc, (c, t)| {
|
||||
let mut x = (std::sync::Arc::as_ptr(t) as usize as u64) ^ ((c.tx as u64) << 40) ^ ((c.ty as u32 as u64) << 8);
|
||||
x = x.wrapping_mul(0x9e37_79b9_7f4a_7c15);
|
||||
acc.wrapping_add(x ^ (x >> 29))
|
||||
})
|
||||
});
|
||||
tiles.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
layer.id.0.hash(h);
|
||||
layer.visible.hash(h);
|
||||
@@ -860,7 +1114,7 @@ fn effect_maps(layer: &Layer, cx: &Ctx) -> std::sync::Arc<effects::FxMaps> {
|
||||
let region = layer_bounds(layer, cx.canvas).inflate(m).intersect(&cx.canvas.inflate(m));
|
||||
if std::env::var_os("PHOTOCRAFT_FX_NOCACHE").is_some() {
|
||||
let shape = render_content(layer, region, cx).map(|b| b.px.iter().map(|p| p[3]).collect()).unwrap_or_default();
|
||||
return std::sync::Arc::new(effects::build_maps(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx)));
|
||||
return std::sync::Arc::new(effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns));
|
||||
}
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
layer_identity(layer, &mut h);
|
||||
@@ -886,7 +1140,7 @@ fn effect_maps(layer: &Layer, cx: &Ctx) -> std::sync::Arc<effects::FxMaps> {
|
||||
.map(|b| b.px.iter().map(|p| p[3]).collect())
|
||||
.unwrap_or_else(|| vec![0.0; region.width() as usize * region.height() as usize])
|
||||
};
|
||||
let maps = effects::build_maps(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx));
|
||||
let maps = effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns);
|
||||
let bytes = maps.bytes();
|
||||
// Counted exactly once, when the entry is built.
|
||||
fx_cache().lock().unwrap_or_else(|e| e.into_inner()).bytes += bytes;
|
||||
@@ -911,7 +1165,7 @@ fn effect_maps(layer: &Layer, cx: &Ctx) -> std::sync::Arc<effects::FxMaps> {
|
||||
|
||||
fn texture_ctx<'a>(layer: &Layer, region: Rect, cx: &Ctx<'a>) -> effects::TextureCtx<'a> {
|
||||
let sb = paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas));
|
||||
effects::TextureCtx { rect: region, patterns: cx.patterns, anchor: layer.effects.reference.unwrap_or((f64::from(sb.x0), f64::from(sb.y0))) }
|
||||
effects::TextureCtx { rect: region, patterns: cx.patterns.source(), anchor: layer.effects.reference.unwrap_or((f64::from(sb.x0), f64::from(sb.y0))) }
|
||||
}
|
||||
|
||||
/// Composite `layer` onto `base` restricted to the base's alpha (clipping mask semantics),
|
||||
@@ -936,7 +1190,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
||||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||||
let mut adjusted = base.clone();
|
||||
adjust::apply_with(adj, &mut adjusted, cx.transfer);
|
||||
let mv = mask_vals(layer, rect);
|
||||
let mv = mask_vals(layer, rect, cx);
|
||||
for (i, p) in base.px.iter_mut().enumerate() {
|
||||
let k = layer.opacity * layer.fill_opacity * mask_k(&mv, i);
|
||||
let a = adjusted.px[i];
|
||||
@@ -950,11 +1204,11 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
||||
if effects::has_effects(layer) {
|
||||
// Effects of a clipped layer are clipped to the base too: render
|
||||
// them over the base (treated as opaque) and keep the base's alpha.
|
||||
let big = rect.inflate(effects::margin(layer));
|
||||
let Some(content) = render_content(layer, big, cx) else { return };
|
||||
// The full-region effect maps already capture their neighbourhoods.
|
||||
let Some(content) = render_content(layer, rect, cx) else { return };
|
||||
let mut opaque = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() };
|
||||
let maps = effect_maps(layer, cx);
|
||||
effects::composite_with_effects(
|
||||
effects::composite_with_effects_prepared(
|
||||
layer,
|
||||
&content,
|
||||
&mut opaque,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,10 @@ static TEXT_GAMMA_SETTING: std::sync::atomic::AtomicU32 = std::sync::atomic::Ato
|
||||
/// 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.
|
||||
///
|
||||
/// 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);
|
||||
|
||||
+194
-10
@@ -173,6 +173,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 +256,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]));
|
||||
}
|
||||
|
||||
@@ -267,9 +283,10 @@ fn solid_and_gradient_fill_layers() {
|
||||
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 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 +601,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 +686,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 +706,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) ----------
|
||||
@@ -1002,3 +1041,148 @@ 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);
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
@@ -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, .. }));
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+225
-22
@@ -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,25 +193,34 @@ 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);
|
||||
@@ -204,6 +252,72 @@ 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> {
|
||||
has_paintable(s).map_err(EngineError::Other)?;
|
||||
let pts = parse_points(p, "paint.stroke")?;
|
||||
let brush = with_blend_mode(resolve_brush(s, p, "paint.stroke")?, p);
|
||||
let seed = brush.seed;
|
||||
let (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);
|
||||
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);
|
||||
@@ -223,8 +337,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 +440,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 +450,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 +462,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 +526,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 +535,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 +551,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 +654,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,118 @@ 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 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,286 @@
|
||||
//! 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, .. } = f {
|
||||
*angle = map_angle(a, *angle);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
}
|
||||
@@ -514,6 +514,7 @@ 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")
|
||||
}
|
||||
|
||||
@@ -123,6 +123,9 @@ pub struct ColorSettings {
|
||||
/// Advanced › "Blend Text Colors Using Gamma" (1 = off; Photoshop's default 1.45): type
|
||||
/// layers mix anti-aliased edges in this gamma (`photocraft_compose::psblend::set_text_gamma`).
|
||||
pub blend_text_gamma: f32,
|
||||
/// Monitor profile the canvas is displayed in: `auto` (the main display's profile when the
|
||||
/// platform supplies it, else sRGB), a built-in RGB profile id or an `.icc` path.
|
||||
pub monitor_profile: String,
|
||||
}
|
||||
|
||||
impl Default for ColorSettings {
|
||||
@@ -141,6 +144,7 @@ impl Default for ColorSettings {
|
||||
bpc: true,
|
||||
dither: true,
|
||||
blend_text_gamma: photocraft_compose::psblend::TEXT_GAMMA,
|
||||
monitor_profile: "auto".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,6 +180,13 @@ pub fn validate_settings(c: &ColorSettings) -> std::result::Result<(), String> {
|
||||
return Err(format!("working space `{spec}` is {:?}, not {space:?}", p.color_space));
|
||||
}
|
||||
}
|
||||
let m = c.monitor_profile.as_str();
|
||||
if !(m.is_empty() || m == "auto") {
|
||||
let p = resolve_profile(m, None, Some(ColorMode::Rgb)).map_err(|e| e.to_string())?;
|
||||
if p.color_space != ColorSpace::Rgb {
|
||||
return Err(format!("monitor profile `{m}` is {:?}, not RGB", p.color_space));
|
||||
}
|
||||
}
|
||||
if Intent::parse(&c.intent).is_none() {
|
||||
return Err(format!("unknown intent `{}` (perceptual|relative|saturation|absolute)", c.intent));
|
||||
}
|
||||
@@ -188,9 +199,11 @@ pub struct ColorState {
|
||||
/// Edit › Color Settings (persisted with the preferences).
|
||||
pub settings: ColorSettings,
|
||||
proofs: HashMap<DocId, ProofView>,
|
||||
/// Monitor profile bytes supplied by the platform (`None` = sRGB display).
|
||||
/// The main display's ICC profile as read by the platform (used when Color Settings ›
|
||||
/// Monitor Profile is `auto`; `None` = sRGB display).
|
||||
pub monitor_profile: Option<Arc<Vec<u8>>>,
|
||||
display_cache: Mutex<HashMap<DisplayKey, Arc<Transform>>>,
|
||||
pub(crate) display: crate::display_color::DisplayCaches,
|
||||
/// View › 32-bit Preview Options per document.
|
||||
pub hdr: HashMap<DocId, crate::proof_sim::HdrPreview>,
|
||||
}
|
||||
@@ -232,7 +245,10 @@ impl ColorState {
|
||||
Some(emb) => {
|
||||
let mismatch = emb.content_hash() != working.content_hash();
|
||||
let base = json!({"embedded": emb.description, "working": working.description, "mismatch": mismatch, "policy": policy.id()});
|
||||
if !mismatch {
|
||||
// 32-bit linear images (EXR/HDR are tagged linear sRGB on import) stay linear, as
|
||||
// Photoshop keeps 32-bit documents in a linear version of the working space.
|
||||
let linear_hdr = doc.depth == SampleType::F32 && emb.content_hash() == Builtin::LinearSrgb.profile().content_hash();
|
||||
if !mismatch || linear_hdr {
|
||||
return merge(base, json!({"action": "kept"}));
|
||||
}
|
||||
let action = match policy {
|
||||
@@ -266,24 +282,20 @@ impl ColorState {
|
||||
self.proofs.get(&doc).cloned().unwrap_or_else(|| ProofView { gamut_threshold: photocraft_cms::gamut::DEFAULT_THRESHOLD, ..Default::default() })
|
||||
}
|
||||
|
||||
/// The document's proofing state when it was ever changed (no default allocated).
|
||||
pub(crate) fn proof_ref(&self, doc: DocId) -> Option<&ProofView> {
|
||||
self.proofs.get(&doc)
|
||||
}
|
||||
|
||||
pub(crate) fn proof_mut(&mut self, doc: DocId) -> &mut ProofView {
|
||||
self.proofs.entry(doc).or_insert_with(|| ProofView { gamut_threshold: photocraft_cms::gamut::DEFAULT_THRESHOLD, ..Default::default() })
|
||||
}
|
||||
|
||||
/// The monitor profile (sRGB when none was supplied or it does not parse as RGB).
|
||||
pub fn monitor(&self) -> Arc<Profile> {
|
||||
self.monitor_profile
|
||||
.as_ref()
|
||||
.and_then(|b| profile_from_bytes(b).ok())
|
||||
.filter(|p| p.color_space == ColorSpace::Rgb)
|
||||
.unwrap_or_else(|| Arc::new(Builtin::Srgb.profile().clone()))
|
||||
}
|
||||
|
||||
/// Transform from the document's composite (what `photocraft_compose::flatten` returns) to
|
||||
/// Transform from the canvas texture values (the composite, `CanvasDisplay::source`) to
|
||||
/// the monitor, including the soft proof when View › Proof Colors is on. Cached per
|
||||
/// profile pair and proof settings.
|
||||
pub fn display_transform(&self, doc: &Document) -> Result<Arc<Transform>> {
|
||||
let src = composite_profile(doc);
|
||||
let src = self.canvas_display(doc)?.source.clone();
|
||||
let dst = self.monitor();
|
||||
let pv = self.proof(doc.id);
|
||||
let proof = pv.enabled.then(|| (pv.setup.profile.content_hash(), pv.setup.intent, pv.setup.bpc, pv.setup.simulate_paper));
|
||||
@@ -294,7 +306,7 @@ impl ColorState {
|
||||
let t = if pv.enabled {
|
||||
Transform::proof(&src, &pv.setup.profile, &dst, pv.setup.intent, pv.setup.bpc, pv.setup.simulate_paper)
|
||||
} else {
|
||||
Transform::new(&src, &dst, Intent::RelativeColorimetric, true)
|
||||
Transform::new(&src, &dst, crate::display_color::DISPLAY_INTENT, crate::display_color::DISPLAY_BPC)
|
||||
}
|
||||
.map_err(cms_err)?;
|
||||
let t = Arc::new(t);
|
||||
@@ -309,25 +321,50 @@ impl ColorState {
|
||||
/// The display transform as an `size³` RGBA 3D LUT (upload with
|
||||
/// [`Lut3d::to_rgba16f_bytes`] and apply in the canvas shader).
|
||||
pub fn display_lut(&self, doc: &Document, size: usize) -> Result<Lut3d> {
|
||||
if let Some(lut) = crate::proof_sim::display_lut(self, doc, size)? {
|
||||
self.display_lut_with(doc, size, true)
|
||||
}
|
||||
|
||||
/// [`ColorState::display_lut`], with or without the 32-bit preview (exposure/gamma).
|
||||
fn display_lut_with(&self, doc: &Document, size: usize, hdr: bool) -> Result<Lut3d> {
|
||||
if let Some(lut) = crate::proof_sim::display_lut_with(self, doc, size, hdr)? {
|
||||
return Ok(lut);
|
||||
}
|
||||
let t = self.display_transform(doc)?;
|
||||
Ok(Lut3d::from_transform(&t, size))
|
||||
}
|
||||
|
||||
/// What the canvas should do for `doc`: `None` when neither Proof Colors nor Gamut Warning is
|
||||
/// on, else an RGBA8 display LUT (`size`³, red fastest) whose alpha is 255 where the colour is
|
||||
/// out of the proof gamut (only with Gamut Warning on).
|
||||
/// View › 32-bit Preview Options of `doc` when they change the display (a 32-bit document
|
||||
/// with a non-default exposure or gamma).
|
||||
pub fn hdr_preview(&self, doc: &Document) -> Option<crate::proof_sim::HdrPreview> {
|
||||
if crate::proof_sim::hdr_active(self, doc) { self.hdr.get(&doc.id).copied() } else { None }
|
||||
}
|
||||
|
||||
/// What the canvas should do for `doc`: `None` when the canvas values go to the screen
|
||||
/// unchanged (the document's display profile matches the monitor and neither Proof Colors,
|
||||
/// Gamut Warning nor a 32-bit preview is on), else an RGBA8 display LUT (`size`³, red fastest)
|
||||
/// mapping canvas texture values to the monitor, whose alpha is 255 where the colour is out
|
||||
/// of the proof gamut (only with Gamut Warning on).
|
||||
pub fn canvas_lut(&self, doc: &Document, size: usize) -> Result<Option<Vec<u8>>> {
|
||||
self.canvas_lut_with(doc, size, true)
|
||||
}
|
||||
|
||||
/// [`ColorState::canvas_lut`] without the 32-bit preview, which the GPU canvas shader applies
|
||||
/// itself (see [`ColorState::hdr_preview`]) so that 32-bit values above 1.0, kept by its float
|
||||
/// texture, are exposed into range rather than clipped by the LUT's 0..1 domain.
|
||||
pub fn gpu_canvas_lut(&self, doc: &Document, size: usize) -> Result<Option<Vec<u8>>> {
|
||||
self.canvas_lut_with(doc, size, false)
|
||||
}
|
||||
|
||||
fn canvas_lut_with(&self, doc: &Document, size: usize, hdr: bool) -> Result<Option<Vec<u8>>> {
|
||||
let size = size.max(2);
|
||||
let pv = self.proof(doc.id);
|
||||
if !pv.enabled && !pv.gamut_warning && !crate::proof_sim::hdr_active(self, doc) {
|
||||
return Ok(None);
|
||||
let display = self.canvas_display(doc)?;
|
||||
if !pv.enabled && !pv.gamut_warning && !(hdr && crate::proof_sim::hdr_active(self, doc)) {
|
||||
return Ok(display.transform.as_ref().map(|t| Lut3d::from_transform(t, size).to_rgba8()));
|
||||
}
|
||||
let lut = self.display_lut(doc, size)?;
|
||||
let lut = self.display_lut_with(doc, size, hdr)?;
|
||||
let mut bytes = lut.to_rgba8();
|
||||
let check =
|
||||
if pv.gamut_warning { Some(GamutCheck::new(&composite_profile(doc), &pv.setup.profile, pv.gamut_threshold).map_err(cms_err)?) } else { None };
|
||||
let check = if pv.gamut_warning { Some(GamutCheck::new(&display.source, &pv.setup.profile, pv.gamut_threshold).map_err(cms_err)?) } else { None };
|
||||
let s = (size - 1) as f32;
|
||||
for (i, px) in bytes.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
let rgb = [(i % size) as f32 / s, ((i / size) % size) as f32 / s, (i / (size * size)) as f32 / s];
|
||||
@@ -787,6 +824,9 @@ fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = str_of("workingGray") {
|
||||
next.working_gray = v;
|
||||
}
|
||||
if let Some(v) = str_of("monitorProfile") {
|
||||
next.monitor_profile = v;
|
||||
}
|
||||
for (k, slot) in [("policyRgb", &mut next.policy_rgb), ("policyCmyk", &mut next.policy_cmyk), ("policyGray", &mut next.policy_gray)] {
|
||||
if let Some(v) = p.get(k).and_then(Value::as_str) {
|
||||
*slot = Policy::parse(v).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: format!("`{k}` must be preserve|convert|off") })?;
|
||||
@@ -830,6 +870,8 @@ fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({
|
||||
"settings": c,
|
||||
"working": {"rgb": desc(ColorMode::Rgb), "cmyk": desc(ColorMode::Cmyk), "gray": desc(ColorMode::Grayscale)},
|
||||
"monitor": s.color.monitor().description,
|
||||
"monitorDetected": s.color.monitor_profile.is_some(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -912,7 +954,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"edit.colorSettings",
|
||||
"Color Settings…",
|
||||
["Edit"],
|
||||
r##"{"workingRgb":"srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020","workingCmyk":"coated-cmyk","workingGray":"sgray|gray-gamma-2.2","policyRgb":"preserve|convert|off","policyCmyk":"preserve|convert|off","policyGray":"preserve|convert|off","askOnMismatch":bool=true,"askOnPaste":bool=true,"askOnMissing":bool=false,"intent":"relative|perceptual|saturation|absolute","blendTextGamma":1.0..2.2|bool=1.45,"bpc":bool=true,"dither":bool=true,"reset":bool=false} (working spaces also accept .icc paths)"##,
|
||||
r##"{"workingRgb":"srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020","workingCmyk":"coated-cmyk","workingGray":"sgray|gray-gamma-2.2","policyRgb":"preserve|convert|off","policyCmyk":"preserve|convert|off","policyGray":"preserve|convert|off","askOnMismatch":bool=true,"askOnPaste":bool=true,"askOnMissing":bool=false,"intent":"relative|perceptual|saturation|absolute","blendTextGamma":1.0..2.2|bool=1.45,"bpc":bool=true,"dither":bool=true,"monitorProfile":"auto|srgb|display-p3|adobe-rgb-compat|prophoto-compat|rec2020","reset":bool=false} (working spaces and the monitor profile also accept .icc paths; monitor `auto` = the main display's profile when the platform provides it, else sRGB)"##,
|
||||
always,
|
||||
color_settings,
|
||||
true,
|
||||
@@ -1179,19 +1221,8 @@ mod settings_tests {
|
||||
assert_eq!(t.color.settings, s.color.settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_text_gamma_setting() {
|
||||
let mut s = Session::new();
|
||||
assert!((s.color.settings.blend_text_gamma - 1.45).abs() < 1e-6);
|
||||
assert!(s.execute("edit.colorSettings", json!({"blendTextGamma": 3.0})).is_err());
|
||||
// The compositor's (application-wide) gamma follows; restored to the default at once.
|
||||
s.execute("edit.colorSettings", json!({"blendTextGamma": 1.45})).unwrap();
|
||||
assert!((photocraft_compose::psblend::text_gamma() - 1.45).abs() < 1e-6);
|
||||
let r = s.execute("edit.colorSettings", json!({"blendTextGamma": false})).unwrap();
|
||||
assert_eq!(r["settings"]["blendTextGamma"], 1.0);
|
||||
s.execute("edit.colorSettings", json!({"blendTextGamma": true})).unwrap();
|
||||
assert!((s.color.settings.blend_text_gamma - 1.45).abs() < 1e-6);
|
||||
}
|
||||
// The Blend Text Colors Using Gamma test is `tests/text_gamma.rs`: it changes the
|
||||
// process-wide text gamma, which would race every unit test here that composites type.
|
||||
|
||||
#[test]
|
||||
fn open_policies() {
|
||||
|
||||
+99
-126
@@ -1,7 +1,6 @@
|
||||
//! The command registry. Ids follow Photoshop's menu structure (see the parity checklist).
|
||||
|
||||
use photocraft_color::{BlendMode, Color, ColorMode, SampleType};
|
||||
use photocraft_doc::adjust::CurvePoint;
|
||||
use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, LayerId, LayerMask, Size};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
@@ -88,6 +87,15 @@ fn has_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
s.active().filter(|d| d.doc.selection.is_some()).map(|_| ()).ok_or_else(|| "no selection".into())
|
||||
}
|
||||
|
||||
/// Image › Image Rotation by a right angle or a flip (pixels, vectors, guides, … all move).
|
||||
fn turn(s: &mut Session, label: &str, t: crate::canvas_geom::Turn) -> Result<Value> {
|
||||
s.edit(label, |doc, _| {
|
||||
crate::canvas_geom::turn_canvas(doc, t);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
macro_rules! cmd {
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true }
|
||||
@@ -165,7 +173,7 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value)
|
||||
return s.edit(label, |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
if let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, p)? {
|
||||
pixels::adjust_surface(surf, &adj, sel.as_ref());
|
||||
pixels::adjust_surface(surf, &adj, sel.as_ref(), ColorMode::Grayscale);
|
||||
surf.prune();
|
||||
}
|
||||
Ok(Value::Null)
|
||||
@@ -174,84 +182,25 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value)
|
||||
let id = layer_param(s, &Value::Null)?;
|
||||
s.edit(label, |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
let mode = doc.mode;
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let surf = l.surface_mut().ok_or_else(|| EngineError::Other("not a pixel layer".into()))?;
|
||||
pixels::adjust_surface(surf, &adj, sel.as_ref());
|
||||
pixels::adjust_surface(surf, &adj, sel.as_ref(), mode);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Lenient adjustment from params for previews: bad params fall back to the kind's defaults.
|
||||
/// Commands use the checked [`crate::adjust_params::from_params`].
|
||||
pub fn adjustment_from_params(kind: &str, p: &Value) -> Adjustment {
|
||||
match kind {
|
||||
"invert" => Adjustment::Invert,
|
||||
"hueSaturation" => Adjustment::HueSaturation {
|
||||
hue: f32_or(p, "hue", 0.0),
|
||||
saturation: f32_or(p, "saturation", 0.0),
|
||||
lightness: f32_or(p, "lightness", 0.0),
|
||||
colorize: p.get("colorize").and_then(Value::as_bool).unwrap_or(false),
|
||||
},
|
||||
"brightnessContrast" => {
|
||||
Adjustment::BrightnessContrast { brightness: f32_or(p, "brightness", 0.0), contrast: f32_or(p, "contrast", 0.0), legacy: false }
|
||||
}
|
||||
"threshold" => Adjustment::Threshold { level: f32_or(p, "level", 128.0) / 255.0 },
|
||||
"posterize" => Adjustment::Posterize { levels: f32_or(p, "levels", 4.0) as u32 },
|
||||
"exposure" => Adjustment::Exposure { exposure: f32_or(p, "exposure", 0.0), offset: f32_or(p, "offset", 0.0), gamma: f32_or(p, "gamma", 1.0) },
|
||||
"vibrance" => Adjustment::Vibrance { vibrance: f32_or(p, "vibrance", 0.0), saturation: f32_or(p, "saturation", 0.0) },
|
||||
"levels" => {
|
||||
// Master keys at the top level; per channel under "red"/"green"/"blue" (0–255 levels).
|
||||
let ch = |p: &Value| photocraft_doc::adjust::LevelsChannel {
|
||||
in_black: f32_or(p, "inBlack", 0.0) / 255.0,
|
||||
in_white: f32_or(p, "inWhite", 255.0) / 255.0,
|
||||
gamma: f32_or(p, "gamma", 1.0).clamp(0.01, 9.99),
|
||||
out_black: f32_or(p, "outBlack", 0.0) / 255.0,
|
||||
out_white: f32_or(p, "outWhite", 255.0) / 255.0,
|
||||
};
|
||||
let per = |k: &str| p.get(k).map(ch).unwrap_or_default();
|
||||
Adjustment::Levels { master: ch(p), per_channel: [per("red"), per("green"), per("blue")] }
|
||||
}
|
||||
"curves" => {
|
||||
// "points" = master curve; "red"/"green"/"blue" = per-channel curves ([[in, out], …], 0–255).
|
||||
let parse = |v: Option<&Value>| -> Option<Vec<CurvePoint>> {
|
||||
let pts: Vec<CurvePoint> = v?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.filter_map(|v| {
|
||||
let a = v.as_array()?;
|
||||
Some(CurvePoint { input: a.first()?.as_f64()? as f32 / 255.0, output: a.get(1)?.as_f64()? as f32 / 255.0 })
|
||||
})
|
||||
.collect();
|
||||
(pts.len() >= 2).then_some(pts)
|
||||
};
|
||||
let mut a = Adjustment::identity_curves();
|
||||
if let Adjustment::Curves { master, per_channel } = &mut a {
|
||||
if let Some(m) = parse(p.get("points")) {
|
||||
*master = m;
|
||||
}
|
||||
for (i, k) in ["red", "green", "blue"].iter().enumerate() {
|
||||
if let Some(c) = parse(p.get(*k)) {
|
||||
per_channel[i] = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
a
|
||||
}
|
||||
"blackWhite" => Adjustment::BlackWhite { weights: [40.0, 60.0, 40.0, 60.0, 20.0, 80.0], tint: None },
|
||||
"photoFilter" => Adjustment::PhotoFilter { color: [0.925, 0.541, 0.0], density: f32_or(p, "density", 25.0) / 100.0, preserve_luminosity: true },
|
||||
"channelMixer" => Adjustment::ChannelMixer { matrix: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]], monochrome: false },
|
||||
"colorBalance" => Adjustment::ColorBalance { shadows: [0.0; 3], midtones: [0.0; 3], highlights: [0.0; 3], preserve_luminosity: true },
|
||||
"gradientMap" => Adjustment::GradientMap { stops: vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])], reverse: false },
|
||||
"selectiveColor" => crate::adjust_cmds::selective_from_params(p, None),
|
||||
// Best effort here (a bad LUT file gives an empty lookup); commands use the checked path.
|
||||
"colorLookup" => crate::adjust_cmds::lookup_from_params(p, None).unwrap_or(Adjustment::ColorLookup {
|
||||
name: String::new(),
|
||||
lut: None,
|
||||
size: 0,
|
||||
tetrahedral: false,
|
||||
dither: false,
|
||||
}),
|
||||
_ => Adjustment::Invert,
|
||||
}
|
||||
crate::adjust_params::from_params(kind, p, None, ColorMode::Rgb)
|
||||
.or_else(|_| crate::adjust_params::default_for(kind, ColorMode::Rgb))
|
||||
.unwrap_or(Adjustment::Invert)
|
||||
}
|
||||
|
||||
fn doc_mode(s: &Session) -> ColorMode {
|
||||
s.active().map_or(ColorMode::Rgb, |d| d.doc.mode)
|
||||
}
|
||||
|
||||
// ---------- the table ----------
|
||||
@@ -637,12 +586,10 @@ fn build() -> Vec<CommandSpec> {
|
||||
}),
|
||||
cmd!("layer.flattenImage", "Flatten Image", ["Layer"], None, "{}", has_doc, |s, _| {
|
||||
s.edit("Flatten Image", |doc, active| {
|
||||
let buf = photocraft_compose::flatten(doc).over_background([1.0, 1.0, 1.0]);
|
||||
let fmt = doc.pixel_format();
|
||||
let data: Vec<f32> = buf.px.iter().flat_map(|p| photocraft_raster::from_rgba(&fmt, *p)).collect();
|
||||
let mut bg = Layer::raster("Background", fmt);
|
||||
bg.locks.transparency = true;
|
||||
crate::pixels_mut(&mut bg)?.write_region(doc.bounds(), &data);
|
||||
*crate::pixels_mut(&mut bg)? = photocraft_compose::flatten_to_surface(doc, fmt, Some([1.0, 1.0, 1.0]));
|
||||
*active = Some(bg.id);
|
||||
doc.layers = vec![bg];
|
||||
Ok(())
|
||||
@@ -690,12 +637,18 @@ fn build() -> Vec<CommandSpec> {
|
||||
cmd!("layer.setAdjustment", "Adjustment Properties", [], None, r##"{"layer":id?, …params of that adjustment kind}"##, has_layer, |s, p| {
|
||||
let id = layer_param(s, p)?;
|
||||
s.edit("Modify Adjustment", |doc, _| {
|
||||
let mode = doc.mode;
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let LayerContent::Adjustment(adj) = &mut l.content else { return Err(EngineError::Other("not an adjustment layer".into())) };
|
||||
if matches!(adj, Adjustment::Unsupported { .. }) {
|
||||
return Err(EngineError::Other("this adjustment can't be edited (kept as imported)".into()));
|
||||
}
|
||||
let kind = adjustment_kind(adj);
|
||||
*adj = match crate::adjust_cmds::update_adjustment(adj, p) {
|
||||
Some(r) => r?,
|
||||
None => adjustment_from_params(kind, p),
|
||||
// No params resets to the defaults; otherwise params merge over the current values.
|
||||
*adj = if crate::adjust_params::user_keys(p).is_empty() {
|
||||
crate::adjust_params::default_for(kind, mode)?
|
||||
} else {
|
||||
crate::adjust_params::from_params(kind, p, Some(adj), mode)?
|
||||
};
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -749,49 +702,33 @@ fn build() -> Vec<CommandSpec> {
|
||||
),
|
||||
// Image
|
||||
cmd!("image.imageRotation.flipCanvasHorizontal", "Flip Canvas Horizontal", ["Image", "Image Rotation"], None, "{}", has_doc, |s, _| {
|
||||
s.edit("Flip Canvas Horizontal", |doc, _| {
|
||||
let w = doc.size.width as i32;
|
||||
pixels::remap_document(doc, &|x, y| (w - 1 - x, y));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
turn(s, "Flip Canvas Horizontal", crate::canvas_geom::Turn::FlipHorizontal)
|
||||
}),
|
||||
cmd!("image.imageRotation.flipCanvasVertical", "Flip Canvas Vertical", ["Image", "Image Rotation"], None, "{}", has_doc, |s, _| {
|
||||
s.edit("Flip Canvas Vertical", |doc, _| {
|
||||
let h = doc.size.height as i32;
|
||||
pixels::remap_document(doc, &|x, y| (x, h - 1 - y));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
turn(s, "Flip Canvas Vertical", crate::canvas_geom::Turn::FlipVertical)
|
||||
}),
|
||||
cmd!("image.imageRotation.180", "180°", ["Image", "Image Rotation"], None, "{}", has_doc, |s, _| {
|
||||
s.edit("Rotate Canvas", |doc, _| {
|
||||
let (w, h) = (doc.size.width as i32, doc.size.height as i32);
|
||||
pixels::remap_document(doc, &|x, y| (w - 1 - x, h - 1 - y));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
turn(s, "Rotate Canvas", crate::canvas_geom::Turn::Rotate180)
|
||||
}),
|
||||
cmd!("image.imageRotation.90cw", "90° Clockwise", ["Image", "Image Rotation"], None, "{}", has_doc, |s, _| {
|
||||
s.edit("Rotate Canvas", |doc, _| {
|
||||
let h = doc.size.height as i32;
|
||||
pixels::remap_document(doc, &|x, y| (h - 1 - y, x));
|
||||
doc.size = Size::new(doc.size.height, doc.size.width);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
turn(s, "Rotate Canvas", crate::canvas_geom::Turn::Cw90)
|
||||
}),
|
||||
cmd!("image.imageRotation.90ccw", "90° Counter Clockwise", ["Image", "Image Rotation"], None, "{}", has_doc, |s, _| {
|
||||
s.edit("Rotate Canvas", |doc, _| {
|
||||
let w = doc.size.width as i32;
|
||||
pixels::remap_document(doc, &|x, y| (y, w - 1 - x));
|
||||
doc.size = Size::new(doc.size.height, doc.size.width);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
turn(s, "Rotate Canvas", crate::canvas_geom::Turn::Ccw90)
|
||||
}),
|
||||
cmd!("image.adjustments.desaturate", "Desaturate", ["Image", "Adjustments"], Some("Cmd+Shift+U"), "{}", has_pixel_layer, |s, _| {
|
||||
destructive_adjust(s, "Desaturate", Adjustment::HueSaturation { hue: 0.0, saturation: -100.0, lightness: 0.0, colorize: false }, &Value::Null)
|
||||
destructive_adjust(
|
||||
s,
|
||||
"Desaturate",
|
||||
Adjustment::HueSaturation {
|
||||
hue: 0.0,
|
||||
saturation: -100.0,
|
||||
lightness: 0.0,
|
||||
colorize: false,
|
||||
ranges: photocraft_doc::adjust::HueRange::defaults(),
|
||||
},
|
||||
&Value::Null,
|
||||
)
|
||||
}),
|
||||
// Paint
|
||||
cmd!(
|
||||
@@ -895,21 +832,51 @@ fn build() -> Vec<CommandSpec> {
|
||||
];
|
||||
|
||||
// Adjustment layers + destructive adjustments, generated from one list.
|
||||
// Levels/Curves channel keys follow the document: red/green/blue (RGB), gray (Grayscale),
|
||||
// cyan/magenta/yellow/black (CMYK), lightness/a/b (Lab); see `adjust_params`.
|
||||
const ADJ: &[(&str, &str, &str)] = &[
|
||||
("brightnessContrast", "Brightness/Contrast…", r##"{"brightness":-150..150,"contrast":-50..100}"##),
|
||||
("levels", "Levels…", r##"{"inBlack":0..255,"inWhite":0..255,"gamma":0.1..10}"##),
|
||||
("curves", "Curves…", r##"{"points":[[in,out],…] in 0..255}"##),
|
||||
("exposure", "Exposure…", r##"{"exposure":-20..20,"offset":-0.5..0.5,"gamma":0.01..9.99}"##),
|
||||
("vibrance", "Vibrance…", r##"{"vibrance":-100..100,"saturation":-100..100}"##),
|
||||
("hueSaturation", "Hue/Saturation…", r##"{"hue":-180..180,"saturation":-100..100,"lightness":-100..100,"colorize":bool}"##),
|
||||
("colorBalance", "Color Balance…", "{}"),
|
||||
("blackWhite", "Black & White…", "{}"),
|
||||
("photoFilter", "Photo Filter…", r##"{"density":0..100}"##),
|
||||
("channelMixer", "Channel Mixer…", "{}"),
|
||||
("brightnessContrast", "Brightness/Contrast…", r##"{"brightness":-150..150=0,"contrast":-50..100=0,"legacy":bool=false}"##),
|
||||
(
|
||||
"levels",
|
||||
"Levels…",
|
||||
r##"{"inBlack":0..253=0,"gamma":0.01..9.99=1,"inWhite":2..255=255,"outBlack":0..255=0,"outWhite":0..255=255,"red":json,"green":json,"blue":json} (top level = composite; per channel {"inBlack","gamma","inWhite","outBlack","outWhite"} under red/green/blue, gray, cyan/magenta/yellow/black or lightness/a/b)"##,
|
||||
),
|
||||
(
|
||||
"curves",
|
||||
"Curves…",
|
||||
r##"{"points":json,"red":json,"green":json,"blue":json} (curves as [[in,out],…] in 0..255, 2..19 points: points = composite; red/green/blue, gray, cyan/magenta/yellow/black or lightness/a/b per channel)"##,
|
||||
),
|
||||
("exposure", "Exposure…", r##"{"exposure":-20..20=0,"offset":-0.5..0.5=0,"gamma":0.01..9.99=1}"##),
|
||||
("vibrance", "Vibrance…", r##"{"vibrance":-100..100=0,"saturation":-100..100=0}"##),
|
||||
(
|
||||
"hueSaturation",
|
||||
"Hue/Saturation…",
|
||||
r##"{"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0,"colorize":bool=false,"reds":json,"yellows":json,"greens":json,"cyans":json,"blues":json,"magentas":json} (per range {"hue","saturation","lightness","range":[4 hue degrees]}; colorize: hue 0..360, saturation 0..100)"##,
|
||||
),
|
||||
(
|
||||
"colorBalance",
|
||||
"Color Balance…",
|
||||
r##"{"shadows":json,"midtones":json,"highlights":json,"preserveLuminosity":bool=true} (each tone [cyan-red, magenta-green, yellow-blue] in -100..100)"##,
|
||||
),
|
||||
(
|
||||
"blackWhite",
|
||||
"Black & White…",
|
||||
r##"{"reds":-200..300=40,"yellows":-200..300=60,"greens":-200..300=40,"cyans":-200..300=60,"blues":-200..300=20,"magentas":-200..300=80,"tint":bool=false,"tintColor":"#rrggbb"}"##,
|
||||
),
|
||||
(
|
||||
"photoFilter",
|
||||
"Photo Filter…",
|
||||
r##"{"filter":"warming85|warmingLBA|warming81|cooling80|coolingLBB|cooling82|red|orange|yellow|green|cyan|blue|violet|magenta|sepia|deepRed|deepBlue|deepEmerald|deepYellow|underwater","color":"#rrggbb","density":0..100=25,"preserveLuminosity":bool=true}"##,
|
||||
),
|
||||
(
|
||||
"channelMixer",
|
||||
"Channel Mixer…",
|
||||
r##"{"red":json,"green":json,"blue":json,"gray":json,"monochrome":bool=false} (per output channel [red %, green %, blue %, constant %] in -200..200; gray = the monochrome mix)"##,
|
||||
),
|
||||
("invert", "Invert", "{}"),
|
||||
("posterize", "Posterize…", r##"{"levels":2..255}"##),
|
||||
("threshold", "Threshold…", r##"{"level":1..255}"##),
|
||||
("gradientMap", "Gradient Map…", "{}"),
|
||||
("posterize", "Posterize…", r##"{"levels":2..255=4}"##),
|
||||
("threshold", "Threshold…", r##"{"level":1..255=128}"##),
|
||||
("gradientMap", "Gradient Map…", r##"{"stops":json,"reverse":bool=false,"dither":bool=false} (stops [[location 0..1, "#rrggbb"], …], 2..64)"##),
|
||||
(
|
||||
"selectiveColor",
|
||||
"Selective Color…",
|
||||
@@ -933,7 +900,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
enabled: has_doc,
|
||||
run: |s, p| {
|
||||
let kind = p.get("__kind").and_then(Value::as_str).unwrap_or("invert").to_string();
|
||||
let adj = if kind == "colorLookup" { crate::adjust_cmds::lookup_from_params(p, None)? } else { adjustment_from_params(&kind, p) };
|
||||
let adj = crate::adjust_params::from_params(&kind, p, None, doc_mode(s))?;
|
||||
new_adjustment(s, adj)
|
||||
},
|
||||
journal: true,
|
||||
@@ -955,7 +922,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
enabled: has_pixel_or_channel,
|
||||
run: |s, p| {
|
||||
let kind = p.get("__kind").and_then(Value::as_str).unwrap_or("invert").to_string();
|
||||
let adj = if kind == "colorLookup" { crate::adjust_cmds::lookup_from_params(p, None)? } else { adjustment_from_params(&kind, p) };
|
||||
let adj = crate::adjust_params::from_params(&kind, p, None, doc_mode(s))?;
|
||||
let label = adj.label().to_string();
|
||||
destructive_adjust(s, &label, adj, p)
|
||||
},
|
||||
@@ -973,6 +940,9 @@ fn build() -> Vec<CommandSpec> {
|
||||
v.extend(crate::edit_cmds::specs());
|
||||
v.extend(crate::color_cmds::specs());
|
||||
v.extend(crate::brush_cmds::specs());
|
||||
v.extend(crate::brush_preset_cmds::specs());
|
||||
v.extend(crate::eraser_cmds::specs());
|
||||
v.extend(crate::preset_import_cmds::specs());
|
||||
v.extend(crate::retouch_cmds::specs());
|
||||
v.extend(crate::image_cmds::specs());
|
||||
v.extend(crate::selection_cmds::specs());
|
||||
@@ -1018,6 +988,9 @@ fn build() -> Vec<CommandSpec> {
|
||||
v.extend(crate::video_cmds::specs());
|
||||
v.extend(crate::wia_cmds::specs());
|
||||
v.extend(crate::variables_cmds::specs());
|
||||
v.extend(crate::plugin_cmds::specs());
|
||||
v.extend(crate::group_view_cmds::specs());
|
||||
v.extend(crate::fx_view_cmds::specs());
|
||||
v
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Colour-managed canvas display: the document's composite shown through document profile →
|
||||
//! monitor profile, on the GPU canvas (folded into the display 3D LUT) and the CPU canvas
|
||||
//! (an 8-bit transform of the composite).
|
||||
//!
|
||||
//! * The composite is in [`composite_profile`] (the document profile for RGB, its gray curve as
|
||||
//! RGB for gray documents, sRGB for CMYK — read through the document's CMYK profile, see
|
||||
//! `photocraft_color::convert::CmykSpace` — and Lab).
|
||||
//! * Linear composites (EXR/HDR, linear profiles) are stored in the 8-bit canvas texture
|
||||
//! sRGB-encoded ([`CanvasDisplay::encode_srgb`]) so shadows keep their precision; the display
|
||||
//! source profile is then the same primaries with the sRGB curve.
|
||||
//! * The monitor profile comes from Edit › Color Settings › Monitor Profile: `auto` (the
|
||||
//! platform's main display profile when the app supplied one, else sRGB), a built-in id or
|
||||
//! an `.icc` path.
|
||||
//! * When the source and the monitor match (sRGB documents on an sRGB display, the common
|
||||
//! case) the transform is the identity and nothing is applied on either path.
|
||||
//!
|
||||
//! Results are cached per (document profile, mode, monitor profile); the rendering intent is
|
||||
//! relative colorimetric with black point compensation, as for Photoshop's monitor display.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use photocraft_cms::{Builtin, ColorSpace, Curve, Intent, Profile, Transform};
|
||||
use photocraft_compose::Buffer;
|
||||
use photocraft_doc::Document;
|
||||
use photocraft_raster::Rgba8Image;
|
||||
|
||||
use crate::color_cmds::{ColorState, composite_profile, mode_space, profile_from_bytes, resolve_profile};
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
/// Display intent and black point compensation of the monitor transform.
|
||||
pub const DISPLAY_INTENT: Intent = Intent::RelativeColorimetric;
|
||||
pub const DISPLAY_BPC: bool = true;
|
||||
|
||||
/// How the canvas shows one document.
|
||||
#[derive(Debug)]
|
||||
pub struct CanvasDisplay {
|
||||
/// The canvas texture stores `srgb_encode(value)` instead of the composite value (linear
|
||||
/// composites; see the module docs).
|
||||
pub encode_srgb: bool,
|
||||
/// Profile of the canvas texture values (the composite profile, or its sRGB-curve twin
|
||||
/// when `encode_srgb`).
|
||||
pub source: Arc<Profile>,
|
||||
/// The monitor profile.
|
||||
pub monitor: Arc<Profile>,
|
||||
/// Texture values → monitor; `None` when that is the identity (within half an 8-bit step).
|
||||
pub transform: Option<Arc<Transform>>,
|
||||
/// Changes whenever any of the above changes (for UI caches).
|
||||
pub key: u64,
|
||||
}
|
||||
|
||||
impl CanvasDisplay {
|
||||
/// Nothing to do: values go to the screen unchanged.
|
||||
pub fn is_identity(&self) -> bool {
|
||||
!self.encode_srgb && self.transform.is_none()
|
||||
}
|
||||
|
||||
/// CPU canvas: a straight-alpha composite → RGBA8 monitor values.
|
||||
pub fn to_rgba8(&self, buf: &Buffer) -> Rgba8Image {
|
||||
let mut img = if self.encode_srgb { encode_rgba8(buf) } else { buf.to_rgba8() };
|
||||
if let Some(t) = &self.transform {
|
||||
apply_u8(t, &mut img.pixels);
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// GPU canvas: a CPU composite as the texture stores it (sRGB-encoded for linear
|
||||
/// composites; the display LUT does the rest). Values above 1.0 are encoded too (the sRGB
|
||||
/// curve extended), for the float canvas texture of 32-bit documents.
|
||||
pub fn texture_buffer<'a>(&self, buf: &'a Buffer) -> Cow<'a, Buffer> {
|
||||
if !self.encode_srgb {
|
||||
return Cow::Borrowed(buf);
|
||||
}
|
||||
let mut b = buf.clone();
|
||||
for p in &mut b.px {
|
||||
for v in &mut p[..3] {
|
||||
// Capped at the largest half float; NaN is dropped by the texel conversion.
|
||||
*v = photocraft_color::convert::linear_to_srgb(v.clamp(0.0, 65504.0));
|
||||
}
|
||||
}
|
||||
Cow::Owned(b)
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear → sRGB-encoded 8-bit codes, indexed by the linear value in 1/65535 steps.
|
||||
fn encode_table() -> &'static [u8] {
|
||||
static T: OnceLock<Vec<u8>> = OnceLock::new();
|
||||
T.get_or_init(|| (0..=65535u32).map(|i| (photocraft_color::convert::linear_to_srgb(i as f32 / 65535.0) * 255.0 + 0.5) as u8).collect())
|
||||
}
|
||||
|
||||
fn encode_rgba8(buf: &Buffer) -> Rgba8Image {
|
||||
let t = encode_table();
|
||||
let mut img = Rgba8Image::new(buf.rect.width(), buf.rect.height());
|
||||
let code = |v: f32| t.get((v.clamp(0.0, 1.0) * 65535.0 + 0.5) as usize).copied().unwrap_or(255);
|
||||
for (o, p) in img.pixels.as_chunks_mut::<4>().0.iter_mut().zip(&buf.px) {
|
||||
*o = [code(p[0]), code(p[1]), code(p[2]), (p[3].clamp(0.0, 1.0) * 255.0 + 0.5) as u8];
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// In-place 8-bit RGBA transform (alpha kept), in blocks so the scratch copy stays small.
|
||||
fn apply_u8(t: &Transform, px: &mut [u8]) {
|
||||
const BLOCK: usize = 1 << 20;
|
||||
let mut src = Vec::new();
|
||||
for chunk in px.chunks_mut(BLOCK * 4) {
|
||||
src.clear();
|
||||
src.extend_from_slice(chunk);
|
||||
t.convert_u8(&src, 4, chunk, 4, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `p` an RGB matrix/TRC profile with linear curves?
|
||||
fn is_linear_rgb(p: &Profile) -> bool {
|
||||
p.color_space == ColorSpace::Rgb && p.is_matrix_shaper() && p.trc.as_ref().is_some_and(|t| t.iter().all(Curve::is_identity))
|
||||
}
|
||||
|
||||
/// The same primaries with the sRGB curve (what an sRGB-encoded texture of a linear composite is in).
|
||||
fn srgb_curve_twin(p: &Profile) -> Profile {
|
||||
let c = photocraft_cms::curve::srgb_trc();
|
||||
let mut q = p.clone();
|
||||
q.trc = Some([c.clone(), c.clone(), c]);
|
||||
q.description = format!("{} (sRGB-encoded)", p.description);
|
||||
q.with_encoded_bytes()
|
||||
}
|
||||
|
||||
/// Does `t` map every lattice point (the neutral axis only for gray documents) to itself
|
||||
/// within half an 8-bit step?
|
||||
fn is_identity(t: &Transform, neutral_only: bool) -> bool {
|
||||
const N: usize = 9;
|
||||
let s = (N - 1) as f32;
|
||||
let mut out = [0.0f32; 16];
|
||||
let mut check = |v: [f32; 3]| {
|
||||
t.eval(&v, &mut out);
|
||||
(0..3).all(|i| (out[i] - v[i]).abs() <= 0.5 / 255.0)
|
||||
};
|
||||
if neutral_only {
|
||||
return (0..=32).all(|i| check([i as f32 / 32.0; 3]));
|
||||
}
|
||||
(0..N).all(|b| (0..N).all(|g| (0..N).all(|r| check([r as f32 / s, g as f32 / s, b as f32 / s]))))
|
||||
}
|
||||
|
||||
fn hash_of(v: impl std::hash::Hash) -> u64 {
|
||||
use std::hash::Hasher;
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
v.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// The resolved monitor profile and what it was resolved from (setting, platform bytes).
|
||||
type MonitorCache = Option<(String, Option<Arc<Vec<u8>>>, Arc<Profile>)>;
|
||||
|
||||
/// Caches of [`ColorState`] for the display (monitor profile and per-document displays).
|
||||
#[derive(Default)]
|
||||
pub struct DisplayCaches {
|
||||
monitor: Mutex<MonitorCache>,
|
||||
canvas: Mutex<HashMap<(ColorSpace, u64, u64), Arc<CanvasDisplay>>>,
|
||||
}
|
||||
|
||||
impl ColorState {
|
||||
/// The monitor profile: Color Settings › Monitor Profile (`auto`: the platform's profile when
|
||||
/// supplied in [`ColorState::monitor_profile`], else sRGB). Non-RGB or unreadable profiles
|
||||
/// fall back to sRGB.
|
||||
pub fn monitor(&self) -> Arc<Profile> {
|
||||
let spec = self.settings.monitor_profile.as_str();
|
||||
let mut cache = self.display.monitor.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((s, b, p)) = cache.as_ref()
|
||||
&& s == spec
|
||||
&& match (b, &self.monitor_profile) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
|
||||
_ => false,
|
||||
}
|
||||
{
|
||||
return p.clone();
|
||||
}
|
||||
let p = if spec.is_empty() || spec == "auto" {
|
||||
self.monitor_profile.as_ref().and_then(|b| profile_from_bytes(b).ok())
|
||||
} else {
|
||||
resolve_profile(spec, None, Some(photocraft_color::ColorMode::Rgb)).ok()
|
||||
}
|
||||
.filter(|p| p.color_space == ColorSpace::Rgb)
|
||||
.unwrap_or_else(|| Arc::new(Builtin::Srgb.profile().clone()));
|
||||
*cache = Some((spec.to_string(), self.monitor_profile.clone(), p.clone()));
|
||||
p
|
||||
}
|
||||
|
||||
/// How the canvas shows `doc` (cached per document profile, mode and monitor profile).
|
||||
pub fn canvas_display(&self, doc: &Document) -> Result<Arc<CanvasDisplay>> {
|
||||
let space = mode_space(doc.mode);
|
||||
let doc_hash = doc.icc_profile.as_ref().and_then(|b| profile_from_bytes(b).ok()).filter(|p| p.color_space == space).map_or(0, |p| p.content_hash());
|
||||
let monitor = self.monitor();
|
||||
let key = (space, doc_hash, monitor.content_hash());
|
||||
if let Some(d) = self.display.canvas.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
return Ok(d.clone());
|
||||
}
|
||||
let composite = composite_profile(doc);
|
||||
let encode_srgb = is_linear_rgb(&composite);
|
||||
let source = if encode_srgb { Arc::new(srgb_curve_twin(&composite)) } else { composite };
|
||||
let transform = if source.content_hash() == monitor.content_hash() {
|
||||
None
|
||||
} else {
|
||||
let t = Transform::new(&source, &monitor, DISPLAY_INTENT, DISPLAY_BPC).map_err(|e| EngineError::Other(format!("colour management: {e}")))?;
|
||||
(!is_identity(&t, space == ColorSpace::Gray)).then(|| Arc::new(t))
|
||||
};
|
||||
let d = Arc::new(CanvasDisplay {
|
||||
encode_srgb,
|
||||
key: hash_of((source.content_hash(), monitor.content_hash(), encode_srgb, transform.is_some())),
|
||||
source,
|
||||
monitor,
|
||||
transform,
|
||||
});
|
||||
let mut c = self.display.canvas.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.len() > 32 {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(key, d.clone());
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Changes whenever the canvas display of `doc` changes: profiles, monitor, Proof Colors,
|
||||
/// Gamut Warning and 32-bit preview settings (for the UI's LUT cache).
|
||||
pub fn display_signature(&self, doc: &Document) -> u64 {
|
||||
let cm = self.canvas_display(doc).map(|d| d.key).unwrap_or(0);
|
||||
// Per frame: read the proof state in place (the default state allocates a profile).
|
||||
let proof = self.proof_ref(doc.id).filter(|pv| pv.enabled || pv.gamut_warning).map(|pv| {
|
||||
let s = &pv.setup;
|
||||
(pv.enabled, pv.gamut_warning, s.profile.content_hash(), s.intent, s.bpc, s.simulate_paper, s.kind, pv.gamut_threshold.to_bits())
|
||||
});
|
||||
let hdr =
|
||||
crate::proof_sim::hdr_active(self, doc).then(|| self.hdr.get(&doc.id).map(|h| (h.highlight_compression, h.exposure.to_bits(), h.gamma.to_bits())));
|
||||
hash_of((cm, proof, hdr))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "display_color_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,251 @@
|
||||
//! Colour-managed display (#46): non-sRGB documents show their colours on an sRGB (or other)
|
||||
//! monitor. Fixtures are built-in (CC0) or synthesized profiles only.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_cms::synth::{CmykParams, cmyk_profile};
|
||||
use photocraft_cms::{Builtin, Intent, Profile, Transform};
|
||||
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::{Document, Layer, LayerContent, Size};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Tolerance of the CPU path against the reference (8-bit quantisation + table evaluation).
|
||||
const TOL: f32 = 2.0;
|
||||
|
||||
fn rgb_doc(profile: Option<&Profile>, rgb: [f32; 3], depth: SampleType) -> Document {
|
||||
let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, depth, Color::rgb(rgb[0], rgb[1], rgb[2]));
|
||||
d.icc_profile = profile.map(Profile::to_bytes);
|
||||
d
|
||||
}
|
||||
|
||||
fn gray_doc(profile: &Profile, v: f32) -> Document {
|
||||
let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Grayscale, SampleType::U8, Color::gray(v));
|
||||
d.icc_profile = Some(profile.to_bytes());
|
||||
d
|
||||
}
|
||||
|
||||
/// A CMYK document of two layers (so composites go through the compositor): the ink below and
|
||||
/// an empty layer on top.
|
||||
fn cmyk_doc(profile: Option<&Profile>, ink: [f32; 4]) -> Document {
|
||||
let fmt = PixelFormat::new(ColorMode::Cmyk, SampleType::U8, true);
|
||||
let mut d = Document::new("t", Size::new(8, 8), ColorMode::Cmyk, SampleType::U8);
|
||||
let mut s = Surface::new(fmt);
|
||||
s.fill_rect(Rect::new(0, 0, 8, 8), &[ink[0], ink[1], ink[2], ink[3], 1.0]);
|
||||
d.layers.push(Layer::new("ink", LayerContent::Raster(s)));
|
||||
d.layers.push(Layer::new("empty", LayerContent::Raster(Surface::new(fmt))));
|
||||
d.icc_profile = profile.map(Profile::to_bytes);
|
||||
d
|
||||
}
|
||||
|
||||
/// Synthetic CMYK profile unlike the built-in coated one (heavier dot gain, small tables).
|
||||
fn test_cmyk() -> Profile {
|
||||
cmyk_profile(&CmykParams { description: "Test Uncoated CMYK".into(), tvi: [0.26, 0.26, 0.26, 0.3], grid_a2b: 7, grid_b2a: 11, ..Default::default() })
|
||||
}
|
||||
|
||||
/// What the CPU canvas shows at the centre pixel (RGBA8).
|
||||
fn shown(s: &Session, d: &Document) -> [f32; 3] {
|
||||
let buf = photocraft_compose::flatten(d);
|
||||
let img = s.color.canvas_display(d).unwrap().to_rgba8(&buf);
|
||||
let i = (4 * d.size.width as usize + 4) * 4;
|
||||
[0, 1, 2].map(|k| img.pixels[i + k] as f32)
|
||||
}
|
||||
|
||||
/// Reference: the CMS evaluating document values → sRGB, in 8-bit codes.
|
||||
fn reference(src: &Profile, v: &[f32]) -> [f32; 3] {
|
||||
let t = Transform::new(src, Builtin::Srgb.profile(), Intent::RelativeColorimetric, true).unwrap();
|
||||
let mut o = [0.0f32; 16];
|
||||
t.eval(v, &mut o);
|
||||
let n = t.outputs();
|
||||
[0, 1, 2].map(|k| o[if n == 1 { 0 } else { k }].clamp(0.0, 1.0) * 255.0)
|
||||
}
|
||||
|
||||
fn close(a: [f32; 3], b: [f32; 3], tol: f32) -> bool {
|
||||
(0..3).all(|i| (a[i] - b[i]).abs() <= tol)
|
||||
}
|
||||
|
||||
fn far(a: [f32; 3], b: [f32; 3], d: f32) -> bool {
|
||||
(0..3).any(|i| (a[i] - b[i]).abs() >= d)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_is_the_identity_fast_path() {
|
||||
let s = Session::new();
|
||||
for d in [rgb_doc(None, [0.8, 0.5, 0.3], SampleType::U8), rgb_doc(Some(Builtin::Srgb.profile()), [0.8, 0.5, 0.3], SampleType::U16)] {
|
||||
let cd = s.color.canvas_display(&d).unwrap();
|
||||
assert!(cd.is_identity(), "sRGB on an sRGB monitor costs nothing");
|
||||
assert!(s.color.canvas_lut(&d, 9).unwrap().is_none(), "no GPU LUT either");
|
||||
assert!(close(shown(&s, &d), [204.0, 127.5, 76.5], 0.6));
|
||||
}
|
||||
// Untagged gray (sGray), CMYK (shown in sRGB) and Lab composites are sRGB too.
|
||||
let g = Document::with_background("g", Size::new(4, 4), ColorMode::Grayscale, SampleType::U8, Color::gray(0.3));
|
||||
assert!(s.color.canvas_display(&g).unwrap().is_identity());
|
||||
assert!(s.color.canvas_display(&cmyk_doc(None, [0.1, 0.5, 0.5, 0.0])).unwrap().is_identity());
|
||||
// Cached: the same Arc for the same profiles.
|
||||
let d = rgb_doc(Some(Builtin::DisplayP3.profile()), [0.5; 3], SampleType::U8);
|
||||
assert!(Arc::ptr_eq(&s.color.canvas_display(&d).unwrap(), &s.color.canvas_display(&d).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_p3_matches_hand_computed_srgb() {
|
||||
// P3 (0.8, 0.5, 0.3): linear (0.6038, 0.2140, 0.0732) → linear sRGB through the published
|
||||
// P3 → sRGB matrix (0.6915, 0.1976, 0.0517) → encoded (216.7, 122.9, 64.3).
|
||||
let s = Session::new();
|
||||
let d = rgb_doc(Some(Builtin::DisplayP3.profile()), [0.8, 0.5, 0.3], SampleType::U8);
|
||||
let got = shown(&s, &d);
|
||||
assert!(close(got, [216.7, 122.9, 64.3], TOL), "{got:?}");
|
||||
assert!(far(got, [204.0, 127.5, 76.5], 10.0), "raw values would be desaturated: {got:?}");
|
||||
// The GPU LUT maps the same value there (the lattice point nearest to it, within interpolation error).
|
||||
let lut = s.color.canvas_lut(&d, 33).expect("lut").expect("not the identity");
|
||||
assert_eq!(lut.len(), 33 * 33 * 33 * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_gamut_and_gray_fixtures_display_correctly() {
|
||||
let s = Session::new();
|
||||
let cases: [(&Profile, [f32; 3]); 3] = [
|
||||
(Builtin::AdobeRgbCompat.profile(), [0.3, 0.7, 0.4]),
|
||||
(Builtin::ProPhotoCompat.profile(), [0.55, 0.35, 0.25]),
|
||||
(Builtin::DisplayP3.profile(), [0.2, 0.6, 0.8]),
|
||||
];
|
||||
for (p, v) in cases {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let d = rgb_doc(Some(p), v, depth);
|
||||
let got = shown(&s, &d);
|
||||
let want = reference(p, &v);
|
||||
assert!(close(got, want, TOL), "{} {depth:?}: {got:?} vs {want:?}", p.description);
|
||||
assert!(far(got, v.map(|x| x * 255.0), 4.0), "{}: shown raw {got:?}", p.description);
|
||||
}
|
||||
}
|
||||
// Gray Gamma 2.2: gray 0.1 is L = 0.1^2.2 = 0.00631 → sRGB 18.6 (raw would be 25.5).
|
||||
let d = gray_doc(Builtin::GrayGamma22.profile(), 0.1);
|
||||
assert!(!s.color.canvas_display(&d).unwrap().is_identity());
|
||||
let got = shown(&s, &d);
|
||||
assert!(close(got, [18.6; 3], TOL), "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_cmyk_profile_is_used_for_display_and_flat_export() {
|
||||
let s = Session::new();
|
||||
let p = test_cmyk();
|
||||
let ink = [0.1, 0.6, 0.7, 0.05];
|
||||
let want = reference(&p, &ink);
|
||||
let coated = reference(Builtin::CoatedCmyk.profile(), &ink);
|
||||
assert!(far(want, coated, 4.0), "the fixture differs from the default: {want:?} {coated:?}");
|
||||
let d = cmyk_doc(Some(&p), ink);
|
||||
let got = shown(&s, &d);
|
||||
assert!(close(got, want, TOL), "canvas {got:?} vs {want:?} (built-in would be {coated:?})");
|
||||
// Untagged documents keep the built-in coated CMYK.
|
||||
assert!(close(shown(&s, &cmyk_doc(None, ink)), coated, TOL));
|
||||
// Flat export of the (layered) document writes sRGB through the embedded profile.
|
||||
let r = photocraft_io::export(&d, "out.png", &photocraft_io::ExportOptions::default()).unwrap();
|
||||
let img = photocraft_codecs::decode(&r.bytes).unwrap().convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8);
|
||||
let px = &img.data()[(4 * 8 + 4) * 4..][..3];
|
||||
assert!(close([px[0] as f32, px[1] as f32, px[2] as f32], want, TOL), "export {px:?} vs {want:?}");
|
||||
// A single-layer CMYK document exported to a format without CMYK converts the same way.
|
||||
let mut one = d.clone();
|
||||
one.layers.truncate(1);
|
||||
let r = photocraft_io::export(&one, "out.png", &photocraft_io::ExportOptions::default()).unwrap();
|
||||
let img = photocraft_codecs::decode(&r.bytes).unwrap().convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8);
|
||||
let px = &img.data()[(4 * 8 + 4) * 4..][..3];
|
||||
assert!(close([px[0] as f32, px[1] as f32, px[2] as f32], want, TOL), "single-layer export {px:?} vs {want:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_documents_are_encoded_for_the_texture() {
|
||||
let s = Session::new();
|
||||
// Linear 0.2 is sRGB 0.4845 (123.5).
|
||||
let d = rgb_doc(Some(Builtin::LinearSrgb.profile()), [0.2, 0.2, 0.2], SampleType::F32);
|
||||
let cd = s.color.canvas_display(&d).unwrap();
|
||||
assert!(cd.encode_srgb && cd.transform.is_none(), "linear sRGB = sRGB primaries: encoding is all it takes");
|
||||
assert!(close(shown(&s, &d), [123.5; 3], 1.0), "{:?}", shown(&s, &d));
|
||||
let buf = photocraft_compose::flatten(&d);
|
||||
let tex = cd.texture_buffer(&buf);
|
||||
assert!((tex.px[0][0] - 0.4845).abs() < 1e-3);
|
||||
// An sRGB document is passed through untouched.
|
||||
let srgb = rgb_doc(None, [0.2; 3], SampleType::F32);
|
||||
let b2 = photocraft_compose::flatten(&srgb);
|
||||
assert!(matches!(s.color.canvas_display(&srgb).unwrap().texture_buffer(&b2), std::borrow::Cow::Borrowed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exr_round_trip_is_linear() {
|
||||
let mut s = Session::new();
|
||||
// An sRGB document exported to EXR is linearised; re-opened it is tagged linear sRGB and
|
||||
// displays as before.
|
||||
let d = rgb_doc(None, [0.5, 0.25, 0.75], SampleType::F32);
|
||||
let r = photocraft_io::export(&d, "x.exr", &photocraft_io::ExportOptions::default()).unwrap();
|
||||
let back = photocraft_io::import("x.exr", &r.bytes).unwrap().document;
|
||||
let icc = back.icc_profile.clone().expect("tagged");
|
||||
assert_eq!(Profile::parse(&icc).unwrap().content_hash(), Builtin::LinearSrgb.profile().content_hash());
|
||||
let v = back.layers[0].surface().unwrap().pixel(1, 1);
|
||||
assert!((v[0] - 0.214).abs() < 2e-3, "stored linear: {v:?}");
|
||||
assert!(close(shown(&s, &back), shown(&s, &d), 1.0));
|
||||
// Opening keeps it linear without a mismatch prompt.
|
||||
let (_, report) = s.open_document(back, None);
|
||||
assert_eq!(report["action"], "kept");
|
||||
assert!(report.get("ask").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_profile_setting() {
|
||||
let mut s = Session::new();
|
||||
let srgb = rgb_doc(None, [0.8, 0.5, 0.3], SampleType::U8);
|
||||
let p3 = rgb_doc(Some(Builtin::DisplayP3.profile()), [0.8, 0.5, 0.3], SampleType::U8);
|
||||
let r = s.execute("edit.colorSettings", json!({"monitorProfile": "display-p3"})).unwrap();
|
||||
assert_eq!(r["monitor"], "Display P3");
|
||||
// On a P3 monitor, P3 documents are the identity and sRGB ones are converted.
|
||||
assert!(s.color.canvas_display(&p3).unwrap().is_identity());
|
||||
assert!(!s.color.canvas_display(&srgb).unwrap().is_identity());
|
||||
let got = shown(&s, &srgb);
|
||||
let mut want = [0.0f32; 3];
|
||||
Transform::new(Builtin::Srgb.profile(), Builtin::DisplayP3.profile(), Intent::RelativeColorimetric, true).unwrap().eval(&[0.8, 0.5, 0.3], &mut want);
|
||||
assert!(close(got, want.map(|v| v * 255.0), TOL), "{got:?} vs {want:?}");
|
||||
// `auto` uses the platform's profile when supplied, else sRGB.
|
||||
s.execute("edit.colorSettings", json!({"monitorProfile": "auto"})).unwrap();
|
||||
assert!(s.color.canvas_display(&srgb).unwrap().is_identity());
|
||||
s.color.monitor_profile = Some(Builtin::DisplayP3.profile().to_bytes());
|
||||
assert_eq!(s.color.monitor().description, "Display P3");
|
||||
assert!(s.color.canvas_display(&p3).unwrap().is_identity());
|
||||
// Garbage platform bytes fall back to sRGB.
|
||||
s.color.monitor_profile = Some(Arc::new(vec![0u8; 16]));
|
||||
assert_eq!(s.color.monitor().content_hash(), Builtin::Srgb.profile().content_hash());
|
||||
// The signature follows the monitor.
|
||||
let a = s.color.display_signature(&p3);
|
||||
s.execute("edit.colorSettings", json!({"monitorProfile": "display-p3"})).unwrap();
|
||||
assert_ne!(a, s.color.display_signature(&p3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_profile_bad_params_fail_gracefully() {
|
||||
let mut s = Session::new();
|
||||
for bad in [
|
||||
json!({"monitorProfile": "coated-cmyk"}),
|
||||
json!({"monitorProfile": "sgray"}),
|
||||
json!({"monitorProfile": "no-such-profile"}),
|
||||
json!({"monitorProfile": "/nonexistent/x.icc"}),
|
||||
] {
|
||||
assert!(s.execute("edit.colorSettings", bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.color.settings.monitor_profile, "auto", "unchanged after errors");
|
||||
// Non-string values are ignored.
|
||||
assert!(s.execute("edit.colorSettings", json!({"monitorProfile": 7})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_colors_still_apply_on_top() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 8, "mode": "rgb", "depth": 8})).unwrap();
|
||||
s.execute("edit.assignProfile", json!({"profile": "display-p3"})).unwrap();
|
||||
let d = s.active().unwrap().doc.clone();
|
||||
let plain = s.color.canvas_lut(&d, 9).unwrap().unwrap();
|
||||
s.execute("view.proofColors", json!({"on": true})).unwrap();
|
||||
let proof = s.color.canvas_lut(&d, 9).unwrap().unwrap();
|
||||
assert_ne!(plain, proof, "the proof changes the LUT");
|
||||
let sig = s.color.display_signature(&d);
|
||||
s.execute("view.gamutWarning", json!({"on": true})).unwrap();
|
||||
assert_ne!(sig, s.color.display_signature(&d));
|
||||
}
|
||||
@@ -66,14 +66,8 @@ fn lift(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Clip {
|
||||
|
||||
/// Merged composite of the visible document as a surface in the document's format.
|
||||
fn merged_surface(doc: &Document) -> Surface {
|
||||
let buf = photocraft_compose::flatten(doc);
|
||||
let fmt = doc.pixel_format();
|
||||
let fmt = PixelFormat::new(fmt.mode, fmt.sample, true);
|
||||
let data: Vec<f32> = buf.px.iter().flat_map(|p| photocraft_raster::from_rgba(&fmt, *p)).collect();
|
||||
let mut s = Surface::new(fmt);
|
||||
s.write_region(doc.bounds(), &data);
|
||||
s.prune();
|
||||
s
|
||||
photocraft_compose::flatten_to_surface(doc, PixelFormat::new(fmt.mode, fmt.sample, true), None)
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, merged: bool) -> Result<Value> {
|
||||
@@ -298,7 +292,7 @@ fn auto_adjust(s: &mut Session, kind: &str) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let adj = Adjustment::Levels { master: LevelsChannel::default(), per_channel: per };
|
||||
let adj = Adjustment::Levels { master: LevelsChannel::default(), per_channel: per, space: Default::default(), black: LevelsChannel::default() };
|
||||
let label = match kind {
|
||||
"contrast" => "Auto Contrast",
|
||||
"color" => "Auto Color",
|
||||
@@ -306,8 +300,9 @@ fn auto_adjust(s: &mut Session, kind: &str) -> Result<Value> {
|
||||
};
|
||||
s.edit(label, |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
let mode = doc.mode;
|
||||
let surf = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or(EngineError::NoLayer(id))?;
|
||||
crate::pixels::adjust_surface(surf, &adj, sel.as_ref());
|
||||
crate::pixels::adjust_surface(surf, &adj, sel.as_ref(), mode);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
|
||||
@@ -759,7 +759,12 @@ fn preset_manager(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let new =
|
||||
p.get("newName").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "missing `newName`"))?.to_string();
|
||||
match kind.as_str() {
|
||||
"brushes" => s.tools.presets[i].name = new,
|
||||
"brushes" => {
|
||||
// A renamed built-in is the user's preset now (and persists as one).
|
||||
let b = &mut s.tools.presets[i];
|
||||
b.name = new;
|
||||
b.builtin = false;
|
||||
}
|
||||
"patterns" => s.patterns.items[i].name = new,
|
||||
_ => s.edit_state.custom_shapes[i].name = new,
|
||||
}
|
||||
@@ -779,6 +784,9 @@ fn preset_manager(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
other => return Err(bad(cmd, format!("unknown action `{other}` (list|rename|delete|move)"))),
|
||||
}
|
||||
if kind == "brushes" {
|
||||
s.brush_presets_changed();
|
||||
}
|
||||
Ok(json!({kind.clone(): preset_names(s, &kind)}))
|
||||
}
|
||||
|
||||
@@ -835,6 +843,7 @@ fn export_import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
nb += 1;
|
||||
}
|
||||
s.brush_presets_changed();
|
||||
}
|
||||
if want("customShapes") {
|
||||
for c in file.custom_shapes {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! The Eraser group's colour-aware tools: Magic Eraser (`paint.magicEraser`, a wand-style region
|
||||
//! erased to transparency) and Background Eraser (`paint.backgroundEraser`, a brush that erases
|
||||
//! colours similar to the one under its hotspot).
|
||||
//!
|
||||
//! Like Photoshop, both turn a Background layer into a normal layer ("Layer 0") first, since the
|
||||
//! Background can't hold transparency. The Magic Eraser paints the background colour on other
|
||||
//! layers with locked transparency; the Background Eraser refuses them. Both respect the selection,
|
||||
//! work in the layer's own colour model and depth, and are one undo step per click / stroke.
|
||||
|
||||
use photocraft_algo::selection as sel;
|
||||
use photocraft_doc::{Layer, LayerContent, LayerId};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_paint::Stroke;
|
||||
use photocraft_paint::bg_erase::{BgEraseSettings, apply_background_eraser};
|
||||
use photocraft_paint::replace::{Limits, Sampling};
|
||||
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 flag(p: &Value, k: &str, d: bool) -> bool {
|
||||
p.get(k).and_then(Value::as_bool).unwrap_or(d)
|
||||
}
|
||||
|
||||
/// A finite number param clamped to `range`, `default` when absent; non-numbers are an error.
|
||||
fn number(p: &Value, cmd: &str, k: &str, default: f64, range: std::ops::RangeInclusive<f64>) -> Result<f64> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(default),
|
||||
Some(v) => {
|
||||
v.as_f64().filter(|f| f.is_finite()).map(|f| f.clamp(*range.start(), *range.end())).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn enum_param<T: serde::de::DeserializeOwned>(p: &Value, cmd: &str, k: &str, default: T, allowed: &str) -> Result<T> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(default),
|
||||
Some(v) => serde_json::from_value(v.clone()).map_err(|_| bad(cmd, format!("`{k}` must be one of {allowed}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The active layer must hold pixels (the erasers don't work on type, shapes, smart objects…).
|
||||
fn has_pixel_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
let d = s.active().ok_or("no document open")?;
|
||||
let l = d.active_layer.and_then(|id| d.doc.layer(id)).ok_or("no active layer")?;
|
||||
if matches!(l.content, LayerContent::Raster(_)) { Ok(()) } else { Err(format!("active layer is a {} layer, not a pixel layer", l.content.kind_name())) }
|
||||
}
|
||||
|
||||
fn active_pixel_layer(s: &Session, cmd: &str) -> Result<LayerId> {
|
||||
has_pixel_layer(s).map_err(|m| bad(cmd, m))?;
|
||||
s.active().and_then(|d| d.active_layer).ok_or_else(|| bad(cmd, "no active layer"))
|
||||
}
|
||||
|
||||
fn is_background(l: &Layer) -> bool {
|
||||
l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, LayerContent::Raster(_))
|
||||
}
|
||||
|
||||
/// Photoshop converts the Background to a normal layer before erasing to transparency.
|
||||
fn unlock_background(l: &mut Layer) {
|
||||
if is_background(l) {
|
||||
l.name = "Layer 0".into();
|
||||
l.locks.transparency = false;
|
||||
l.locks.position = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn pixels_locked(l: &Layer) -> Option<EngineError> {
|
||||
(l.locks.pixels || l.locks.all).then(|| EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name)))
|
||||
}
|
||||
|
||||
fn damage(s: &mut Session, dmg: Rect, erased: bool) -> Value {
|
||||
if let Some(st) = s.active_mut()
|
||||
&& !dmg.is_empty()
|
||||
{
|
||||
st.last_damage = Some(dmg);
|
||||
}
|
||||
json!({ "erased": erased, "damage": [dmg.x0, dmg.y0, dmg.width(), dmg.height()] })
|
||||
}
|
||||
|
||||
/// `paint.magicEraser`: erase the wand region around `(x, y)`.
|
||||
fn magic_eraser(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "paint.magicEraser";
|
||||
let coord = |k: &str| -> Result<f64> {
|
||||
p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && v.abs() < 1e9).ok_or_else(|| bad(CMD, format!("missing or invalid `{k}`")))
|
||||
};
|
||||
let (x, y) = (coord("x")?.floor() as i32, coord("y")?.floor() as i32);
|
||||
let tolerance = number(p, CMD, "tolerance", 32.0, 0.0..=255.0)? as f32;
|
||||
let opacity = number(p, CMD, "opacity", 100.0, 1.0..=100.0)? as f32 / 100.0;
|
||||
let (anti_alias, contiguous, sample_all) = (flag(p, "antiAlias", true), flag(p, "contiguous", true), flag(p, "sampleAllLayers", false));
|
||||
let id = active_pixel_layer(s, CMD)?;
|
||||
{
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
if let Some(e) = d.doc.layer(id).and_then(pixels_locked) {
|
||||
return Err(e);
|
||||
}
|
||||
// Clicking off the canvas erases nothing (and records no history step).
|
||||
if !d.doc.bounds().contains(x, y) {
|
||||
return Ok(damage(s, Rect::EMPTY, false));
|
||||
}
|
||||
}
|
||||
let (area, img) = crate::selection_cmds::sample_rgba8(s, sample_all)?;
|
||||
let region = sel::wand_region(&img, area, (x, y), tolerance, contiguous, anti_alias);
|
||||
drop(img);
|
||||
let Some(region) = region else { return Ok(damage(s, Rect::EMPTY, false)) };
|
||||
let bg = s.tools.background;
|
||||
let dmg = s.edit("Magic Eraser", |doc, _| {
|
||||
let selection = doc.selection.clone();
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
unlock_background(l);
|
||||
let lock = l.locks.transparency;
|
||||
let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?;
|
||||
let fmt = surf.format();
|
||||
// Locked transparency: paint the background colour instead (Photoshop).
|
||||
let lock_color = lock.then(|| photocraft_raster::from_rgba(&fmt, [bg[0], bg[1], bg[2], 1.0]));
|
||||
Ok(photocraft_algo::erase::magic_erase(surf, ®ion, opacity, selection.as_ref(), lock_color.as_deref()))
|
||||
})?;
|
||||
Ok(damage(s, dmg, true))
|
||||
}
|
||||
|
||||
/// `paint.backgroundEraser`: a stroke erasing colours similar to the sampled one.
|
||||
fn background_eraser(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "paint.backgroundEraser";
|
||||
let pts = crate::brush_cmds::parse_points(p, CMD)?;
|
||||
if pts.iter().any(|q| !q.x.is_finite() || !q.y.is_finite() || q.x.abs() > 1e7 || q.y.abs() > 1e7) {
|
||||
return Err(bad(CMD, "`points` must be finite canvas coordinates"));
|
||||
}
|
||||
let brush = crate::brush_cmds::resolve_brush(s, p, CMD)?;
|
||||
if !(brush.size.is_finite() && brush.size <= 5000.0) {
|
||||
return Err(bad(CMD, "`size` must be at most 5000 px"));
|
||||
}
|
||||
let bs = BgEraseSettings {
|
||||
sampling: enum_param(p, CMD, "sampling", Sampling::Continuous, "continuous|once|backgroundSwatch")?,
|
||||
limits: enum_param(p, CMD, "limits", Limits::Contiguous, "discontiguous|contiguous|findEdges")?,
|
||||
tolerance: number(p, CMD, "tolerance", 50.0, 0.0..=100.0)? as f32 / 100.0,
|
||||
protect_foreground: flag(p, "protectForegroundColor", false),
|
||||
foreground: s.tools.foreground,
|
||||
background: s.tools.background,
|
||||
};
|
||||
let id = active_pixel_layer(s, CMD)?;
|
||||
{
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if let Some(e) = pixels_locked(l) {
|
||||
return Err(e);
|
||||
}
|
||||
if l.locks.transparency && !is_background(l) {
|
||||
return Err(EngineError::Other(format!("Could not use the Background Eraser because the transparency of layer \"{}\" is locked", l.name)));
|
||||
}
|
||||
}
|
||||
let stroke = Stroke { brush, points: pts };
|
||||
let dmg = s.edit("Background Eraser", |doc, _| {
|
||||
let selection = doc.selection.clone();
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
unlock_background(l);
|
||||
let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?;
|
||||
Ok(apply_background_eraser(surf, &stroke, &bs, selection.as_ref()))
|
||||
})?;
|
||||
Ok(damage(s, dmg, true))
|
||||
}
|
||||
|
||||
/// Eraser-group command specs.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec {
|
||||
id: "paint.magicEraser",
|
||||
label: "Magic Eraser",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"x":px,"y":px,"tolerance":0..255=32,"antiAlias":bool=true,"contiguous":bool=true,"sampleAllLayers":bool=false,"opacity":1..100=100}"##,
|
||||
enabled: has_pixel_layer,
|
||||
run: magic_eraser,
|
||||
journal: true,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "paint.backgroundEraser",
|
||||
label: "Background Eraser",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"brush":{…}?,"preset":name?,"sampling":"continuous|once|backgroundSwatch"="continuous","limits":"discontiguous|contiguous|findEdges"="contiguous","tolerance":0..100=50,"protectForegroundColor":bool=false,"seed":u64?}"##,
|
||||
enabled: has_pixel_layer,
|
||||
run: background_eraser,
|
||||
journal: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,277 @@
|
||||
use super::*;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
const MODES: [&str; 3] = ["rgb", "cmyk", "gray"];
|
||||
const DEPTHS: [u32; 3] = [8, 16, 32];
|
||||
|
||||
/// White document with a black 20×20 square at (10, 10) on its Background layer.
|
||||
fn session(mode: &str, depth: u32) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 48, "mode": mode, "depth": depth})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20, "antiAlias": false})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#000000"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn layer(s: &Session) -> &Layer {
|
||||
let d = s.active().unwrap();
|
||||
d.doc.layer(d.active_layer.unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn surface(s: &Session) -> &Surface {
|
||||
layer(s).surface().unwrap()
|
||||
}
|
||||
|
||||
fn alpha(s: &Session, x: i32, y: i32) -> f32 {
|
||||
surface(s).rgba(x, y)[3]
|
||||
}
|
||||
|
||||
fn history_len(s: &Session) -> usize {
|
||||
s.active().unwrap().history.past_len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magic_eraser_turns_the_background_into_a_layer_at_every_depth_and_mode() {
|
||||
for mode in MODES {
|
||||
for depth in DEPTHS {
|
||||
let mut s = session(mode, depth);
|
||||
let before = surface(&s).clone();
|
||||
let h = history_len(&s);
|
||||
let r = s.execute("paint.magicEraser", json!({"x": 2, "y": 2, "antiAlias": false})).unwrap();
|
||||
assert_eq!(r["erased"], true);
|
||||
assert_eq!(history_len(&s), h + 1, "one undo step {mode} {depth}");
|
||||
let l = layer(&s);
|
||||
assert_eq!(l.name, "Layer 0", "{mode} {depth}");
|
||||
assert!(!l.locks.transparency && !l.locks.position);
|
||||
assert_eq!(alpha(&s, 2, 2), 0.0, "white erased {mode} {depth}");
|
||||
assert_eq!(alpha(&s, 60, 40), 0.0, "contiguous white erased {mode} {depth}");
|
||||
assert_eq!(alpha(&s, 20, 20), 1.0, "black square kept {mode} {depth}");
|
||||
// The square's colour is untouched in the document's own channels.
|
||||
assert_eq!(surface(&s).pixel(20, 20), before.pixel(20, 20));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(layer(&s).name, "Background");
|
||||
assert_eq!(surface(&s), &before);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magic_eraser_options() {
|
||||
// Non-contiguous: a second, separate black square goes too.
|
||||
let mut s = session("rgb", 8);
|
||||
s.execute("select.rect", json!({"x": 40, "y": 10, "width": 10, "height": 10, "antiAlias": false})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#000000"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let mut a = s;
|
||||
a.execute("paint.magicEraser", json!({"x": 20, "y": 20, "contiguous": true, "antiAlias": false})).unwrap();
|
||||
assert_eq!(alpha(&a, 20, 20), 0.0);
|
||||
assert_eq!(alpha(&a, 45, 15), 1.0, "contiguous keeps the other square");
|
||||
a.execute("edit.undo", json!({})).unwrap();
|
||||
a.execute("paint.magicEraser", json!({"x": 20, "y": 20, "contiguous": false, "antiAlias": false})).unwrap();
|
||||
assert_eq!(alpha(&a, 45, 15), 0.0, "non-contiguous erases every match");
|
||||
assert_eq!(alpha(&a, 2, 2), 1.0);
|
||||
|
||||
// Opacity 50: half erased.
|
||||
let mut s = session("rgb", 16);
|
||||
s.execute("paint.magicEraser", json!({"x": 2, "y": 2, "opacity": 50, "antiAlias": false})).unwrap();
|
||||
assert!((alpha(&s, 2, 2) - 0.5).abs() < 0.01);
|
||||
|
||||
// Tolerance: a near-white grey is erased only within tolerance.
|
||||
for (tol, gone) in [(0.0, false), (40.0, true)] {
|
||||
let mut s = session("rgb", 8);
|
||||
s.execute("select.rect", json!({"x": 0, "y": 40, "width": 64, "height": 8, "antiAlias": false})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#e6e6e6"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.execute("paint.magicEraser", json!({"x": 2, "y": 2, "tolerance": tol, "antiAlias": false})).unwrap();
|
||||
assert_eq!(alpha(&s, 5, 44) == 0.0, gone, "tolerance {tol}");
|
||||
}
|
||||
|
||||
// The selection limits the erased area.
|
||||
let mut s = session("rgb", 8);
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 32, "height": 48, "antiAlias": false})).unwrap();
|
||||
s.execute("paint.magicEraser", json!({"x": 2, "y": 2, "antiAlias": false})).unwrap();
|
||||
assert_eq!(alpha(&s, 2, 2), 0.0);
|
||||
assert_eq!(alpha(&s, 50, 2), 1.0, "outside the selection kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magic_eraser_samples_all_layers_and_paints_background_under_a_transparency_lock() {
|
||||
// A half-opaque red band over the Background: the composite band is split by the black square,
|
||||
// the layer's own band isn't.
|
||||
let band = |s: &mut Session| {
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 0, "y": 14, "width": 64, "height": 4, "antiAlias": false})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
s.edit("opacity", |doc, _| {
|
||||
doc.layer_mut(id).unwrap().opacity = 0.5;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
};
|
||||
let mut s = session("rgb", 8);
|
||||
band(&mut s);
|
||||
s.execute("paint.magicEraser", json!({"x": 2, "y": 15, "tolerance": 0, "sampleAllLayers": true, "antiAlias": false})).unwrap();
|
||||
assert_eq!(alpha(&s, 2, 15), 0.0);
|
||||
assert_eq!(alpha(&s, 50, 15), 1.0, "the composite band is split by the black square");
|
||||
let mut s = session("rgb", 8);
|
||||
band(&mut s);
|
||||
s.execute("paint.magicEraser", json!({"x": 2, "y": 15, "tolerance": 0, "antiAlias": false})).unwrap();
|
||||
assert_eq!(alpha(&s, 50, 15), 0.0, "the layer's own band is one region");
|
||||
|
||||
// A normal layer with locked transparency gets the background colour instead.
|
||||
let mut s = session("rgb", 8);
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("tools.setColors", json!({"background": "#00ff00"})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
s.edit("lock", |doc, _| {
|
||||
doc.layer_mut(id).unwrap().locks.transparency = true;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("paint.magicEraser", json!({"x": 2, "y": 2, "antiAlias": false})).unwrap();
|
||||
assert_eq!(surface(&s).rgba(2, 2), [0.0, 1.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magic_eraser_fails_gracefully() {
|
||||
let mut s = session("rgb", 8);
|
||||
for p in [json!({}), json!({"x": 1}), json!({"x": "a", "y": 1}), json!({"x": 1, "y": 1, "tolerance": "high"}), json!({"x": 1e300, "y": 1})] {
|
||||
assert!(s.execute("paint.magicEraser", p.clone()).is_err(), "{p}");
|
||||
}
|
||||
// Off the canvas: nothing erased, no history step.
|
||||
let h = history_len(&s);
|
||||
let r = s.execute("paint.magicEraser", json!({"x": -5, "y": 400})).unwrap();
|
||||
assert_eq!(r["erased"], false);
|
||||
assert_eq!(history_len(&s), h);
|
||||
// Out-of-range numbers clamp.
|
||||
s.execute("paint.magicEraser", json!({"x": 2, "y": 2, "tolerance": 9999, "opacity": -3})).unwrap();
|
||||
// Locked pixels and non-pixel layers are refused.
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
s.edit("lock", |doc, _| {
|
||||
doc.layer_mut(id).unwrap().locks.pixels = true;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(s.execute("paint.magicEraser", json!({"x": 2, "y": 2})).is_err());
|
||||
let mut e = Session::new();
|
||||
assert!(e.execute("paint.magicEraser", json!({"x": 2, "y": 2})).is_err(), "no document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_eraser_erases_the_sampled_colour_at_every_depth_and_mode() {
|
||||
for mode in MODES {
|
||||
for depth in DEPTHS {
|
||||
let mut s = session(mode, depth);
|
||||
let h = history_len(&s);
|
||||
// Start on white, drag across the black square: only white goes.
|
||||
s.execute(
|
||||
"paint.backgroundEraser",
|
||||
json!({"points": [[4, 20], [40, 20]], "size": 16, "hardness": 1, "sampling": "once", "limits": "discontiguous", "tolerance": 10}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(history_len(&s), h + 1);
|
||||
assert_eq!(layer(&s).name, "Layer 0", "{mode} {depth}");
|
||||
assert_eq!(alpha(&s, 6, 20), 0.0, "white under the brush erased {mode} {depth}");
|
||||
assert_eq!(alpha(&s, 20, 20), 1.0, "black kept {mode} {depth}");
|
||||
assert_eq!(alpha(&s, 36, 20), 0.0, "white past the square erased {mode} {depth}");
|
||||
assert_eq!(alpha(&s, 6, 40), 1.0, "outside the brush kept {mode} {depth}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_eraser_options() {
|
||||
// Continuous sampling erases the black square once the hotspot is over it.
|
||||
let mut s = session("rgb", 8);
|
||||
s.execute(
|
||||
"paint.backgroundEraser",
|
||||
json!({"points": [[4, 20], [40, 20]], "size": 16, "hardness": 1, "sampling": "continuous", "limits": "discontiguous", "tolerance": 10}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(alpha(&s, 20, 20), 0.0);
|
||||
// Protect Foreground Color (black) keeps the square even under continuous sampling.
|
||||
let mut s = session("rgb", 8);
|
||||
s.execute("tools.setColors", json!({"foreground": "#000000"})).unwrap();
|
||||
s.execute(
|
||||
"paint.backgroundEraser",
|
||||
json!({"points": [[4, 20], [40, 20]], "size": 16, "hardness": 1, "limits": "discontiguous", "tolerance": 10, "protectForegroundColor": true}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(alpha(&s, 20, 20), 1.0);
|
||||
assert_eq!(alpha(&s, 6, 20), 0.0);
|
||||
// Background swatch: with a black swatch only the square goes.
|
||||
let mut s = session("rgb", 8);
|
||||
s.execute("tools.setColors", json!({"background": "#000000"})).unwrap();
|
||||
s.execute(
|
||||
"paint.backgroundEraser",
|
||||
json!({"points": [[4, 20], [40, 20]], "size": 16, "hardness": 1, "sampling": "backgroundSwatch", "limits": "discontiguous", "tolerance": 10}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(alpha(&s, 20, 20), 0.0);
|
||||
assert_eq!(alpha(&s, 6, 20), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_eraser_fails_gracefully() {
|
||||
let mut s = session("rgb", 8);
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"points": []}),
|
||||
json!({"points": "x"}),
|
||||
json!({"points": [[1, 1]], "sampling": "sometimes"}),
|
||||
json!({"points": [[1, 1]], "limits": 3}),
|
||||
json!({"points": [[1, 1]], "tolerance": "lots"}),
|
||||
json!({"points": [[1, 1]], "size": 1e9}),
|
||||
json!({"points": [[1e300, 1]]}),
|
||||
] {
|
||||
assert!(s.execute("paint.backgroundEraser", p.clone()).is_err(), "{p}");
|
||||
}
|
||||
// A normal layer with locked transparency is refused.
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
s.edit("lock", |doc, _| {
|
||||
doc.layer_mut(id).unwrap().locks.transparency = true;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(s.execute("paint.backgroundEraser", json!({"points": [[4, 4]]})).is_err());
|
||||
}
|
||||
|
||||
/// `cargo test --release -p photocraft-engine --lib bench_magic_eraser -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_magic_eraser_24mp() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 6000, "height": 4000})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 1500, "y": 1000, "width": 3000, "height": 2000, "ellipse": true})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#204080"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
for (label, p) in [
|
||||
("contiguous, background (~19 MP)", json!({"x": 10, "y": 10})),
|
||||
("non-contiguous", json!({"x": 10, "y": 10, "contiguous": false})),
|
||||
("contiguous, ellipse (~4.7 MP)", json!({"x": 3000, "y": 2000})),
|
||||
] {
|
||||
let mut best = f64::MAX;
|
||||
for _ in 0..3 {
|
||||
let t0 = std::time::Instant::now();
|
||||
s.execute("paint.magicEraser", p.clone()).unwrap();
|
||||
best = best.min(t0.elapsed().as_secs_f64() * 1000.0);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
println!("magic eraser 6000x4000 {label}: {best:.1} ms");
|
||||
}
|
||||
// Phase breakdown.
|
||||
let t0 = std::time::Instant::now();
|
||||
let (area, img) = crate::selection_cmds::sample_rgba8(&s, false).unwrap();
|
||||
let t1 = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
let region = sel::wand_region(&img, area, (10, 10), 32.0, true, true).unwrap();
|
||||
let t2 = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
let mut surf = surface(&s).clone();
|
||||
photocraft_algo::erase::magic_erase(&mut surf, ®ion, 1.0, None, None);
|
||||
let t3 = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
println!("phases: sample {t1:.1} ms, wand {:.1} ms, erase {:.1} ms", t2 - t1, t3 - t2);
|
||||
}
|
||||
@@ -114,8 +114,10 @@ pub(crate) fn list_images(dir: &str) -> Result<Vec<String>> {
|
||||
}
|
||||
|
||||
/// Extensions the batch commands pick up from a folder.
|
||||
const OPENABLE: &[&str] =
|
||||
&["psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", "ppm", "pgm"];
|
||||
const OPENABLE: &[&str] = &[
|
||||
"psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", "ppm", "pgm", "dng", "cr2",
|
||||
"nef", "nrw", "arw", "pef",
|
||||
];
|
||||
|
||||
pub(crate) fn file_name(path: &str) -> String {
|
||||
path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
|
||||
@@ -197,7 +199,7 @@ pub(crate) fn buffer_surface(buf: &photocraft_compose::Buffer, fmt: PixelFormat)
|
||||
|
||||
/// The flattened image of `doc` as a surface in `fmt`, placed at the origin.
|
||||
pub(crate) fn flattened(doc: &Document, fmt: PixelFormat) -> Surface {
|
||||
buffer_surface(&photocraft_compose::flatten(doc), fmt)
|
||||
photocraft_compose::flatten_to_surface(doc, fmt, None)
|
||||
}
|
||||
|
||||
// ---------- close / revert / save a copy / open as ----------
|
||||
@@ -265,11 +267,13 @@ pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<&
|
||||
Some(ext) => format!("{}.{}", stem(name), ext.trim_start_matches('.')),
|
||||
None => name.to_string(),
|
||||
};
|
||||
let mut doc = import(&decode_name, bytes)?;
|
||||
let r = photocraft_io::import(&decode_name, bytes).map_err(|e| EngineError::Other(format!("{decode_name}: {e}")))?;
|
||||
let mut doc = r.document;
|
||||
doc.name = file_name(name);
|
||||
// Color Settings policies (preserve / convert / discard the embedded profile).
|
||||
let (i, color) = s.open_document(doc, path);
|
||||
Ok(json!({"document": i, "color": color}))
|
||||
// Import notes (e.g. how a camera raw was developed, or that only its preview opened).
|
||||
Ok(json!({"document": i, "color": color, "warnings": r.warnings}))
|
||||
}
|
||||
|
||||
fn open_as(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -48,6 +48,23 @@ fn close_all_and_close_others() {
|
||||
assert!(!s.is_enabled("file.closeAll"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_raw_opens_as_16_bit_with_notes() {
|
||||
use photocraft_raw::testgen::{DngSpec, mosaic, scene};
|
||||
let (w, h) = (24, 16);
|
||||
let mut spec = DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535));
|
||||
spec.as_shot_neutral = Some([0.5, 1.0, 0.7]);
|
||||
let mut s = Session::new();
|
||||
let r = open_bytes_as(&mut s, "IMG_0001.dng", &spec.build(), None, None).unwrap();
|
||||
assert!(r["warnings"].as_array().is_some_and(|w| w.iter().any(|m| m.as_str().is_some_and(|m| m.contains("DNG")))), "{r}");
|
||||
assert_eq!((doc(&s).size.width, doc(&s).size.height), (24, 16));
|
||||
assert_eq!(doc(&s).depth, photocraft_color::SampleType::U16);
|
||||
// Damaged raw data is an error, not a crash.
|
||||
let mut bad = spec.build();
|
||||
bad.truncate(bad.len() / 2);
|
||||
assert!(open_bytes_as(&mut s, "bad.dng", &bad, None, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revert_reloads_as_one_undoable_step() {
|
||||
let dir = tmp("revert");
|
||||
|
||||
@@ -149,6 +149,10 @@ pub fn apply_filter_to_surface(
|
||||
if let Some(out) = crate::lens_cmds::apply_to_surface(id, params, surf, canvas) {
|
||||
return Some(out);
|
||||
}
|
||||
// WebAssembly plug-in smart filters (plugin_cmds).
|
||||
if id == crate::plugin_cmds::RUN {
|
||||
return crate::plugin_cmds::apply_to_surface(id, params, surf, selection, canvas);
|
||||
}
|
||||
let fp = params_for(id, params)?;
|
||||
let sel_bounds = selection.map(photocraft_raster::Surface::content_bounds);
|
||||
let content = surf.content_bounds();
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Layers panel: showing or hiding a layer's effects list under its row (the triangle beside
|
||||
//! the fx badge, #144). View state ([`crate::DocState::fx_collapsed`]): no history step, and a
|
||||
//! clean document stays clean.
|
||||
|
||||
use photocraft_doc::LayerId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const ID: &str = "layer.setEffectsExpanded";
|
||||
|
||||
fn bad(msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: ID.into(), msg: msg.into() }
|
||||
}
|
||||
|
||||
fn has_effects(s: &Session) -> std::result::Result<(), String> {
|
||||
let d = s.active().ok_or("no document open")?;
|
||||
if d.doc.walk().iter().any(|(_, _, l)| !l.effects.items.is_empty()) { Ok(()) } else { Err("no layer has effects".into()) }
|
||||
}
|
||||
|
||||
fn opt_bool(p: &Value, key: &str) -> Result<Option<bool>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_bool().map(Some).ok_or_else(|| bad(format!("`{key}` must be true or false"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `{"layer":id?,"expanded":bool?,"all":bool?}`: show or hide a layer's effects list (no
|
||||
/// `expanded`: toggle). With `all`, every layer with effects takes the new state (⌥-click).
|
||||
fn set_expanded(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let want = opt_bool(p, "expanded")?;
|
||||
let all = opt_bool(p, "all")?.unwrap_or(false);
|
||||
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
let layer = match p.get("layer") {
|
||||
None | Some(Value::Null) if all => None,
|
||||
None | Some(Value::Null) => Some(st.active_layer.ok_or_else(|| bad("no layer given and no active layer"))?),
|
||||
Some(v) => Some(LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be a layer id"))?)),
|
||||
};
|
||||
let with_fx: Vec<LayerId> = st.doc.walk().iter().filter(|(_, _, l)| !l.effects.items.is_empty()).map(|(_, _, l)| l.id).collect();
|
||||
let current = match layer {
|
||||
Some(id) => {
|
||||
let l = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if l.effects.items.is_empty() {
|
||||
return Err(bad(format!("layer {} has no effects", id.0)));
|
||||
}
|
||||
!st.fx_collapsed.contains(&id)
|
||||
}
|
||||
None => with_fx.iter().any(|id| !st.fx_collapsed.contains(id)),
|
||||
};
|
||||
let expanded = want.unwrap_or(!current);
|
||||
let targets = if all { with_fx } else { layer.into_iter().collect() };
|
||||
// Forget ids no longer in the document so the list can't grow without bound.
|
||||
let doc = st.doc.clone();
|
||||
st.fx_collapsed.retain(|id| doc.layer(*id).is_some() && !targets.contains(id));
|
||||
if !expanded {
|
||||
st.fx_collapsed.extend(targets.iter().copied());
|
||||
}
|
||||
Ok(json!({"expanded": expanded, "layers": targets.len()}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![CommandSpec {
|
||||
id: ID,
|
||||
label: "Expand/Collapse Effects",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"layer":id?,"expanded":bool?,"all":bool?} (no expanded: toggle; all: every layer with effects; view state, not an undo step)"##,
|
||||
enabled: has_effects,
|
||||
run: set_expanded,
|
||||
journal: false,
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn session() -> (Session, u64, u64) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 32, "height": 32})).unwrap();
|
||||
let a = s.execute("layer.new.layer", json!({"name": "a"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
|
||||
s.execute("layer.layerStyle.dropShadow", json!({"layer": a})).unwrap();
|
||||
let b = s.execute("layer.new.layer", json!({"name": "b"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#00ff00"})).unwrap();
|
||||
s.execute("layer.layerStyle.stroke", json!({})).unwrap();
|
||||
(s, a, b)
|
||||
}
|
||||
|
||||
fn open(s: &Session, id: u64) -> bool {
|
||||
!s.active().unwrap().fx_collapsed.contains(&LayerId(id))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggles_one_layer_or_all_without_history() {
|
||||
let (mut s, a, b) = session();
|
||||
let st = s.active_mut().unwrap();
|
||||
st.saved_revision = st.revision;
|
||||
let steps = s.active().unwrap().history.entries().len();
|
||||
assert!(open(&s, a) && open(&s, b), "effects lists start open");
|
||||
assert_eq!(s.execute(ID, json!({"layer": a})).unwrap()["expanded"], false);
|
||||
assert!(!open(&s, a) && open(&s, b));
|
||||
assert_eq!(s.active().unwrap().history.entries().len(), steps, "no history step");
|
||||
assert_eq!(s.active().unwrap().saved_revision, s.active().unwrap().revision, "still clean");
|
||||
s.execute(ID, json!({"layer": a, "all": true})).unwrap();
|
||||
assert!(open(&s, a) && open(&s, b), "toggling an open-and-closed set from a closed layer opens all");
|
||||
s.execute(ID, json!({"all": true, "expanded": false})).unwrap();
|
||||
assert!(!open(&s, a) && !open(&s, b));
|
||||
s.execute(ID, json!({"layer": b, "expanded": true})).unwrap();
|
||||
assert!(!open(&s, a) && open(&s, b));
|
||||
assert_eq!(s.active().unwrap().fx_collapsed.len(), 1, "no duplicates");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_fail_gracefully() {
|
||||
let (mut s, _, _) = session();
|
||||
let bg = s.active().unwrap().doc.layers[0].id.0;
|
||||
for p in [
|
||||
json!({"layer": bg}),
|
||||
json!({"layer": 9999}),
|
||||
json!({"layer": "x"}),
|
||||
json!({"layer": -1}),
|
||||
json!({"expanded": "yes", "all": true}),
|
||||
json!({"all": 3}),
|
||||
] {
|
||||
assert!(s.execute(ID, p.clone()).is_err(), "{p}");
|
||||
}
|
||||
let mut empty = Session::new();
|
||||
assert!(empty.execute(ID, json!({})).is_err());
|
||||
empty.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
assert!(empty.execute(ID, json!({"all": true})).is_err(), "no effects: disabled");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Layers panel disclosure state: expanding and collapsing layer groups (#126).
|
||||
//!
|
||||
//! A group's open/closed state is document data ([`photocraft_doc::Group::expanded`], the PSD
|
||||
//! section divider's open/closed folder type, saved in `.pcraft` too) but, as in Photoshop,
|
||||
//! toggling it is a view change: no history step, and a clean document stays clean.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_doc::{Layer, LayerContent, LayerId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const ID: &str = "layer.setExpanded";
|
||||
|
||||
fn bad(msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: ID.into(), msg: msg.into() }
|
||||
}
|
||||
|
||||
fn has_group(s: &Session) -> std::result::Result<(), String> {
|
||||
let d = s.active().ok_or("no document open")?;
|
||||
if d.doc.walk().iter().any(|(_, _, l)| l.is_group()) { Ok(()) } else { Err("the document has no layer groups".into()) }
|
||||
}
|
||||
|
||||
fn opt_bool(p: &Value, key: &str) -> Result<Option<bool>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_bool().map(Some).ok_or_else(|| bad(format!("`{key}` must be true or false"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_all(layers: &mut [Layer], expanded: bool) -> usize {
|
||||
let mut n = 0;
|
||||
for l in layers {
|
||||
if let LayerContent::Group(g) = &mut l.content {
|
||||
g.expanded = expanded;
|
||||
n += 1 + set_all(&mut g.children, expanded);
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// `{"layer":id?,"expanded":bool?,"all":bool?}`: open or close a group (no `expanded`: toggle).
|
||||
/// With `all`, every group in the document takes the new state (Photoshop's ⌥-click on a
|
||||
/// disclosure triangle); without a layer, `all` toggles from "any group open".
|
||||
fn set_expanded(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let want = opt_bool(p, "expanded")?;
|
||||
let all = opt_bool(p, "all")?.unwrap_or(false);
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let layer = match p.get("layer") {
|
||||
None | Some(Value::Null) if all => None,
|
||||
None | Some(Value::Null) => Some(st.active_layer.ok_or_else(|| bad("no layer given and no active layer"))?),
|
||||
Some(v) => Some(LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be a layer id"))?)),
|
||||
};
|
||||
let current = match layer {
|
||||
Some(id) => match &st.doc.layer(id).ok_or(EngineError::NoLayer(id))?.content {
|
||||
LayerContent::Group(g) => g.expanded,
|
||||
_ => return Err(bad(format!("layer {} is not a group", id.0))),
|
||||
},
|
||||
None => st.doc.walk().iter().any(|(_, _, l)| matches!(&l.content, LayerContent::Group(g) if g.expanded)),
|
||||
};
|
||||
let expanded = want.unwrap_or(!current);
|
||||
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
// Copy-on-write: history snapshots keep their own state (layers share tiles, so this is cheap).
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
let changed = if all {
|
||||
set_all(&mut doc.layers, expanded)
|
||||
} else {
|
||||
let id = layer.ok_or_else(|| bad("no layer"))?;
|
||||
match doc.layer_mut(id).map(|l| &mut l.content) {
|
||||
Some(LayerContent::Group(g)) => {
|
||||
g.expanded = expanded;
|
||||
1
|
||||
}
|
||||
_ => return Err(EngineError::NoLayer(id)),
|
||||
}
|
||||
};
|
||||
Ok(json!({"expanded": expanded, "groups": changed}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![CommandSpec {
|
||||
id: ID,
|
||||
label: "Expand/Collapse Group",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"layer":id?,"expanded":bool?,"all":bool?} (no expanded: toggle; all: every group; not an undo step)"##,
|
||||
enabled: has_group,
|
||||
run: set_expanded,
|
||||
journal: false,
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn session() -> (Session, u64, u64) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 32, "height": 32})).unwrap();
|
||||
s.execute("layer.new.layer", json!({"name": "deep"})).unwrap();
|
||||
let inner = s.execute("layer.groupLayers", json!({"name": "inner"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let outer = s.execute("layer.groupLayers", json!({"layer": inner, "name": "outer"})).unwrap()["layer"].as_u64().unwrap();
|
||||
(s, inner, outer)
|
||||
}
|
||||
|
||||
fn expanded(s: &Session, id: u64) -> bool {
|
||||
matches!(&s.active().unwrap().doc.layer(LayerId(id)).unwrap().content, LayerContent::Group(g) if g.expanded)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggles_one_group_without_history_or_dirtying() {
|
||||
let (mut s, inner, outer) = session();
|
||||
let st = s.active_mut().unwrap();
|
||||
st.saved_revision = st.revision;
|
||||
let undo_steps = s.active().unwrap().history.entries().len();
|
||||
assert!(expanded(&s, inner) && expanded(&s, outer));
|
||||
assert_eq!(s.execute(ID, json!({"layer": inner})).unwrap()["expanded"], false);
|
||||
assert!(!expanded(&s, inner) && expanded(&s, outer));
|
||||
assert_eq!(s.active().unwrap().history.entries().len(), undo_steps, "no history step");
|
||||
assert_eq!(s.active().unwrap().saved_revision, s.active().unwrap().revision, "still clean");
|
||||
s.execute(ID, json!({"layer": inner, "expanded": true})).unwrap();
|
||||
assert!(expanded(&s, inner));
|
||||
// The inspect JSON reports it.
|
||||
s.execute(ID, json!({"layer": outer, "expanded": false})).unwrap();
|
||||
assert_eq!(s.execute("document.inspect", json!({})).unwrap()["layers"][0]["expanded"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_sets_every_group() {
|
||||
let (mut s, inner, outer) = session();
|
||||
s.execute(ID, json!({"layer": outer, "all": true})).unwrap();
|
||||
assert!(!expanded(&s, inner) && !expanded(&s, outer));
|
||||
assert_eq!(s.execute(ID, json!({"all": true})).unwrap()["groups"], 2);
|
||||
assert!(expanded(&s, inner) && expanded(&s, outer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_fail_gracefully() {
|
||||
let (mut s, _, _) = session();
|
||||
let bg = s.active().unwrap().doc.layers[0].id.0;
|
||||
for p in [
|
||||
json!({"layer": bg}),
|
||||
json!({"layer": 9999}),
|
||||
json!({"layer": "x"}),
|
||||
json!({"layer": -1}),
|
||||
json!({"expanded": "yes", "all": true}),
|
||||
json!({"all": 3}),
|
||||
] {
|
||||
assert!(s.execute(ID, p.clone()).is_err(), "{p}");
|
||||
}
|
||||
let mut empty = Session::new();
|
||||
assert!(empty.execute(ID, json!({})).is_err());
|
||||
empty.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
assert!(empty.execute(ID, json!({"all": true})).is_err(), "no groups: disabled");
|
||||
}
|
||||
}
|
||||
@@ -129,25 +129,17 @@ fn image_size(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
*surf = resize_surface(surf, sx, sy, if is_mask { Resample::Bilinear } else { filter });
|
||||
});
|
||||
let k = ((sx + sy) / 2.0) as f32;
|
||||
for_each_layer(&mut doc.layers, &mut |l| {
|
||||
scale_effects(&mut l.effects, k);
|
||||
if let Some(r) = &mut l.effects.reference {
|
||||
*r = (r.0 * sx, r.1 * sy);
|
||||
}
|
||||
});
|
||||
for_each_layer(&mut doc.layers, &mut |l| scale_effects(&mut l.effects, k));
|
||||
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
|
||||
ch.surface = resize_surface(&ch.surface, sx, sy, Resample::Bilinear);
|
||||
}
|
||||
if let Some(sel) = &doc.selection {
|
||||
doc.selection = Some(resize_surface(sel, sx, sy, Resample::Bilinear));
|
||||
}
|
||||
for g in &mut doc.guides.horizontal {
|
||||
*g *= sy as f32;
|
||||
}
|
||||
for g in &mut doc.guides.vertical {
|
||||
*g *= sx as f32;
|
||||
}
|
||||
// Vector geometry, guides and marks scale with the pixels; vectors re-render sharp.
|
||||
crate::canvas_geom::transform_geometry(doc, &photocraft_geom::Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] });
|
||||
doc.size = Size::new(nw, nh);
|
||||
crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::All);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "width": nw, "height": nh }))
|
||||
@@ -159,29 +151,15 @@ fn translate_doc(doc: &mut Document, dx: i32, dy: i32) {
|
||||
return;
|
||||
}
|
||||
for_each_surface(&mut doc.layers, true, &mut |surf, _| *surf = translate_surface(surf, dx, dy));
|
||||
fn shift_fx_reference(layers: &mut [photocraft_doc::Layer], dx: f64, dy: f64) {
|
||||
for l in layers {
|
||||
if let Some(r) = &mut l.effects.reference {
|
||||
*r = (r.0 + dx, r.1 + dy);
|
||||
}
|
||||
if let LayerContent::Group(g) = &mut l.content {
|
||||
shift_fx_reference(&mut g.children, dx, dy);
|
||||
}
|
||||
}
|
||||
}
|
||||
shift_fx_reference(&mut doc.layers, f64::from(dx), f64::from(dy));
|
||||
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
|
||||
ch.surface = translate_surface(&ch.surface, dx, dy);
|
||||
}
|
||||
if let Some(sel) = &doc.selection {
|
||||
doc.selection = Some(translate_surface(sel, dx, dy));
|
||||
}
|
||||
for g in &mut doc.guides.horizontal {
|
||||
*g += dy as f32;
|
||||
}
|
||||
for g in &mut doc.guides.vertical {
|
||||
*g += dx as f32;
|
||||
}
|
||||
// Type, shapes, smart objects, vector masks, paths, guides, slices, notes… (caches above
|
||||
// are already translated exactly, so nothing needs re-rendering for the move itself).
|
||||
crate::canvas_geom::transform_geometry(doc, &photocraft_geom::Affine::translate(f64::from(dx), f64::from(dy)));
|
||||
}
|
||||
|
||||
/// Crops the document to `r` (in current document coordinates).
|
||||
@@ -191,6 +169,7 @@ fn crop_doc(doc: &mut Document, r: Rect, delete_pixels: bool) {
|
||||
}
|
||||
translate_doc(doc, -r.x0, -r.y0);
|
||||
doc.size = Size::new(r.width(), r.height());
|
||||
crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::Shapes);
|
||||
}
|
||||
|
||||
fn anchor_factors(a: &str) -> (f64, f64) {
|
||||
@@ -242,6 +221,7 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Canvas Size", |doc, _| {
|
||||
translate_doc(doc, dx, dy);
|
||||
doc.size = Size::new(nw, nh);
|
||||
crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::Shapes);
|
||||
let canvas = doc.bounds();
|
||||
let old = Rect::from_xywh(dx, dy, ow as u32, oh as u32);
|
||||
// The locked Background layer is extended with the extension colour.
|
||||
@@ -334,10 +314,12 @@ fn convert_mode(s: &mut Session, mode: ColorMode, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Image → Mode → 8/16/32 Bits/Channel.
|
||||
fn convert_depth(s: &mut Session, depth: SampleType) -> Result<Value> {
|
||||
let current = s.active().ok_or(EngineError::NoDocument)?.doc.depth;
|
||||
if current == depth {
|
||||
// History::record clears redo even when an edit closure leaves the document unchanged.
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
s.edit("Bit Depth", |doc, _| {
|
||||
if doc.depth == depth {
|
||||
return Ok(());
|
||||
}
|
||||
for_each_surface(&mut doc.layers, true, &mut |surf, _| {
|
||||
let f = surf.format().with_sample(depth);
|
||||
*surf = surf.convert(f);
|
||||
@@ -570,6 +552,11 @@ mod tests {
|
||||
assert_eq!((d.mode, d.depth), (ColorMode::Rgb, SampleType::U8));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(doc(&s).depth, SampleType::F32);
|
||||
let revision = s.active().unwrap().revision;
|
||||
s.execute("image.mode.bits32", json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().revision, revision, "an unchanged bit depth must preserve the history branch");
|
||||
assert!(s.redo(), "selecting the current depth must not discard the undone conversion");
|
||||
assert_eq!(doc(&s).depth, SampleType::U8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -85,6 +85,7 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId]) -> Value {
|
||||
match &l.content {
|
||||
LayerContent::Group(g) => {
|
||||
v["children"] = Value::Array(g.children.iter().rev().map(|c| layer_sel(c, selected)).collect());
|
||||
v["expanded"] = json!(g.expanded);
|
||||
if let Some(a) = &g.artboard {
|
||||
v["artboard"] = json!({"rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()], "background": a.background.name(), "preset": a.preset});
|
||||
}
|
||||
|
||||
@@ -8,25 +8,32 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod adjust_cmds;
|
||||
pub mod adjust_params;
|
||||
pub mod align_cmds;
|
||||
pub mod analysis_cmds;
|
||||
pub mod artboard_cmds;
|
||||
pub mod automate_cmds;
|
||||
pub mod brush_cmds;
|
||||
pub mod brush_preset_cmds;
|
||||
pub mod build_info;
|
||||
mod canvas_geom;
|
||||
pub mod channel_cmds;
|
||||
pub mod color_cmds;
|
||||
pub mod commands;
|
||||
pub mod comps_cmds;
|
||||
pub mod display_color;
|
||||
pub mod distort_cmds;
|
||||
pub mod edit_cmds;
|
||||
pub mod edit_menu_cmds;
|
||||
pub mod eraser_cmds;
|
||||
pub mod extra_cmds;
|
||||
pub mod file_cmds;
|
||||
pub mod filters;
|
||||
pub mod filters_ext;
|
||||
mod frame_cmds;
|
||||
pub mod fx_view_cmds;
|
||||
pub mod gallery_cmds;
|
||||
pub mod group_view_cmds;
|
||||
pub mod image_cmds;
|
||||
pub mod inspect;
|
||||
pub mod layer_menu_cmds;
|
||||
@@ -42,7 +49,10 @@ pub mod pattern_cmds;
|
||||
pub mod photo_cmds;
|
||||
pub mod pick_cmds;
|
||||
mod pixels;
|
||||
pub mod plugin_cmds;
|
||||
pub mod prefs;
|
||||
pub mod preset_import_cmds;
|
||||
pub mod preset_store;
|
||||
pub mod presets;
|
||||
pub mod print_cmds;
|
||||
pub mod proof_sim;
|
||||
@@ -136,6 +146,9 @@ pub struct DocState {
|
||||
pub channel_view: channel_cmds::ChannelView,
|
||||
/// Select › Isolate Layers: the Layers panel lists only these layers (empty = off; view state).
|
||||
pub isolated_layers: Vec<LayerId>,
|
||||
/// Layers panel: layers whose effects list is collapsed under their row (the fx triangle;
|
||||
/// view state, not history). Effects lists start open.
|
||||
pub fx_collapsed: Vec<LayerId>,
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
@@ -154,6 +167,7 @@ impl DocState {
|
||||
coalesce: None,
|
||||
channel_view: Default::default(),
|
||||
isolated_layers: Vec::new(),
|
||||
fx_collapsed: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The selected layers in bottom-to-top document order, always including the active layer.
|
||||
@@ -180,8 +194,14 @@ pub struct ToolState {
|
||||
pub brush: photocraft_paint::BrushSettings,
|
||||
/// Brush presets (built-ins plus user presets; see `brush.presets.*`).
|
||||
pub presets: Vec<photocraft_paint::BrushPreset>,
|
||||
/// Bumped whenever `presets` changes ([`Session::brush_presets_changed`]); the preset store
|
||||
/// syncs when it moves.
|
||||
pub presets_rev: u64,
|
||||
/// Mixer Brush paint carried between strokes.
|
||||
pub mixer: photocraft_paint::mixer::MixerState,
|
||||
/// The coalescing key of the running `tools.setBrush` gesture and the brush before it, so the
|
||||
/// gesture journals as one call ([`brush_cmds::coalesce_journal`]).
|
||||
pub brush_gesture: Option<(String, photocraft_paint::BrushSettings)>,
|
||||
}
|
||||
|
||||
impl Default for ToolState {
|
||||
@@ -189,9 +209,15 @@ impl Default for ToolState {
|
||||
Self {
|
||||
foreground: [0.0, 0.0, 0.0, 1.0],
|
||||
background: [1.0, 1.0, 1.0, 1.0],
|
||||
brush: Default::default(),
|
||||
// Photoshop's Brush tool starts at 10 % Smoothing.
|
||||
brush: photocraft_paint::BrushSettings {
|
||||
smoothing: photocraft_paint::brush::Smoothing { amount: 0.1, ..Default::default() },
|
||||
..Default::default()
|
||||
},
|
||||
presets: photocraft_paint::presets::builtin(),
|
||||
presets_rev: 0,
|
||||
mixer: Default::default(),
|
||||
brush_gesture: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,6 +258,9 @@ pub struct Session {
|
||||
/// File menu state: Lock Slices, Image Assets, last Print / Save for Web settings, script
|
||||
/// event log (see `automate_cmds`).
|
||||
pub file_menu: automate_cmds::FileMenuState,
|
||||
/// Persistent brush preset store (desktop only; `None` keeps presets session-only, as in
|
||||
/// headless and test sessions). See `preset_store`.
|
||||
pub preset_store: Option<preset_store::PresetStore>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -261,9 +290,16 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Add a document (from File → New, an import, etc.) and make it active.
|
||||
pub fn add_document(&mut self, doc: Document, path: Option<String>) -> usize {
|
||||
/// Preserve its identity unless another open document already owns it.
|
||||
/// Identity-dependent callers must read the admitted document at the returned index.
|
||||
pub fn add_document(&mut self, mut doc: Document, path: Option<String>) -> usize {
|
||||
// Persisted IDs can overlap the allocator, so check every replacement too.
|
||||
while self.docs.iter().any(|st| st.doc.id == doc.id) {
|
||||
doc.id = photocraft_doc::DocId::fresh();
|
||||
}
|
||||
let mut st = DocState::new(doc, path);
|
||||
st.history.max_states = self.prefs.get().performance.history_states.max(1) as usize;
|
||||
st.history.max_bytes = self.prefs.get().performance.history_budget_bytes();
|
||||
self.docs.push(st);
|
||||
let i = self.docs.len() - 1;
|
||||
self.active = Some(i);
|
||||
@@ -304,7 +340,8 @@ impl Session {
|
||||
let r = r?;
|
||||
edit_menu_cmds::after_command(self, id);
|
||||
automate_cmds::after_command(self, id);
|
||||
if spec.journal {
|
||||
self.sync_preset_store();
|
||||
if spec.journal && !brush_cmds::coalesce_journal(self, id, ¶ms) {
|
||||
self.journal.push((id.to_string(), params));
|
||||
}
|
||||
Ok(r)
|
||||
@@ -334,6 +371,7 @@ impl Session {
|
||||
let st = self.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
if key.is_none() || st.coalesce != key || !st.history.can_undo() {
|
||||
st.history.record(label, before);
|
||||
st.history.trim(&st.doc);
|
||||
}
|
||||
st.coalesce = key;
|
||||
st.revision += 1;
|
||||
|
||||
@@ -81,6 +81,7 @@ fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
doc.size = new;
|
||||
let canvas = doc.bounds();
|
||||
let fmt = doc.pixel_format();
|
||||
let dpi = doc.resolution_dpi;
|
||||
for l in doc.layers.iter_mut() {
|
||||
let background = l.name == "Background" && l.locks.position && matches!(l.content, LayerContent::Raster(_));
|
||||
if background && let Some(surf) = l.surface_mut() {
|
||||
@@ -104,22 +105,18 @@ fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
crate::transform_cmds::transform_layer(None, l, &h, Some(a), interp)?;
|
||||
l.locks = locks;
|
||||
}
|
||||
// What Free Transform leaves alone: unlinked vector masks, gradient angles, artboards…
|
||||
crate::canvas_geom::transform_layer_geometry(l, &a, false, dpi);
|
||||
}
|
||||
crate::canvas_geom::transform_doc_marks(doc, &a);
|
||||
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
|
||||
ch.surface = crate::transform_cmds::warp_gray(&ch.surface, &h, interp);
|
||||
}
|
||||
if let Some(sel) = &doc.selection {
|
||||
doc.selection = Some(crate::transform_cmds::warp_gray(sel, &h, Interp::Bilinear)).filter(|s| !s.content_bounds().is_empty());
|
||||
}
|
||||
// Type and smart objects re-render from their new transforms.
|
||||
let ids: Vec<LayerId> =
|
||||
doc.walk().iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_) | LayerContent::Smart(_))).map(|(_, _, l)| l.id).collect();
|
||||
for id in ids {
|
||||
let snapshot = doc.clone();
|
||||
if let Some(l) = doc.layer_mut(id) {
|
||||
crate::transform_cmds::refresh_text(&snapshot, l);
|
||||
}
|
||||
}
|
||||
// Type, shapes and smart objects re-render from their new geometry.
|
||||
crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::All);
|
||||
doc.guides = Default::default();
|
||||
Ok(new)
|
||||
})?;
|
||||
|
||||
@@ -6,7 +6,9 @@ use photocraft_geom::Rect;
|
||||
use photocraft_raster::{Surface, from_rgba, to_rgba};
|
||||
|
||||
/// Apply an adjustment destructively to a surface, weighted by an optional selection.
|
||||
pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>) {
|
||||
/// Applies `adj` to a surface (any colour model and depth, via straight RGBA) through the
|
||||
/// selection; `mode` is the document's, for the tone transfer (e.g. Exposure in Grayscale).
|
||||
pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>, mode: photocraft_color::ColorMode) {
|
||||
let r = s.content_bounds();
|
||||
if r.is_empty() {
|
||||
return;
|
||||
@@ -16,7 +18,7 @@ pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surf
|
||||
let raw = s.read_region(r);
|
||||
let mut buf = Buffer { rect: r, px: raw.chunks_exact(n).map(|p| to_rgba(&fmt, p)).collect() };
|
||||
let orig = buf.clone();
|
||||
adjust::apply(adj, &mut buf);
|
||||
adjust::apply_with(adj, &mut buf, adjust::Transfer::for_mode(mode));
|
||||
let w = r.width() as usize;
|
||||
let mut out = Vec::with_capacity(raw.len());
|
||||
for (i, (a, o)) in buf.px.iter().zip(&orig.px).enumerate() {
|
||||
@@ -95,29 +97,6 @@ pub fn remap_surface(s: &Surface, map: impl Fn(i32, i32) -> (i32, i32)) -> Surfa
|
||||
out
|
||||
}
|
||||
|
||||
/// Apply a coordinate mapping to every raster surface and mask in the document.
|
||||
pub fn remap_document(doc: &mut Document, map: &dyn Fn(i32, i32) -> (i32, i32)) {
|
||||
fn rec(layers: &mut [Layer], map: &dyn Fn(i32, i32) -> (i32, i32)) {
|
||||
for l in layers {
|
||||
if let Some(m) = &mut l.mask {
|
||||
m.surface = remap_surface(&m.surface, map);
|
||||
}
|
||||
match &mut l.content {
|
||||
LayerContent::Raster(s) => *s = remap_surface(s, map),
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge `upper` onto `lower` producing a raster layer (Layer → Merge Down).
|
||||
pub fn merge_down(doc_bounds: Rect, lower: &Layer, upper: &Layer, format: photocraft_color::PixelFormat) -> Layer {
|
||||
let area = [lower, upper].iter().map(|l| l.surface().map(|s| s.content_bounds()).unwrap_or(doc_bounds)).fold(Rect::EMPTY, |a, b| a.union(&b));
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
//! `plugin.*` commands: sandboxed WebAssembly filter plug-ins (`photocraft-plugins`).
|
||||
//!
|
||||
//! Plug-ins are installed per process (`plugin.install`, or the Plug-ins preference folder),
|
||||
//! listed with `plugin.list` and run with `plugin.run` on the active pixel layer, a targeted
|
||||
//! channel, or a smart object (recorded as a smart filter, re-run on re-render while the plug-in
|
||||
//! is installed). Runs respect the selection and are one undo step.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use photocraft_doc::{LayerContent, SmartFilter};
|
||||
use photocraft_plugins::{Plugin, registry};
|
||||
use photocraft_raster::Surface;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// The smart-filter command id plug-in runs are recorded under.
|
||||
pub const RUN: &str = "plugin.run";
|
||||
|
||||
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||||
}
|
||||
|
||||
fn err(e: photocraft_plugins::Error) -> EngineError {
|
||||
EngineError::Other(e.to_string())
|
||||
}
|
||||
|
||||
fn always(_: &Session) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// JSON description of an installed plug-in.
|
||||
pub fn describe(p: &Plugin) -> Value {
|
||||
let m = p.manifest();
|
||||
json!({
|
||||
"id": m.id, "name": m.name, "version": m.version, "kind": m.kind, "author": m.author, "description": m.description,
|
||||
"params": m.params_notation(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()),
|
||||
"overlap": m.overlap, "area": m.area, "size": p.size(), "source": p.source(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The plug-in's own parameters from command params: `params` if given, else the top-level keys
|
||||
/// minus the ones the command itself uses.
|
||||
fn plugin_params(p: &Value) -> Value {
|
||||
if let Some(v) = p.get("params") {
|
||||
return v.clone();
|
||||
}
|
||||
match p {
|
||||
Value::Object(m) => Value::Object(
|
||||
m.iter()
|
||||
.filter(|(k, _)| !matches!(k.as_str(), "id" | "layer" | "target" | "channel") && !k.starts_with("__"))
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect(),
|
||||
),
|
||||
_ => Value::Object(Map::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_by_id(cmd: &str, p: &Value) -> Result<std::sync::Arc<Plugin>> {
|
||||
let id = p.get("id").and_then(Value::as_str).ok_or_else(|| bad(cmd, "missing `id` (see plugin.list)"))?;
|
||||
registry::get(id).ok_or_else(|| err(photocraft_plugins::Error::NotFound(id.into())))
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let plugin = plugin_by_id(RUN, p)?;
|
||||
let params = plugin_params(p);
|
||||
// Bad parameters fail before anything is touched.
|
||||
let resolved = Value::Object(plugin.manifest().resolve_params(¶ms).map_err(|e| bad(RUN, e.to_string()))?);
|
||||
let layer = crate::commands::layer_param(s, p)?;
|
||||
let label = plugin.manifest().name.clone();
|
||||
let id = plugin.id().to_string();
|
||||
s.edit(&label, |doc, _| {
|
||||
let selection = doc.selection.clone();
|
||||
let canvas = doc.bounds();
|
||||
if let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, p)? {
|
||||
*surf = plugin.apply(surf, canvas, selection.as_ref(), &resolved).map_err(err)?;
|
||||
return Ok(());
|
||||
}
|
||||
let l = doc.layer_mut(layer).ok_or(EngineError::NoLayer(layer))?;
|
||||
match &mut l.content {
|
||||
LayerContent::Raster(surf) => {
|
||||
*surf = plugin.apply(surf, canvas, selection.as_ref(), &resolved).map_err(err)?;
|
||||
Ok(())
|
||||
}
|
||||
LayerContent::Smart(_) => {
|
||||
let sf = SmartFilter {
|
||||
command: RUN.into(),
|
||||
params: json!({"id": id, "params": resolved}),
|
||||
blend: photocraft_color::BlendMode::Normal,
|
||||
opacity: 1.0,
|
||||
visible: true,
|
||||
};
|
||||
crate::smart_cmds::add_smart_filter(doc, layer, sf, selection.as_ref())
|
||||
}
|
||||
other => Err(EngineError::Other(format!("plug-in filters need a pixel layer (active layer is a {} layer)", other.kind_name()))),
|
||||
}
|
||||
})?;
|
||||
Ok(json!({"layer": layer.0, "plugin": id}))
|
||||
}
|
||||
|
||||
/// Smart-filter re-render of a recorded `plugin.run` (see `filters::apply_filter_to_surface`).
|
||||
/// `None` when `command` isn't a plug-in run or the plug-in isn't installed (the smart filter is
|
||||
/// then skipped, as for other unknown filters) or fails.
|
||||
pub(crate) fn apply_to_surface(command: &str, params: &Value, surf: &Surface, selection: Option<&Surface>, canvas: photocraft_geom::Rect) -> Option<Surface> {
|
||||
if command != RUN {
|
||||
return None;
|
||||
}
|
||||
let plugin = registry::get(params.get("id")?.as_str()?)?;
|
||||
plugin.apply(surf, canvas, selection, params.get("params").unwrap_or(&Value::Null)).ok()
|
||||
}
|
||||
|
||||
/// Largest base64 payload `plugin.install` accepts (the module limit, encoded).
|
||||
const MAX_DATA_CHARS: usize = (32 << 20) / 3 * 4 + 4;
|
||||
|
||||
fn b64_decode(s: &str) -> Option<Vec<u8>> {
|
||||
let val = |c: u8| -> Option<u32> {
|
||||
Some(u32::from(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'+' | b'-' => 62,
|
||||
b'/' | b'_' => 63,
|
||||
_ => return None,
|
||||
}))
|
||||
};
|
||||
let clean: Vec<u8> = s.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=').collect();
|
||||
if clean.len() % 4 == 1 {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(clean.len() / 4 * 3 + 2);
|
||||
for chunk in clean.chunks(4) {
|
||||
let mut n = 0u32;
|
||||
for (i, &c) in chunk.iter().enumerate() {
|
||||
n |= val(c)? << (18 - 6 * i);
|
||||
}
|
||||
let bytes = [(n >> 16) as u8, (n >> 8) as u8, n as u8];
|
||||
out.extend_from_slice(bytes.get(..chunk.len() - 1)?);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn install(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "plugin.install";
|
||||
let plugin = if let Some(data) = p.get("data") {
|
||||
let data = data.as_str().ok_or_else(|| bad(CMD, "`data` must be a base64 string"))?;
|
||||
if data.len() > MAX_DATA_CHARS {
|
||||
return Err(bad(CMD, "`data` is larger than the module limit"));
|
||||
}
|
||||
let bytes = b64_decode(data).ok_or_else(|| bad(CMD, "`data` is not valid base64"))?;
|
||||
Plugin::load(&bytes, photocraft_plugins::Limits::default()).map_err(err)?
|
||||
} else if let Some(path) = p.get("path") {
|
||||
let path = path.as_str().filter(|s| !s.is_empty()).ok_or_else(|| bad(CMD, "`path` must be a file path"))?;
|
||||
load_path(path)?
|
||||
} else {
|
||||
return Err(bad(CMD, "give `path` (a .wasm file) or `data` (the module as base64)"));
|
||||
};
|
||||
let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(true);
|
||||
if !replace && registry::get(plugin.id()).is_some() {
|
||||
return Err(bad(CMD, format!("a plug-in with id {:?} is already installed", plugin.id())));
|
||||
}
|
||||
Ok(describe(®istry::install(plugin)))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_path(path: &str) -> Result<Plugin> {
|
||||
registry::load_file(std::path::Path::new(path), photocraft_plugins::Limits::default()).map_err(err)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn load_path(_: &str) -> Result<Plugin> {
|
||||
Err(EngineError::Other("installing from a path is not available on the web; pass the module as base64 `data`".into()))
|
||||
}
|
||||
|
||||
/// The plug-in folder last loaded from the preferences, and what happened.
|
||||
static FOLDER: Mutex<Option<(String, Value)>> = Mutex::new(None);
|
||||
|
||||
/// Loads the Plug-ins preference folder (`*.wasm`) when it is enabled and has changed since the
|
||||
/// last load. Called whenever preferences are applied (startup, `prefs.set`). Native only.
|
||||
pub(crate) fn sync_prefs(s: &Session) {
|
||||
let pi = &s.prefs().plug_ins;
|
||||
let folder = pi.additional_plugins_folder.trim();
|
||||
if !pi.use_additional_plugins_folder || folder.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut last = FOLDER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if last.as_ref().is_some_and(|(f, _)| f == folder) {
|
||||
return;
|
||||
}
|
||||
*last = Some((folder.to_string(), load_folder(folder)));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_folder(folder: &str) -> Value {
|
||||
match registry::load_folder(std::path::Path::new(folder)) {
|
||||
Ok(r) => json!({"folder": folder, "loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::<Vec<_>>()}),
|
||||
Err(e) => json!({"folder": folder, "error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn load_folder(folder: &str) -> Value {
|
||||
json!({"folder": folder, "error": "plug-in folders are not available on the web"})
|
||||
}
|
||||
|
||||
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let folder = match p.get("path") {
|
||||
Some(v) => v.as_str().filter(|f| !f.trim().is_empty()).ok_or_else(|| bad("plugin.reload", "`path` must be a folder"))?.trim().to_string(),
|
||||
None => s.prefs().plug_ins.additional_plugins_folder.trim().to_string(),
|
||||
};
|
||||
if folder.is_empty() {
|
||||
return Err(bad("plugin.reload", "no folder: pass `path` or set Preferences › Plug-ins › Additional Plug-ins Folder"));
|
||||
}
|
||||
let report = load_folder(&folder);
|
||||
if let Some(e) = report.get("error").and_then(Value::as_str) {
|
||||
return Err(EngineError::Other(e.to_string()));
|
||||
}
|
||||
*FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = Some((folder, report.clone()));
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// The plug-in command specs.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec {
|
||||
id: "plugin.list",
|
||||
label: "List Plug-ins",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "{}",
|
||||
enabled: always,
|
||||
run: |_, _| {
|
||||
let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, r)| r.clone());
|
||||
Ok(json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::<Vec<_>>(), "folder": folder}))
|
||||
},
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "plugin.install",
|
||||
label: "Install Plug-in…",
|
||||
menu: &["Filter", "Plug-ins"],
|
||||
shortcut: None,
|
||||
params: r##"{"path":text,"data":json,"replace":bool=true} (path: a .wasm file, native only; data: the module as base64)"##,
|
||||
enabled: always,
|
||||
run: install,
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "plugin.remove",
|
||||
label: "Remove Plug-in",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"id":text}"##,
|
||||
enabled: always,
|
||||
run: |_, p| {
|
||||
let id = p.get("id").and_then(Value::as_str).ok_or_else(|| bad("plugin.remove", "missing `id`"))?;
|
||||
if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(photocraft_plugins::Error::NotFound(id.into()))) }
|
||||
},
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "plugin.reload",
|
||||
label: "Reload Plug-ins",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"path":text} (a folder of .wasm plug-ins; default: the Plug-ins preference folder)"##,
|
||||
enabled: always,
|
||||
run: reload,
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
id: RUN,
|
||||
label: "Run Plug-in",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"id":text,"params":json} (plug-in parameters under "params" or at the top level; see plugin.list)"##,
|
||||
enabled: crate::filters::has_filterable_layer,
|
||||
run,
|
||||
journal: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "plugin_cmds/tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,228 @@
|
||||
use super::*;
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
/// The example Invert plug-in, built from `examples/plugins/invert-rs`.
|
||||
const INVERT: &[u8] = include_bytes!("../../../plugins/tests/fixtures/invert.wasm");
|
||||
const INVERT_ID: &str = "org.photocraft.example.invert";
|
||||
|
||||
fn b64(b: &[u8]) -> String {
|
||||
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::new();
|
||||
for c in b.chunks(3) {
|
||||
let n = (u32::from(c[0]) << 16) | (u32::from(*c.get(1).unwrap_or(&0)) << 8) | u32::from(*c.get(2).unwrap_or(&0));
|
||||
for i in 0..4 {
|
||||
out.push(if i <= c.len() { T[(n >> (18 - 6 * i)) as usize & 63] as char } else { '=' });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A WAT plug-in with its own `id` whose `pc_filter` body is `filter`.
|
||||
fn wat_plugin(id: &str, params: &str, filter: &str) -> Vec<u8> {
|
||||
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"filter","params":{params}}}"#);
|
||||
let src = format!(
|
||||
r#"(module
|
||||
(memory (export "memory") 1)
|
||||
(data (i32.const 16) "{data}")
|
||||
(func (export "pc_abi_version") (result i32) (i32.const 1))
|
||||
(func (export "pc_manifest") (result i64) (i64.or (i64.shl (i64.const {len}) (i64.const 32)) (i64.const 16)))
|
||||
(func (export "pc_alloc") (param $n i32) (result i32) (local $old i32)
|
||||
(local.set $old (memory.grow (i32.shr_u (i32.add (local.get $n) (i32.const 65535)) (i32.const 16))))
|
||||
(if (result i32) (i32.eq (local.get $old) (i32.const -1)) (then (i32.const 0)) (else (i32.mul (local.get $old) (i32.const 65536)))))
|
||||
(func (export "pc_filter") (param $buf i32) (param $len i32) (param $w i32) (param $h i32)
|
||||
(param $ch i32) (param $fmt i32) (param $pp i32) (param $pl i32) (result i32) {filter}))"#,
|
||||
data = manifest.replace('"', "\\\""),
|
||||
len = manifest.len()
|
||||
);
|
||||
wat::parse_str(src).unwrap()
|
||||
}
|
||||
|
||||
/// Sets every sample to 0.5.
|
||||
const GREY: &str = r#"(local $i i32)
|
||||
(block $done (loop $l
|
||||
(br_if $done (i32.ge_u (local.get $i) (local.get $len)))
|
||||
(f32.store (i32.add (local.get $buf) (local.get $i)) (f32.const 0.5))
|
||||
(local.set $i (i32.add (local.get $i) (i32.const 4)))
|
||||
(br $l)))
|
||||
(i32.const 0)"#;
|
||||
|
||||
fn doc(depth: u32) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 28, "depth": depth})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.edit("pattern", |doc, active| {
|
||||
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||
for y in 0..28 {
|
||||
for x in 0..40 {
|
||||
let v = ((x * 5 + y * 3) % 17) as f32 / 16.0;
|
||||
let a = if (x * y) % 11 == 3 { 0.0 } else { 0.3 + (x % 3) as f32 * 0.35 };
|
||||
surf.write_pixel(x, y, &[v, 1.0 - v, (x % 4) as f32 / 3.0, a]);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn pixels(s: &Session) -> Vec<f32> {
|
||||
let d = s.active().unwrap();
|
||||
d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().read_region(Rect::new(0, 0, 40, 28))
|
||||
}
|
||||
|
||||
fn select_partial(s: &mut Session) {
|
||||
s.edit("sel", |doc, _| {
|
||||
let mut sel = Surface::new(photocraft_color::PixelFormat::GRAY8);
|
||||
sel.fill_rect(Rect::new(4, 4, 30, 20), &[1.0]);
|
||||
sel.fill_rect(Rect::new(4, 4, 30, 9), &[0.4]);
|
||||
doc.selection = Some(sel);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn example_invert_matches_builtin_invert_and_round_trips_the_registry() {
|
||||
// One test owns the example plug-in's id (the registry is process-wide).
|
||||
let r = Session::new().execute("plugin.install", json!({"data": b64(INVERT)})).unwrap();
|
||||
assert_eq!(r["id"], INVERT_ID);
|
||||
assert_eq!(r["name"], "Invert (WebAssembly)");
|
||||
let list = Session::new().execute("plugin.list", json!({})).unwrap();
|
||||
assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["id"] == INVERT_ID));
|
||||
|
||||
for depth in [8, 16, 32] {
|
||||
for with_selection in [false, true] {
|
||||
let mut a = doc(depth);
|
||||
let mut b = doc(depth);
|
||||
if with_selection {
|
||||
select_partial(&mut a);
|
||||
select_partial(&mut b);
|
||||
}
|
||||
let before = pixels(&a);
|
||||
a.execute("image.adjustments.invert", json!({})).unwrap();
|
||||
let r = b.execute("plugin.run", json!({"id": INVERT_ID})).unwrap();
|
||||
assert_eq!(r["plugin"], INVERT_ID);
|
||||
assert_eq!(pixels(&a), pixels(&b), "depth {depth}, selection {with_selection}");
|
||||
assert_ne!(pixels(&b), before);
|
||||
// One undo step.
|
||||
b.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(pixels(&b), before, "depth {depth} undo");
|
||||
}
|
||||
}
|
||||
|
||||
// Smart objects record a smart filter.
|
||||
let mut s = doc(8);
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
s.execute("plugin.run", json!({"id": INVERT_ID})).unwrap();
|
||||
let d = s.active().unwrap();
|
||||
let LayerContent::Smart(sm) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!("not a smart object") };
|
||||
assert_eq!(sm.smart_filters.len(), 1);
|
||||
assert_eq!(sm.smart_filters[0].command, RUN);
|
||||
assert_eq!(sm.smart_filters[0].params["id"], INVERT_ID);
|
||||
|
||||
// Install from a path (native), then remove.
|
||||
let dir = std::env::temp_dir().join(format!("pc-plugin-cmds-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("invert.wasm");
|
||||
std::fs::write(&path, INVERT).unwrap();
|
||||
let r = Session::new().execute("plugin.install", json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
assert_eq!(r["source"], path.to_str().unwrap());
|
||||
assert!(Session::new().execute("plugin.install", json!({"path": path.to_str().unwrap(), "replace": false})).is_err());
|
||||
Session::new().execute("plugin.remove", json!({"id": INVERT_ID})).unwrap();
|
||||
assert!(doc(8).execute("plugin.run", json!({"id": INVERT_ID})).is_err(), "removed");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_are_validated_and_passed() {
|
||||
let bytes = wat_plugin("test.cmds.grey", r#"{"amount":{"type":"number","min":0,"max":1,"default":1}}"#, GREY);
|
||||
Session::new().execute("plugin.install", json!({"data": b64(&bytes)})).unwrap();
|
||||
let list = Session::new().execute("plugin.list", json!({})).unwrap();
|
||||
let me = list["plugins"].as_array().unwrap().iter().find(|p| p["id"] == "test.cmds.grey").unwrap().clone();
|
||||
assert_eq!(me["params"], r#"{"amount":0..1=1}"#);
|
||||
let mut s = doc(16);
|
||||
let before = pixels(&s);
|
||||
let undo_before = s.active().unwrap().history.can_undo();
|
||||
// Wrong type: refused before any edit (no undo step).
|
||||
assert!(matches!(s.execute("plugin.run", json!({"id": "test.cmds.grey", "amount": "x"})), Err(EngineError::BadParams { .. })));
|
||||
assert!(s.execute("plugin.run", json!({"id": "test.cmds.grey", "params": {"amount": true}})).is_err());
|
||||
assert_eq!(pixels(&s), before);
|
||||
assert_eq!(s.active().unwrap().history.can_undo(), undo_before);
|
||||
// Flat and nested params both work.
|
||||
s.execute("plugin.run", json!({"id": "test.cmds.grey", "amount": 0.5})).unwrap();
|
||||
s.execute("plugin.run", json!({"id": "test.cmds.grey", "params": {"amount": 0.25}})).unwrap();
|
||||
assert!((pixels(&s)[0] - 0.5).abs() < 1e-4);
|
||||
Session::new().execute("plugin.remove", json!({"id": "test.cmds.grey"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_plugins_fail_without_touching_the_document() {
|
||||
for (id, body) in [
|
||||
("test.cmds.trap", "unreachable"),
|
||||
("test.cmds.spin", "(loop $l (br $l)) (i32.const 0)"),
|
||||
("test.cmds.oob", "(i32.store (i32.const -4) (i32.const 1)) (i32.const 0)"),
|
||||
] {
|
||||
Session::new().execute("plugin.install", json!({"data": b64(&wat_plugin(id, "{}", body))})).unwrap();
|
||||
let mut s = doc(8);
|
||||
let before = pixels(&s);
|
||||
let undo_before = s.active().unwrap().history.can_undo();
|
||||
let t = std::time::Instant::now();
|
||||
assert!(s.execute("plugin.run", json!({"id": id})).is_err(), "{id}");
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{id} took {:?}", t.elapsed());
|
||||
assert_eq!(pixels(&s), before, "{id}");
|
||||
assert_eq!(s.active().unwrap().history.can_undo(), undo_before, "{id}: no undo step");
|
||||
Session::new().execute("plugin.remove", json!({"id": id})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_are_errors_not_panics() {
|
||||
let mut s = doc(8);
|
||||
for (cmd, p) in [
|
||||
("plugin.install", json!({})),
|
||||
("plugin.install", json!({"data": 5})),
|
||||
("plugin.install", json!({"data": "!!!not base64"})),
|
||||
("plugin.install", json!({"data": "QUJD"})),
|
||||
("plugin.install", json!({"data": b64(b"\0asm\x01\0\0\0junk")})),
|
||||
("plugin.install", json!({"path": ""})),
|
||||
("plugin.install", json!({"path": 3})),
|
||||
("plugin.install", json!({"path": "/definitely/not/here.wasm"})),
|
||||
("plugin.install", json!({"path": "/"})),
|
||||
("plugin.remove", json!({})),
|
||||
("plugin.remove", json!({"id": "no.such.plugin"})),
|
||||
("plugin.run", json!({})),
|
||||
("plugin.run", json!({"id": 7})),
|
||||
("plugin.run", json!({"id": "no.such.plugin"})),
|
||||
("plugin.reload", json!({"path": 1})),
|
||||
("plugin.reload", json!({"path": ""})),
|
||||
("plugin.reload", json!({"path": "/definitely/not/here"})),
|
||||
] {
|
||||
assert!(s.execute(cmd, p.clone()).is_err(), "{cmd} {p}");
|
||||
}
|
||||
// No document: running is disabled.
|
||||
assert!(Session::new().execute("plugin.run", json!({"id": "x"})).is_err());
|
||||
assert_eq!(b64_decode("aGk="), Some(b"hi".to_vec()));
|
||||
assert_eq!(b64_decode("aGk"), Some(b"hi".to_vec()));
|
||||
assert_eq!(b64_decode("a"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preference_folder_is_loaded() {
|
||||
let dir = std::env::temp_dir().join(format!("pc-plugin-prefs-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("grey.wasm"), wat_plugin("test.cmds.folder", "{}", GREY)).unwrap();
|
||||
std::fs::write(dir.join("broken.wasm"), b"nope").unwrap();
|
||||
let mut s = Session::new();
|
||||
// Off by default: setting the folder alone loads nothing.
|
||||
s.execute("prefs.set", json!({"path": "plugIns.additionalPluginsFolder", "value": dir.to_str().unwrap()})).unwrap();
|
||||
assert!(registry::get("test.cmds.folder").is_none());
|
||||
s.execute("prefs.set", json!({"path": "plugIns.useAdditionalPluginsFolder", "value": true})).unwrap();
|
||||
assert!(registry::get("test.cmds.folder").is_some());
|
||||
let list = s.execute("plugin.list", json!({})).unwrap();
|
||||
assert_eq!(list["folder"]["failed"].as_array().map(Vec::len), Some(1));
|
||||
registry::remove("test.cmds.folder");
|
||||
let r = s.execute("plugin.reload", json!({})).unwrap();
|
||||
assert_eq!(r["loaded"], json!(["test.cmds.folder"]));
|
||||
registry::remove("test.cmds.folder");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -71,6 +71,11 @@ choice!(
|
||||
/// opacity contour), Full Size Brush Tip (the whole diameter).
|
||||
PaintingCursor { Standard = "standard", Precise = "precise", NormalTip = "normalTip", FullSizeTip = "fullSizeTip" } default NormalTip
|
||||
);
|
||||
choice!(
|
||||
/// Right mouse button on the canvas with a painting tool: open the Brush Preset picker at the
|
||||
/// pointer (Photoshop), or erase with the current brush while dragging (Krita, Paint).
|
||||
RightClickPaint { BrushPicker = "brushPicker", Erase = "erase" } default BrushPicker
|
||||
);
|
||||
choice!(OtherCursor { Standard = "standard", Precise = "precise" } default Standard);
|
||||
choice!(CheckerSize { None = "none", Small = "small", Medium = "medium", Large = "large" } default Medium);
|
||||
choice!(CheckerColors { Light = "light", Medium = "medium", Dark = "dark", Red = "red", Orange = "orange", Green = "green", Blue = "blue", Purple = "purple", Custom = "custom" } default Light);
|
||||
@@ -254,6 +259,8 @@ pub struct Tools {
|
||||
pub show_transformation_values: bool,
|
||||
pub overscroll: bool,
|
||||
pub double_click_layer_mask_launches_select_and_mask: bool,
|
||||
/// What the right mouse button does on the canvas with the Brush and other painting tools.
|
||||
pub right_click_with_painting_tools: RightClickPaint,
|
||||
}
|
||||
|
||||
impl Default for Tools {
|
||||
@@ -268,6 +275,7 @@ impl Default for Tools {
|
||||
show_transformation_values: true,
|
||||
overscroll: true,
|
||||
double_click_layer_mask_launches_select_and_mask: true,
|
||||
right_click_with_painting_tools: RightClickPaint::BrushPicker,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -351,7 +359,8 @@ impl Default for Export {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Performance {
|
||||
/// Memory Photocraft may use, in MB (informational budget; drives the caches below).
|
||||
/// Memory PhotoCraft may use, in MB: bounds each document's pixels plus its History (the
|
||||
/// oldest states are dropped beyond it).
|
||||
pub memory_usage_mb: u32,
|
||||
/// Undo steps kept per document (History panel states).
|
||||
pub history_states: u32,
|
||||
@@ -366,6 +375,14 @@ pub struct Performance {
|
||||
pub legacy_compositing: bool,
|
||||
}
|
||||
|
||||
impl Performance {
|
||||
/// Pixel memory a document and its History may hold (Memory Usage), in bytes: beyond it
|
||||
/// the oldest history states are dropped.
|
||||
pub fn history_budget_bytes(&self) -> usize {
|
||||
(self.memory_usage_mb as usize).saturating_mul(1 << 20)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Performance {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -552,6 +569,8 @@ impl GuidesGridAndSlices {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PlugIns {
|
||||
/// Load the WebAssembly plug-ins (`*.wasm`) in `additional_plugins_folder` (native only).
|
||||
pub use_additional_plugins_folder: bool,
|
||||
pub additional_plugins_folder: String,
|
||||
pub show_extension_panels: bool,
|
||||
pub allow_scripts_to_connect: bool,
|
||||
@@ -560,7 +579,13 @@ pub struct PlugIns {
|
||||
|
||||
impl Default for PlugIns {
|
||||
fn default() -> Self {
|
||||
Self { additional_plugins_folder: String::new(), show_extension_panels: true, allow_scripts_to_connect: false, generator_enabled: false }
|
||||
Self {
|
||||
use_additional_plugins_folder: false,
|
||||
additional_plugins_folder: String::new(),
|
||||
show_extension_panels: true,
|
||||
allow_scripts_to_connect: false,
|
||||
generator_enabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -697,6 +722,10 @@ pub struct Preferences {
|
||||
pub workspaces: BTreeMap<String, Value>,
|
||||
/// Window › Workspace › Lock Workspace: panels can't be moved.
|
||||
pub workspace_locked: bool,
|
||||
/// The live panel layout (dock group heights, order, collapsed groups, open panels), saved
|
||||
/// as the user changes it and restored at launch when Workspace › Remember Workspace
|
||||
/// Changes is on. JSON owned by the shell.
|
||||
pub panel_layout: Value,
|
||||
/// File › Scripts › Script Events Manager: event → script bindings.
|
||||
pub script_events: crate::automate_cmds::ScriptEvents,
|
||||
}
|
||||
@@ -741,6 +770,7 @@ pub fn choices(path: &str) -> Option<&'static [&'static str]> {
|
||||
"export.metadata" => ExportMetadata::NAMES,
|
||||
"cursors.painting" => PaintingCursor::NAMES,
|
||||
"cursors.other" => OtherCursor::NAMES,
|
||||
"tools.rightClickWithPaintingTools" => RightClickPaint::NAMES,
|
||||
"transparencyAndGamut.gridSize" => CheckerSize::NAMES,
|
||||
"transparencyAndGamut.gridColors" => CheckerColors::NAMES,
|
||||
"unitsAndRulers.rulers" | "guidesGridAndSlices.gridUnit" => Unit::NAMES,
|
||||
@@ -1062,11 +1092,15 @@ impl Session {
|
||||
/// the layer-effect cache budget.
|
||||
pub fn apply_prefs(&mut self) {
|
||||
let n = self.prefs().performance.history_states.max(1) as usize;
|
||||
let bytes = self.prefs().performance.history_budget_bytes();
|
||||
let budget = self.prefs().performance.effect_cache_mb as usize;
|
||||
for st in &mut self.docs {
|
||||
st.history.max_states = n;
|
||||
st.history.max_bytes = bytes;
|
||||
st.history.trim(&st.doc);
|
||||
}
|
||||
photocraft_compose::set_effect_cache_budget(budget << 20);
|
||||
crate::plugin_cmds::sync_prefs(self);
|
||||
}
|
||||
|
||||
/// Everything persisted as one JSON document: the preferences plus `colorSettings`.
|
||||
|
||||
@@ -187,3 +187,20 @@ fn choice_and_range_tables_cover_enum_fields() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_click_with_painting_tools_pref() {
|
||||
let mut s = session();
|
||||
assert_eq!(s.prefs().tools.right_click_with_painting_tools, RightClickPaint::BrushPicker, "Photoshop: the Brush Preset picker");
|
||||
assert_eq!(s.execute("prefs.get", json!({"path": "tools.rightClickWithPaintingTools"})).unwrap(), json!("brushPicker"));
|
||||
assert_eq!(choices("tools.rightClickWithPaintingTools"), Some(RightClickPaint::NAMES));
|
||||
s.execute("prefs.set", json!({"path": "tools.rightClickWithPaintingTools", "value": "erase"})).unwrap();
|
||||
assert_eq!(s.prefs().tools.right_click_with_painting_tools, RightClickPaint::Erase);
|
||||
for bad in [json!("smudge"), json!(1), json!(null), json!(["erase"])] {
|
||||
assert!(s.execute("prefs.set", json!({"path": "tools.rightClickWithPaintingTools", "value": bad})).is_err());
|
||||
}
|
||||
assert_eq!(s.prefs().tools.right_click_with_painting_tools, RightClickPaint::Erase, "rejected values change nothing");
|
||||
// Preferences saved before the option existed load with the default.
|
||||
let old: Tools = serde_json::from_value(json!({"showTooltips": false})).unwrap();
|
||||
assert_eq!(old.right_click_with_painting_tools, RightClickPaint::BrushPicker);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
//! Importing Photoshop preset files.
|
||||
//!
|
||||
//! - `brush.presets.importAbr` adds the presets of an `.abr` file (v1, v2, v6+) to the brush
|
||||
//! library as one group (Brushes panel › Import Brushes…, Preset Manager). Parsing lives in
|
||||
//! `photocraft-psd` (`abr`), the mapping onto [`BrushSettings`] in `photocraft-io` (`abr_map`).
|
||||
//! - `gradient.presets.importGrd` adds the gradients of a `.grd` file (version 5) to the
|
||||
//! Gradients panel as one group.
|
||||
//!
|
||||
//! [`BrushSettings`]: photocraft_paint::BrushSettings
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Largest accepted file (Photoshop's biggest bundled sets are well under this).
|
||||
pub const MAX_FILE_BYTES: u64 = 512 << 20;
|
||||
|
||||
/// File bytes from `data` (base64) or `path`, plus a default group name.
|
||||
pub(crate) fn file_bytes(p: &Value, cmd: &str, what: &str) -> Result<(Vec<u8>, String)> {
|
||||
if let Some(d) = p.get("data") {
|
||||
let s = d.as_str().ok_or_else(|| bad(cmd, "`data` must be a base64 string"))?;
|
||||
if s.len() as u64 > MAX_FILE_BYTES / 3 * 4 + 4 {
|
||||
return Err(bad(cmd, "`data` is too large"));
|
||||
}
|
||||
let bytes = photocraft_paint::tile::b64_decode(s).ok_or_else(|| bad(cmd, "`data` is not valid base64"))?;
|
||||
return Ok((bytes, String::new()));
|
||||
}
|
||||
let path =
|
||||
p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(cmd, format!("give `path` ({what}) or `data` (base64)")))?;
|
||||
let len = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.len();
|
||||
if len > MAX_FILE_BYTES {
|
||||
return Err(bad(cmd, format!("{path}: file too large ({len} bytes)")));
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let stem = std::path::Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
|
||||
Ok((bytes, stem))
|
||||
}
|
||||
|
||||
/// A name not used by any preset in `taken` (case-insensitive): `name`, `name 2`, `name 3`…
|
||||
fn unique(name: &str, taken: &[String]) -> String {
|
||||
let free = |n: &str| !taken.iter().any(|t| t.eq_ignore_ascii_case(n));
|
||||
if free(name) {
|
||||
return name.to_string();
|
||||
}
|
||||
(2..).map(|i| format!("{name} {i}")).find(|n| free(n)).unwrap_or_else(|| name.to_string())
|
||||
}
|
||||
|
||||
fn import_abr(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.presets.importAbr";
|
||||
let (bytes, stem) = file_bytes(p, cmd, ".abr file")?;
|
||||
let group = match p.get("group") {
|
||||
Some(v) => v.as_str().map(str::trim).filter(|g| !g.is_empty()).ok_or_else(|| bad(cmd, "`group` must be a non-empty string"))?.to_string(),
|
||||
None if !stem.is_empty() => stem,
|
||||
None => "Imported Brushes".to_string(),
|
||||
};
|
||||
let imp = photocraft_io::abr_map::read_abr(&bytes, &group).map_err(|e| bad(cmd, e))?;
|
||||
// Re-importing a file replaces its group instead of duplicating it.
|
||||
if p.get("replace").and_then(Value::as_bool).unwrap_or(true) {
|
||||
s.tools.presets.retain(|x| x.builtin || x.group != group);
|
||||
}
|
||||
let mut taken: Vec<String> = s.tools.presets.iter().map(|x| x.name.clone()).collect();
|
||||
let mut names = Vec::with_capacity(imp.presets.len());
|
||||
for mut preset in imp.presets {
|
||||
preset.name = unique(&preset.name, &taken);
|
||||
taken.push(preset.name.clone());
|
||||
names.push(preset.name.clone());
|
||||
s.tools.presets.push(preset);
|
||||
}
|
||||
s.brush_presets_changed();
|
||||
if p.get("select").and_then(Value::as_bool).unwrap_or(false)
|
||||
&& let Some(first) = names.first()
|
||||
&& let Some(pr) = photocraft_paint::presets::find(&s.tools.presets, first)
|
||||
{
|
||||
s.tools.brush = pr.brush.clone().picked_over(&s.tools.brush);
|
||||
}
|
||||
Ok(json!({ "group": group, "imported": names, "count": names.len(), "version": imp.version, "warnings": imp.warnings }))
|
||||
}
|
||||
|
||||
/// Brush-file import command specs.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec {
|
||||
id: "brush.presets.importAbr",
|
||||
label: "Import Brushes…",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"path":".abr file"?,"data":base64 bytes?,"group":string?=file name,"replace":bool=true (replace a group of the same name),"select":bool=false (make the first imported preset current)}"##,
|
||||
enabled: always,
|
||||
run: import_abr,
|
||||
journal: true,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "gradient.presets.importGrd",
|
||||
label: "Import Gradients…",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"path":".grd file"?,"data":base64 bytes?,"group":string?=file name,"replace":bool=true (replace a group of the same name)}"##,
|
||||
enabled: always,
|
||||
run: import_grd,
|
||||
journal: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ gradients (.grd)
|
||||
|
||||
fn hsb_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] {
|
||||
let h = h.rem_euclid(360.0) / 60.0;
|
||||
let c = v * s;
|
||||
let x = c * (1.0 - (h % 2.0 - 1.0).abs());
|
||||
let (r, g, b) = match h as u32 {
|
||||
0 => (c, x, 0.0),
|
||||
1 => (x, c, 0.0),
|
||||
2 => (0.0, c, x),
|
||||
3 => (0.0, x, c),
|
||||
4 => (x, 0.0, c),
|
||||
_ => (c, 0.0, x),
|
||||
};
|
||||
let m = v - c;
|
||||
[r + m, g + m, b + m]
|
||||
}
|
||||
|
||||
/// CIE Lab (D50, as Photoshop stores it) to sRGB through XYZ with Bradford adaptation to D65.
|
||||
fn lab_to_rgb([l, a, b]: [f32; 3]) -> [f32; 3] {
|
||||
let fy = (l + 16.0) / 116.0;
|
||||
let (fx, fz) = (fy + a / 500.0, fy - b / 200.0);
|
||||
let inv = |t: f32| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f32 / 29.0).powi(2) * (t - 4.0 / 29.0) };
|
||||
let (x, y, z) = (0.9642 * inv(fx), inv(fy), 0.8249 * inv(fz));
|
||||
// XYZ (D50) → linear sRGB (D65), Bradford-adapted matrix.
|
||||
let r = 3.1339 * x - 1.6169 * y - 0.4906 * z;
|
||||
let g = -0.9788 * x + 1.9161 * y + 0.0335 * z;
|
||||
let bb = 0.0719 * x - 0.2290 * y + 1.4052 * z;
|
||||
let gamma = |c: f32| {
|
||||
let c = c.clamp(0.0, 1.0);
|
||||
if c <= 0.003_130_8 { 12.92 * c } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }
|
||||
};
|
||||
[gamma(r), gamma(g), gamma(bb)]
|
||||
}
|
||||
|
||||
fn stop_color(c: photocraft_psd::grd::GrdColor) -> crate::presets::gradients::StopColor {
|
||||
use crate::presets::gradients::StopColor;
|
||||
use photocraft_psd::grd::GrdColor;
|
||||
let clamp = |c: [f32; 3]| StopColor::Rgb(c.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }));
|
||||
match c {
|
||||
GrdColor::Foreground => StopColor::Foreground,
|
||||
GrdColor::Background => StopColor::Background,
|
||||
GrdColor::Rgb(c) => clamp(c),
|
||||
GrdColor::Hsb(c) => clamp(hsb_to_rgb(c)),
|
||||
// Naive (profile-free) ink → RGB, like an uncalibrated preview.
|
||||
GrdColor::Cmyk([c, m, y, k]) => clamp([(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]),
|
||||
GrdColor::Lab(c) => clamp(lab_to_rgb(c)),
|
||||
GrdColor::Gray(g) => clamp([1.0 - g; 3]),
|
||||
}
|
||||
}
|
||||
|
||||
fn import_grd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use crate::presets::{Group, gradients::GradientPreset};
|
||||
let cmd = "gradient.presets.importGrd";
|
||||
let (bytes, stem) = file_bytes(p, cmd, ".grd file")?;
|
||||
let group = match p.get("group") {
|
||||
Some(v) => v.as_str().map(str::trim).filter(|g| !g.is_empty()).ok_or_else(|| bad(cmd, "`group` must be a non-empty string"))?.to_string(),
|
||||
None if !stem.is_empty() => stem,
|
||||
None => "Imported Gradients".to_string(),
|
||||
};
|
||||
let grads = photocraft_psd::grd::parse(&bytes).map_err(|e| bad(cmd, format!("not a readable Photoshop gradient file: {e}")))?;
|
||||
let mut warnings = Vec::new();
|
||||
let mut items = Vec::new();
|
||||
let (mut noise, mut midpoints) = (0, false);
|
||||
for (i, g) in grads.iter().enumerate() {
|
||||
if g.noise || g.stops.len() < 2 {
|
||||
noise += 1;
|
||||
continue;
|
||||
}
|
||||
midpoints |= g.stops.iter().any(|st| (st.midpoint - 0.5).abs() > 0.01) || g.opacity.iter().any(|o| (o.2 - 0.5).abs() > 0.01);
|
||||
let mut stops: Vec<(f32, _)> = g.stops.iter().map(|st| (st.location, stop_color(st.color))).collect();
|
||||
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut opacity: Vec<(f32, f32)> = g.opacity.iter().map(|o| (o.0, o.1)).collect();
|
||||
opacity.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
if opacity.iter().all(|o| o.1 >= 1.0) {
|
||||
opacity.clear();
|
||||
}
|
||||
let name = if g.name.trim().is_empty() { format!("Gradient {}", i + 1) } else { g.name.trim().to_string() };
|
||||
items.push(GradientPreset { name, stops, opacity });
|
||||
}
|
||||
if noise > 0 {
|
||||
warnings.push(format!("{noise} noise gradients are not supported and were skipped"));
|
||||
}
|
||||
if midpoints {
|
||||
warnings.push("colour and opacity midpoints other than 50 % are drawn at 50 %".to_string());
|
||||
}
|
||||
if items.is_empty() {
|
||||
return Err(bad(cmd, "the file holds no gradients PhotoCraft can use (noise gradients only)"));
|
||||
}
|
||||
let names: Vec<String> = items.iter().map(|g| g.name.clone()).collect();
|
||||
let st = &mut s.presets;
|
||||
if p.get("replace").and_then(Value::as_bool).unwrap_or(true) {
|
||||
st.gradients.retain(|g| g.name != group);
|
||||
}
|
||||
st.gradients.push(Group::new(&group, items));
|
||||
s.presets_changed();
|
||||
Ok(json!({"group": group, "imported": names, "count": names.len(), "warnings": warnings}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_psd::abr::{AbrSample, LegacyBrush, LegacyTip, write_v6, write_v12};
|
||||
use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value as DV};
|
||||
|
||||
fn abr_v2() -> Vec<u8> {
|
||||
let tip = AbrSample { id: String::new(), width: 6, height: 4, depth: 8, data: vec![255; 24] };
|
||||
write_v12(
|
||||
2,
|
||||
&[
|
||||
LegacyBrush { name: "Hard Round".into(), spacing: 25, anti_alias: true, tip: LegacyTip::Sampled(tip) },
|
||||
LegacyBrush {
|
||||
name: String::new(),
|
||||
spacing: 10,
|
||||
anti_alias: true,
|
||||
tip: LegacyTip::Computed { diameter: 9, hardness: 100, angle: 0, roundness: 100 },
|
||||
},
|
||||
],
|
||||
true,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_adds_a_group_and_paints() {
|
||||
let mut s = Session::new();
|
||||
let before = s.tools.presets.len();
|
||||
let data = photocraft_paint::tile::b64_encode(&abr_v2());
|
||||
let r = s.execute("brush.presets.importAbr", json!({"data": data, "group": "Legacy", "select": true})).unwrap();
|
||||
assert_eq!(r["count"], 2);
|
||||
// The name clashing with the built-in Hard Round is made unique.
|
||||
assert_eq!(r["imported"], json!(["Hard Round 2", "9 px Round"]));
|
||||
assert_eq!(s.tools.presets.len(), before + 2);
|
||||
assert!(s.tools.presets.iter().filter(|p| p.group == "Legacy").all(|p| !p.builtin));
|
||||
assert!(matches!(s.tools.brush.tip, photocraft_paint::TipShape::Sampled(_)));
|
||||
// Re-import replaces the group.
|
||||
s.execute("brush.presets.importAbr", json!({"data": data, "group": "Legacy"})).unwrap();
|
||||
assert_eq!(s.tools.presets.len(), before + 2);
|
||||
// Imported presets paint.
|
||||
s.execute("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||||
s.execute("paint.stroke", json!({"points": [[5, 30], [60, 30]], "preset": "9 px Round"})).unwrap();
|
||||
// From a file on disk, the group defaults to the file name.
|
||||
let dir = std::env::temp_dir().join(format!("pc-abr-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("My Set.abr");
|
||||
let preset = Descriptor::new("brushPreset")
|
||||
.with("Nm ", DV::Text(UnicodeString::new_nul("Speckle")))
|
||||
.with("Brsh", DV::Descriptor(Descriptor::new("sampledBrush").with("sampledData", DV::Text(UnicodeString::new_nul("$s")))));
|
||||
let tip = AbrSample { id: "$s".into(), width: 5, height: 5, depth: 16, data: vec![0x80; 50] };
|
||||
std::fs::write(&path, write_v6(2, &[tip], &[], &[preset], true).unwrap()).unwrap();
|
||||
let r = s.execute("brush.presets.importAbr", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!((r["group"].as_str(), r["imported"][0].as_str(), r["version"].as_u64()), (Some("My Set"), Some("Speckle"), Some(6)));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_fail_gracefully() {
|
||||
let mut s = Session::new();
|
||||
let n = s.tools.presets.len();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"path": ""}),
|
||||
json!({"path": "/nonexistent/brushes.abr"}),
|
||||
json!({"data": 42}),
|
||||
json!({"data": "%%%not base64"}),
|
||||
json!({"data": ""}),
|
||||
json!({"data": "AAAA"}),
|
||||
json!({"data": photocraft_paint::tile::b64_encode(b"8BPS\0\x01junk")}),
|
||||
json!({"data": photocraft_paint::tile::b64_encode(&abr_v2()), "group": 5}),
|
||||
json!({"data": photocraft_paint::tile::b64_encode(&abr_v2()), "group": " "}),
|
||||
json!({"data": photocraft_paint::tile::b64_encode(&abr_v2()[..20])}),
|
||||
] {
|
||||
assert!(s.execute("brush.presets.importAbr", p.clone()).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.tools.presets.len(), n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grd_import_adds_a_gradient_group() {
|
||||
use crate::presets::gradients::StopColor;
|
||||
use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop, write};
|
||||
let grads = vec![
|
||||
GrdGradient {
|
||||
name: "Ember".into(),
|
||||
noise: false,
|
||||
stops: vec![
|
||||
GrdStop { location: 1.0, midpoint: 0.5, color: GrdColor::Rgb([1.0, 0.0, 0.0]) },
|
||||
GrdStop { location: 0.0, midpoint: 0.5, color: GrdColor::Foreground },
|
||||
],
|
||||
opacity: vec![(0.0, 1.0, 0.5), (1.0, 0.0, 0.5)],
|
||||
},
|
||||
GrdGradient { name: "Static".into(), noise: true, ..Default::default() },
|
||||
];
|
||||
let mut s = Session::new();
|
||||
let data = photocraft_paint::tile::b64_encode(&write(&grads));
|
||||
let r = s.execute("gradient.presets.importGrd", json!({"data": data, "group": "Fire"})).unwrap();
|
||||
assert_eq!(r["imported"], json!(["Ember"]));
|
||||
assert_eq!(r["warnings"].as_array().map(Vec::len), Some(1));
|
||||
let g = s.presets.gradients.iter().find(|g| g.name == "Fire").unwrap();
|
||||
assert_eq!(g.items[0].stops, vec![(0.0, StopColor::Foreground), (1.0, StopColor::Rgb([1.0, 0.0, 0.0]))]);
|
||||
assert_eq!(g.items[0].opacity, vec![(0.0, 1.0), (1.0, 0.0)]);
|
||||
// Re-import replaces the group.
|
||||
let n = s.presets.gradients.len();
|
||||
s.execute("gradient.presets.importGrd", json!({"data": data, "group": "Fire"})).unwrap();
|
||||
assert_eq!(s.presets.gradients.len(), n);
|
||||
for p in [json!({}), json!({"data": "@@"}), json!({"data": photocraft_paint::tile::b64_encode(b"8BGR\0\x03")}), json!({"path": "/nope.grd"})] {
|
||||
assert!(s.execute("gradient.presets.importGrd", p.clone()).is_err(), "{p}");
|
||||
}
|
||||
let only_noise = photocraft_paint::tile::b64_encode(&write(&grads[1..]));
|
||||
assert!(s.execute("gradient.presets.importGrd", json!({"data": only_noise})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_conversions() {
|
||||
assert_eq!(hsb_to_rgb([120.0, 1.0, 1.0]), [0.0, 1.0, 0.0]);
|
||||
let w = lab_to_rgb([100.0, 0.0, 0.0]);
|
||||
assert!(w.iter().all(|v| (v - 1.0).abs() < 0.01), "{w:?}");
|
||||
let k = lab_to_rgb([0.0, 0.0, 0.0]);
|
||||
assert!(k.iter().all(|v| *v < 0.01), "{k:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_names() {
|
||||
let taken = vec!["A".to_string(), "a 2".to_string()];
|
||||
assert_eq!(unique("a", &taken), "a 3");
|
||||
assert_eq!(unique("B", &taken), "B");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,753 @@
|
||||
//! Persistent brush preset store: user and imported (`.abr`) brush presets survive a restart.
|
||||
//!
|
||||
//! Sampled tips would bloat the preferences document, so brush presets live in their own store,
|
||||
//! a directory next to the preferences (desktop: `<config dir>/Presets`):
|
||||
//!
|
||||
//! - `<group>-<hash>.pcbrushes`: one JSON file per preset group (folder) holding the presets'
|
||||
//! settings with every bitmap (sampled tip, Dual Brush tip, texture tile) replaced by a
|
||||
//! reference to a tip file;
|
||||
//! - `tips/<hash>.pctip`: content-addressed bitmaps, deflated, stored at 8 bits per sample when
|
||||
//! the tip came from 8-bit data and 16 bits otherwise. Renaming or re-grouping a preset only
|
||||
//! rewrites a small JSON file; a tip no group references any more is deleted;
|
||||
//! - `index.json`: group order, the order of every preset (built-ins included, so drag and drop
|
||||
//! in the Brushes panel persists) and the built-in presets the user deleted.
|
||||
//!
|
||||
//! Built-in presets are never written (they are regenerated on start). The store syncs after
|
||||
//! every command that changes the brush presets ([`Session::brush_presets_changed`]); only groups
|
||||
//! whose content changed are rewritten. Every write is atomic (temp file + rename) and bounded
|
||||
//! ([`MAX_GROUP_BYTES`], [`MAX_TIP_BYTES`], [`MAX_STORE_BYTES`]); corrupt or oversized files are
|
||||
//! skipped with a warning and left on disk, never a panic.
|
||||
//!
|
||||
//! A [`Session`] has no store by default, so headless CLI/MCP sessions and tests never write.
|
||||
//! The desktop app opens the store on a background thread ([`open`]) and attaches it with
|
||||
//! [`Session::attach_preset_store`] once loaded, so the first frame never waits for it. The web
|
||||
//! app has no store (localStorage is too small for sampled tips): its brush presets are
|
||||
//! session-only. Gradient presets, including imported `.grd` groups, are small and persist with
|
||||
//! the preferences document (`presets.gradients`).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Read;
|
||||
|
||||
use photocraft_paint::{BrushPreset, BrushSettings, GrayTile, Pattern, TipShape};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Group file extension.
|
||||
pub const GROUP_EXT: &str = "pcbrushes";
|
||||
/// Tip file extension (inside [`TIPS_DIR`]).
|
||||
pub const TIP_EXT: &str = "pctip";
|
||||
/// Subdirectory holding the tip bitmaps.
|
||||
pub const TIPS_DIR: &str = "tips";
|
||||
/// Group order and deleted built-ins.
|
||||
pub const INDEX_FILE: &str = "index.json";
|
||||
/// Largest group file read or written.
|
||||
pub const MAX_GROUP_BYTES: u64 = 16 << 20;
|
||||
/// Largest tip file read or written.
|
||||
pub const MAX_TIP_BYTES: u64 = 64 << 20;
|
||||
/// Largest index file read.
|
||||
pub const MAX_INDEX_BYTES: u64 = 4 << 20;
|
||||
/// The whole store never grows beyond this; further groups are skipped with a warning.
|
||||
pub const MAX_STORE_BYTES: u64 = 2 << 30;
|
||||
/// Largest tip side accepted from disk.
|
||||
pub const MAX_TIP_SIDE: u32 = 16384;
|
||||
/// Largest tip sample count accepted from disk (64 Mi samples).
|
||||
pub const MAX_TIP_SAMPLES: u64 = 1 << 26;
|
||||
/// Most group files loaded.
|
||||
pub const MAX_GROUPS: usize = 4096;
|
||||
/// Most presets loaded from one group file.
|
||||
pub const MAX_PRESETS_PER_GROUP: usize = 50_000;
|
||||
|
||||
const GROUP_FORMAT: &str = "photocraft-brush-group";
|
||||
const TIP_MAGIC: &[u8; 6] = b"PCTIP1";
|
||||
const TIP_HEADER: usize = 6 + 1 + 4 + 4;
|
||||
|
||||
/// Where the store's files live. File names are relative (`name.pcbrushes`, `tips/x.pctip`).
|
||||
pub trait PresetBackend: Send {
|
||||
/// Every file in the store with its size in bytes (the top level plus [`TIPS_DIR`]).
|
||||
/// A missing store is empty, not an error.
|
||||
fn list(&self) -> Result<Vec<(String, u64)>, String>;
|
||||
/// Read a file, failing when it is larger than `max` bytes.
|
||||
fn read(&self, name: &str, max: u64) -> Result<Vec<u8>, String>;
|
||||
/// Replace a file atomically (a crash leaves the old or the new file, never half of one).
|
||||
fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String>;
|
||||
/// Delete a file; deleting a missing file succeeds.
|
||||
fn remove(&self, name: &str) -> Result<(), String>;
|
||||
}
|
||||
|
||||
/// Store file names are generated (`[A-Za-z0-9 _.-]`, optionally under `tips/`); anything else
|
||||
/// (path separators, `..`) is refused so a backend never leaves its directory.
|
||||
fn valid_name(name: &str) -> bool {
|
||||
let leaf = name.strip_prefix("tips/").unwrap_or(name);
|
||||
!leaf.is_empty() && leaf.len() <= 128 && !leaf.starts_with('.') && leaf.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '_' | '-' | '.'))
|
||||
}
|
||||
|
||||
/// An in-memory store (tests, and frontends without a file system). Clones share the files.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct MemBackend {
|
||||
pub files: std::sync::Arc<std::sync::Mutex<std::collections::BTreeMap<String, Vec<u8>>>>,
|
||||
}
|
||||
|
||||
impl MemBackend {
|
||||
fn lock(&self) -> Result<std::sync::MutexGuard<'_, std::collections::BTreeMap<String, Vec<u8>>>, String> {
|
||||
self.files.lock().map_err(|_| "preset store lock poisoned".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl PresetBackend for MemBackend {
|
||||
fn list(&self) -> Result<Vec<(String, u64)>, String> {
|
||||
Ok(self.lock()?.iter().map(|(k, v)| (k.clone(), v.len() as u64)).collect())
|
||||
}
|
||||
fn read(&self, name: &str, max: u64) -> Result<Vec<u8>, String> {
|
||||
let files = self.lock()?;
|
||||
let b = files.get(name).ok_or_else(|| format!("{name}: not found"))?;
|
||||
if b.len() as u64 > max {
|
||||
return Err(format!("{name}: too large ({} bytes)", b.len()));
|
||||
}
|
||||
Ok(b.clone())
|
||||
}
|
||||
fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
if !valid_name(name) {
|
||||
return Err(format!("invalid store file name `{name}`"));
|
||||
}
|
||||
self.lock()?.insert(name.to_string(), bytes.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
fn remove(&self, name: &str) -> Result<(), String> {
|
||||
self.lock()?.remove(name);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A store directory on disk (desktop).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub struct DirBackend {
|
||||
root: std::path::PathBuf,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl DirBackend {
|
||||
pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
|
||||
DirBackend { root: root.into() }
|
||||
}
|
||||
pub fn root(&self) -> &std::path::Path {
|
||||
&self.root
|
||||
}
|
||||
fn path(&self, name: &str) -> Result<std::path::PathBuf, String> {
|
||||
if !valid_name(name) {
|
||||
return Err(format!("invalid store file name `{name}`"));
|
||||
}
|
||||
Ok(self.root.join(name))
|
||||
}
|
||||
fn list_dir(&self, sub: &str, out: &mut Vec<(String, u64)>) -> Result<(), String> {
|
||||
let dir = if sub.is_empty() { self.root.clone() } else { self.root.join(sub) };
|
||||
let rd = match std::fs::read_dir(&dir) {
|
||||
Ok(rd) => rd,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(format!("{}: {e}", dir.display())),
|
||||
};
|
||||
for entry in rd.flatten() {
|
||||
let Ok(meta) = entry.metadata() else { continue };
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(leaf) = entry.file_name().to_str().map(str::to_string) else { continue };
|
||||
let name = if sub.is_empty() { leaf } else { format!("{sub}/{leaf}") };
|
||||
if valid_name(&name) {
|
||||
out.push((name, meta.len()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl PresetBackend for DirBackend {
|
||||
fn list(&self) -> Result<Vec<(String, u64)>, String> {
|
||||
let mut out = Vec::new();
|
||||
self.list_dir("", &mut out)?;
|
||||
self.list_dir(TIPS_DIR, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
fn read(&self, name: &str, max: u64) -> Result<Vec<u8>, String> {
|
||||
let path = self.path(name)?;
|
||||
let f = std::fs::File::open(&path).map_err(|e| format!("{name}: {e}"))?;
|
||||
let mut out = Vec::new();
|
||||
f.take(max.saturating_add(1)).read_to_end(&mut out).map_err(|e| format!("{name}: {e}"))?;
|
||||
if out.len() as u64 > max {
|
||||
return Err(format!("{name}: larger than {max} bytes"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let path = self.path(name)?;
|
||||
let dir = path.parent().ok_or_else(|| format!("{name}: no parent directory"))?;
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let leaf = path.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let tmp = dir.join(format!(".{leaf}.tmp"));
|
||||
std::fs::write(&tmp, bytes).map_err(|e| format!("{name}: {e}"))?;
|
||||
std::fs::rename(&tmp, &path).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
format!("{name}: {e}")
|
||||
})
|
||||
}
|
||||
fn remove(&self, name: &str) -> Result<(), String> {
|
||||
match std::fs::remove_file(self.path(name)?) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(format!("{name}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ tips
|
||||
|
||||
/// Content hash of a tip (32 hex digits); the tip file's name.
|
||||
fn tip_hash(t: &GrayTile) -> String {
|
||||
let mut h = blake3::Hasher::new();
|
||||
h.update(&t.width.to_le_bytes());
|
||||
h.update(&t.height.to_le_bytes());
|
||||
let mut buf = Vec::with_capacity(8192);
|
||||
for chunk in t.data.chunks(4096) {
|
||||
buf.clear();
|
||||
for v in chunk {
|
||||
buf.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
h.update(&buf);
|
||||
}
|
||||
h.finalize().to_hex()[..32].to_string()
|
||||
}
|
||||
|
||||
/// `PCTIP1`, bits (8|16), width, height (u32 LE), then the zlib-deflated samples.
|
||||
pub fn encode_tip(t: &GrayTile) -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
let eight = t.data.iter().all(|v| v % 257 == 0);
|
||||
let mut out = Vec::with_capacity(TIP_HEADER + t.data.len() / 4);
|
||||
out.extend_from_slice(TIP_MAGIC);
|
||||
out.push(if eight { 8 } else { 16 });
|
||||
out.extend_from_slice(&t.width.to_le_bytes());
|
||||
out.extend_from_slice(&t.height.to_le_bytes());
|
||||
let mut z = flate2::write::ZlibEncoder::new(out, flate2::Compression::fast());
|
||||
let mut buf = Vec::with_capacity(8192);
|
||||
let mut ok = true;
|
||||
for chunk in t.data.chunks(4096) {
|
||||
buf.clear();
|
||||
if eight {
|
||||
buf.extend(chunk.iter().map(|v| (v / 257) as u8));
|
||||
} else {
|
||||
for v in chunk {
|
||||
buf.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
ok &= z.write_all(&buf).is_ok();
|
||||
}
|
||||
match z.finish() {
|
||||
Ok(v) if ok => v,
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of [`encode_tip`]; any malformed, truncated or oversized input is an error.
|
||||
pub fn decode_tip(bytes: &[u8]) -> Result<GrayTile, String> {
|
||||
let head = bytes.get(..TIP_HEADER).ok_or("truncated tip header")?;
|
||||
if &head[..6] != TIP_MAGIC {
|
||||
return Err("not a PhotoCraft tip".into());
|
||||
}
|
||||
let bits = head[6];
|
||||
let u32_at = |i: usize| u32::from_le_bytes([head[i], head[i + 1], head[i + 2], head[i + 3]]);
|
||||
let (w, h) = (u32_at(7), u32_at(11));
|
||||
if w == 0 || h == 0 || w > MAX_TIP_SIDE || h > MAX_TIP_SIDE {
|
||||
return Err(format!("bad tip size {w}×{h}"));
|
||||
}
|
||||
let n = u64::from(w) * u64::from(h);
|
||||
if n > MAX_TIP_SAMPLES {
|
||||
return Err(format!("tip too large ({w}×{h})"));
|
||||
}
|
||||
let bytes_per = match bits {
|
||||
8 => 1u64,
|
||||
16 => 2,
|
||||
b => return Err(format!("unsupported tip depth {b}")),
|
||||
};
|
||||
let want = n * bytes_per;
|
||||
let mut raw = Vec::with_capacity(want as usize);
|
||||
flate2::read::ZlibDecoder::new(&bytes[TIP_HEADER..]).take(want + 1).read_to_end(&mut raw).map_err(|e| format!("tip data: {e}"))?;
|
||||
if raw.len() as u64 != want {
|
||||
return Err(format!("tip data has {} bytes, expected {want}", raw.len()));
|
||||
}
|
||||
let data: Vec<u16> =
|
||||
if bits == 8 { raw.iter().map(|&v| u16::from(v) * 257).collect() } else { raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect() };
|
||||
Ok(GrayTile { width: w, height: h, data })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ group files
|
||||
|
||||
/// References from a stored preset to its tip files.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct TipRefs {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tip: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
dual_tip: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
texture: Option<String>,
|
||||
}
|
||||
|
||||
impl TipRefs {
|
||||
fn hashes(&self) -> impl Iterator<Item = &String> {
|
||||
[&self.tip, &self.dual_tip, &self.texture].into_iter().flatten()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct StoredPreset {
|
||||
name: String,
|
||||
brush: BrushSettings,
|
||||
#[serde(default)]
|
||||
tips: TipRefs,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GroupFile {
|
||||
format: String,
|
||||
version: u32,
|
||||
group: String,
|
||||
presets: Vec<StoredPreset>,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct IndexFile {
|
||||
version: u32,
|
||||
/// Group files in display order.
|
||||
groups: Vec<String>,
|
||||
/// Built-in presets the user deleted (by name).
|
||||
hidden_builtins: Vec<String>,
|
||||
/// Every preset's name in library (panel) order; empty when it is the default order.
|
||||
order: Vec<String>,
|
||||
}
|
||||
|
||||
/// The group's file name: a readable, sanitised prefix plus a hash of the exact name, so
|
||||
/// different names never share a file.
|
||||
pub fn group_file_name(group: &str) -> String {
|
||||
let mut stem: String = group.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, ' ' | '_' | '-') { c } else { '_' }).take(48).collect();
|
||||
stem = stem.trim().to_string();
|
||||
if stem.is_empty() {
|
||||
stem = "Ungrouped".into();
|
||||
}
|
||||
let h = blake3::hash(group.as_bytes()).to_hex();
|
||||
format!("{stem}-{}.{GROUP_EXT}", &h[..8])
|
||||
}
|
||||
|
||||
/// Take the bitmaps out of a preset's settings (they are stored as tip files).
|
||||
fn split_tips(mut b: BrushSettings) -> (BrushSettings, [Option<GrayTile>; 3]) {
|
||||
let tip = match std::mem::take(&mut b.tip) {
|
||||
TipShape::Sampled(t) => Some(t),
|
||||
TipShape::Round => None,
|
||||
};
|
||||
let dual = match std::mem::take(&mut b.dual_brush.tip) {
|
||||
TipShape::Sampled(t) => Some(t),
|
||||
TipShape::Round => None,
|
||||
};
|
||||
let texture = match std::mem::take(&mut b.texture.pattern) {
|
||||
Pattern::Tile(t) => Some(t),
|
||||
other => {
|
||||
b.texture.pattern = other;
|
||||
None
|
||||
}
|
||||
};
|
||||
(b, [tip, dual, texture])
|
||||
}
|
||||
|
||||
/// The opposite of [`split_tips`]: `None` when a referenced tip is missing.
|
||||
fn join_tips(mut b: BrushSettings, refs: &TipRefs, tips: &HashMap<String, GrayTile>) -> Option<BrushSettings> {
|
||||
if let Some(h) = &refs.tip {
|
||||
b.tip = TipShape::Sampled(tips.get(h)?.clone());
|
||||
}
|
||||
if let Some(h) = &refs.dual_tip {
|
||||
b.dual_brush.tip = TipShape::Sampled(tips.get(h)?.clone());
|
||||
}
|
||||
if let Some(h) = &refs.texture {
|
||||
b.texture.pattern = Pattern::Tile(tips.get(h)?.clone());
|
||||
}
|
||||
Some(b)
|
||||
}
|
||||
|
||||
fn tip_file(hash: &str) -> String {
|
||||
format!("{TIPS_DIR}/{hash}.{TIP_EXT}")
|
||||
}
|
||||
|
||||
/// What a group looked like when it was last written (or loaded).
|
||||
struct Synced {
|
||||
presets: Vec<BrushPreset>,
|
||||
tips: Vec<String>,
|
||||
bytes: u64,
|
||||
}
|
||||
|
||||
/// The persistent brush preset store attached to a session (see the module docs).
|
||||
pub struct PresetStore {
|
||||
backend: Box<dyn PresetBackend>,
|
||||
/// `tools.presets_rev` at the last sync.
|
||||
synced_rev: Option<u64>,
|
||||
/// Group file name → its last written content.
|
||||
groups: HashMap<String, Synced>,
|
||||
/// Tip hash → file size, for every tip file the store owns.
|
||||
tips: HashMap<String, u64>,
|
||||
index: Option<IndexFile>,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A loaded store: the presets on disk plus what the session should hide.
|
||||
pub struct Opened {
|
||||
pub store: PresetStore,
|
||||
/// User and imported presets, in group order.
|
||||
pub presets: Vec<BrushPreset>,
|
||||
/// Built-in presets the user deleted.
|
||||
pub hidden_builtins: Vec<String>,
|
||||
/// The saved library order (preset names), empty when none was saved.
|
||||
pub order: Vec<String>,
|
||||
/// Files that were skipped (corrupt, oversized, missing tips).
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Load a store (any thread; the desktop app does this in the background at start).
|
||||
pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
|
||||
let mut warnings = Vec::new();
|
||||
let files = backend.list().unwrap_or_else(|e| {
|
||||
warnings.push(format!("brush presets: {e}"));
|
||||
Vec::new()
|
||||
});
|
||||
let index: Option<IndexFile> = if files.iter().any(|(n, _)| n == INDEX_FILE) {
|
||||
match backend.read(INDEX_FILE, MAX_INDEX_BYTES).and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string())) {
|
||||
Ok(i) => Some(i),
|
||||
Err(e) => {
|
||||
warnings.push(format!("brush presets: {INDEX_FILE} skipped: {e}"));
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Group files in index order, then the rest by name.
|
||||
let mut group_files: Vec<(String, u64)> = files.iter().filter(|(n, _)| !n.contains('/') && n.ends_with(&format!(".{GROUP_EXT}"))).cloned().collect();
|
||||
group_files.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
if let Some(ix) = &index {
|
||||
let pos = |n: &str| ix.groups.iter().position(|g| g == n).unwrap_or(usize::MAX);
|
||||
group_files.sort_by_key(|(n, _)| pos(n));
|
||||
}
|
||||
if group_files.len() > MAX_GROUPS {
|
||||
warnings.push(format!("brush presets: only the first {MAX_GROUPS} of {} groups were loaded", group_files.len()));
|
||||
group_files.truncate(MAX_GROUPS);
|
||||
}
|
||||
let tip_sizes: HashMap<String, u64> =
|
||||
files.iter().filter_map(|(n, sz)| Some((n.strip_prefix(&format!("{TIPS_DIR}/"))?.strip_suffix(&format!(".{TIP_EXT}"))?.to_string(), *sz))).collect();
|
||||
|
||||
// Parse the group files.
|
||||
let mut parsed: Vec<(String, u64, GroupFile)> = Vec::new();
|
||||
let mut failed = false;
|
||||
for (name, size) in group_files {
|
||||
let r = backend.read(&name, MAX_GROUP_BYTES).and_then(|b| serde_json::from_slice::<GroupFile>(&b).map_err(|e| e.to_string())).and_then(|g| {
|
||||
if g.format != GROUP_FORMAT {
|
||||
Err(format!("unknown format `{}`", g.format))
|
||||
} else if g.version != 1 {
|
||||
Err(format!("unsupported version {}", g.version))
|
||||
} else if g.presets.len() > MAX_PRESETS_PER_GROUP {
|
||||
Err(format!("{} presets (limit {MAX_PRESETS_PER_GROUP})", g.presets.len()))
|
||||
} else {
|
||||
Ok(g)
|
||||
}
|
||||
});
|
||||
match r {
|
||||
Ok(g) => parsed.push((name, size, g)),
|
||||
Err(e) => {
|
||||
failed = true;
|
||||
warnings.push(format!("brush presets: {name} skipped: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Decode every referenced tip once.
|
||||
let wanted: Vec<String> = {
|
||||
let mut seen = HashSet::new();
|
||||
parsed.iter().flat_map(|(_, _, g)| g.presets.iter().flat_map(|p| p.tips.hashes())).filter(|h| seen.insert(h.as_str())).cloned().collect()
|
||||
};
|
||||
let decoded = decode_tips(backend.as_ref(), &wanted, &tip_sizes);
|
||||
let mut tips: HashMap<String, GrayTile> = HashMap::with_capacity(decoded.len());
|
||||
for (h, r) in wanted.iter().zip(decoded) {
|
||||
match r {
|
||||
Ok(t) => {
|
||||
tips.insert(h.clone(), t);
|
||||
}
|
||||
Err(e) => warnings.push(format!("brush presets: tip {h} skipped: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
let mut store = PresetStore { backend, synced_rev: None, groups: HashMap::new(), tips: HashMap::new(), index: None, warnings: Vec::new() };
|
||||
let mut presets = Vec::new();
|
||||
for (file, size, g) in parsed {
|
||||
let mut items = Vec::with_capacity(g.presets.len());
|
||||
let mut hashes = Vec::new();
|
||||
let mut missing = 0;
|
||||
for sp in g.presets {
|
||||
match join_tips(sp.brush, &sp.tips, &tips) {
|
||||
Some(brush) => {
|
||||
hashes.extend(sp.tips.hashes().cloned());
|
||||
items.push(BrushPreset { name: sp.name, brush, builtin: false, group: g.group.clone() });
|
||||
}
|
||||
None => missing += 1,
|
||||
}
|
||||
}
|
||||
if missing > 0 {
|
||||
// Left on disk untouched (a sync never deletes a file it did not load).
|
||||
failed = true;
|
||||
warnings.push(format!("brush presets: {file} skipped: {missing} preset(s) have missing or unreadable tips"));
|
||||
continue;
|
||||
}
|
||||
for h in &hashes {
|
||||
if let Some(sz) = tip_sizes.get(h) {
|
||||
store.tips.insert(h.clone(), *sz);
|
||||
}
|
||||
}
|
||||
presets.extend(items.iter().cloned());
|
||||
store.groups.insert(file, Synced { presets: items, tips: hashes, bytes: size });
|
||||
}
|
||||
// Tip files nothing references are garbage (e.g. left by a crash), unless a group failed to
|
||||
// load: its tips must survive so a fixed file still works.
|
||||
if !failed {
|
||||
for (h, sz) in &tip_sizes {
|
||||
store.tips.entry(h.clone()).or_insert(*sz);
|
||||
}
|
||||
}
|
||||
let hidden_builtins = index.as_ref().map(|i| i.hidden_builtins.clone()).unwrap_or_default();
|
||||
let order = index.as_ref().map(|i| i.order.clone()).unwrap_or_default();
|
||||
store.index = index;
|
||||
Opened { store, presets, hidden_builtins, order, warnings }
|
||||
}
|
||||
|
||||
/// Read and decode tips, in parallel on native targets.
|
||||
fn decode_tips(backend: &dyn PresetBackend, hashes: &[String], sizes: &HashMap<String, u64>) -> Vec<Result<GrayTile, String>> {
|
||||
let read = |h: &String| -> Result<Vec<u8>, String> {
|
||||
if !sizes.contains_key(h) {
|
||||
return Err("file missing".into());
|
||||
}
|
||||
backend.read(&tip_file(h), MAX_TIP_BYTES)
|
||||
};
|
||||
// Reads stay sequential (the backend need not be Sync); decoding is the expensive part.
|
||||
let raw: Vec<Result<Vec<u8>, String>> = hashes.iter().map(read).collect();
|
||||
let dec = |r: &Result<Vec<u8>, String>| r.as_ref().map_err(Clone::clone).and_then(|b| decode_tip(b));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
raw.par_iter().map(dec).collect()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
raw.iter().map(dec).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a store directory (desktop). Never fails: problems become warnings.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn open_dir(root: impl Into<std::path::PathBuf>) -> Opened {
|
||||
open(Box::new(DirBackend::new(root)))
|
||||
}
|
||||
|
||||
/// Open a store directory on a background thread; the receiver yields it once loaded.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn open_dir_async(root: std::path::PathBuf) -> std::sync::mpsc::Receiver<Opened> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let spawned = std::thread::Builder::new().name("brush-presets".into()).spawn(move || {
|
||||
let _ = tx.send(open_dir(root));
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
// No thread: the caller simply never gets a store (presets stay session-only).
|
||||
eprintln!("brush presets: {e}");
|
||||
}
|
||||
rx
|
||||
}
|
||||
|
||||
impl PresetStore {
|
||||
/// Warnings from writes since the last call (the shell shows them in the status bar).
|
||||
pub fn take_warnings(&mut self) -> Vec<String> {
|
||||
std::mem::take(&mut self.warnings)
|
||||
}
|
||||
|
||||
/// Total bytes of the files the store owns.
|
||||
pub fn bytes(&self) -> u64 {
|
||||
self.groups.values().map(|g| g.bytes).sum::<u64>() + self.tips.values().sum::<u64>()
|
||||
}
|
||||
|
||||
/// Bring the files in line with `presets` (built-ins skipped): write changed groups, delete
|
||||
/// removed ones and their unreferenced tips, update the index.
|
||||
pub fn sync(&mut self, presets: &[BrushPreset]) {
|
||||
// User presets by group, in first-appearance order.
|
||||
let mut order: Vec<(String, Vec<&BrushPreset>)> = Vec::new();
|
||||
for p in presets.iter().filter(|p| !p.builtin) {
|
||||
match order.iter_mut().find(|(g, _)| *g == p.group) {
|
||||
Some((_, v)) => v.push(p),
|
||||
None => order.push((p.group.clone(), vec![p])),
|
||||
}
|
||||
}
|
||||
let mut files = Vec::with_capacity(order.len());
|
||||
for (group, items) in &order {
|
||||
let file = group_file_name(group);
|
||||
files.push(file.clone());
|
||||
let unchanged = self.groups.get(&file).is_some_and(|s| s.presets.len() == items.len() && s.presets.iter().zip(items).all(|(a, b)| a == *b));
|
||||
if unchanged {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = self.write_group(&file, group, items) {
|
||||
self.warnings.push(format!("Couldn't save brush presets `{}`: {e}", if group.is_empty() { "(ungrouped)" } else { group }));
|
||||
}
|
||||
}
|
||||
// Groups that are gone.
|
||||
let gone: Vec<String> = self.groups.keys().filter(|f| !files.contains(f)).cloned().collect();
|
||||
for f in gone {
|
||||
match self.backend.remove(&f) {
|
||||
Ok(()) => drop(self.groups.remove(&f)),
|
||||
Err(e) => self.warnings.push(format!("Couldn't delete brush presets file: {e}")),
|
||||
}
|
||||
}
|
||||
// Tips nothing references any more.
|
||||
let used: HashSet<&String> = self.groups.values().flat_map(|g| g.tips.iter()).collect();
|
||||
let unused: Vec<String> = self.tips.keys().filter(|h| !used.contains(h)).cloned().collect();
|
||||
for h in unused {
|
||||
match self.backend.remove(&tip_file(&h)) {
|
||||
Ok(()) => drop(self.tips.remove(&h)),
|
||||
Err(e) => self.warnings.push(format!("Couldn't delete brush tip: {e}")),
|
||||
}
|
||||
}
|
||||
// Index: group order and deleted built-ins.
|
||||
let hidden: Vec<String> = builtin_names().iter().filter(|n| !presets.iter().any(|p| p.name.eq_ignore_ascii_case(n))).map(|n| n.to_string()).collect();
|
||||
let groups: Vec<String> = files.into_iter().filter(|f| self.groups.contains_key(f)).collect();
|
||||
// The full order is only saved once it differs from the default (built-ins, then the rest).
|
||||
let order: Vec<String> = presets.iter().map(|p| p.name.clone()).collect();
|
||||
let default_order: Vec<String> = presets.iter().filter(|p| p.builtin).chain(presets.iter().filter(|p| !p.builtin)).map(|p| p.name.clone()).collect();
|
||||
let builtins_in_order = {
|
||||
let names = builtin_names();
|
||||
let mut it = presets.iter().filter(|p| p.builtin).map(|p| names.iter().position(|n| *n == p.name));
|
||||
let first = it.next().flatten();
|
||||
it.try_fold(first, |prev, cur| match (prev, cur) {
|
||||
(Some(a), Some(b)) if b > a => Some(Some(b)),
|
||||
_ => None,
|
||||
})
|
||||
.is_some()
|
||||
};
|
||||
let order = if builtins_in_order && order == default_order { Vec::new() } else { order };
|
||||
let want = IndexFile { version: 1, groups, hidden_builtins: hidden, order };
|
||||
let same = self.index.as_ref().is_some_and(|i| i.groups == want.groups && i.hidden_builtins == want.hidden_builtins && i.order == want.order);
|
||||
if !same {
|
||||
let empty = want.groups.is_empty() && want.hidden_builtins.is_empty() && want.order.is_empty();
|
||||
let r = if empty && self.index.is_none() {
|
||||
Ok(())
|
||||
} else {
|
||||
serde_json::to_vec_pretty(&want).map_err(|e| e.to_string()).and_then(|b| self.backend.write(INDEX_FILE, &b))
|
||||
};
|
||||
match r {
|
||||
Ok(()) => self.index = Some(want),
|
||||
Err(e) => self.warnings.push(format!("Couldn't save the brush preset index: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_group(&mut self, file: &str, group: &str, items: &[&BrushPreset]) -> Result<(), String> {
|
||||
let others: u64 = self.groups.iter().filter(|(f, _)| f.as_str() != file).map(|(_, g)| g.bytes).sum::<u64>() + self.tips.values().sum::<u64>();
|
||||
let mut stored = Vec::with_capacity(items.len());
|
||||
let mut hashes = Vec::new();
|
||||
let mut new_bytes = 0u64;
|
||||
for p in items {
|
||||
let (brush, bitmaps) = split_tips(p.brush.clone());
|
||||
let mut refs = [None, None, None];
|
||||
for (slot, t) in refs.iter_mut().zip(bitmaps) {
|
||||
let Some(t) = t else { continue };
|
||||
if !t.is_valid() {
|
||||
return Err(format!("preset `{}` has an invalid tip", p.name));
|
||||
}
|
||||
let h = tip_hash(&t);
|
||||
if !self.tips.contains_key(&h) {
|
||||
let bytes = encode_tip(&t);
|
||||
if bytes.is_empty() || bytes.len() as u64 > MAX_TIP_BYTES {
|
||||
return Err(format!("the tip of `{}` is too large to save", p.name));
|
||||
}
|
||||
new_bytes += bytes.len() as u64;
|
||||
if others + new_bytes > MAX_STORE_BYTES {
|
||||
return Err(format!("the preset store is full ({} MB limit)", MAX_STORE_BYTES >> 20));
|
||||
}
|
||||
self.backend.write(&tip_file(&h), &bytes)?;
|
||||
self.tips.insert(h.clone(), bytes.len() as u64);
|
||||
}
|
||||
hashes.push(h.clone());
|
||||
*slot = Some(h);
|
||||
}
|
||||
let [tip, dual_tip, texture] = refs;
|
||||
stored.push(StoredPreset { name: p.name.clone(), brush, tips: TipRefs { tip, dual_tip, texture } });
|
||||
}
|
||||
let gf = GroupFile { format: GROUP_FORMAT.into(), version: 1, group: group.to_string(), presets: stored };
|
||||
let bytes = serde_json::to_vec(&gf).map_err(|e| e.to_string())?;
|
||||
if bytes.len() as u64 > MAX_GROUP_BYTES {
|
||||
return Err(format!("the group is too large to save ({} MB limit)", MAX_GROUP_BYTES >> 20));
|
||||
}
|
||||
if others + new_bytes + bytes.len() as u64 > MAX_STORE_BYTES {
|
||||
return Err(format!("the preset store is full ({} MB limit)", MAX_STORE_BYTES >> 20));
|
||||
}
|
||||
self.backend.write(file, &bytes)?;
|
||||
self.groups.insert(file.to_string(), Synced { presets: items.iter().map(|p| (*p).clone()).collect(), tips: hashes, bytes: bytes.len() as u64 });
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_names() -> &'static [String] {
|
||||
static NAMES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
|
||||
NAMES.get_or_init(|| photocraft_paint::presets::builtin().into_iter().map(|p| p.name).collect())
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Record a brush preset change (create, delete, rename, import…): the attached store, if
|
||||
/// any, syncs after the running command.
|
||||
pub fn brush_presets_changed(&mut self) {
|
||||
self.tools.presets_rev += 1;
|
||||
}
|
||||
|
||||
/// Attach a loaded store: merge its presets into the library (a stored preset replaces the
|
||||
/// built-in of the same name; presets created before the store finished loading win), hide
|
||||
/// deleted built-ins, then sync. Returns the load warnings.
|
||||
pub fn attach_preset_store(&mut self, opened: Opened) -> Vec<String> {
|
||||
let Opened { store, presets, hidden_builtins, order, mut warnings } = opened;
|
||||
let lib = &mut self.tools.presets;
|
||||
lib.retain(|p| !(p.builtin && hidden_builtins.iter().any(|h| h.eq_ignore_ascii_case(&p.name))));
|
||||
for p in presets {
|
||||
match lib.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&p.name)) {
|
||||
Some(x) if x.builtin => *x = p,
|
||||
Some(_) => {}
|
||||
None => lib.push(p),
|
||||
}
|
||||
}
|
||||
// The saved panel order (stable: presets it doesn't name keep their order, at the end).
|
||||
if !order.is_empty() {
|
||||
let pos: HashMap<String, usize> = order.iter().enumerate().map(|(i, n)| (n.to_lowercase(), i)).collect();
|
||||
lib.sort_by_key(|p| pos.get(&p.name.to_lowercase()).copied().unwrap_or(usize::MAX));
|
||||
}
|
||||
self.preset_store = Some(store);
|
||||
self.brush_presets_changed();
|
||||
self.sync_preset_store();
|
||||
if let Some(st) = self.preset_store.as_mut() {
|
||||
warnings.extend(st.take_warnings());
|
||||
}
|
||||
warnings
|
||||
}
|
||||
|
||||
/// Write pending brush preset changes to the attached store (no-op without one).
|
||||
pub fn sync_preset_store(&mut self) {
|
||||
let rev = self.tools.presets_rev;
|
||||
if let Some(st) = self.preset_store.as_mut()
|
||||
&& st.synced_rev != Some(rev)
|
||||
{
|
||||
st.sync(&self.tools.presets);
|
||||
st.synced_rev = Some(rev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,361 @@
|
||||
use super::*;
|
||||
use photocraft_psd::abr::{AbrSample, LegacyBrush, LegacyTip, write_v12};
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A fresh, empty directory under the system temp dir (tests never write anywhere else).
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(tag: &str) -> Self {
|
||||
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let d = std::env::temp_dir().join(format!("pc-preset-store-{}-{tag}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
TempDir(d)
|
||||
}
|
||||
fn files(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = DirBackend::new(&self.0).list().unwrap().into_iter().map(|(n, _)| n).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A session with the store in `dir` attached.
|
||||
fn session(dir: &TempDir) -> (Session, Vec<String>) {
|
||||
let mut s = Session::new();
|
||||
let w = s.attach_preset_store(open_dir(&dir.0));
|
||||
(s, w)
|
||||
}
|
||||
|
||||
fn tip8(w: u32, h: u32, seed: u32) -> GrayTile {
|
||||
GrayTile::from_fn(w, h, |x, y| ((x * 7 + y * 13 + seed) % 256) as f32 / 255.0)
|
||||
}
|
||||
|
||||
fn tip16(w: u32, h: u32, seed: u32) -> GrayTile {
|
||||
let data = (0..w * h).map(|i| (i.wrapping_mul(2654435761).wrapping_add(seed) >> 16) as u16).collect();
|
||||
GrayTile { width: w, height: h, data }
|
||||
}
|
||||
|
||||
fn sampled(name: &str, group: &str, tip: GrayTile) -> BrushPreset {
|
||||
let brush = BrushSettings { size: 42.0, spacing: 0.1, tip: TipShape::Sampled(tip), ..Default::default() };
|
||||
BrushPreset { name: name.into(), brush, builtin: false, group: group.into() }
|
||||
}
|
||||
|
||||
fn user(s: &Session) -> Vec<BrushPreset> {
|
||||
s.tools.presets.iter().filter(|p| !p.builtin).cloned().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sessions_have_no_store_by_default() {
|
||||
let mut s = Session::new();
|
||||
assert!(s.preset_store.is_none(), "headless sessions must stay hermetic");
|
||||
s.execute("brush.presets.save", json!({"name": "Mine"})).unwrap();
|
||||
assert!(s.preset_store.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tip_codec_round_trips_8_and_16_bit() {
|
||||
let t8 = tip8(37, 21, 3);
|
||||
let t16 = tip16(40, 33, 9);
|
||||
let e8 = encode_tip(&t8);
|
||||
let e16 = encode_tip(&t16);
|
||||
assert_eq!(e8[6], 8, "8-bit sourced tips are stored at 8 bits");
|
||||
assert_eq!(e16[6], 16);
|
||||
assert_eq!(decode_tip(&e8).unwrap(), t8);
|
||||
assert_eq!(decode_tip(&e16).unwrap(), t16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tip_decoder_rejects_garbage() {
|
||||
let good = encode_tip(&tip16(16, 16, 1));
|
||||
for n in 0..good.len() {
|
||||
assert!(decode_tip(&good[..n]).is_err(), "truncated at {n}");
|
||||
}
|
||||
let mut bad = good.clone();
|
||||
bad[6] = 12;
|
||||
assert!(decode_tip(&bad).is_err());
|
||||
// A header claiming a huge bitmap is refused before allocating.
|
||||
let mut huge = good.clone();
|
||||
huge[7..11].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(decode_tip(&huge).is_err());
|
||||
let mut big = good.clone();
|
||||
big[7..11].copy_from_slice(&MAX_TIP_SIDE.to_le_bytes());
|
||||
big[11..15].copy_from_slice(&MAX_TIP_SIDE.to_le_bytes());
|
||||
assert!(decode_tip(&big).is_err());
|
||||
let mut rng = 0x1234_5678u32;
|
||||
for _ in 0..200 {
|
||||
let len = (rng % 64) as usize;
|
||||
let v: Vec<u8> = (0..len)
|
||||
.map(|_| {
|
||||
rng ^= rng << 13;
|
||||
rng ^= rng >> 17;
|
||||
rng ^= rng << 5;
|
||||
rng as u8
|
||||
})
|
||||
.collect();
|
||||
let _ = decode_tip(&v);
|
||||
let mut w = TIP_MAGIC.to_vec();
|
||||
w.extend_from_slice(&v);
|
||||
let _ = decode_tip(&w);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_with_sampled_8_and_16_bit_tips_round_trips() {
|
||||
let dir = TempDir::new("roundtrip");
|
||||
let (mut s, w) = session(&dir);
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
assert!(dir.files().is_empty(), "attaching an empty store writes nothing");
|
||||
let mut a = sampled("Eight", "Inks", tip8(64, 48, 1));
|
||||
a.brush.dual_brush.tip = TipShape::Sampled(tip8(12, 12, 5));
|
||||
a.brush.texture.pattern = Pattern::Tile(tip16(32, 32, 7));
|
||||
let b = sampled("Sixteen", "Inks", tip16(50, 70, 2));
|
||||
let mut c = sampled("Shared tip", "Other", tip8(64, 48, 1));
|
||||
c.brush.size = 9.0;
|
||||
s.tools.presets.extend([a, b, c]);
|
||||
s.brush_presets_changed();
|
||||
s.sync_preset_store();
|
||||
let files = dir.files();
|
||||
assert_eq!(files.iter().filter(|f| f.ends_with(".pcbrushes")).count(), 2, "{files:?}");
|
||||
// Four distinct bitmaps: the 64×48 tip is shared by two presets.
|
||||
assert_eq!(files.iter().filter(|f| f.starts_with("tips/")).count(), 4, "{files:?}");
|
||||
assert!(files.contains(&INDEX_FILE.to_string()));
|
||||
|
||||
let (t, w) = session(&dir);
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
assert_eq!(user(&t), user(&s));
|
||||
let order: Vec<String> = user(&t).into_iter().map(|p| p.name).collect();
|
||||
assert_eq!(order, ["Eight", "Sixteen", "Shared tip"]);
|
||||
// Nothing changed: reloading rewrote nothing.
|
||||
let before: Vec<_> = DirBackend::new(&dir.0).list().unwrap();
|
||||
let mut t = t;
|
||||
t.brush_presets_changed();
|
||||
t.sync_preset_store();
|
||||
assert_eq!(DirBackend::new(&dir.0).list().unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_persist_and_delete_removes_from_disk() {
|
||||
let dir = TempDir::new("commands");
|
||||
let (mut s, _) = session(&dir);
|
||||
s.tools.brush.tip = TipShape::Sampled(tip16(20, 20, 4));
|
||||
s.execute("brush.presets.save", json!({"name": "Scribble"})).unwrap();
|
||||
assert_eq!(dir.files().iter().filter(|f| f.starts_with("tips/")).count(), 1);
|
||||
let (t, _) = session(&dir);
|
||||
let p = photocraft_paint::presets::find(&t.tools.presets, "Scribble").expect("saved preset reloads");
|
||||
assert_eq!(p.brush.tip, s.tools.brush.tip);
|
||||
|
||||
// Rename through the Preset Manager, then delete.
|
||||
s.execute("edit.presets.presetManager", json!({"action": "rename", "kind": "brushes", "name": "Scribble", "newName": "Doodle"})).unwrap();
|
||||
let (t, _) = session(&dir);
|
||||
assert!(photocraft_paint::presets::find(&t.tools.presets, "Doodle").is_some());
|
||||
assert!(photocraft_paint::presets::find(&t.tools.presets, "Scribble").is_none());
|
||||
s.execute("brush.presets.delete", json!({"name": "Doodle"})).unwrap();
|
||||
assert_eq!(dir.files(), [INDEX_FILE], "the group file and its tip are gone");
|
||||
let (t, _) = session(&dir);
|
||||
assert!(photocraft_paint::presets::find(&t.tools.presets, "Doodle").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_abr_group_persists() {
|
||||
let dir = TempDir::new("abr");
|
||||
let (mut s, _) = session(&dir);
|
||||
let tip = AbrSample { id: String::new(), width: 6, height: 4, depth: 8, data: (0..24).map(|i| i * 10).collect() };
|
||||
let abr = write_v12(
|
||||
2,
|
||||
&[
|
||||
LegacyBrush { name: "Grit".into(), spacing: 25, anti_alias: true, tip: LegacyTip::Sampled(tip) },
|
||||
LegacyBrush {
|
||||
name: "Round 9".into(),
|
||||
spacing: 10,
|
||||
anti_alias: true,
|
||||
tip: LegacyTip::Computed { diameter: 9, hardness: 100, angle: 0, roundness: 100 },
|
||||
},
|
||||
],
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let data = photocraft_paint::tile::b64_encode(&abr);
|
||||
s.execute("brush.presets.importAbr", json!({"data": data, "group": "Legacy Set"})).unwrap();
|
||||
let (t, w) = session(&dir);
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
let grp: Vec<&BrushPreset> = t.tools.presets.iter().filter(|p| p.group == "Legacy Set").collect();
|
||||
assert_eq!(grp.len(), 2);
|
||||
assert_eq!(user(&t), user(&s));
|
||||
// Deleting every preset of the group removes its file and tips.
|
||||
let names: Vec<String> = grp.iter().map(|p| p.name.clone()).collect();
|
||||
for n in names {
|
||||
s.execute("brush.presets.delete", json!({"name": n})).unwrap();
|
||||
}
|
||||
assert!(!dir.files().iter().any(|f| f.ends_with(".pcbrushes") || f.starts_with("tips/")), "{:?}", dir.files());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_builtins_stay_deleted_and_overrides_replace_them() {
|
||||
let dir = TempDir::new("builtins");
|
||||
let (mut s, _) = session(&dir);
|
||||
let first = s.tools.presets[0].name.clone();
|
||||
let second = s.tools.presets[1].name.clone();
|
||||
s.execute("brush.presets.delete", json!({"name": first})).unwrap();
|
||||
s.tools.brush.size = 77.0;
|
||||
s.execute("brush.presets.save", json!({"name": second})).unwrap();
|
||||
let (t, _) = session(&dir);
|
||||
assert!(photocraft_paint::presets::find(&t.tools.presets, &first).is_none());
|
||||
let p = photocraft_paint::presets::find(&t.tools.presets, &second).unwrap();
|
||||
assert_eq!((p.builtin, p.brush.size), (false, 77.0));
|
||||
assert_eq!(t.tools.presets.iter().filter(|p| p.name.eq_ignore_ascii_case(&second)).count(), 1);
|
||||
// No built-in is ever written.
|
||||
let n = dir.files().iter().filter(|f| f.ends_with(".pcbrushes")).count();
|
||||
assert_eq!(n, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_with_many_groups_loads_them_all() {
|
||||
let dir = TempDir::new("many");
|
||||
let (mut s, _) = session(&dir);
|
||||
for g in 0..25 {
|
||||
for i in 0..4 {
|
||||
s.tools.presets.push(sampled(&format!("P{g}-{i}"), &format!("Group {g}"), tip8(16 + i, 16, g)));
|
||||
}
|
||||
}
|
||||
s.brush_presets_changed();
|
||||
s.sync_preset_store();
|
||||
let (t, w) = session(&dir);
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
assert_eq!(user(&t).len(), 100);
|
||||
let groups: Vec<String> = user(&t).iter().map(|p| p.group.clone()).fold(Vec::new(), |mut v, g| {
|
||||
if !v.contains(&g) {
|
||||
v.push(g);
|
||||
}
|
||||
v
|
||||
});
|
||||
assert_eq!(groups, (0..25).map(|g| format!("Group {g}")).collect::<Vec<_>>(), "group order survives");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reordering_presets_and_groups_persists() {
|
||||
let names = |s: &Session| s.tools.presets.iter().map(|p| (p.name.clone(), p.group.clone())).collect::<Vec<_>>();
|
||||
let dir = TempDir::new("reorder");
|
||||
let (mut s, _) = session(&dir);
|
||||
for (n, g) in [("Ink 1", "Inks"), ("Ink 2", "Inks"), ("Wash", "Washes")] {
|
||||
s.execute("brush.presets.save", json!({"name": n})).unwrap();
|
||||
s.tools.presets.iter_mut().find(|p| p.name == n).unwrap().group = g.into();
|
||||
}
|
||||
s.brush_presets_changed();
|
||||
s.sync_preset_store();
|
||||
// Untouched order: no order list is written (the default order is implied).
|
||||
let (t, _) = session(&dir);
|
||||
assert_eq!(names(&t), names(&s));
|
||||
// Reorder a built-in within its group, a user preset across groups, and whole groups.
|
||||
let builtin_group = s.tools.presets[0].group.clone();
|
||||
let last_builtin = s.tools.presets.iter().rfind(|p| p.builtin && p.group == builtin_group).unwrap().name.clone();
|
||||
let first = s.tools.presets[0].name.clone();
|
||||
s.execute("brush.presets.move", json!({"name": last_builtin, "before": first})).unwrap();
|
||||
s.execute("brush.presets.move", json!({"name": "Ink 2", "group": "Washes", "index": 0})).unwrap();
|
||||
s.execute("brush.presets.moveGroup", json!({"group": "Washes", "index": 0})).unwrap();
|
||||
s.execute("brush.presets.moveGroup", json!({"group": "Inks", "before": builtin_group})).unwrap();
|
||||
assert_eq!(s.tools.presets[0].name, "Ink 2");
|
||||
let (t, w) = session(&dir);
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
assert_eq!(names(&t), names(&s), "the panel order survives a restart");
|
||||
assert!(t.tools.presets.iter().find(|p| p.name == last_builtin).unwrap().builtin, "reordering keeps a built-in built-in");
|
||||
// Renaming and deleting a group persist too.
|
||||
s.execute("brush.presets.renameGroup", json!({"group": "Inks", "newName": "Pens"})).unwrap();
|
||||
s.execute("brush.presets.deleteGroup", json!({"group": "Washes"})).unwrap();
|
||||
let (t, _) = session(&dir);
|
||||
assert_eq!(names(&t), names(&s));
|
||||
assert!(t.tools.presets.iter().all(|p| p.group != "Washes" && p.group != "Inks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_and_oversized_files_are_skipped_with_a_warning() {
|
||||
let dir = TempDir::new("corrupt");
|
||||
let (mut s, _) = session(&dir);
|
||||
s.tools.presets.push(sampled("Good", "Fine", tip8(8, 8, 1)));
|
||||
s.tools.presets.push(sampled("Lost", "Broken tip", tip16(9, 9, 2)));
|
||||
s.brush_presets_changed();
|
||||
s.sync_preset_store();
|
||||
let be = DirBackend::new(&dir.0);
|
||||
be.write("junk-00000000.pcbrushes", b"{not json").unwrap();
|
||||
be.write("wrong-00000000.pcbrushes", br#"{"format":"other","version":1,"group":"x","presets":[]}"#).unwrap();
|
||||
// Corrupt the 16-bit tip of "Broken tip".
|
||||
let h = tip_hash(&tip16(9, 9, 2));
|
||||
be.write(&tip_file(&h), b"PCTIP1\x10garbage").unwrap();
|
||||
let (mut t, w) = session(&dir);
|
||||
assert_eq!(w.len(), 4, "{w:?}");
|
||||
assert!(photocraft_paint::presets::find(&t.tools.presets, "Good").is_some());
|
||||
assert!(photocraft_paint::presets::find(&t.tools.presets, "Lost").is_none());
|
||||
// The broken files are left alone for the user (never deleted by a sync).
|
||||
t.execute("brush.presets.save", json!({"name": "Another"})).unwrap();
|
||||
let files = dir.files();
|
||||
assert!(files.contains(&"junk-00000000.pcbrushes".to_string()) && files.contains(&tip_file(&h)), "{files:?}");
|
||||
|
||||
// Oversized files are refused without reading them whole.
|
||||
let mem = MemBackend::default();
|
||||
mem.files.lock().unwrap().insert("big-00000000.pcbrushes".into(), vec![b' '; MAX_GROUP_BYTES as usize + 1]);
|
||||
mem.files.lock().unwrap().insert(INDEX_FILE.into(), b"[1,2".to_vec());
|
||||
let o = open(Box::new(mem));
|
||||
assert!(o.presets.is_empty());
|
||||
assert_eq!(o.warnings.len(), 2, "{:?}", o.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_failures_are_warnings() {
|
||||
let dir = TempDir::new("readonly");
|
||||
// The store "directory" is a file: every write fails.
|
||||
std::fs::write(&dir.0, b"not a dir").unwrap();
|
||||
let mut s = Session::new();
|
||||
s.attach_preset_store(open_dir(&dir.0));
|
||||
s.tools.brush.tip = TipShape::Sampled(tip8(4, 4, 0));
|
||||
s.execute("brush.presets.save", json!({"name": "Nope"})).unwrap();
|
||||
let w = s.preset_store.as_mut().unwrap().take_warnings();
|
||||
assert!(!w.is_empty());
|
||||
let _ = std::fs::remove_file(&dir.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backends_refuse_paths_outside_the_store() {
|
||||
let mem = MemBackend::default();
|
||||
for n in ["../x.pcbrushes", "a/b.pcbrushes", "tips/../x", ".hidden", "", "tips/"] {
|
||||
assert!(mem.write(n, b"x").is_err(), "{n}");
|
||||
}
|
||||
assert!(group_file_name("../../etc/passwd").ends_with(".pcbrushes"));
|
||||
assert!(valid_name(&group_file_name("../../etc/passwd")));
|
||||
assert!(valid_name(&group_file_name("")));
|
||||
assert!(valid_name(&group_file_name("日本語 ブラシ")));
|
||||
assert_ne!(group_file_name("a/b"), group_file_name("a_b"));
|
||||
}
|
||||
|
||||
/// `cargo test --release -p photocraft-engine --lib preset_store::tests::bench_load_500 -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_load_500_sampled_presets() {
|
||||
let dir = TempDir::new("bench");
|
||||
let (mut s, _) = session(&dir);
|
||||
for i in 0..500u32 {
|
||||
// A mix of 8-bit (most .abr tips) and 16-bit tips, 64..319 px.
|
||||
let side = 64 + (i * 37) % 256;
|
||||
let t = if i % 4 == 0 { tip16(side, side, i) } else { tip8(side, side, i) };
|
||||
s.tools.presets.push(sampled(&format!("Brush {i}"), &format!("Set {}", i / 50), t));
|
||||
}
|
||||
s.brush_presets_changed();
|
||||
let t0 = std::time::Instant::now();
|
||||
s.sync_preset_store();
|
||||
let write = t0.elapsed();
|
||||
let bytes = s.preset_store.as_ref().unwrap().bytes();
|
||||
let t0 = std::time::Instant::now();
|
||||
let o = open_dir(&dir.0);
|
||||
let load = t0.elapsed();
|
||||
assert_eq!(o.presets.len(), 500);
|
||||
let t0 = std::time::Instant::now();
|
||||
s.execute("edit.presets.presetManager", json!({"action": "rename", "kind": "brushes", "name": "Brush 3", "newName": "Renamed"})).unwrap();
|
||||
let rename = t0.elapsed();
|
||||
eprintln!("500 sampled presets: {:.1} MB on disk; write {write:?}, load {load:?}, rename {rename:?}", bytes as f64 / 1e6);
|
||||
}
|
||||
@@ -22,12 +22,12 @@ use photocraft_doc::Document;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::color_cmds::{ColorState, composite_profile};
|
||||
use crate::color_cmds::ColorState;
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// What a proof simulates.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ProofKind {
|
||||
/// The proof profile (Working CMYK, Custom…).
|
||||
@@ -146,14 +146,20 @@ pub fn dichromat(rgb_lin: [f32; 3], protan: bool) -> [f32; 3] {
|
||||
|
||||
/// The display LUT when a simulation or 32-bit preview applies (None = plain profile proof).
|
||||
pub fn display_lut(c: &ColorState, doc: &Document, size: usize) -> Result<Option<Lut3d>> {
|
||||
display_lut_with(c, doc, size, true)
|
||||
}
|
||||
|
||||
/// [`display_lut`], leaving the 32-bit preview out when `include_hdr` is false (the GPU canvas
|
||||
/// applies it in its shader).
|
||||
pub fn display_lut_with(c: &ColorState, doc: &Document, size: usize, include_hdr: bool) -> Result<Option<Lut3d>> {
|
||||
let pv = c.proof(doc.id);
|
||||
let kind = if pv.enabled { pv.setup.kind } else { ProofKind::Profile };
|
||||
let hdr = if hdr_active(c, doc) { c.hdr.get(&doc.id).copied() } else { None };
|
||||
let hdr = if include_hdr { c.hdr_preview(doc) } else { None };
|
||||
if kind == ProofKind::Profile && hdr.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let err = |e: photocraft_cms::CmsError| EngineError::Other(format!("colour management: {e}"));
|
||||
let src = composite_profile(doc);
|
||||
let src = c.canvas_display(doc)?.source.clone();
|
||||
let mon = c.monitor();
|
||||
let srgb = Builtin::Srgb.profile();
|
||||
let setup = &pv.setup;
|
||||
@@ -163,7 +169,7 @@ pub fn display_lut(c: &ColorState, doc: &Document, size: usize) -> Result<Option
|
||||
let t = if pv.enabled {
|
||||
Transform::proof(&src, &setup.profile, &mon, setup.intent, setup.bpc, setup.simulate_paper)
|
||||
} else {
|
||||
Transform::new(&src, &mon, Intent::RelativeColorimetric, true)
|
||||
Transform::new(&src, &mon, crate::display_color::DISPLAY_INTENT, crate::display_color::DISPLAY_BPC)
|
||||
}
|
||||
.map_err(err)?;
|
||||
Box::new(move |v| eval3(&t, &v))
|
||||
@@ -423,6 +429,9 @@ mod tests {
|
||||
let q = sample(&lut, [1, 1, 1]);
|
||||
assert!((q[0] - 0.354).abs() < 0.02, "{q:?}");
|
||||
assert!(s.color.canvas_lut(&d, 5).unwrap().is_some());
|
||||
// The GPU canvas applies it in its shader: its LUT leaves it out (none for sRGB).
|
||||
assert_eq!(s.color.hdr_preview(&d), Some(HdrPreview { highlight_compression: false, exposure: 1.0, gamma: 1.0 }));
|
||||
assert!(s.color.gpu_canvas_lut(&d, 5).unwrap().is_none());
|
||||
// Highlight Compression: identity mapping, so no LUT.
|
||||
s.execute("view.thirtyTwoBitPreviewOptions", json!({"method": "highlightCompression"})).unwrap();
|
||||
assert!(!hdr_active(&s.color, &d));
|
||||
|
||||
Loaded 100 of 286 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user