diff --git a/.config/nextest.toml b/.config/nextest.toml
index 2ef6d6f215..a1e0a2ee0a 100644
--- a/.config/nextest.toml
+++ b/.config/nextest.toml
@@ -11,7 +11,7 @@ nextest-version = "0.9.150"
#
# GPU tests: each test is its own process here, so the in-process `gpu_lock()` mutexes no longer
# serialize them. Too many concurrent wgpu devices time out (`PollError: ... Wait timed out`), so
-# this group caps them at 4.
+# this group caps them at 4. scripts/ci-gpu-lavapipe.py reads this group's filters directly.
[test-groups]
gpu = { max-threads = 4 }
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c15bc9542b..2c3597f5ee 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -151,7 +151,7 @@ jobs:
# egui/winit/rfd.
packages: >-
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
- libgtk-3-dev
+ libgtk-3-dev mesa-vulkan-drivers libvulkan1
# The tests build with the optional craft-fonts input (crates/text/build.rs), so the
# Japanese font tests that skip without it run. Same commit as release.yml.
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
@@ -164,7 +164,7 @@ jobs:
timeout-minutes: 100
# fmt, clippy, layers, scorecard, tests, panic_hunt, doctests and the wasm32 check, with
# `--locked --features heif`, then the missing-translations report (xtask/src/main.rs).
- command: cargo xtask ci
+ command: python3 scripts/test-ci-gpu-lavapipe.py && cargo xtask ci && python3 scripts/ci-gpu-lavapipe.py
# Windows tests run as two legs in parallel: the unit tests and the integration tests. As one job
# they took ~28 min on the Windows runner; the legs take ~13 and ~15. Splitting the integration
diff --git a/.github/workflows/house-rules.yml b/.github/workflows/house-rules.yml
new file mode 100644
index 0000000000..de64ad3ee4
--- /dev/null
+++ b/.github/workflows/house-rules.yml
@@ -0,0 +1,28 @@
+name: House rules
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - AGENTS.md
+ - CLAUDE.md
+ - .github/workflows/house-rules.yml
+ pull_request:
+ paths:
+ - AGENTS.md
+ - CLAUDE.md
+ - .github/workflows/house-rules.yml
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: house-rules-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ house-rules:
+ uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
+ with:
+ rust: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9f41a2e8c1..d1efb21c08 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,7 +19,14 @@ concurrency:
cancel-in-progress: false
jobs:
+ # Regenerates contributors/contributors.json for the release commit; every build bundles it.
+ credits:
+ permissions:
+ contents: read
+ pull-requests: read
+ uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
release:
+ needs: credits
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
@@ -27,6 +34,7 @@ jobs:
app-title: PhotoCraft
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
version: ${{ inputs.version }}
+ contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -42,3 +50,4 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+ LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
diff --git a/AGENTS.md b/AGENTS.md
index 32074b4e45..70cbd9e355 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,3 +1,56 @@
+
+## House rules
+
+These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
+
+1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
+2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
+3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
+4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
+5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
+6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
+7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
+8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
+9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
+10. **Keep secrets and personal details out** of the repository, logs and requests.
+11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
+12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
+13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
+14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
+15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
+
+
+## Rust Craft repositories
+
+These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
+
+1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
+2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
+3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
+4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
+5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
+6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
+
+## Licence allowlist
+
+Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
+
+- `ISC`
+- `MIT`
+- `BSD-2-Clause`
+- `BSD-3-Clause`
+- `Apache-2.0`
+- `OFL-1.1`
+- `Ubuntu-font-1.0`
+- `Bitstream-Vera`
+- `CC0-1.0`
+- `CC-BY-4.0`
+- `CC-BY-SA-4.0`
+- `LicenseRef-Public-Domain`
+
+`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
+
+
# AGENTS.md: guide for AI agents and contributors
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, VectorCraft, EffectCraft, FilmCraft, LightCraft, PdfCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Shared Rust components live in [storytold/craft-libs](https://github.com/storytold/craft-libs) and are pinned by immutable git revision. UI libraries stay at L6. 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/`.
@@ -70,7 +123,6 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
8. **Performance is a feature.** Benchmark heavy operations on a 24–36 MP image in release. Work per tile in parallel (rayon), skip empty tiles, never scan a full surface per frame (cache per revision), and record before/after timings in the dev log.
9. **Never panic on input** (see *Never crash* above). A command's `run` closure and anything it calls must return `Err`, never panic, for *any* params or document state: validate params, check bounds before indexing or dividing, and reject absurd sizes before allocating. The `panic_hunt` integration test fuzzes every command with adversarial params and must stay green.
-10. **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
## 4. Picking work
@@ -130,7 +182,7 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
- `docs/parity-checklist.md`: generated Photoshop menu coverage.
- `docs/scorecard.md`: generated scorecard (sources: `scorecard/*.toml`, `perf/budgets.toml`, `perf/baseline.json`, corpus floors, prefs audit).
- `docs/releasing.md`: cutting a release (`cargo xtask version`, the `release` branch), signing secrets, packaging scripts in `packaging/`.
-- `plan/` (local, gitignored): research, parity plan, execution plan, estimates.
+- `plan/` is an optional gitignored notebook (research, parity notes, estimates). Do not ask for it and do not stop when it is absent.
- `log/` (local, gitignored): the dev log.
## 8. Keeping the native format complete
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 700bccb19f..f11c7f30e0 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -25,19 +25,28 @@ next to them.
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
+| `crates/ui-egui/src/i18n/az.tsv` | Azerbaijani (Latin script) UI translations | PhotoCraft contributors; AI contributor: OpenAI Codex | PhotoCraft English labels; Google Translate drafts with edited core terminology, contexts and plurals | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
+| `crates/ui-egui/src/i18n/bs.tsv` | Bosnian (Latin, Ijekavian) UI translations | Nine (@nine-codes) with Claude Code, AI-assisted; not yet reviewed by a native speaker | Original translations of PhotoCraft's English labels, shared with storytold/craft-i18n `locales/bs/` | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/ru.tsv` | Russian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/sv.tsv` | Swedish UI translations | @kler, @davvarn and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/fi.tsv` | Finnish UI translations | @Janiz-zzz, @metuuu and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/da.tsv` | Danish UI translations | @pechnikov2026-star and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål UI translations | @nikolainyegaard, @Bolla76 and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
+| `crates/ui-egui/src/i18n/he.tsv` | Hebrew UI translations | @tronicum with Claude Code, AI-assisted (machine-translated); not yet reviewed by a native speaker | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
+| `crates/ui-egui/src/i18n/ur.tsv` | Urdu UI translations | @tronicum with Claude Code, AI-assisted (machine-translated); not yet reviewed by a native speaker | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
+| `crates/ui-egui/src/i18n/ro.tsv` | Romanian UI translations | @tronicum with Claude Code, AI-assisted (machine-translated); not yet reviewed by a native speaker | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
+| `crates/ui-egui/src/i18n/th.tsv` | Thai UI translations | @Thanawatttt and PhotoCraft contributors, with opencode (DeepSeek) as an AI contributor | Machine-assisted first draft from PhotoCraft's English labels, reviewed for UI terminology | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
+| `crates/ui-egui/src/i18n/ta.tsv` | Tamil UI translations | @newsbaba24x7 and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
+| `crates/ui-egui/src/i18n/ka.tsv` | Georgian UI translations | @devius with Claude Code, AI-assisted | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | 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 | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
-| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.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`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `history-brush.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg` (115 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.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-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,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 | 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/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
+| `assets/icons/crop-overlay-*.svg` | Crop overlay glyphs: Rule of Thirds, Grid, Diagonal, Triangle, Golden Ratio and Golden Spiral inside the crop-mark frame | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/font-similar.svg`, `assets/icons/font-variable.svg` | Font menu filter glyphs: two waves (Show Similar Fonts) and a T over a slider (Show Variable Fonts), drawn on the 24 px icon grid | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/convert-point.svg` | Convert Point tool glyph: an angle with a square anchor on its apex, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
@@ -55,6 +64,7 @@ next to them.
| `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | 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 `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
+| `assets/icons/history-brush.svg` | History Brush tool glyph: Lucide `brush` plus counter-clockwise history rewind arrow | Lucide Icons and Contributors; arrow by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush`, scaled to 70%, inside a dashed upright rounded "C" selection | Lucide Icons and Contributors; selection by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
@@ -96,6 +106,7 @@ The other built-in ICC profiles and the generated LUT looks are produced by code
| `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
| `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below |
| `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 |
+| `docs/images/guide-position-*-tab.png` | New Guide before and after focusing Position with Tab (no artwork) | PhotoCraft contributors | Offscreen `snapshot` example with `ui.key Tab` | MIT OR Apache-2.0 |
| `docs/images/embedded-vector-editor-en.png` | Embedded AI/PDF/EPS conversion warning and editor chooser (no artwork) | Don Omondi | Offscreen `smart_svg_ui::tests::capture_embedded_vector_conversion_prompt` with authored bytes and mock services | MIT OR Apache-2.0 |
| `docs/images/embedded-vector-tabs-linux.png` | Shared VectorCraft window after a portal editor save and ordinary document launch | Don Omondi | Local Linux GUI acceptance with authored orange rectangle/blue circle SVGs; no private artwork | MIT OR Apache-2.0 |
| `docs/images/gradient-overlay-method-*.png` | Gradient Overlay Method selector, before and after (synthetic gradient) | @oRE-o | Rendered offscreen with the `snapshot` example; no external artwork | MIT OR Apache-2.0 |
diff --git a/Cargo.lock b/Cargo.lock
index ab0417b79b..cec58ffb71 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -118,6 +118,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+[[package]]
+name = "aes"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures 0.2.17",
+]
+
[[package]]
name = "ahash"
version = "0.8.12"
@@ -751,6 +762,15 @@ dependencies = [
"generic-array",
]
+[[package]]
+name = "block-padding"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
+dependencies = [
+ "generic-array",
+]
+
[[package]]
name = "block2"
version = "0.5.1"
@@ -828,6 +848,12 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+[[package]]
+name = "bytecount"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
+
[[package]]
name = "bytemuck"
version = "1.25.2"
@@ -947,6 +973,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
+[[package]]
+name = "cbc"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
+dependencies = [
+ "cipher",
+]
+
[[package]]
name = "cc"
version = "1.5.1"
@@ -1051,6 +1086,16 @@ dependencies = [
"half",
]
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
[[package]]
name = "clang-sys"
version = "1.9.1"
@@ -1796,6 +1841,15 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+[[package]]
+name = "ecb"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7"
+dependencies = [
+ "cipher",
+]
+
[[package]]
name = "ecolor"
version = "0.37.0"
@@ -3010,6 +3064,28 @@ dependencies = [
"cc",
]
+[[package]]
+name = "icu_casemap"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2edfd120d8445de853cf53a1abec916f638f933a70a6f4bf8f7e2d7b7f884e9"
+dependencies = [
+ "icu_casemap_data",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties",
+ "icu_provider",
+ "potential_utf",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_casemap_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "059fcda0fc8d33effc6633a8cf0d8a3c17caf7d7c91918fba9918744f501e4e8"
+
[[package]]
name = "icu_collections"
version = "2.3.0"
@@ -3018,6 +3094,7 @@ checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
"displaydoc",
"potential_utf",
+ "serde",
"utf8_iter",
"yoke",
"zerofrom",
@@ -3291,6 +3368,16 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "block-padding",
+ "generic-array",
+]
+
[[package]]
name = "interpolate_name"
version = "0.2.4"
@@ -3884,6 +3971,34 @@ dependencies = [
"imgref",
]
+[[package]]
+name = "lopdf"
+version = "0.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7184fdea2bc3cd272a1acec4030c321a8f9875e877b3f92a53f2f6033fdc289"
+dependencies = [
+ "aes",
+ "bitflags 2.13.2",
+ "cbc",
+ "ecb",
+ "encoding_rs",
+ "flate2",
+ "getrandom 0.3.4",
+ "indexmap",
+ "itoa",
+ "log",
+ "md-5",
+ "nom 8.0.0",
+ "nom_locate",
+ "rand 0.9.5",
+ "rangemap",
+ "sha2",
+ "stringprep",
+ "thiserror 2.0.21",
+ "ttf-parser",
+ "weezl",
+]
+
[[package]]
name = "lru-slab"
version = "0.1.3"
@@ -3911,6 +4026,16 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "md-5"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
+dependencies = [
+ "cfg-if",
+ "digest",
+]
+
[[package]]
name = "memchr"
version = "2.8.3"
@@ -4142,6 +4267,17 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "nom_locate"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d"
+dependencies = [
+ "bytecount",
+ "memchr",
+ "nom 8.0.0",
+]
+
[[package]]
name = "noop_proc_macro"
version = "0.3.0"
@@ -5076,15 +5212,18 @@ dependencies = [
"clap",
"getrandom 0.3.4",
"photocraft-codecs",
+ "photocraft-color",
"photocraft-compose",
"photocraft-doc",
"photocraft-engine",
"photocraft-format",
"photocraft-geom",
+ "photocraft-gpu",
"photocraft-io",
"photocraft-psd",
"photocraft-raster",
"photocraft-raw",
+ "pollster 1.0.1",
"rmcp",
"rustix 1.1.5",
"schemars",
@@ -5092,6 +5231,7 @@ dependencies = [
"serde_json",
"thiserror 2.0.21",
"tokio",
+ "wgpu",
]
[[package]]
@@ -5103,14 +5243,20 @@ dependencies = [
"clap_mangen",
"photocraft-automation",
"photocraft-codecs",
+ "photocraft-doc",
"photocraft-engine",
"photocraft-format",
"photocraft-io",
+ "photocraft-psd",
+ "photocraft-raster",
"photocraft-raw",
+ "serde",
"serde_json",
+ "tempfile",
"thiserror 2.0.21",
"tokio",
"winresource",
+ "xtask",
]
[[package]]
@@ -5219,6 +5365,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.21",
"wat",
+ "web-time",
"winsafe",
]
@@ -5270,6 +5417,7 @@ dependencies = [
"photocraft-vector",
"pollster 1.0.1",
"rayon",
+ "thiserror 2.0.21",
"wgpu",
]
@@ -5289,6 +5437,7 @@ dependencies = [
"crc32fast",
"flate2",
"imagesize",
+ "lopdf",
"miniz_oxide 0.9.1",
"photocraft-affinity",
"photocraft-algo",
@@ -5312,6 +5461,7 @@ dependencies = [
"serde_json",
"thiserror 2.0.21",
"tiff",
+ "ttf-parser",
"zune-core 0.5.3",
"zune-jpegxl",
]
@@ -5379,6 +5529,7 @@ dependencies = [
"photocraft-geom",
"proptest",
"rayon",
+ "rustix 1.1.5",
"serde",
"sysinfo",
"winsafe",
@@ -5404,10 +5555,13 @@ name = "photocraft-tablet"
version = "0.20.1"
dependencies = [
"block2 0.6.2",
+ "log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
+ "windows",
+ "wintab",
"x11rb",
]
@@ -5439,6 +5593,7 @@ name = "photocraft-ui-egui"
version = "0.20.1"
dependencies = [
"base64 0.23.1",
+ "chrono",
"craft-print",
"craft-ui",
"craft-update",
@@ -5448,6 +5603,8 @@ dependencies = [
"egui_kittest",
"epaint_default_fonts",
"flate2",
+ "icu_casemap",
+ "icu_locale_core",
"log",
"photocraft-algo",
"photocraft-cms",
@@ -5504,6 +5661,7 @@ dependencies = [
"photocraft-io",
"photocraft-paint",
"photocraft-psd",
+ "photocraft-raster",
"photocraft-ui-egui",
"rfd",
"serde_json",
@@ -6046,6 +6204,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
+[[package]]
+name = "rangemap"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d"
+
[[package]]
name = "rav1e"
version = "0.8.1"
@@ -7274,6 +7438,17 @@ dependencies = [
"serde",
]
+[[package]]
+name = "stringprep"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+ "unicode-properties",
+]
+
[[package]]
name = "strsim"
version = "0.11.1"
@@ -7931,6 +8106,15 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+[[package]]
+name = "unicode-normalization"
+version = "0.1.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
+dependencies = [
+ "tinyvec",
+]
+
[[package]]
name = "unicode-properties"
version = "0.1.4"
@@ -9053,6 +9237,25 @@ version = "0.0.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6"
+[[package]]
+name = "wintab"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e05efdae68aa69ef1f4bc0148a5c6cf7f2bc234963a723252fd75f2f9e70d86"
+dependencies = [
+ "windows",
+ "wintab-sys",
+]
+
+[[package]]
+name = "wintab-sys"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d3d0743ba07fe30ca1ccc1471ee50adf32bc70932b86a00c8f1051177950de2"
+dependencies = [
+ "windows",
+]
+
[[package]]
name = "winx"
version = "0.36.4"
@@ -9261,6 +9464,7 @@ dependencies = [
"chrono",
"serde",
"serde_json",
+ "tempfile",
"toml",
]
diff --git a/ROADMAP.md b/ROADMAP.md
index 2aace61b67..70637897d8 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away
-> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Object Finder retains large enclosed foregrounds (#6548); Russian catalog covers every dynamic label; previously minor: oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
+> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-22 · **Change:** minor (Print: Japanese paper sizes — はがき, 往復はがき, JIS B5/B4, A6, L判, 2L判, KG判, 六切, 四切; previously: Object Finder retains large enclosed foregrounds (#6548); Russian catalog covers every dynamic label; previously minor: oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from
the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md)
@@ -122,7 +122,10 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Entry |
|---|---|
+| 2026-11-02 | Layer Style: with Gradient Overlay selected, a drag on the image moves the gradient (its centre as a fraction of the layer's bounds, `params.offset`), as a drag moves a shadow (#10763) |
+| 2026-11-02 | Load Actions: Mac Roman step names with é ä ö ü Ä or ° read as Mac Roman instead of windows-1252 or Cyrillic (#10774): windows-1252's rare 0x80–0x9F letters and a lone Cyrillic letter in a Latin word no longer count as common text, the degree sign does, and the file's own Unicode text settles a word-initial rare letter and breaks exact ties toward windows-1252 or Mac Roman |
| 2026-10-21 | Linux can use font-free Adwaita controls when System Title Bar is selected, while the integrated CSD title bar remains the default (as in #4439). Saved preferences are respected; startup and preference-dialog regressions cover both choices (#6266). |
+| 2026-10-22 | Print paper sizes: はがき (100 × 148 mm), 往復はがき (148 × 200), JIS B5 (182 × 257), JIS B4 (257 × 364), A6, L判 (89 × 127), 2L判 (127 × 178), KG判 (102 × 152), 六切 (203 × 254) and 四切 (254 × 305) are selectable in the Print dialog and `file.print`, matched by their customary names (`はがき`, `L判`, `JIS B5`…), and the PDF page takes their exact size. Existing papers unchanged. |
| 2026-10-17 | Object Finder classifies background by boundary coverage, retaining large enclosed foregrounds and rejecting surrounding backdrop (#6548); learned segmentation remains open (#2788) |
| 2026-10-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized (`parity.md` → `parity-checklist.md`, `parity-estimate.md` merged into `target-app-parity.md`). 0.6.0 released. |
| 2026-10-09 | Remove tool, Rotate View, crop rotation; OpenRaster and Paint.NET; 39 Affinity documents in the corpus |
@@ -137,6 +140,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-22 | minor | Print: Japanese paper sizes (はがき, 往復はがき, JIS B5/B4, A6, L判, 2L判, KG判, 六切, 四切) in the Print dialog, `file.print` and the PDF output |
| 2026-10-17 | minor | Object Finder retains large enclosed foregrounds (#6548) |
| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/it/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
| 2026-10-15 | minor | Russian catalog covers every dynamic UI label the other catalogs translate |
diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml
index 94e04b518a..4542a71da9 100644
--- a/apps/photocraft-cli/Cargo.toml
+++ b/apps/photocraft-cli/Cargo.toml
@@ -7,9 +7,12 @@ license.workspace = true
build = "build.rs"
[features]
+default = ["gpu"]
# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose
# it; official builds (CI, packaging/, release) build with `--features heif`.
heif = ["photocraft-codecs/heif"]
+# Optional native GPU rendering backend, enabled by default in native CLI builds.
+gpu = ["photocraft-automation/gpu"]
[lints]
workspace = true
@@ -22,10 +25,16 @@ path = "src/lib.rs"
name = "photocraft-cli"
path = "src/main.rs"
+[[example]]
+name = "gpu_bench"
+required-features = ["gpu"]
+
[dependencies]
photocraft-automation = { path = "../../crates/automation" }
+photocraft-doc = { workspace = true }
photocraft-engine = { workspace = true }
photocraft-io = { workspace = true }
+photocraft-raster = { workspace = true }
photocraft-format = { path = "../../crates/format" }
photocraft-codecs = { workspace = true }
clap = { workspace = true }
@@ -34,12 +43,18 @@ thiserror = { workspace = true }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
[dev-dependencies]
+serde = { workspace = true }
+tempfile = "3"
+# Reading back a saved PSD's merged-image flag (#8064).
+photocraft-psd = { workspace = true }
# Synthetic camera raws (#7789).
photocraft-raw = { workspace = true, features = ["testgen"] }
# Shell completions and man pages (examples/man-and-completions.rs, for the packages); not in
# the binary.
clap_complete = { workspace = true }
clap_mangen = { workspace = true }
+# The PSD write recipe runner (tests/it/psd_write_oracle.rs).
+xtask = { path = "../../xtask" }
[build-dependencies]
# Icon + VERSIONINFO for photocraft-cli.exe; build.rs only uses it when targeting Windows.
diff --git a/apps/photocraft-cli/examples/gpu_bench.rs b/apps/photocraft-cli/examples/gpu_bench.rs
new file mode 100644
index 0000000000..ff88ff6eb7
--- /dev/null
+++ b/apps/photocraft-cli/examples/gpu_bench.rs
@@ -0,0 +1,221 @@
+//! Benchmark example measuring headless CPU vs GPU rendering and export performance.
+//!
+//! Usage:
+//! ```sh
+//! cargo run --release -p photocraft-cli --example gpu_bench -- [options]
+//! ```
+
+use std::path::PathBuf;
+use std::time::Instant;
+
+use clap::Parser;
+use photocraft_automation::files;
+use photocraft_automation::gpu::GpuRenderer;
+use photocraft_doc::{BlendMode, Color, ColorMode, Document, Layer, LayerContent, Rect, SampleType, Size, Surface};
+use photocraft_io::ExportOptions;
+
+#[derive(Parser, Debug)]
+#[command(name = "gpu_bench", about = "Benchmark headless CPU vs GPU rendering and export performance")]
+struct BenchArgs {
+ /// Input image or PSD file (optional; defaults to synthetic mockup)
+ #[arg(short, long)]
+ input: Option,
+
+ /// Synthetic document width in pixels (1..=16384)
+ #[arg(short, long, default_value_t = 2048, value_parser = clap::value_parser!(u32).range(1..=16384))]
+ width: u32,
+
+ /// Synthetic document height in pixels (1..=16384)
+ #[arg(short = 'H', long, default_value_t = 2048, value_parser = clap::value_parser!(u32).range(1..=16384))]
+ height: u32,
+
+ /// Synthetic layer count (1..=128)
+ #[arg(short, long, default_value_t = 8, value_parser = clap::value_parser!(u32).range(1..=128))]
+ layers: u32,
+
+ /// Number of warm GPU runs (0..=50)
+ #[arg(short = 'r', long = "warm-runs", default_value_t = 3, value_parser = clap::value_parser!(u32).range(0..=50))]
+ warm_runs: u32,
+}
+
+fn main() -> Result<(), Box> {
+ let args = BenchArgs::parse();
+ let layers = args.layers as usize;
+ let warm_runs = args.warm_runs as usize;
+
+ println!("# PhotoCraft CLI Headless Rendering Benchmark");
+ println!();
+
+ const MAX_SYNTHETIC_PIXELS: u64 = 256 * 1024 * 1024;
+ let doc = if let Some(path) = &args.input {
+ println!("- **Input Source**: file `{}`", path.display());
+ let open_res = files::open(path)?;
+ open_res.document
+ } else {
+ let total_pixels = u64::from(args.width) * u64::from(args.height) * u64::from(args.layers);
+ if total_pixels > MAX_SYNTHETIC_PIXELS {
+ return Err(format!(
+ "requested synthetic benchmark volume ({}x{} x {} layers = {} total pixels) exceeds budget of {} pixels (~{} MB); please lower width, height, or layer count",
+ args.width,
+ args.height,
+ args.layers,
+ total_pixels,
+ MAX_SYNTHETIC_PIXELS,
+ (MAX_SYNTHETIC_PIXELS * 4) / (1024 * 1024)
+ )
+ .into());
+ }
+ println!("- **Input Source**: synthetic mockup ({}x{}, {} layers)", args.width, args.height, args.layers);
+ sample_doc(args.width, args.height, layers)
+ };
+
+ println!("- **Dimensions**: {}x{}", doc.size.width, doc.size.height);
+ println!("- **Layer Count**: {}", doc.layers.len());
+ println!();
+
+ let tmp_dir = std::env::temp_dir().join(format!("pc-bench-{}", std::process::id()));
+ let _ = std::fs::create_dir_all(&tmp_dir);
+ let cpu_out = tmp_dir.join("cpu_out.png");
+ let gpu_cold_out = tmp_dir.join("gpu_cold_out.png");
+ let gpu_warm_out = tmp_dir.join("gpu_warm_out.png");
+
+ let opts = ExportOptions::default();
+
+ // 1. CPU Run
+ print!("Benchmarking CPU export... ");
+ let t0 = Instant::now();
+ files::save_with_renderer(&doc, &cpu_out, Some("png"), &opts, None, None)?;
+ let cpu_dur = t0.elapsed();
+ println!("{:.2} ms", cpu_dur.as_secs_f64() * 1000.0);
+
+ // 2. GPU Cold Run
+ print!("Benchmarking GPU cold export... ");
+ let mut gpu_renderer = GpuRenderer::lazy();
+ let t0 = Instant::now();
+ let gpu_cold_res = files::save_with_renderer(&doc, &gpu_cold_out, Some("png"), &opts, None, Some(&mut gpu_renderer));
+ let gpu_cold_dur = t0.elapsed();
+
+ let (gpu_succeeded, is_fallback) = match gpu_cold_res {
+ Ok(warnings) => {
+ let fallback = warnings.iter().any(|w| w.contains("falling back to CPU"));
+ if fallback {
+ println!("{:.2} ms (FALLBACK TO CPU: {:?})", gpu_cold_dur.as_secs_f64() * 1000.0, warnings);
+ (true, true)
+ } else if gpu_renderer.context().is_none() {
+ // The export never reached the renderer (e.g. a single-layer input takes the
+ // native path), so no GPU work ran: report it as CPU work, not a GPU result.
+ println!("{:.2} ms (renderer not used)", gpu_cold_dur.as_secs_f64() * 1000.0);
+ (false, true)
+ } else {
+ println!("{:.2} ms", gpu_cold_dur.as_secs_f64() * 1000.0);
+ let adapter = gpu_renderer.adapter_name().unwrap_or("unknown");
+ let is_soft = if gpu_renderer.is_software() { " (software rasterizer)" } else { " (hardware)" };
+ println!("- **GPU Adapter**: `{adapter}`{is_soft}");
+ (true, false)
+ }
+ }
+ Err(e) => {
+ println!("FAILED ({e})");
+ let _ = std::fs::remove_dir_all(&tmp_dir);
+ return Ok(());
+ }
+ };
+
+ // 3. GPU Warm Runs
+ let mut warm_durations = Vec::with_capacity(warm_runs);
+ let mut warm_fallbacks = 0usize;
+ if gpu_succeeded && !is_fallback && warm_runs > 0 {
+ for run in 1..=warm_runs {
+ print!("Benchmarking GPU warm export (run {run}/{warm_runs})... ");
+ let t0 = Instant::now();
+ let warm_res = files::save_with_renderer(&doc, &gpu_warm_out, Some("png"), &opts, None, Some(&mut gpu_renderer))?;
+ let d = t0.elapsed();
+ if warm_res.iter().any(|w| w.contains("falling back to CPU")) {
+ warm_fallbacks += 1;
+ println!("{:.2} ms (FALLBACK TO CPU)", d.as_secs_f64() * 1000.0);
+ } else {
+ println!("{:.2} ms", d.as_secs_f64() * 1000.0);
+ warm_durations.push(d);
+ }
+ }
+ }
+
+ println!();
+ println!("### Summary Results");
+ println!("| Mode | Duration | Relative to CPU | Note |");
+ println!("|---|---|---|---|");
+ println!("| CPU Baseline | {:.2} ms | 1.00x | Reference CPU compositor |", cpu_dur.as_secs_f64() * 1000.0);
+ if is_fallback {
+ println!("| GPU Cold | {:.2} ms | N/A | GPU not used (CPU fallback or a CPU-only export path); no GPU speedup |", gpu_cold_dur.as_secs_f64() * 1000.0);
+ println!("| GPU Warm | N/A | N/A | Not measured (GPU not used) |");
+ } else {
+ let is_software = gpu_renderer.is_software();
+ let adapter_note = if is_software { "Software rasterizer (e.g. lavapipe/llvmpipe)" } else { "Hardware GPU" };
+ println!(
+ "| GPU Cold | {:.2} ms | {:.2}x | Cold adapter initialization + pipeline build ({adapter_note}) |",
+ gpu_cold_dur.as_secs_f64() * 1000.0,
+ cpu_dur.as_secs_f64() / gpu_cold_dur.as_secs_f64()
+ );
+ if !warm_durations.is_empty() {
+ let avg_warm: f64 = warm_durations.iter().map(|d| d.as_secs_f64()).sum::() / (warm_durations.len() as f64);
+ // Runs that fell back to the CPU are excluded from the average: say how many.
+ let note = match warm_fallback_note(warm_runs, warm_fallbacks) {
+ Some(fallbacks) => format!("Reused device context and pipeline caches ({adapter_note}); {fallbacks} (excluded from the average)"),
+ None => format!("Reused device context and pipeline caches ({adapter_note})"),
+ };
+ println!("| GPU Warm (avg) | {:.2} ms | {:.2}x | {note} |", avg_warm * 1000.0, cpu_dur.as_secs_f64() / avg_warm);
+ } else if let Some(note) = warm_fallback_note(warm_runs, warm_fallbacks) {
+ println!("| GPU Warm | N/A | N/A | {note} |");
+ } else {
+ println!("| GPU Warm | N/A | N/A | Not measured (0 warm runs requested) |");
+ }
+ }
+
+ let _ = std::fs::remove_dir_all(&tmp_dir);
+ Ok(())
+}
+
+/// The warm-run summary note when every measured warm run fell back to the CPU.
+fn warm_fallback_note(warm_runs: usize, warm_fallbacks: usize) -> Option {
+ (warm_fallbacks > 0).then(|| format!("{warm_fallbacks} of {warm_runs} warm runs fell back to CPU"))
+}
+
+fn sample_doc(w: u32, h: u32, layer_count: usize) -> Document {
+ let mut doc = Document::with_background("bench", Size::new(w, h), ColorMode::Rgb, SampleType::U8, Color::WHITE);
+ let blend_modes = [BlendMode::Normal, BlendMode::Multiply, BlendMode::Screen, BlendMode::Overlay, BlendMode::Darken, BlendMode::Lighten];
+
+ for idx in 0..layer_count {
+ let mut s = Surface::new(doc.pixel_format());
+ let x0 = (idx as i32 * 20) % (w as i32 / 2).max(1);
+ let y0 = (idx as i32 * 20) % (h as i32 / 2).max(1);
+ let rw = (w as i32 - x0).max(1);
+ let rh = (h as i32 - y0).max(1);
+ s.fill_rect(Rect::new(x0, y0, x0 + rw, y0 + rh), &[0.3, 0.5, 0.7, 0.8]);
+ let mut layer = Layer::new(format!("Layer {}", idx + 1), LayerContent::Raster(s));
+ layer.opacity = 0.85;
+ layer.blend = blend_modes[idx % blend_modes.len()];
+ doc.layers.push(layer);
+ }
+
+ doc
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn synthetic_documents_allow_one_pixel_dimensions() {
+ for (width, height) in [(1, 1), (1, 16), (16, 1)] {
+ let doc = sample_doc(width, height, 2);
+ assert_eq!(doc.size, Size::new(width, height));
+ }
+ }
+
+ #[test]
+ fn warm_summary_reports_fallbacks_instead_of_zero_runs() {
+ assert_eq!(warm_fallback_note(3, 3).as_deref(), Some("3 of 3 warm runs fell back to CPU"));
+ assert_eq!(warm_fallback_note(3, 1).as_deref(), Some("1 of 3 warm runs fell back to CPU"));
+ assert_eq!(warm_fallback_note(0, 0), None);
+ }
+}
diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs
index c3583bf83d..87c0b5599c 100644
--- a/apps/photocraft-cli/src/args.rs
+++ b/apps/photocraft-cli/src/args.rs
@@ -11,6 +11,7 @@ use std::fmt;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
+pub(crate) use crate::gpu::GpuArgs;
use clap::error::ErrorKind;
use clap::parser::ValueSource;
use clap::{ArgAction, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand};
@@ -86,6 +87,12 @@ pub(crate) struct ExportArgs {
/// composite/full.png; it may be omitted. Check the manifest's full_composite member.
#[arg(long)]
pub(crate) pcraft_full_composite: bool,
+ /// A layered PSD/PSB stores Photoshop's white placeholder instead of the merged image
+ /// (Maximize Compatibility off): a smaller file and a faster save, but readers that show only
+ /// the merged image see white. Bitmap, Indexed Color and Duotone documents still write the
+ /// real image
+ #[arg(long)]
+ pub(crate) no_maximize_compatibility: bool,
}
/// Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …)
@@ -107,6 +114,8 @@ pub(crate) struct ConvertArgs {
#[arg(long)]
pub(crate) approve_high_memory: bool,
#[command(flatten)]
+ pub(crate) gpu: GpuArgs,
+ #[command(flatten)]
pub(crate) export: ExportArgs,
}
@@ -120,6 +129,9 @@ pub(crate) struct InfoArgs {
/// Explicitly approve high-memory PSD/PSB imports within the hard memory budget
#[arg(long)]
pub(crate) approve_high_memory: bool,
+ /// Also list every text run and paragraph run, anti-aliasing and full layer-effect settings
+ #[arg(long)]
+ pub(crate) detail: bool,
}
/// Open a file (or start a new document), run engine commands in order, save the result
@@ -152,6 +164,8 @@ pub(crate) struct RunArgs {
#[arg(long)]
pub(crate) approve_high_memory: bool,
#[command(flatten)]
+ pub(crate) gpu: GpuArgs,
+ #[command(flatten)]
pub(crate) export: ExportArgs,
/// `--cmd` and `--params` values in command-line order, set by [`parse`].
#[arg(skip)]
@@ -210,6 +224,8 @@ pub(crate) struct BatchArgs {
#[arg(long)]
pub(crate) approve_high_memory: bool,
#[command(flatten)]
+ pub(crate) gpu: GpuArgs,
+ #[command(flatten)]
pub(crate) export: ExportArgs,
}
@@ -253,6 +269,8 @@ pub(crate) struct McpArgs {
#[arg(skip)]
pub(crate) bridge_from_env: bool,
#[command(flatten)]
+ pub(crate) gpu: GpuArgs,
+ #[command(flatten)]
pub(crate) control: ControlArgs,
#[command(flatten)]
pub(crate) automation: AutomationArgs,
@@ -274,6 +292,8 @@ pub(crate) struct ServeArgs {
#[arg(skip)]
pub(crate) port_from_env: bool,
#[command(flatten)]
+ pub(crate) gpu: GpuArgs,
+ #[command(flatten)]
pub(crate) control: ControlArgs,
#[command(flatten)]
pub(crate) automation: AutomationArgs,
@@ -311,6 +331,12 @@ enum ServerOptionError {
/// authority the bridge can't give (#8035).
#[error("configure automation roots on the desktop app; --automation-read-root and --automation-write-root require headless MCP mode")]
BridgeRoots,
+ /// Invalid value for PHOTOCRAFT_GPU.
+ #[error("invalid value '{0}' for 'PHOTOCRAFT_GPU': expected '1' or '0'")]
+ GpuVar(String),
+ /// Explicit GPU flags require headless MCP mode, not bridge mode.
+ #[error("configure rendering on the desktop app; --gpu and --no-gpu require headless MCP mode")]
+ BridgeGpu,
/// A token flag without the bridge or port that reads it would be ignored (#8035).
#[error("{flag} requires {transport}")]
NeedsTransport { flag: &'static str, transport: &'static str },
@@ -319,8 +345,8 @@ enum ServerOptionError {
impl ServerOptionError {
fn into_clap(self, cmd: &mut clap::Command) -> clap::Error {
let kind = match self {
- Self::BridgeVar(_) => ErrorKind::ValueValidation,
- Self::BridgeRoots => ErrorKind::ArgumentConflict,
+ Self::BridgeVar(_) | Self::GpuVar(_) => ErrorKind::ValueValidation,
+ Self::BridgeRoots | Self::BridgeGpu => ErrorKind::ArgumentConflict,
Self::NeedsTransport { .. } => ErrorKind::MissingRequiredArgument,
};
cmd.error(kind, self)
@@ -425,7 +451,10 @@ pub(crate) fn parse(args: &[OsString]) -> Result {
let mut cli = Cli::from_arg_matches(&matches)?;
let from_env = |m: &ArgMatches, id: &str| m.value_source(id) == Some(ValueSource::EnvVariable);
match (&mut cli.command, matches.subcommand()) {
- (Command::Run(r), Some((_, m))) => r.steps = steps(m),
+ (Command::Run(r), Some((_, m))) => {
+ r.steps = steps(m);
+ check_gpu_env(&mut cmd, m)?;
+ }
(Command::Mcp(a), Some((name, m))) => {
a.bridge_from_env = from_env(m, "bridge");
if a.bridge_from_env
@@ -448,8 +477,15 @@ pub(crate) fn parse(args: &[OsString]) -> Result {
if a.bridge.is_some() && (a.automation.automation_read_root.is_some() || a.automation.automation_write_root.is_some()) {
return Err(ServerOptionError::BridgeRoots.into_clap(sub));
}
+ if a.bridge.is_some() && (m.value_source("gpu") == Some(ValueSource::CommandLine) || m.value_source("no_gpu") == Some(ValueSource::CommandLine))
+ {
+ return Err(ServerOptionError::BridgeGpu.into_clap(sub));
+ }
check_server(sub, m, a.bridge.is_some(), "--bridge")?;
}
+ if a.bridge.is_none() {
+ check_gpu_env(&mut cmd, m)?;
+ }
}
(Command::Serve(a), Some((name, m))) => {
a.port_from_env = from_env(m, "port");
@@ -460,12 +496,32 @@ pub(crate) fn parse(args: &[OsString]) -> Result {
}
check_server(sub, m, a.port.is_some(), "--port")?;
}
+ check_gpu_env(&mut cmd, m)?;
+ }
+ (Command::Convert(_), Some((_, m))) | (Command::Batch(_), Some((_, m))) => {
+ check_gpu_env(&mut cmd, m)?;
}
_ => {}
}
Ok(cli)
}
+fn check_gpu_env(cmd: &mut clap::Command, m: &ArgMatches) -> Result<(), clap::Error> {
+ if m.value_source("gpu") == Some(ValueSource::CommandLine) || m.value_source("no_gpu") == Some(ValueSource::CommandLine) {
+ return Ok(());
+ }
+ if let Some(v) = std::env::var_os("PHOTOCRAFT_GPU") {
+ // A non-UTF-8 value is still a value other than empty, "0" or "1": reject it rather
+ // than silently treating it as unset and running on the CPU.
+ let v = v.to_string_lossy();
+ let trimmed = v.trim();
+ if !trimmed.is_empty() && trimmed != "1" && trimmed != "0" {
+ return Err(ServerOptionError::GpuVar(trimmed.to_string()).into_clap(cmd));
+ }
+ }
+ Ok(())
+}
+
/// Refuse a token flag without the bridge or port that reads it (as a flag or its variable). A
/// token variable alone may be a stale export, so it isn't refused.
fn check_server(sub: &mut clap::Command, m: &ArgMatches, has_transport: bool, transport: &'static str) -> Result<(), clap::Error> {
diff --git a/apps/photocraft-cli/src/automation.rs b/apps/photocraft-cli/src/automation.rs
index 062f2f2eef..34dbd1ce8a 100644
--- a/apps/photocraft-cli/src/automation.rs
+++ b/apps/photocraft-cli/src/automation.rs
@@ -34,7 +34,13 @@ pub(crate) fn commands(a: &CommandsArgs, out: &mut dyn Write) -> Result {
pub(crate) fn serve(a: &ServeArgs, err: &mut dyn Write) -> Result {
use std::sync::{Arc, Mutex};
- let h = Arc::new(Mutex::new(Headless::with_workspace(a.automation.workspace()?)));
+ let mut h = Headless::with_workspace(a.automation.workspace()?);
+ let policy = crate::gpu::GpuPolicy::from_args(&a.gpu).map_err(crate::CliError::Other)?;
+ policy.apply_to_headless(&mut h);
+ for d in h.drain_diagnostics() {
+ let _ = writeln!(err, "warning: {d}");
+ }
+ let h = Arc::new(Mutex::new(h));
match a.port {
Some(port) => {
if a.port_from_env {
@@ -75,7 +81,15 @@ pub(crate) fn mcp(a: &McpArgs, err: &mut dyn Write) -> Result {
let token = security::client_token(a.control.control_token.as_deref(), a.control.control_token_file.as_deref())?;
PhotocraftMcp::bridge(&addr.to_string(), &token)?
}
- None => PhotocraftMcp::headless_with_workspace(a.automation.workspace()?),
+ None => {
+ let mut h = Headless::with_workspace(a.automation.workspace()?);
+ let policy = crate::gpu::GpuPolicy::from_args(&a.gpu).map_err(crate::CliError::Other)?;
+ policy.apply_to_headless(&mut h);
+ for d in h.drain_diagnostics() {
+ let _ = writeln!(err, "warning: {d}");
+ }
+ PhotocraftMcp::with_headless(h)
+ }
};
let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build()?;
Ok(rt.block_on(server.serve_stdio())?)
diff --git a/apps/photocraft-cli/src/batch.rs b/apps/photocraft-cli/src/batch.rs
index 347f829443..0d05fd7e71 100644
--- a/apps/photocraft-cli/src/batch.rs
+++ b/apps/photocraft-cli/src/batch.rs
@@ -10,7 +10,7 @@ use crate::{
CliError, Result,
actions::load_actions_file,
args::{BatchArgs, DropletArgs},
- output::warn_all,
+ output::{warn_all, warn_dedup},
};
/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a
@@ -51,7 +51,8 @@ pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) ->
let (in_dir, out_dir) = (a.in_dir.as_path(), a.out.as_path());
let (actions, mut set, warnings) = load_actions_file(&a.actions, a.action.as_deref())?;
set.iter_mut().for_each(strip_shell_steps);
- warn_all(err, &warnings);
+ let mut seen_warnings = std::collections::HashSet::::new();
+ warn_dedup(err, &warnings, &mut seen_warnings);
let opts = ExportOptions::from(&a.export);
// Results are saved as `/.`, so an `--out` that is the `--in` folder would
// replace the originals (#492).
@@ -109,17 +110,20 @@ pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) ->
}
std::fs::create_dir_all(out_dir)?;
let mut failures = Vec::new();
+ let policy = crate::gpu::GpuPolicy::from_args(&a.gpu).map_err(CliError::Other)?;
+ let mut renderer = policy.create_renderer();
for (input, target) in inputs.iter().zip(targets) {
let input = Path::new(&input.path);
// The selected format encodes the bytes, whatever extension `--naming` gives the file.
// Without --format, a file PhotoCraft only opens (.kra, raw, …) is saved as PSD, the
// same format `--naming`'s {ext} plans.
let ext = format.map_or_else(|| photocraft_engine::file_cmds::batch_same_format(&input.to_string_lossy()), str::to_owned);
+ let mut h = Headless::trusted_local();
+ h.set_renderer(renderer.take());
let r = (|| -> Result> {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
- let mut h = Headless::trusted_local();
// Steps that play another action find it in the set; the session's playback stack
// stops an action that calls itself.
h.session.actions.list = set.clone();
@@ -140,15 +144,30 @@ pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) ->
let r = h.save(None, Some(&target), Some(&ext), &opts)?;
Ok(serde_json::from_value(r["warnings"].clone()).unwrap_or_default())
})();
+ let diags = h.drain_diagnostics();
+ // Release the item's GPU caches before the renderer moves on to the next file.
+ if h.session.active_index().is_some() {
+ let _ = h.close(None);
+ }
+ if let Some(recovered) = h.take_renderer() {
+ renderer = Some(recovered);
+ }
match r {
Ok(ws) => {
ok += 1;
let _ = writeln!(out, "ok {} -> {}", input.display(), target.display());
+ // Each file's own export warnings (e.g. transparency composited over white) are
+ // reported for that file, even when an earlier file had the same warning.
warn_all(err, &ws);
+ // A fallback diagnostic can also be in this file's export warnings; report it
+ // once, through the per-file warnings above, not twice.
+ let pending: Vec = diags.into_iter().filter(|d| !ws.contains(d)).collect();
+ warn_dedup(err, &pending, &mut seen_warnings);
}
Err(e) => {
failed += 1;
let _ = writeln!(err, "FAIL {}: {e}", input.display());
+ warn_dedup(err, &diags, &mut seen_warnings);
failures.push((input.to_string_lossy().into_owned(), e.to_string()));
if a.stop_on_error {
break;
@@ -267,3 +286,79 @@ mod batch_action_set_tests {
let _ = std::fs::remove_dir_all(&dir);
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use photocraft_doc::{Color, ColorMode, Document, Rect, SampleType, Size};
+ use photocraft_io::{BandRenderer, IoError, RenderBand, RenderError};
+ use std::sync::Arc;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+
+ struct RecordingRenderer {
+ calls: Arc,
+ }
+
+ impl BandRenderer for RecordingRenderer {
+ fn render_bands(
+ &mut self,
+ doc: &Document,
+ rect: Rect,
+ band_rows: i32,
+ ctl: &photocraft_raster::Interrupt<'_>,
+ sink: &mut dyn FnMut(RenderBand) -> Result<(), IoError>,
+ ) -> Result<(), RenderError> {
+ self.calls.fetch_add(1, Ordering::SeqCst);
+ photocraft_io::CpuRenderer.render_bands(doc, rect, band_rows, ctl, sink)
+ }
+ }
+
+ #[test]
+ fn batch_loop_recovers_renderer_after_error_on_first_item() {
+ let calls = Arc::new(AtomicUsize::new(0));
+ let mut renderer: Option> = Some(Box::new(RecordingRenderer { calls: calls.clone() }));
+
+ let tmp = std::env::temp_dir().join(format!("pc-batch-regress-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&tmp);
+ std::fs::create_dir_all(&tmp).unwrap();
+
+ let bad_file = tmp.join("01_bad.png");
+ std::fs::write(&bad_file, b"not a valid png or document file").unwrap();
+
+ let good_file = tmp.join("02_good.pcraft");
+ let mut good_doc = Document::with_background("test", Size::new(32, 32), ColorMode::Rgb, SampleType::U8, Color::WHITE);
+ let extra_layer = good_doc.layers[0].clone();
+ good_doc.layers.push(extra_layer);
+ photocraft_automation::files::save(&good_doc, &good_file, None, &Default::default(), None).unwrap();
+
+ let target_dir = tmp.join("out");
+ std::fs::create_dir_all(&target_dir).unwrap();
+
+ let inputs = [bad_file, good_file];
+ let opts = ExportOptions::default();
+
+ for (idx, input) in inputs.iter().enumerate() {
+ let mut h = Headless::trusted_local();
+ h.set_renderer(renderer.take());
+ let r = (|| -> Result> {
+ h.open(input)?;
+ let target = target_dir.join(format!("{idx}.png"));
+ let r = h.save(None, Some(&target), Some("png"), &opts)?;
+ Ok(serde_json::from_value(r["warnings"].clone()).unwrap_or_default())
+ })();
+ if let Some(recovered) = h.take_renderer() {
+ renderer = Some(recovered);
+ }
+ if idx == 0 {
+ assert!(r.is_err(), "first corrupt item must fail");
+ } else {
+ assert!(r.is_ok(), "second valid item must succeed: {:?}", r);
+ }
+ }
+
+ assert!(renderer.is_some(), "renderer must be preserved across batch items");
+ assert_eq!(calls.load(Ordering::SeqCst), 1, "renderer must have been invoked on the second item");
+
+ let _ = std::fs::remove_dir_all(&tmp);
+ }
+}
diff --git a/apps/photocraft-cli/src/document.rs b/apps/photocraft-cli/src/document.rs
index 4c6678bfb1..95e8d4d8e2 100644
--- a/apps/photocraft-cli/src/document.rs
+++ b/apps/photocraft-cli/src/document.rs
@@ -10,7 +10,7 @@ use photocraft_io::ExportOptions;
use crate::{
CliError, Result,
args::{ConvertArgs, InfoArgs, RunArgs, Step},
- output::{JsonStyle, print_json, warn_all},
+ output::{JsonStyle, print_json, warn_all, warn_dedup},
};
/// An output that is the input file itself, however it is spelt (relative, with `./`, through a
@@ -34,10 +34,22 @@ pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write
let mut o = files::open_with_options(input, &import_opts)?;
// Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw.
photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document);
- warn_all(err, &o.warnings);
- warn_all(err, &missing_font_warnings(&o.document));
- let ws = files::save(&o.document, output, a.export.format.as_deref(), &opts, None)?;
- warn_all(err, &ws);
+ let mut seen_warnings = std::collections::HashSet::::new();
+ warn_dedup(err, &o.warnings, &mut seen_warnings);
+ warn_dedup(err, &missing_font_warnings(&o.document), &mut seen_warnings);
+ let policy = crate::gpu::GpuPolicy::from_args(&a.gpu).map_err(CliError::Other)?;
+ let mut renderer = policy.create_renderer();
+ let ws = match renderer.as_mut() {
+ Some(renderer) => files::save_with_renderer(&o.document, output, a.export.format.as_deref(), &opts, None, Some(&mut **renderer)),
+ None => files::save(&o.document, output, a.export.format.as_deref(), &opts, None),
+ };
+ // A failed GPU export can leave fallback reasons queued: report them before the error
+ // propagates, or the user would never learn why the GPU was not used.
+ if let Some(renderer) = renderer.as_mut() {
+ warn_dedup(err, &renderer.drain_diagnostics(), &mut seen_warnings);
+ }
+ let ws = ws?;
+ warn_dedup(err, &ws, &mut seen_warnings);
Ok(())
}
@@ -45,7 +57,10 @@ pub(crate) fn info(a: &InfoArgs, out: &mut dyn Write) -> Result {
let file = &a.file;
let mut h = Headless::trusted_local();
let opened = h.open_with_approval(file, a.approve_high_memory)?;
- let mut doc = h.inspect(None)?;
+ let mut doc = match (a.detail, h.session.active()) {
+ (true, Some(st)) => photocraft_engine::inspect::document_detail(st),
+ _ => h.inspect(None)?,
+ };
let mut warnings = opened["warnings"].as_array().cloned().unwrap_or_default();
if let Some(st) = h.session.active() {
warnings.extend(missing_font_warnings(&st.doc).into_iter().map(Value::String));
@@ -73,9 +88,14 @@ fn command_list(a: &RunArgs) -> Result> {
Ok(cmds)
}
+/// Reported once by `run --gpu` when a step is `file.export.quickExport`.
+const QUICK_EXPORT_CPU_NOTE: &str = "file.export.quickExport runs on the CPU compositor; --gpu applies to --out and the export commands that take a renderer";
+
pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result {
let opts = ExportOptions::from(&a.export);
let mut h = Headless::trusted_local();
+ let policy = crate::gpu::GpuPolicy::from_args(&a.gpu).map_err(CliError::Other)?;
+ policy.apply_to_headless(&mut h);
// Opening, running commands and exporting can all notice the same missing font:
// one `run` invocation reports each warning line at most once.
let mut reported_fonts = std::collections::HashSet::::new();
@@ -100,12 +120,25 @@ pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) ->
continue;
}
let r = h.command_run(&id, params).map_err(|source| CliError::Command { id: id.clone(), source })?;
- if let Some(message) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
+ // A direct Batch reports per-file errors and continues, as in Scripts, unless it was
+ // cancelled, ran out of script steps or couldn't write its error log; failed action or
+ // script calls still stop the command list before export.
+ let failure = if id == "file.automate.batch" {
+ photocraft_engine::actions_cmds::batch_stop(&r)
+ } else {
+ photocraft_engine::actions_cmds::nested_failure(&id, &r)
+ };
+ if let Some(message) = failure {
return Err(CliError::NestedFailure { id, message });
}
let new_warnings: Vec =
h.session.active().map(|st| missing_font_warnings(&st.doc)).unwrap_or_default().into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
warn_all(err, &new_warnings);
+ // Quick Export composites and encodes inside the engine, which has no renderer: say so
+ // once, so `--gpu` never looks as if it applied to it (docs/development.md, GPU).
+ if policy.enabled && id == "file.export.quickExport" {
+ warn_dedup(err, &[QUICK_EXPORT_CPU_NOTE.to_string()], &mut reported_fonts);
+ }
let mut result = json!({"command": id, "result": r});
if !new_warnings.is_empty() {
result["warnings"] = json!(new_warnings);
@@ -117,11 +150,19 @@ pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) ->
h.session.active().map(|st| missing_font_warnings(&st.doc)).unwrap_or_default().into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
warn_all(err, &remaining);
if let Some(o) = a.out.as_deref() {
- let r = h.save(None, Some(o), a.export.format.as_deref(), &opts)?;
+ let saved = h.save(None, Some(o), a.export.format.as_deref(), &opts);
+ // Report GPU diagnostics even when the export fails: the fallback reason is queued
+ // during the failed export and would otherwise be lost on the error path.
+ let failed_diags: Vec = h.drain_diagnostics().into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
+ warn_all(err, &failed_diags);
+ let r = saved?;
let ws: Vec = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
let ws: Vec = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
warn_all(err, &ws);
}
+ let diags = h.drain_diagnostics();
+ let diags: Vec = diags.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
+ warn_all(err, &diags);
Ok(())
}
diff --git a/apps/photocraft-cli/src/error.rs b/apps/photocraft-cli/src/error.rs
index 55e8df48e2..8222f0ba06 100644
--- a/apps/photocraft-cli/src/error.rs
+++ b/apps/photocraft-cli/src/error.rs
@@ -38,6 +38,8 @@ pub(crate) enum CliError {
BatchOption(String),
#[error("{0} file(s) failed")]
FilesFailed(usize),
+ #[error("{0}")]
+ Other(String),
}
pub(crate) type Result = std::result::Result;
diff --git a/apps/photocraft-cli/src/gpu.rs b/apps/photocraft-cli/src/gpu.rs
new file mode 100644
index 0000000000..82171c64d3
--- /dev/null
+++ b/apps/photocraft-cli/src/gpu.rs
@@ -0,0 +1,112 @@
+//! GPU acceleration policy and options for photocraft-cli.
+
+use photocraft_automation::Headless;
+use photocraft_io::BandRenderer;
+
+/// Optional GPU composition for supported flat exports and full-size previews.
+#[derive(clap::Args)]
+pub(crate) struct GpuArgs {
+ /// Prefer GPU rendering, falling back to CPU when unavailable or unsupported
+ #[arg(long, conflicts_with = "no_gpu")]
+ pub(crate) gpu: bool,
+ /// Use CPU rendering, overriding PHOTOCRAFT_GPU
+ #[arg(long, conflicts_with = "gpu")]
+ pub(crate) no_gpu: bool,
+}
+
+/// Resolved GPU rendering policy.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) struct GpuPolicy {
+ pub(crate) enabled: bool,
+}
+
+impl GpuPolicy {
+ /// Resolve policy from arguments and environment.
+ ///
+ /// Precedence:
+ /// - Explicit `--gpu` enables GPU.
+ /// - Explicit `--no-gpu` disables GPU.
+ /// - Neither flag: `PHOTOCRAFT_GPU` env var (1 = enabled, 0 or empty/unset = disabled).
+ /// Any other nonempty value is rejected.
+ pub(crate) fn from_args(args: &GpuArgs) -> Result {
+ if args.gpu {
+ return Ok(Self { enabled: true });
+ }
+ if args.no_gpu {
+ return Ok(Self { enabled: false });
+ }
+ match std::env::var_os("PHOTOCRAFT_GPU") {
+ Some(val) => {
+ let val = val.to_string_lossy();
+ let trimmed = val.trim();
+ if trimmed.is_empty() || trimmed == "0" {
+ Ok(Self { enabled: false })
+ } else if trimmed == "1" {
+ Ok(Self { enabled: true })
+ } else {
+ Err(format!("invalid value '{trimmed}' for 'PHOTOCRAFT_GPU': expected '1' or '0'"))
+ }
+ }
+ None => Ok(Self { enabled: false }),
+ }
+ }
+
+ /// Apply policy to a headless automation session.
+ pub(crate) fn apply_to_headless(&self, h: &mut Headless) {
+ #[cfg(all(feature = "gpu", not(target_arch = "wasm32")))]
+ if self.enabled {
+ h.enable_gpu();
+ }
+ #[cfg(any(not(feature = "gpu"), target_arch = "wasm32"))]
+ if self.enabled {
+ h.set_renderer(Some(Box::new(DisabledGpuRenderer::new())));
+ }
+ }
+
+ /// Create an optional boxed renderer under this policy.
+ #[cfg(all(feature = "gpu", not(target_arch = "wasm32")))]
+ pub(crate) fn create_renderer(&self) -> Option> {
+ if self.enabled { Some(Box::new(photocraft_automation::gpu::GpuRenderer::lazy())) } else { None }
+ }
+
+ #[cfg(any(not(feature = "gpu"), target_arch = "wasm32"))]
+ pub(crate) fn create_renderer(&self) -> Option> {
+ if self.enabled { Some(Box::new(DisabledGpuRenderer::new())) } else { None }
+ }
+}
+
+/// Renderer stub emitted when GPU is requested in builds compiled without `--features gpu`.
+#[cfg(any(not(feature = "gpu"), target_arch = "wasm32"))]
+struct DisabledGpuRenderer {
+ emitted: bool,
+}
+
+#[cfg(any(not(feature = "gpu"), target_arch = "wasm32"))]
+impl DisabledGpuRenderer {
+ fn new() -> Self {
+ Self { emitted: false }
+ }
+}
+
+#[cfg(any(not(feature = "gpu"), target_arch = "wasm32"))]
+impl BandRenderer for DisabledGpuRenderer {
+ fn render_bands(
+ &mut self,
+ _doc: &photocraft_doc::Document,
+ _rect: photocraft_doc::Rect,
+ _band_rows: i32,
+ _ctl: &photocraft_raster::Interrupt<'_>,
+ _sink: &mut dyn FnMut(photocraft_io::RenderBand) -> Result<(), photocraft_io::IoError>,
+ ) -> Result<(), photocraft_io::RenderError> {
+ Err(photocraft_io::RenderError::Unavailable("GPU support is not compiled in this build (compile with --features gpu)".into()))
+ }
+
+ fn drain_diagnostics(&mut self) -> Vec {
+ if self.emitted {
+ Vec::new()
+ } else {
+ self.emitted = true;
+ vec!["GPU acceleration requested but this build was compiled without GPU support (compile with --features gpu); falling back to CPU".into()]
+ }
+ }
+}
diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs
index c431b5529f..7e815db5d2 100644
--- a/apps/photocraft-cli/src/lib.rs
+++ b/apps/photocraft-cli/src/lib.rs
@@ -14,6 +14,7 @@ mod automation;
mod batch;
mod document;
mod error;
+mod gpu;
mod output;
pub use actions::{ActionsError, Steps, load_actions, load_actions_file, parse_actions};
diff --git a/apps/photocraft-cli/src/output.rs b/apps/photocraft-cli/src/output.rs
index ac37ec0537..05861d7477 100644
--- a/apps/photocraft-cli/src/output.rs
+++ b/apps/photocraft-cli/src/output.rs
@@ -33,6 +33,7 @@ impl From<&ExportArgs> for ExportOptions {
o.encode.dds.compression = c;
}
o.encode.dds.mipmaps = a.dds_mipmaps;
+ o.psd_maximize_compatibility = !a.no_maximize_compatibility;
o
}
}
@@ -43,6 +44,14 @@ pub(crate) fn warn_all(err: &mut dyn Write, ws: &[String]) {
}
}
+pub(crate) fn warn_dedup(err: &mut dyn Write, ws: &[String], seen: &mut std::collections::HashSet) {
+ for w in ws {
+ if seen.insert(w.clone()) {
+ let _ = writeln!(err, "warning: {w}");
+ }
+ }
+}
+
/// How [`print_json`] lays the JSON out.
pub(crate) enum JsonStyle {
Pretty,
diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs
index 386eafe62c..a0c72c39b2 100644
--- a/apps/photocraft-cli/tests/it/cli.rs
+++ b/apps/photocraft-cli/tests/it/cli.rs
@@ -15,7 +15,7 @@ fn bin() -> Command {
cmd
}
-const ENV_VARS: [&str; 3] = ["PHOTOCRAFT_CONTROL_PORT", "PHOTOCRAFT_CONTROL_TOKEN", "PHOTOCRAFT_CONTROL_TOKEN_FILE"];
+const ENV_VARS: [&str; 4] = ["PHOTOCRAFT_CONTROL_PORT", "PHOTOCRAFT_CONTROL_TOKEN", "PHOTOCRAFT_CONTROL_TOKEN_FILE", "PHOTOCRAFT_GPU"];
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("pc-cli-{}-{name}", std::process::id()));
@@ -363,6 +363,73 @@ fn run_stops_after_failed_action_without_exporting() {
std::fs::remove_dir_all(d).unwrap();
}
+/// A direct Batch keeps its partial result without preventing later commands or export.
+#[test]
+fn run_continues_after_batch_file_errors_and_keeps_partial_results() {
+ let d = tmp("run-partial-batch");
+ let good = d.join("good.png");
+ let bad = d.join("bad.png");
+ let batch_out = d.join("batch");
+ let caller_out = d.join("caller.pcraft");
+ write_png(&good, 6, 4, 3);
+ std::fs::write(&bad, b"not a PNG").unwrap();
+ let params = json!({"input":[good,bad],"output":batch_out,"format":"pcraft","steps":[
+ ["layer.renameLayer",{"name":"Batch output"}]
+ ]})
+ .to_string();
+ let stdout = ok(bin()
+ .args(["run", "--new", r#"{"width":8,"height":8}"#])
+ .args(["--cmd", "file.automate.batch", "--params", ¶ms])
+ .args(["--cmd", "layer.new.layer", "--params", r#"{"name":"After batch"}"#])
+ .args(["--cmd", "document.inspect", "--out"])
+ .arg(&caller_out))
+ .stdout;
+ let rows: Vec = stdout.lines().map(|line| serde_json::from_str(line).unwrap()).collect();
+ assert_eq!(rows.len(), 3);
+ assert_eq!(rows[0]["command"], "file.automate.batch");
+ let result = &rows[0]["result"];
+ let files = result["files"].as_array().unwrap();
+ assert_eq!(files.len(), 1);
+ assert_eq!(Path::new(files[0].as_str().unwrap()).canonicalize().unwrap(), batch_out.join("good.pcraft").canonicalize().unwrap());
+ assert_eq!(result["errors"].as_array().unwrap().len(), 1);
+ assert_eq!(result["errors"][0]["file"], json!(bad));
+ assert!(result["errors"][0]["error"].as_str().is_some_and(|message| !message.is_empty()));
+ assert!(batch_out.join("good.pcraft").is_file());
+ assert!(!batch_out.join("bad.pcraft").exists());
+ assert_eq!(rows[2]["command"], "document.inspect");
+ assert!(rows[2]["result"]["layers"].as_array().unwrap().iter().any(|layer| layer["name"] == "After batch"));
+ assert!(caller_out.is_file());
+ let saved: Value = serde_json::from_str(&ok(bin().arg("info").arg(&caller_out)).stdout).unwrap();
+ assert!(saved["layers"].as_array().unwrap().iter().any(|layer| layer["name"] == "After batch"));
+ std::fs::remove_dir_all(d).unwrap();
+}
+
+/// A direct Batch whose error log can't be written stops the command list before export, also
+/// when a file failed as well (per-file errors alone would let it go on).
+#[test]
+fn run_stops_when_a_direct_batch_cannot_write_its_error_log() {
+ let d = tmp("run-batch-log-fails");
+ let bad = d.join("bad.png");
+ let out = d.join("result.png");
+ std::fs::write(&bad, b"not a PNG").unwrap();
+ // The folder itself as the log path: the log write fails.
+ let params = json!({"input":[bad],"output":d.join("batch"),"format":"png","steps":[],"errorLog":d}).to_string();
+ let result = bin()
+ .args(["run", "--new", r#"{"width":8,"height":8}"#])
+ .args(["--cmd", "file.automate.batch", "--params", ¶ms])
+ .args(["--cmd", "document.inspect", "--out"])
+ .arg(&out)
+ .output()
+ .unwrap();
+ let error = String::from_utf8_lossy(&result.stderr);
+ assert_eq!(result.status.code(), Some(1), "stdout: {}\nstderr: {error}", String::from_utf8_lossy(&result.stdout));
+ assert!(error.contains("error log"), "{error}");
+ let rows: Vec = String::from_utf8_lossy(&result.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
+ assert!(rows.iter().all(|row| row["command"] != "document.inspect"), "later commands must not run");
+ assert!(!out.exists(), "a stopped command list must not export");
+ std::fs::remove_dir_all(d).unwrap();
+}
+
#[test]
fn run_continues_after_successful_action_and_exports() {
let d = tmp("run-successful-action");
@@ -800,6 +867,24 @@ fn dds_converts_both_ways_with_the_compression_asked_for() {
}
}
+/// #8064: `--no-maximize-compatibility` writes a layered PSD/PSB with Photoshop's placeholder
+/// instead of the merged image, flagged in resource 1057; without it, the real image.
+#[test]
+fn no_maximize_compatibility_writes_the_psd_placeholder() {
+ let d = tmp("maximize-compat");
+ let a = d.join("a.png");
+ write_png(&a, 32, 16, 3);
+ for (args, ext, real) in
+ [(&[][..], "psd", true), (&["--no-maximize-compatibility"][..], "psd", false), (&["--no-maximize-compatibility"][..], "psb", false)]
+ {
+ let out = d.join(format!("a.{ext}"));
+ ok(bin().arg("run").arg(&a).args(["--cmd", "layer.new.layer"]).arg("--out").arg(&out).args(args));
+ let psd = photocraft_psd::PsdFile::from_bytes(&std::fs::read(&out).unwrap()).unwrap();
+ assert_eq!(psd.has_real_merged_data(), Some(real), "{args:?} {ext}");
+ }
+ std::fs::remove_dir_all(d).unwrap();
+}
+
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is a
/// usage error and nothing is written.
#[test]
@@ -1721,3 +1806,30 @@ fn tiff_options_choose_compression_transparency_and_pyramid() {
assert!(!out.exists(), "{flag} {bad} wrote a file");
}
}
+
+#[test]
+fn info_detail_adds_text_runs_and_antialiasing() {
+ fn type_layer(doc: &Value) -> Option<&Value> {
+ let mut stack: Vec<&Value> = doc["layers"].as_array()?.iter().collect();
+ while let Some(l) = stack.pop() {
+ if l["kind"] == "Type" {
+ return Some(l);
+ }
+ stack.extend(l["children"].as_array().into_iter().flatten());
+ }
+ None
+ }
+ let dir = tmp("info-detail");
+ let file = dir.join("t.psd");
+ ok(bin()
+ .args(["run", "--new", r#"{"width":64,"height":64}"#, "--cmd", "type.create", "--params", r#"{"text":"Hi","x":4,"y":30,"name":"T"}"#, "--out"])
+ .arg(&file));
+ let plain: Value = serde_json::from_str(&ok(bin().arg("info").arg(&file).arg("--compact")).stdout).unwrap();
+ let text = &type_layer(&plain).expect("type layer")["text"];
+ assert!(text.get("runs").is_none() && text.get("antialias").is_none(), "{text}");
+ let detail: Value = serde_json::from_str(&ok(bin().arg("info").arg(&file).args(["--compact", "--detail"])).stdout).unwrap();
+ let text = &type_layer(&detail).expect("type layer")["text"];
+ assert!(text.get("antialias").is_some(), "{text}");
+ assert!(text["runs"].as_array().is_some_and(|r| !r.is_empty()), "{text}");
+ let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/apps/photocraft-cli/tests/it/gpu.rs b/apps/photocraft-cli/tests/it/gpu.rs
new file mode 100644
index 0000000000..eeb3faa8db
--- /dev/null
+++ b/apps/photocraft-cli/tests/it/gpu.rs
@@ -0,0 +1,335 @@
+//! Tests for photocraft-cli GPU rendering flags, precedence, and fallback behavior.
+
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use serde_json::Value;
+
+fn bin() -> Command {
+ let mut cmd = Command::new(crate::binary());
+ cmd.env_remove("PHOTOCRAFT_CONTROL_PORT");
+ cmd.env_remove("PHOTOCRAFT_CONTROL_TOKEN");
+ cmd.env_remove("PHOTOCRAFT_CONTROL_TOKEN_FILE");
+ cmd.env_remove("PHOTOCRAFT_GPU");
+ cmd
+}
+
+fn tmp(name: &str) -> PathBuf {
+ let d = std::env::temp_dir().join(format!("pc-cli-gpu-{}-{name}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&d);
+ std::fs::create_dir_all(&d).unwrap();
+ d
+}
+
+fn write_png(path: &Path, w: u32, h: u32) {
+ let data: Vec = (0..w * h * 4).map(|i| (i as u8).wrapping_mul(7)).collect();
+ let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgba, data).unwrap();
+ std::fs::write(path, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
+}
+
+#[test]
+fn gpu_and_no_gpu_conflict() {
+ let d = tmp("conflict");
+ let in_png = d.join("in.png");
+ let out_png = d.join("out.png");
+ write_png(&in_png, 16, 16);
+
+ let o = bin().args(["convert", "--gpu", "--no-gpu", in_png.to_str().unwrap(), out_png.to_str().unwrap()]).output().unwrap();
+ assert_eq!(o.status.code(), Some(2));
+ let stderr = String::from_utf8_lossy(&o.stderr);
+ assert!(stderr.contains("cannot be used with") || stderr.contains("conflicts with"), "{stderr}");
+ let _ = std::fs::remove_dir_all(&d);
+}
+
+#[test]
+fn gpu_env_var_validation() {
+ let d = tmp("env_val");
+ let in_png = d.join("in.png");
+ let out_png = d.join("out.png");
+ write_png(&in_png, 16, 16);
+
+ // Invalid non-empty value rejected with usage error naming the variable
+ for bad in ["2", "true", "yes", "false", "on", "off"] {
+ let o = bin().env("PHOTOCRAFT_GPU", bad).args(["convert", in_png.to_str().unwrap(), out_png.to_str().unwrap()]).output().unwrap();
+ assert_eq!(o.status.code(), Some(2), "expected exit 2 for PHOTOCRAFT_GPU={bad}");
+ let stderr = String::from_utf8_lossy(&o.stderr);
+ assert!(stderr.contains("PHOTOCRAFT_GPU"), "expected stderr to name variable: {stderr}");
+ assert!(stderr.contains("expected '1' or '0'"), "expected usage note: {stderr}");
+ }
+
+ // Empty or blank value is accepted as unset (CPU default)
+ for empty in ["", " "] {
+ let out_sub = d.join(format!("out_{}.png", empty.trim()));
+ let o = bin().env("PHOTOCRAFT_GPU", empty).args(["convert", in_png.to_str().unwrap(), out_sub.to_str().unwrap()]).output().unwrap();
+ assert!(o.status.success(), "expected success for PHOTOCRAFT_GPU={empty:?}");
+ assert!(out_sub.is_file());
+ }
+
+ let _ = std::fs::remove_dir_all(&d);
+}
+
+#[cfg(unix)]
+#[test]
+fn gpu_env_var_rejects_a_non_utf8_value() {
+ use std::ffi::OsString;
+ use std::os::unix::ffi::OsStringExt;
+
+ let d = tmp("env_non_utf8");
+ let in_png = d.join("in.png");
+ let out_png = d.join("out.png");
+ write_png(&in_png, 16, 16);
+
+ let bad = OsString::from_vec(vec![0xff, 0xfe]);
+ let o = bin().env("PHOTOCRAFT_GPU", bad).args(["convert", in_png.to_str().unwrap(), out_png.to_str().unwrap()]).output().unwrap();
+ assert_eq!(o.status.code(), Some(2), "a non-UTF-8 PHOTOCRAFT_GPU must be a usage error");
+ let stderr = String::from_utf8_lossy(&o.stderr);
+ assert!(stderr.contains("PHOTOCRAFT_GPU"), "the usage error must name the variable: {stderr}");
+ let _ = std::fs::remove_dir_all(&d);
+}
+
+#[test]
+fn explicit_flags_override_environment() {
+ let d = tmp("override");
+ let in_png = d.join("in.png");
+ let in_layers = d.join("in.pcraft");
+ let out1 = d.join("out1.png");
+ let out2 = d.join("out2.png");
+ write_png(&in_png, 16, 16);
+ // A single-layer PNG takes the native export path and never calls the renderer; a
+ // multi-layer document composites, so it observes whether `--gpu` reached the backend.
+ let o = bin().args(["run", "--new", r#"{"width":16,"height":16}"#, "--cmd", "layer.new.layer", "--out", in_layers.to_str().unwrap()]).output().unwrap();
+ assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));
+
+ // --no-gpu overrides PHOTOCRAFT_GPU=1
+ let o = bin().env("PHOTOCRAFT_GPU", "1").args(["convert", "--no-gpu", in_png.to_str().unwrap(), out1.to_str().unwrap()]).output().unwrap();
+ assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));
+ assert!(out1.is_file());
+
+ // --gpu overrides PHOTOCRAFT_GPU=0
+ let o = bin().env("PHOTOCRAFT_GPU", "0").args(["convert", "--gpu", in_layers.to_str().unwrap(), out2.to_str().unwrap()]).output().unwrap();
+ assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));
+ assert!(out2.is_file());
+ let stderr = String::from_utf8_lossy(&o.stderr);
+ // The renderer reports either its adapter, an unavailable adapter, or (in a build without
+ // GPU support) that GPU rendering was requested; the CPU default emits none of these.
+ let markers = ["GPU adapter:", "no GPU adapter available", "could not build GPU compositor", "falling back to CPU"];
+ assert!(markers.iter().any(|marker| stderr.contains(marker)), "explicit --gpu must reach the renderer when the environment disables it: {stderr}");
+
+ // Explicit flag overrides even bad environment value
+ let out3 = d.join("out3.png");
+ let o = bin().env("PHOTOCRAFT_GPU", "invalid").args(["convert", "--no-gpu", in_png.to_str().unwrap(), out3.to_str().unwrap()]).output().unwrap();
+ assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));
+ assert!(out3.is_file());
+
+ let _ = std::fs::remove_dir_all(&d);
+}
+
+#[test]
+fn mcp_bridge_rejects_explicit_gpu_flags_and_ignores_env() {
+ // Explicit --gpu with --bridge rejected
+ let o = bin().args(["mcp", "--bridge", "127.0.0.1:50000", "--gpu"]).output().unwrap();
+ assert_eq!(o.status.code(), Some(2));
+ let stderr = String::from_utf8_lossy(&o.stderr);
+ assert!(stderr.contains("configure rendering on the desktop app"), "{stderr}");
+
+ // Explicit --no-gpu with --bridge rejected
+ let o = bin().args(["mcp", "--bridge", "127.0.0.1:50000", "--no-gpu"]).output().unwrap();
+ assert_eq!(o.status.code(), Some(2));
+ let stderr = String::from_utf8_lossy(&o.stderr);
+ assert!(stderr.contains("configure rendering on the desktop app"), "{stderr}");
+
+ // An invalid PHOTOCRAFT_GPU value is ignored in bridge mode: the desktop process controls
+ // rendering, so the bridge must not validate the variable or reject the request for it.
+ let o = bin().env("PHOTOCRAFT_GPU", "invalid").args(["mcp", "--bridge", "127.0.0.1:50000"]).stdin(std::process::Stdio::null()).output().unwrap();
+ let stderr = String::from_utf8_lossy(&o.stderr);
+ assert!(!stderr.contains("PHOTOCRAFT_GPU"), "bridge mode must ignore the GPU environment: {stderr}");
+}
+
+#[test]
+fn convert_and_run_honor_gpu_flag_and_preserve_stdout_protocol() {
+ let d = tmp("protocol");
+ let out_png = d.join("out.png");
+
+ // run --gpu output on stdout is clean JSON
+ let o =
+ bin().args(["run", "--gpu", "--new", r#"{"width":32,"height":32}"#, "--cmd", "layer.new.layer", "--out", out_png.to_str().unwrap()]).output().unwrap();
+ assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));
+ assert!(out_png.is_file());
+ let stdout = String::from_utf8_lossy(&o.stdout);
+ for line in stdout.lines() {
+ let v: Value = serde_json::from_str(line).expect("each stdout line must be valid JSON");
+ assert!(v.get("command").is_some());
+ }
+
+ let _ = std::fs::remove_dir_all(&d);
+}
+
+#[test]
+fn batch_recovers_renderer_after_failed_item_and_preserves_gpu_policy() {
+ let d = tmp("batch_regress");
+ let in_dir = d.join("in");
+ let out_dir = d.join("out");
+ std::fs::create_dir_all(&in_dir).unwrap();
+ std::fs::create_dir_all(&out_dir).unwrap();
+
+ // First item is corrupt and fails to open
+ let corrupt_png = in_dir.join("01_corrupt.png");
+ std::fs::write(&corrupt_png, b"invalid png bytes").unwrap();
+
+ // Second item is valid
+ let valid_png = in_dir.join("02_valid.png");
+ write_png(&valid_png, 32, 32);
+
+ let actions = d.join("actions.json");
+ std::fs::write(&actions, serde_json::json!([{"command": "layer.new.layer", "params": {"name": "Batch"}}]).to_string()).unwrap();
+ let o = bin()
+ .args(["batch", "--gpu", "--actions"])
+ .arg(&actions)
+ .args(["--in", in_dir.to_str().unwrap(), "--out", out_dir.to_str().unwrap()])
+ .output()
+ .unwrap();
+
+ // Exit code 1 because 1 file failed
+ assert_eq!(o.status.code(), Some(1));
+ let stdout = String::from_utf8_lossy(&o.stdout);
+ let stderr = String::from_utf8_lossy(&o.stderr);
+
+ assert!(stdout.contains("1 succeeded, 1 failed"), "stdout: {stdout}");
+ assert!(stderr.contains("FAIL"), "stderr must report failing item: {stderr}");
+ let markers = ["GPU adapter:", "no GPU adapter available", "could not build GPU compositor", "falling back to CPU"];
+ assert!(markers.iter().any(|marker| stderr.contains(marker)), "--gpu must reach the batch renderer: {stderr}");
+ assert!(out_dir.join("02_valid.png").is_file(), "second item must have been saved successfully");
+
+ let _ = std::fs::remove_dir_all(&d);
+}
+
+#[test]
+fn diagnostics_preserve_clean_stdout_and_emit_to_stderr() {
+ let d = tmp("diag_clean");
+ let in_png = d.join("in.png");
+ let out_png = d.join("out.png");
+ write_png(&in_png, 32, 32);
+
+ let o = bin().args(["convert", "--gpu", in_png.to_str().unwrap(), out_png.to_str().unwrap()]).output().unwrap();
+
+ assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));
+ assert!(out_png.is_file());
+ // Convert emits no stdout; diagnostics must go to stderr
+ assert!(o.stdout.is_empty(), "convert stdout must remain completely empty, got: {:?}", String::from_utf8_lossy(&o.stdout));
+ let stderr = String::from_utf8_lossy(&o.stderr);
+ assert!(stderr.contains("falling back to CPU"), "single-layer convert must report its CPU fallback on stderr: {stderr}");
+
+ let _ = std::fs::remove_dir_all(&d);
+}
+
+#[test]
+fn serve_gpu_preserves_clean_protocol_stdout_and_drains_diagnostics_to_stderr() {
+ use std::io::{BufRead, BufReader, Write};
+ use std::process::Stdio;
+
+ let mut child = bin().args(["serve", "--gpu"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
+
+ let mut stdin = child.stdin.take().unwrap();
+ let mut stdout = BufReader::new(child.stdout.take().unwrap());
+
+ // 1. Initial valid command
+ writeln!(stdin, "{}", serde_json::json!({"id": 1, "method": "doc.new", "params": {"width": 16, "height": 16}})).unwrap();
+ stdin.flush().unwrap();
+
+ let mut line1 = String::new();
+ assert!(stdout.read_line(&mut line1).unwrap() > 0, "server must respond");
+ let v1: Value = serde_json::from_str(&line1).expect("line 1 on stdout must be valid JSON");
+ assert_eq!(v1["id"], 1);
+ assert_eq!(v1["ok"], true, "{v1}");
+
+ // 2. A second layer so the render below composites instead of taking the native path.
+ writeln!(stdin, "{}", serde_json::json!({"id": 2, "method": "engine.execute", "params": {"command": "layer.new.layer", "params": {"name": "Top"}}}))
+ .unwrap();
+ stdin.flush().unwrap();
+
+ let mut line2 = String::new();
+ assert!(stdout.read_line(&mut line2).unwrap() > 0, "server must respond to the layer command");
+ let v2: Value = serde_json::from_str(&line2).expect("line 2 on stdout must be valid JSON");
+ assert_eq!(v2["id"], 2);
+ assert_eq!(v2["ok"], true, "{v2}");
+
+ // 3. Failure recovery: invalid request receives error reply without crashing server
+ writeln!(stdin, "{}", serde_json::json!({"id": 3, "method": "invalid.nonexistent.method", "params": {}})).unwrap();
+ stdin.flush().unwrap();
+
+ let mut line3 = String::new();
+ assert!(stdout.read_line(&mut line3).unwrap() > 0, "server must respond to invalid request");
+ let v3: Value = serde_json::from_str(&line3).expect("line 3 on stdout must be valid JSON");
+ assert_eq!(v3["id"], 3);
+ assert_eq!(v3["ok"], false, "{v3}");
+
+ // 4. Subsequent valid command still succeeds (proves session & renderer recovery)
+ writeln!(stdin, "{}", serde_json::json!({"id": 4, "method": "doc.inspect"})).unwrap();
+ stdin.flush().unwrap();
+
+ let mut line4 = String::new();
+ assert!(stdout.read_line(&mut line4).unwrap() > 0, "server must respond to the inspect request");
+ let v4: Value = serde_json::from_str(&line4).expect("line 4 on stdout must be valid JSON");
+ assert_eq!(v4["id"], 4);
+ assert_eq!(v4["ok"], true, "{v4}");
+
+ // 5. Render the composite: one JSON line on stdout, diagnostics only on stderr.
+ writeln!(stdin, "{}", serde_json::json!({"id": 5, "method": "doc.render", "params": {"maxSide": 16}})).unwrap();
+ stdin.flush().unwrap();
+
+ let mut line5 = String::new();
+ assert!(stdout.read_line(&mut line5).unwrap() > 0, "server must answer the render request");
+ let v5: Value = serde_json::from_str(&line5).expect("line 5 on stdout must be valid JSON");
+ assert_eq!(v5["id"], 5);
+ assert_eq!(v5["ok"], true, "{v5}");
+ assert!(v5["result"]["base64"].as_str().is_some_and(|b| !b.is_empty()), "render must return PNG bytes: {v5}");
+
+ // Close stdin and verify graceful exit
+ drop(stdin);
+ let output = child.wait_with_output().unwrap();
+ assert!(output.status.success());
+
+ // Every render that reaches the renderer reports its adapter or why it fell back, whichever
+ // build and adapter this test runs on.
+ // `doc.render` returns the diagnostics of its own render in the reply's `warnings`; any left
+ // in the queue go to stderr. Either way they stay off the protocol lines' framing.
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ let reported = format!("{stderr}\n{}", v5["result"]["warnings"]);
+ let markers = ["GPU adapter:", "no GPU adapter available", "could not build GPU compositor", "falling back to CPU", "GPU acceleration requested"];
+ assert!(markers.iter().any(|m| reported.contains(m)), "render must report its GPU backend or fallback in its reply or on stderr: {reported}");
+}
+
+#[test]
+fn cpu_default_does_not_emit_gpu_warnings() {
+ let d = tmp("cpu_default");
+ let in_png = d.join("in.png");
+ let out_png = d.join("out.png");
+ write_png(&in_png, 16, 16);
+
+ let o = bin().args(["convert", in_png.to_str().unwrap(), out_png.to_str().unwrap()]).output().unwrap();
+ assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));
+ assert!(out_png.is_file());
+
+ let stderr = String::from_utf8_lossy(&o.stderr);
+ assert!(!stderr.contains("GPU"), "default CPU convert should not emit GPU warnings, got: {stderr}");
+
+ let _ = std::fs::remove_dir_all(&d);
+}
+
+#[test]
+fn run_gpu_quick_export_reports_that_it_runs_on_the_cpu() {
+ let d = tmp("quick_export");
+ let out_png = d.join("quick.png");
+ let params = format!(r#"{{"path":{}}}"#, serde_json::to_string(&out_png.to_string_lossy()).unwrap());
+ let args = ["run", "--new", r#"{"width":16,"height":16,"background":"white"}"#, "--cmd", "file.export.quickExport", "--params", params.as_str()];
+ let o = bin().args(args).arg("--gpu").output().unwrap();
+ assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));
+ assert!(out_png.is_file());
+ let stderr = String::from_utf8_lossy(&o.stderr);
+ assert_eq!(stderr.matches("quickExport runs on the CPU compositor").count(), 1, "one CPU note expected: {stderr}");
+
+ let o = bin().args(args).output().unwrap();
+ assert!(o.status.success());
+ assert!(!String::from_utf8_lossy(&o.stderr).contains("runs on the CPU compositor"), "no note without --gpu");
+ let _ = std::fs::remove_dir_all(&d);
+}
diff --git a/apps/photocraft-cli/tests/it/main.rs b/apps/photocraft-cli/tests/it/main.rs
index 2729f74251..411791d94c 100644
--- a/apps/photocraft-cli/tests/it/main.rs
+++ b/apps/photocraft-cli/tests/it/main.rs
@@ -1,10 +1,13 @@
//! The crate's integration tests, linked into one test binary.
mod cli;
+mod gpu;
#[cfg(all(target_os = "linux", target_env = "gnu"))]
mod malloc_tuning;
+
mod man_and_completions;
mod orientation;
+mod psd_write_oracle;
/// Nextest archives relocate binaries; plain Cargo keeps the compile-time path valid.
fn binary() -> std::ffi::OsString {
diff --git a/apps/photocraft-cli/tests/it/psd_write_oracle.rs b/apps/photocraft-cli/tests/it/psd_write_oracle.rs
new file mode 100644
index 0000000000..524f476fa1
--- /dev/null
+++ b/apps/photocraft-cli/tests/it/psd_write_oracle.rs
@@ -0,0 +1,45 @@
+//! Exercise the recipe runner with the real CLI and a synthetic editable PSD.
+
+use std::fs;
+use std::path::Path;
+use std::process::Command;
+
+use serde_json::{Value, json};
+
+#[test]
+fn edited_psd_roundtrip_is_checked_and_bad_expectations_fail() {
+ let temp = tempfile::tempdir().unwrap();
+ let root = temp.path();
+ let base = root.join("base.psd");
+ let cli = crate::binary();
+ let made = Command::new(&cli)
+ .args(["run", "--new", r#"{"width":64,"height":64}"#, "--cmd", "type.create", "--params", r#"{"text":"Oracle","x":4,"y":30,"name":"Oracle"}"#, "--out"])
+ .arg(&base)
+ .output()
+ .unwrap();
+ assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
+ let hash = xtask::sha256::hex(&fs::read(&base).unwrap());
+ let mut recipe = json!({"version":1,"corpus_revision":"synthetic","cases":[{
+ "id":"style", "base":"base.psd", "base_sha256":hash, "target":{"kind":"Type","name":"Oracle"},
+ "commands":[{"command":"type.antiAlias.crisp","params":{}}],
+ "checks":[{"op":"changed_to","path":"/text/antialias","value":"Crisp"},{"op":"unchanged","path":"/text/text"}]
+ }]});
+ let recipe_path = root.join("recipes.json");
+ let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
+ fs::write(&recipe_path, serde_json::to_vec(&recipe).unwrap()).unwrap();
+ let cli_path = Path::new(&cli).to_str().unwrap();
+ let corpus = root.to_str().unwrap();
+ let recipe_name = recipe_path.to_str().unwrap();
+ let output = root.join("success");
+ let args = [recipe_name, corpus, cli_path, output.to_str().unwrap()];
+ xtask::psd_write_oracle::run(&src, &args).unwrap();
+ let report: Value = serde_json::from_slice(&fs::read(output.join("report.json")).unwrap()).unwrap();
+ assert_eq!(report["cases"][0]["status"], "pass");
+ assert_eq!(report["cases"][0]["photoshop"]["opens"], "not_run");
+ recipe["cases"][0]["checks"][0]["value"] = json!("Sharp");
+ fs::write(&recipe_path, serde_json::to_vec(&recipe).unwrap()).unwrap();
+ let bad_output = root.join("failure");
+ assert!(xtask::psd_write_oracle::run(&src, &[recipe_name, corpus, cli_path, bad_output.to_str().unwrap()]).is_err());
+ let bad: Value = serde_json::from_slice(&fs::read(bad_output.join("report.json")).unwrap()).unwrap();
+ assert_eq!(bad["cases"][0]["status"], "fail");
+}
diff --git a/apps/photocraft-web-boot/index.html b/apps/photocraft-web-boot/index.html
index 8ed9a6961e..7d15b18daa 100644
--- a/apps/photocraft-web-boot/index.html
+++ b/apps/photocraft-web-boot/index.html
@@ -16,6 +16,15 @@
this page, no Rust runs to say so, so the page itself shows what to do after a while. */
#photocraft_stalled { color: #8c8c8c; visibility: hidden; animation: photocraft-stalled 0s 20s forwards; }
@keyframes photocraft-stalled { to { visibility: visible; } }
+ /* Revealed by the app when the module traps (an allocation it could not satisfy, or a panic):
+ the page would otherwise sit on the last frame, frozen. Static text, so it shows even if Rust
+ can no longer build any. */
+ #photocraft_stopped { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
+ padding: 0 16px; text-align: center; color: #e0e0e0; background: rgba(38, 38, 38, 0.92); font: 14px system-ui, sans-serif; pointer-events: auto; }
+ #photocraft_stopped[hidden] { display: none; }
+ #photocraft_stopped p { margin: 0 0 8px; max-width: 460px; line-height: 1.5; }
+ #photocraft_stopped a { color: #ffffff; }
+ #photocraft_stopped_detail { color: #b8b8b8; }
@@ -26,5 +35,10 @@
download or come from a different version of this page. Reload to try again; the browser
console lists what failed.
+
+
PhotoCraft stopped.
+
Changes since the last save in this tab can't be recovered. Reload to start again.
+
+