Merge origin/main into feat/cli-metadata-option (#9975)

Conflicts with main's --no-maximize-compatibility (#8064) in the CLI
export args, output mapping and dev-dependencies, and in the
file-format parity status line and revision history: both sides kept.

Co-Authored-By: ljkx <ljkx@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed 2026-11-08 01:26:47 +01:00
commit 50b877645f
1032 files changed
+162488 -6872

No files matched your search

+1 -1
View File
@@ -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 }
+2 -2
View File
@@ -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
+28
View File
@@ -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
+9
View File
@@ -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 }}
+54 -2
View File
@@ -1,3 +1,56 @@
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
## 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.
<!-- craft-house-rules:end -->
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
## 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.
<!-- craft-house-rules:end -->
# 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
+12 -1
View File
@@ -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) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `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 | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `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 | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-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 | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/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 <https://github.com/lucide-icons/lucide> `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 <https://github.com/lucide-icons/lucide> `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 <https://github.com/lucide-icons/lucide> `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 <https://github.com/lucide-icons/lucide> `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 <https://github.com/lucide-icons/lucide> `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 <https://github.com/lucide-icons/lucide> `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 |
Generated
+204
View File
@@ -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]]
@@ -5104,14 +5244,20 @@ dependencies = [
"photocraft-automation",
"photocraft-cms",
"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]]
@@ -5220,6 +5366,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.21",
"wat",
"web-time",
"winsafe",
]
@@ -5271,6 +5418,7 @@ dependencies = [
"photocraft-vector",
"pollster 1.0.1",
"rayon",
"thiserror 2.0.21",
"wgpu",
]
@@ -5290,6 +5438,7 @@ dependencies = [
"crc32fast",
"flate2",
"imagesize",
"lopdf",
"miniz_oxide 0.9.1",
"photocraft-affinity",
"photocraft-algo",
@@ -5313,6 +5462,7 @@ dependencies = [
"serde_json",
"thiserror 2.0.21",
"tiff",
"ttf-parser",
"zune-core 0.5.3",
"zune-jpegxl",
]
@@ -5380,6 +5530,7 @@ dependencies = [
"photocraft-geom",
"proptest",
"rayon",
"rustix 1.1.5",
"serde",
"sysinfo",
"winsafe",
@@ -5405,10 +5556,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",
]
@@ -5440,6 +5594,7 @@ name = "photocraft-ui-egui"
version = "0.20.1"
dependencies = [
"base64 0.23.1",
"chrono",
"craft-print",
"craft-ui",
"craft-update",
@@ -5449,6 +5604,8 @@ dependencies = [
"egui_kittest",
"epaint_default_fonts",
"flate2",
"icu_casemap",
"icu_locale_core",
"log",
"photocraft-algo",
"photocraft-cms",
@@ -5505,6 +5662,7 @@ dependencies = [
"photocraft-io",
"photocraft-paint",
"photocraft-psd",
"photocraft-raster",
"photocraft-ui-egui",
"rfd",
"serde_json",
@@ -6047,6 +6205,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"
@@ -7275,6 +7439,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"
@@ -7932,6 +8107,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"
@@ -9054,6 +9238,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"
@@ -9262,6 +9465,7 @@ dependencies = [
"chrono",
"serde",
"serde_json",
"tempfile",
"toml",
]
+5 -1
View File
@@ -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 |
+15
View File
@@ -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,6 +43,10 @@ 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 }
# A real ICC profile for the --metadata test (#9466).
photocraft-cms = { workspace = true }
# Synthetic camera raws (#7789).
@@ -42,6 +55,8 @@ photocraft-raw = { workspace = true, features = ["testgen"] }
# 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.
+221
View File
@@ -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<PathBuf>,
/// 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<dyn std::error::Error>> {
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::<f64>() / (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<String> {
(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);
}
}
+59 -3
View File
@@ -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,
/// The document metadata a flat export embeds: all (the default) keeps the document's XMP
/// packet and free-form text where the format holds them, as Save As does; none leaves them
/// out, as Export As's Metadata: None. Nothing else changes: the ICC profile, EXIF and
@@ -113,6 +120,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,
}
@@ -126,6 +135,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
@@ -158,6 +170,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)]
@@ -216,6 +230,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,
}
@@ -259,6 +275,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,
@@ -280,6 +298,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,
@@ -319,6 +339,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 },
@@ -327,8 +353,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)
@@ -441,7 +467,10 @@ pub(crate) fn parse(args: &[OsString]) -> Result<Cli, clap::Error> {
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
@@ -464,8 +493,15 @@ pub(crate) fn parse(args: &[OsString]) -> Result<Cli, clap::Error> {
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");
@@ -476,12 +512,32 @@ pub(crate) fn parse(args: &[OsString]) -> Result<Cli, clap::Error> {
}
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> {
+16 -2
View File
@@ -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())?)
+98 -3
View File
@@ -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::<String>::new();
warn_dedup(err, &warnings, &mut seen_warnings);
let opts = ExportOptions::from(&a.export);
// Results are saved as `<out>/<stem>.<ext>`, 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<Vec<String>> {
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<String> = 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<AtomicUsize>,
}
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<Box<dyn BandRenderer>> = 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<Vec<String>> {
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);
}
}
+49 -8
View File
@@ -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::<String>::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<Vec<(String, Value)>> {
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::<String>::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<String> =
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<String> = h.drain_diagnostics().into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
warn_all(err, &failed_diags);
let r = saved?;
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
let ws: Vec<String> = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
warn_all(err, &ws);
}
let diags = h.drain_diagnostics();
let diags: Vec<String> = diags.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
warn_all(err, &diags);
Ok(())
}
+2
View File
@@ -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<T = (), E = CliError> = std::result::Result<T, E>;
+112
View File
@@ -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<Self, String> {
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<Box<dyn BandRenderer>> {
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<Box<dyn BandRenderer>> {
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<String> {
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()]
}
}
}
+1
View File
@@ -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};
+9
View File
@@ -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;
// Conversions keep the whole packet unless asked, as Save As does (#733, #9466).
if let Some(m) = a.metadata {
o.xmp = m;
@@ -47,6 +48,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<String>) {
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,
+113 -1
View File
@@ -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", &params])
.args(["--cmd", "layer.new.layer", "--params", r#"{"name":"After batch"}"#])
.args(["--cmd", "document.inspect", "--out"])
.arg(&caller_out))
.stdout;
let rows: Vec<Value> = 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", &params])
.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<Value> = 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]
@@ -1803,3 +1888,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);
}
+335
View File
@@ -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<u8> = (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);
}
+3
View File
@@ -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 {
@@ -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");
}
+14
View File
@@ -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; }
</style>
</head>
<body>
@@ -26,5 +35,10 @@
download or come from a different version of this page. <a href="">Reload</a> to try again; the browser
console lists what failed.</p>
</div>
<div id="photocraft_stopped" hidden role="alert">
<p><strong>PhotoCraft stopped.</strong></p>
<p>Changes since the last save in this tab can't be recovered. <a href="">Reload</a> to start again.</p>
<p id="photocraft_stopped_detail"></p>
</div>
</body>
</html>
+2 -1
View File
@@ -24,6 +24,7 @@ photocraft-io = { workspace = true }
photocraft-codecs = { workspace = true }
photocraft-doc = { workspace = true }
photocraft-ui-egui = { workspace = true }
photocraft-raster = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
@@ -34,7 +35,7 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "RequestMode", "Navigator"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "RequestMode", "Navigator", "ErrorEvent", "PromiseRejectionEvent"] }
[dev-dependencies]
serde_json = { workspace = true }
+14
View File
@@ -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; }
</style>
</head>
<body>
@@ -26,5 +35,10 @@
download or come from a different version of this page. <a href="">Reload</a> to try again; the browser
console lists what failed.</p>
</div>
<div id="photocraft_stopped" hidden role="alert">
<p><strong>PhotoCraft stopped.</strong></p>
<p>Changes since the last save in this tab can't be recovered. <a href="">Reload</a> to start again.</p>
<p id="photocraft_stopped_detail"></p>
</div>
</body>
</html>
+148
View File
@@ -0,0 +1,148 @@
//! What the page says when the module stops, and the `?free-mb` diagnostic flag (#10024).
//!
//! A failed allocation on `wasm32-unknown-unknown` aborts the module: the page keeps the last
//! frame and nothing says why. `web.rs` listens for the trap in the page and shows the static
//! `#photocraft_stopped` element; the decisions that need no browser live here, so native tests
//! cover them.
use std::sync::{Mutex, PoisonError};
/// The message of the last panic, kept for the trap that follows it (a panic aborts the module).
static LAST_PANIC: Mutex<String> = Mutex::new(String::new());
/// The most characters of a panic message the page shows.
const MAX_PANIC_CHARS: usize = 300;
/// Truncates `message` to [`MAX_PANIC_CHARS`] characters, on a character boundary.
pub fn truncate(message: &str) -> String {
match message.char_indices().nth(MAX_PANIC_CHARS) {
Some((end, _)) => format!("{}\u{2026}", message.get(..end).unwrap_or_default()),
None => message.to_owned(),
}
}
/// Remembers a panic's message (and where it happened) for [`take_panic`].
pub fn record_panic(message: &str) {
*LAST_PANIC.lock().unwrap_or_else(PoisonError::into_inner) = truncate(message);
}
/// The message [`record_panic`] kept, if any.
pub fn take_panic() -> Option<String> {
let mut last = LAST_PANIC.lock().unwrap_or_else(PoisonError::into_inner);
(!last.is_empty()).then(|| std::mem::take(&mut *last))
}
/// `true` when an uncaught error, by its `name` and `message`, is the module trapping or the
/// browser refusing it memory, rather than some other script error or failed request. Browsers
/// word and capitalize these differently (Safari: `Unreachable code should not be executed`), so
/// the message is matched ignoring case.
pub fn is_trap(name: &str, message: &str) -> bool {
let message = message.to_ascii_lowercase();
name == "RuntimeError"
|| ["unreachable", "memory access out of bounds", "maximum call stack", "could not allocate memory", "out of memory"]
.iter()
.any(|m| message.contains(m))
}
/// Trap messages (lowercase, matched ignoring case) that mean an allocation failed:
/// `unreachable` with no panic before it is the allocation-failure abort (the only one that runs
/// no Rust), the others are the browser refusing to grow the memory. `memory access out of
/// bounds` is not among them: it is a bug.
const OUT_OF_MEMORY: [&str; 3] = ["unreachable", "could not allocate memory", "out of memory"];
/// The line under "PhotoCraft stopped": a captured panic means an internal error; a trap with no
/// panic before it is out of memory when its message says so (or says nothing), and otherwise
/// unexpected.
pub fn detail(panic: Option<&str>, trap_message: &str) -> String {
if let Some(p) = panic {
return format!("It hit an internal error: {p}");
}
let lower = trap_message.to_ascii_lowercase();
if lower.is_empty() || OUT_OF_MEMORY.iter().any(|m| lower.contains(m)) {
"It ran out of memory.".to_owned()
} else {
format!("It stopped unexpectedly: {}", truncate(trap_message))
}
}
/// `?free-mb=N` from a URL query (`?cpu&free-mb=300`): pretend only N MiB can be allocated, so
/// the memory gates and the low-memory warning can be seen without gigabytes of images.
pub fn free_mb(query: &str) -> Option<u64> {
query.trim_start_matches('?').split('&').find_map(|pair| pair.strip_prefix("free-mb=")?.parse().ok())
}
/// The bytes `free-mb` asks for, without overflowing.
pub fn free_bytes(mb: u64) -> u64 {
mb.saturating_mul(1 << 20)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_panic_message_is_shown_as_an_internal_error() {
assert_eq!(detail(Some("index out of bounds"), "unreachable"), "It hit an internal error: index out of bounds");
}
#[test]
fn a_trap_with_no_panic_is_an_out_of_memory_abort() {
assert_eq!(detail(None, "unreachable"), "It ran out of memory.");
assert_eq!(detail(None, "RuntimeError: unreachable executed"), "It ran out of memory.");
assert_eq!(detail(None, ""), "It ran out of memory.");
assert_eq!(detail(None, "WebAssembly.Memory(): could not allocate memory"), "It ran out of memory.");
// Safari and Firefox word and capitalize theirs differently.
assert_eq!(detail(None, "Unreachable code should not be executed"), "It ran out of memory.");
assert_eq!(detail(None, "out of memory"), "It ran out of memory.");
}
#[test]
fn another_kind_of_trap_is_not_blamed_on_memory() {
let text = detail(None, "divide by zero");
assert!(text.starts_with("It stopped unexpectedly: divide by zero"), "{text}");
// A bad memory access is a bug, not memory running out.
let text = detail(None, "memory access out of bounds");
assert!(text.starts_with("It stopped unexpectedly: memory access out of bounds"), "{text}");
}
#[test]
fn only_traps_count() {
assert!(is_trap("RuntimeError", "unreachable"));
assert!(is_trap("RuntimeError", ""));
assert!(is_trap("RangeError", "Maximum call stack size exceeded"));
assert!(is_trap("RangeError", "WebAssembly.Memory(): could not allocate memory"));
// Matched ignoring case: Safari's wording, reported without the RuntimeError name.
assert!(is_trap("Error", "Unreachable code should not be executed"));
assert!(is_trap("Error", "Out of memory"));
assert!(!is_trap("TypeError", "Failed to fetch"));
assert!(!is_trap("Error", "ResizeObserver loop completed"));
}
#[test]
fn long_messages_are_cut_on_a_character_boundary() {
let long = "\u{e9}".repeat(1000);
let cut = truncate(&long);
assert_eq!(cut.chars().count(), MAX_PANIC_CHARS + 1);
assert!(cut.ends_with('\u{2026}'));
assert_eq!(truncate("short"), "short");
assert_eq!(truncate(&"x".repeat(MAX_PANIC_CHARS)).chars().count(), MAX_PANIC_CHARS);
}
#[test]
fn the_recorded_panic_is_taken_once() {
record_panic("boom at a.rs:1");
assert_eq!(take_panic().as_deref(), Some("boom at a.rs:1"));
assert_eq!(take_panic(), None);
}
#[test]
fn free_mb_is_read_from_the_query() {
assert_eq!(free_mb(""), None);
assert_eq!(free_mb("?cpu"), None);
assert_eq!(free_mb("?free-mb=300"), Some(300));
assert_eq!(free_mb("?cpu&free-mb=400&webgl"), Some(400));
assert_eq!(free_mb("?free-mb=lots"), None);
assert_eq!(free_bytes(300), 300 << 20);
assert_eq!(free_bytes(u64::MAX), u64::MAX);
}
}
+24 -1
View File
@@ -12,10 +12,15 @@
//! - dropped files are read asynchronously by `web::WebShell` and delivered through `Services::inbox`.
//!
//! URL query flags: `?cpu` forces the CPU canvas path (same as `PHOTOCRAFT_CPU_CANVAS=1`);
//! `?webgl` forces the WebGL2 backend instead of WebGPU.
//! `?webgl` forces the WebGL2 backend instead of WebGPU; `?free-mb=N` pretends only N MiB can be
//! allocated, so the memory gates and the low-memory warning can be seen without gigabytes of
//! images (diagnostics only).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(any(target_arch = "wasm32", test))]
mod diagnostics;
#[cfg(any(target_arch = "wasm32", test))]
mod preset_bridge;
@@ -48,4 +53,22 @@ mod tests {
assert!(html.contains("animation: photocraft-stalled"), "the hint appears after a delay");
assert!(!html.contains("<script"), "trunk injects the generated loader; the page has no script of its own");
}
/// A module that traps (an allocation it could not satisfy, or a panic) leaves the last frame
/// on screen. The page already holds the message, hidden, with a way to reload; Rust only
/// reveals it and fills in the detail line, so it works even when Rust can no longer build text.
#[test]
fn the_page_holds_a_hidden_stopped_message_with_a_reload_link() {
let html = include_str!("../index.html");
let (_, element) = html.split_once("<div id=\"photocraft_stopped\"").expect("a stopped-message element");
let (element, _) = element.split_once("</div>").expect("the element is closed");
assert!(element.trim_start().starts_with("hidden"), "hidden until a trap: {element}");
assert!(element.contains("role=\"alert\""), "{element}");
assert!(element.contains("<a href=\"\">Reload</a>"), "{element}");
assert!(element.contains("can't be recovered"), "says what is lost: {element}");
assert!(element.contains("id=\"photocraft_stopped_detail\""), "a line for the cause: {element}");
assert!(html.contains("#photocraft_stopped[hidden]"), "display: flex must not defeat the hidden attribute");
assert!(html.contains("pointer-events: auto"), "the link works over the canvas");
assert!(!html.contains("<script"), "still no script of its own");
}
}
+104 -9
View File
@@ -15,6 +15,7 @@ use photocraft_ui_egui::{FileDialogAnswer, FileDialogRequest, PhotocraftApp, Ser
use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
type PlaceInbox = photocraft_ui_egui::PlaceInbox;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd), layer styles (.asl) and swatches (.aco, .act, .ase), which go to the preset libraries.
@@ -25,6 +26,8 @@ const OPEN_EXTS: &[&str] = &[
"afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
/// Set once the module has trapped: the page is dead, so nothing is left to protect.
static STOPPED: AtomicBool = AtomicBool::new(false);
const CANVAS_ID: &str = "photocraft_canvas";
/// Fonts the host serves next to the page (`photocraft_ui_egui::served_fonts`): craft-fonts' manifest
@@ -57,6 +60,10 @@ pub fn start() {
};
let q = query();
let force_cpu = q.contains("cpu");
// `?free-mb=N`: the memory gates and the low-memory warning see only N MiB (diagnostics).
if let Some(mb) = crate::diagnostics::free_mb(&q) {
photocraft_raster::headroom::simulate_available(Some(crate::diagnostics::free_bytes(mb)));
}
let mut options = eframe::WebOptions::default();
photocraft_ui_egui::gpu_canvas::use_adapter_limits(&mut options.wgpu_options.wgpu_setup);
if q.contains("webgl")
@@ -75,7 +82,13 @@ pub fn start() {
}
};
let pen_target = canvas.clone();
let result = eframe::WebRunner::new()
let runner = eframe::WebRunner::new();
// After the runner's own panic handler, so the message is kept and the runner's handler
// still runs (a panic aborts the module).
keep_panic_message();
// Before the app is built: a trap while building it shows the stopped message too.
listen_for_trap();
let result = runner
.start(
canvas,
options,
@@ -83,7 +96,7 @@ pub fn start() {
PhotocraftApp::install_type_reset_hooks(&cc.egui_ctx);
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
let inbox: Inbox = Arc::default();
let place_inbox: Inbox = Arc::default();
let place_inbox: PlaceInbox = Arc::default();
let served: Served = Arc::default();
load_font_manifest(served.clone(), cc.egui_ctx.clone());
let (session, warnings) = crate::preset_bridge::session(presets.clone());
@@ -180,11 +193,15 @@ fn drag_pos_service(canvas: web_sys::HtmlCanvasElement, pos: DragPos) -> photocr
/// Closing or reloading the tab while a document has unsaved changes asks first, as closing the
/// desktop window does: the browser shows its own "Leave site?" prompt (#1380). `unsaved` is
/// refreshed every frame by [`WebShell`].
/// refreshed every frame by [`WebShell`]. Once the module has stopped ([`STOPPED`]) the prompt is
/// skipped: the document can't be saved any more, and Reload is the only way on.
fn guard_unload(unsaved: Arc<AtomicBool>) {
use wasm_bindgen::closure::Closure;
let Some(window) = web_sys::window() else { return };
let cb = Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(move |e: web_sys::BeforeUnloadEvent| {
if STOPPED.load(Ordering::Relaxed) {
return;
}
if unsaved.load(Ordering::Relaxed) {
e.prevent_default();
// Older browsers show the prompt only when a return value is set.
@@ -196,6 +213,67 @@ fn guard_unload(unsaved: Arc<AtomicBool>) {
}
}
/// Keeps the message of a panic, then calls the handler that was installed before (the web
/// runner's, which logs it). A panic aborts the module, so the trap that follows has no message of
/// its own; this is what lets the stopped page tell an internal error from running out of memory.
fn keep_panic_message() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
crate::diagnostics::record_panic(&info.to_string());
previous(info);
}));
}
/// Shows the page's static `#photocraft_stopped` message when the module traps. The page learns of
/// a trap as an uncaught `WebAssembly.RuntimeError` (the `error` event) or, inside a spawned
/// future, an unhandled rejection. The module may be mid-call and its runner still borrowed, so
/// the handler touches no app state: it sets [`STOPPED`] and the element, nothing more.
fn listen_for_trap() {
use wasm_bindgen::closure::Closure;
let Some(window) = web_sys::window() else { return };
let on_error = Closure::<dyn FnMut(web_sys::ErrorEvent)>::new(move |e: web_sys::ErrorEvent| {
let error = e.error();
let (name, message) = error_parts(&error, &e.message());
if crate::diagnostics::is_trap(&name, &message) {
show_stopped(&message);
}
});
if window.add_event_listener_with_callback("error", on_error.as_ref().unchecked_ref()).is_ok() {
on_error.forget();
}
let on_rejection = Closure::<dyn FnMut(web_sys::PromiseRejectionEvent)>::new(move |e: web_sys::PromiseRejectionEvent| {
let (name, message) = error_parts(&e.reason(), "");
if crate::diagnostics::is_trap(&name, &message) {
show_stopped(&message);
}
});
if window.add_event_listener_with_callback("unhandledrejection", on_rejection.as_ref().unchecked_ref()).is_ok() {
on_rejection.forget();
}
}
/// The name and message of a thrown value; `fallback` is the event's own message when the value
/// is not an `Error`.
fn error_parts(value: &wasm_bindgen::JsValue, fallback: &str) -> (String, String) {
match value.dyn_ref::<js_sys::Error>() {
Some(error) => (String::from(error.name()), String::from(error.message())),
None => (String::new(), value.as_string().unwrap_or_else(|| fallback.to_owned())),
}
}
/// Reveals `#photocraft_stopped` with the cause. Does as little as possible.
fn show_stopped(trap_message: &str) {
STOPPED.store(true, Ordering::Relaxed);
let detail = crate::diagnostics::detail(crate::diagnostics::take_panic().as_deref(), trap_message);
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
if let Some(el) = document.get_element_by_id("photocraft_stopped_detail") {
el.set_text_content(Some(&detail));
}
if let Some(el) = document.get_element_by_id("photocraft_stopped") {
let _ = el.remove_attribute("hidden");
}
}
fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
@@ -267,7 +345,7 @@ struct WebShell {
/// Dropped files to open as documents.
inbox: Inbox,
/// Dropped files to place on the active document's canvas.
place_inbox: Inbox,
place_inbox: PlaceInbox,
/// Read by the `beforeunload` listener ([`guard_unload`]).
unsaved: Arc<AtomicBool>,
served: Served,
@@ -354,16 +432,30 @@ impl eframe::App for WebShell {
// Decided now, where the drop landed: on the document's canvas the files are placed
// as layers (as on the desktop), elsewhere opened.
let places = self.app.dropped_on_canvas(ctx, &names);
let files: Vec<_> = dropped.into_iter().zip(names).zip(places).collect();
let target_doc = self.app.session.active().map(|st| st.doc.id);
let files: Vec<_> = dropped.into_iter().zip(names).zip(places).map(|((f, name), place)| (f, name, place, target_doc)).collect();
let (inbox, place_inbox) = (self.inbox.clone(), self.place_inbox.clone());
let ctx = ctx.clone();
// One after another, so they open or place in the order they were dropped.
wasm_bindgen_futures::spawn_local(async move {
for ((f, name), place) in files {
for (f, name, place, target_doc) in files {
match f.bytes_async().await {
Ok(bytes) => {
let queue = if place { &place_inbox } else { &inbox };
queue.lock().unwrap_or_else(PoisonError::into_inner).push((name, bytes));
if place {
if let Some(doc) = target_doc {
let mut queued = place_inbox.lock().unwrap_or_else(PoisonError::into_inner);
let held = queued.iter().fold(0usize, |sum, (_, _, b)| sum.saturating_add(b.len()));
// Reads that finish before the next frame drains the inbox are
// bounded like the app's own drop queue.
if photocraft_ui_egui::file_open::place_inbox_takes(queued.len(), held, bytes.len()) {
queued.push((doc, name, bytes));
} else {
log::warn!("dropped file {name} wasn't placed: too many dropped files are waiting");
}
}
} else {
inbox.lock().unwrap_or_else(PoisonError::into_inner).push((name, bytes));
}
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
@@ -407,7 +499,7 @@ impl eframe::App for WebShell {
}
}
fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services {
fn services(inbox: Inbox, place_inbox: PlaceInbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services {
Services {
screen_pick: screen_color_service(),
import: Some(Box::new(|name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions| {
@@ -465,6 +557,9 @@ fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::Cu
// Preferences live in the browser's localStorage.
load_prefs: Some(Box::new(|| local_storage()?.get_item(PREFS_KEY).ok().flatten())),
save_prefs: Some(Box::new(|text: &str| local_storage().ok_or("no localStorage")?.set_item(PREFS_KEY, text).map_err(|e| format!("{e:?}")))),
load_gpu_memo: None,
save_gpu_memo: None,
shader_compiler: None,
..Default::default()
}
}
+25 -11
View File
@@ -202,17 +202,22 @@ pub fn rasterize_mask(svg: &[u8], px: u32) -> Option<Vec<u8>> {
image_href_resolver: usvg::ImageHrefResolver { resolve_data: Box::new(|_, _, _| None), resolve_string: Box::new(|_, _| None) },
..usvg::Options::default()
};
let tree = usvg::Tree::from_data(svg, &opts).ok()?;
let size = tree.size();
let side = size.width().max(size.height());
if !side.is_finite() || side <= 0.0 {
return None;
}
let s = px as f32 / side;
let (dx, dy) = ((px as f32 - size.width() * s) / 2.0, (px as f32 - size.height() * s) / 2.0);
let mut pixmap = tiny_skia::Pixmap::new(px, px)?;
resvg::render(&tree, tiny_skia::Transform::from_scale(s, s).post_translate(dx, dy), &mut pixmap.as_mut());
Some(pixmap.data().as_chunks::<4>().0.iter().map(|[_, _, _, a]| *a).collect())
// usvg and resvg panic on some malformed drawings: such an icon is unreadable, not a crash.
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let tree = usvg::Tree::from_data(svg, &opts).ok()?;
let size = tree.size();
let side = size.width().max(size.height());
if !side.is_finite() || side <= 0.0 {
return None;
}
let s = px as f32 / side;
let (dx, dy) = ((px as f32 - size.width() * s) / 2.0, (px as f32 - size.height() * s) / 2.0);
let mut pixmap = tiny_skia::Pixmap::new(px, px)?;
resvg::render(&tree, tiny_skia::Transform::from_scale(s, s).post_translate(dx, dy), &mut pixmap.as_mut());
Some(pixmap.data().as_chunks::<4>().0.iter().map(|[_, _, _, a]| *a).collect())
}))
.ok()
.flatten()
}
#[cfg(test)]
@@ -303,6 +308,15 @@ mod tests {
assert!(rasterize_mask(SQUARE.as_bytes(), 100_000).is_none());
}
/// resvg panics rendering an offset past the pixmap's size: the icon is unreadable instead.
#[test]
fn a_mask_the_renderer_panics_on_is_none() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<filter id="f"><feOffset dx="3e9" dy="-3e9"/></filter><rect width="16" height="16" filter="url(#f)"/>
</svg>"##;
assert!(rasterize_mask(svg.as_bytes(), 16).is_none());
}
/// An icon's `<image>` is never loaded: not a device file, not an embedded picture.
#[test]
fn masks_load_no_images() {
+11 -1
View File
@@ -588,6 +588,7 @@ fn main() -> eframe::Result {
&& app.session.prefs().performance.effective_rendering_mode() != photocraft_engine::prefs::RenderingMode::Cpu
{
app.set_wgpu(rs);
app.start_compositor(&cc.egui_ctx);
} else {
// The window still draws with wgpu: record its errors instead of panicking,
// and notice when the device is lost (#3438).
@@ -669,9 +670,18 @@ fn main() -> eframe::Result {
if let Some(monitor) = tablet::install_windows() {
let _ = tablet_monitor_win.set(monitor);
}
// With Windows Ink disabled for the tablet, WinTab supplies the pen pressure, tilt,
// rotation and eraser end; the vendor driver still moves the cursor.
#[cfg(target_os = "windows")]
if let Some(poll) =
tablet::install_wintab(cc, &app.stylus.feed, app.session.prefs().tools.tablet_input == photocraft_engine::prefs::TabletInput::WinTab)
{
app.services.tablet_poll = Some(poll);
}
#[cfg(target_os = "linux")]
tablet::spawn_x11(&app.stylus.feed, display);
app.services.right_button_down = tablet::right_button_service();
app.services.pen_end = tablet::pen_end_service();
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
app.open_paths(&files);
if !unreadable_paths.is_empty() {
@@ -1030,7 +1040,7 @@ mod tests {
read
};
let r = read(r#"{"interface":{"enableAnimations":"nope"},"integrations":{"allowAgentControl":false,"controlPort":7878}}"#);
let r = read(r#"{"interface":{"showTooltips":"nope"},"integrations":{"allowAgentControl":false,"controlPort":7878}}"#);
assert!(!r.allow_agent_control, "a bad value in another section");
assert_eq!(r.control_port, 7878);
let r = read(r#"{"integrations":{"allowAgentControl":false,"controlPort":"x"}}"#);
+43
View File
@@ -341,6 +341,41 @@ pub fn prefs_file() -> Option<PathBuf> {
config_dir().map(|d| d.join("preferences.json"))
}
fn gpu_memo_file() -> Option<PathBuf> {
config_dir().map(|d| d.join("gpu-compositor.json"))
}
fn load_gpu_memo_text() -> Option<String> {
use std::io::Read;
let file = std::fs::File::open(gpu_memo_file()?).ok()?;
let cap = photocraft_ui_egui::gpu_memo::MAX_GPU_MEMO_BYTES;
let mut bytes = Vec::new();
file.take((cap + 1) as u64).read_to_end(&mut bytes).ok()?;
if bytes.len() > cap {
log::info!("ignoring oversized GPU compositor memo");
return None;
}
String::from_utf8(bytes).ok()
}
fn shader_compiler_identity() -> String {
#[cfg(target_os = "windows")]
{
let exe = std::env::current_exe().ok();
let exe_dir = exe.as_deref().and_then(Path::parent);
match crate::gpu_startup::dx12_compiler(std::env::var("WGPU_DX12_COMPILER").ok().as_deref(), exe_dir) {
eframe::wgpu::Dx12Compiler::Fxc => "fxc".into(),
eframe::wgpu::Dx12Compiler::DynamicDxc { dxc_path } => format!("dxc:{dxc_path}"),
eframe::wgpu::Dx12Compiler::StaticDxc => "static-dxc".into(),
eframe::wgpu::Dx12Compiler::Auto => "auto".into(),
}
}
#[cfg(not(target_os = "windows"))]
{
String::new()
}
}
/// The brush preset store (one file per preset group plus tip bitmaps; see
/// `photocraft_engine::preset_store`).
pub fn presets_dir() -> Option<PathBuf> {
@@ -791,6 +826,14 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())),
photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))),
save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))),
load_gpu_memo: Some(Box::new(load_gpu_memo_text)),
save_gpu_memo: Some(Box::new(|text: &str| {
if text.len() > photocraft_ui_egui::gpu_memo::MAX_GPU_MEMO_BYTES {
return Err("GPU compositor memo exceeds 64 KiB".into());
}
write_atomic(&gpu_memo_file().ok_or("no config directory")?, text.as_bytes())
})),
shader_compiler: Some(shader_compiler_identity()),
system_theme: crate::appearance::service(),
append_text: Some(Box::new(|path: &str, text: &str| {
use std::io::Write;
+95 -6
View File
@@ -1,12 +1,14 @@
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79), and
//! barrel button on Windows (#5905).
//! barrel button (#5905) and eraser end (#2285) on Windows.
//!
//! winit 0.30 drops tablet data on both macOS and X11, so `photocraft-tablet` reads it beside winit
//! (an AppKit local event monitor; XInput2 raw events on a second X connection) and this module writes
//! each sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
//! Pointer Events and automation's simulated pen. On Windows, winit forwards `WM_POINTER` pressure
//! as touch force, but drops `PEN_FLAG_BARREL` (`0x0001`); `photocraft-tablet` recovers the barrel
//! button through a thread-local message hook beside winit.
//! as touch force, but drops `PEN_FLAG_BARREL` (`0x0001`) and the eraser end (`PEN_FLAG_INVERTED`);
//! `photocraft-tablet` recovers them through thread-local message hooks beside winit. With Windows
//! Ink disabled for the tablet, the optional WinTab mode reads pressure, tilt, rotation and the
//! eraser end from a WinTab 1.4 system context instead ([`install_wintab`]).
//!
//! Wayland compositors give a pen only to clients that bind the tablet protocol
//! (`zwp_tablet_v2`), which winit 0.30 doesn't (and binding it on winit's connection needs
@@ -32,11 +34,50 @@ pub fn sink(feed: StylusFeed) -> impl Fn(Option<Sample>) + Send + 'static {
move |s| feed.set(s.map(pen_sample))
}
/// Install the Windows pointer message hook (main thread; keep the result until the event loop
/// returns). On failure this logs why: the pen barrel button is then missed.
/// Install the Windows pointer message hooks (main thread; keep the result until the event loop
/// returns). On failure this logs why: the pen barrel button and eraser end are then missed.
#[cfg(target_os = "windows")]
pub fn install_windows() -> Option<photocraft_tablet::windows::Monitor> {
photocraft_tablet::windows::Monitor::install().map_err(|e| log::warn!("{e}")).ok()
let monitor = photocraft_tablet::windows::Monitor::install().map_err(|e| log::warn!("{e}")).ok()?;
if !monitor.tracks_leave() {
log::warn!("tablet input: the WH_CALLWNDPROC hook did not install, so the pen leaving the window may be missed");
}
Some(monitor)
}
/// Installs the WinTab backend on the real PhotoCraft window and returns its per-frame poll.
///
/// `enabled` is Preferences > Tools > Pen Tablet > Tablet input set to WinTab. With Windows Ink disabled there, the vendor
/// driver still moves the cursor; this backend only supplies pressure/tilt/rotation/eraser
/// through the normal stylus feed. On failure it logs why and leaves Windows Ink untouched.
#[cfg(target_os = "windows")]
pub fn install_wintab(cc: &eframe::CreationContext<'_>, feed: &StylusFeed, enabled: bool) -> Option<photocraft_ui_egui::TabletPollFn> {
if !enabled {
return None;
}
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
let hwnd = match cc.window_handle().ok()?.as_raw() {
RawWindowHandle::Win32(handle) => handle.hwnd.get(),
_ => return None,
};
let ctx = cc.egui_ctx.clone();
if let Err(error) = photocraft_tablet::wintab::install(hwnd, Box::new(move || ctx.request_repaint())) {
log::warn!("WinTab backend: {error}");
return None;
}
let sink = sink(feed.clone());
log::info!("WinTab backend active (system context)");
let logged = std::cell::Cell::new(false);
Some(Box::new(move |focused| {
photocraft_tablet::wintab::poll(focused, &|sample| {
if let Some(sample) = sample
&& !logged.replace(true)
{
log::info!("WinTab sample: pressure={:.3} tilt=({:.1},{:.1}) eraser={}", sample.pressure, sample.tilt_x, sample.tilt_y, sample.eraser);
}
sink(sample);
});
}))
}
/// Stylus barrel button down check (Windows). The right mouse button is left to egui, which
@@ -51,6 +92,18 @@ pub fn right_button_service() -> Option<photocraft_ui_egui::RightButtonDownFn> {
None
}
/// Which end of the pen is in use (Windows): flipping the pen to its eraser end selects the
/// Eraser (#2285). Elsewhere the end arrives with the samples in the stylus feed.
#[cfg(target_os = "windows")]
pub fn pen_end_service() -> Option<photocraft_ui_egui::PenEndFn> {
Some(Box::new(photocraft_tablet::windows::take_pen_end))
}
#[cfg(not(target_os = "windows"))]
pub fn pen_end_service() -> Option<photocraft_ui_egui::PenEndFn> {
None
}
/// Install the AppKit tablet monitor (main thread, once winit created the shared application:
/// from eframe's app creation, not before the event loop). Keep the result until the event loop
/// returns. On failure this logs why: pens then paint like a mouse.
@@ -382,4 +435,40 @@ mod tests {
assert!(photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
assert_eq!(app.ui.tool, Tool::Brush);
}
/// Windows: what the pointer hooks record reaches the Eraser switch through the service the
/// app installs (#2285).
#[cfg(target_os = "windows")]
#[test]
fn windows_eraser_end_switches_to_the_eraser_and_back() {
use photocraft_tablet::windows::{
PEN_FLAG_ERASER, PEN_FLAG_INVERTED, WM_POINTERDOWN, WM_POINTERLEAVE, WM_POINTERUP, WM_POINTERUPDATE, handle_pointer_message,
};
use photocraft_ui_egui::stylus::Stylus;
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.services.pen_end = pen_end_service();
app.ui.tool = Tool::Brush;
let hover = |pen_flags: u32| handle_pointer_message(WM_POINTERUPDATE, 1, move |_| Some(pen_flags));
hover(0);
assert!(!Stylus::sync_eraser_tool(&mut app), "the tip changes nothing");
hover(PEN_FLAG_ERASER);
assert!(!Stylus::sync_eraser_tool(&mut app), "an eraser button on the barrel is not the eraser end");
handle_pointer_message(WM_POINTERLEAVE, 1, |_| None);
hover(PEN_FLAG_INVERTED);
assert!(Stylus::sync_eraser_tool(&mut app));
assert_eq!(app.ui.tool, Tool::Eraser);
handle_pointer_message(WM_POINTERLEAVE, 1, |_| None);
assert!(!Stylus::sync_eraser_tool(&mut app), "out of range");
hover(0);
assert!(Stylus::sync_eraser_tool(&mut app));
assert_eq!(app.ui.tool, Tool::Brush, "the tip restores the Brush");
// An eraser-end tap and the pen leaving, all before the next frame: the frame that
// handles the queued click still selects the Eraser.
handle_pointer_message(WM_POINTERDOWN, 1, |_| Some(PEN_FLAG_INVERTED | PEN_FLAG_ERASER));
handle_pointer_message(WM_POINTERUP, 1, |_| None);
handle_pointer_message(WM_POINTERLEAVE, 1, |_| None);
assert!(Stylus::sync_eraser_tool(&mut app));
assert_eq!(app.ui.tool, Tool::Eraser);
assert!(!Stylus::sync_eraser_tool(&mut app), "out of range");
}
}
+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5H19V19H5Z" />
<path d="M9.67 2.8V5M14.33 2.8V5M9.67 19v2.2M14.33 19v2.2M2.8 9.67H5M2.8 14.33H5M19 9.67h2.2M19 14.33h2.2" />
<path d="M5 5L19 19M19 5L5 19" />
</svg>

After

Width:  |  Height:  |  Size: 387 B

@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5H19V19H5Z" />
<path d="M9.67 2.8V5M14.33 2.8V5M9.67 19v2.2M14.33 19v2.2M2.8 9.67H5M2.8 14.33H5M19 9.67h2.2M19 14.33h2.2" />
<path d="M10.35 5V19M13.65 5V19M5 10.35H19M5 13.65H19" />
</svg>

After

Width:  |  Height:  |  Size: 411 B

@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5H19V19H5Z" />
<path d="M9.67 2.8V5M14.33 2.8V5M9.67 19v2.2M14.33 19v2.2M2.8 9.67H5M2.8 14.33H5M19 9.67h2.2M19 14.33h2.2" />
<path d="M15.31 10.14L15.33 10.26L15.33 10.38L15.30 10.52L15.23 10.65L15.14 10.77L15.01 10.87L14.85 10.96L14.67 11.00L14.46 11.01L14.25 10.98L14.04 10.89L13.83 10.75L13.65 10.56L13.51 10.32L13.41 10.04L13.37 9.72L13.40 9.38L13.52 9.03L13.71 8.70L13.99 8.39L14.35 8.13L14.79 7.94L15.29 7.85L15.83 7.86L16.39 8.00L16.95 8.27L17.46 8.68L17.91 9.22L18.25 9.89L18.46 10.67L18.50 11.52L18.35 12.42L17.98 13.33L17.40 14.19L16.59 14.96L15.57 15.58L14.37 15.99L13.03 16.15L11.58 16.01L10.11 15.54L8.68 14.71L7.38 13.52L6.29 11.99L5.50 10.14" />
</svg>

After

Width:  |  Height:  |  Size: 889 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5H19V19H5Z" />
<path d="M9.67 2.8V5M14.33 2.8V5M9.67 19v2.2M14.33 19v2.2M2.8 9.67H5M2.8 14.33H5M19 9.67h2.2M19 14.33h2.2" />
<path d="M8.5 5V19M12 5V19M15.5 5V19M5 8.5H19M5 12H19M5 15.5H19" />
</svg>

After

Width:  |  Height:  |  Size: 421 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5H19V19H5Z" />
<path d="M9.67 2.8V5M14.33 2.8V5M9.67 19v2.2M14.33 19v2.2M2.8 9.67H5M2.8 14.33H5M19 9.67h2.2M19 14.33h2.2" />
<path d="M9.67 5V19M14.33 5V19M5 9.67H19M5 14.33H19" />
</svg>

After

Width:  |  Height:  |  Size: 409 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5H19V19H5Z" />
<path d="M9.67 2.8V5M14.33 2.8V5M9.67 19v2.2M14.33 19v2.2M2.8 9.67H5M2.8 14.33H5M19 9.67h2.2M19 14.33h2.2" />
<path d="M5 5L19 19M5 19L12 12" />
</svg>

After

Width:  |  Height:  |  Size: 388 B

+17
View File
@@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m11 10 3 3" />
<path d="M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z" />
<path d="M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031" />
<path d="M3 8.5A6.5 6.5 0 0 1 14 3.5" />
<path d="M3.5 4.5 3 8.5l4-.5" />
</svg>

After

Width:  |  Height:  |  Size: 476 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 17v5" />
<path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" />
</svg>

After

Width:  |  Height:  |  Size: 450 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>

After

Width:  |  Height:  |  Size: 297 B

+4 -2
View File
@@ -63,6 +63,8 @@ Filesystem capabilities are implemented. General tool and method capabilities re
The five shared core tools are `command_list`, `command_run`, `command_batch`, `doc_inspect`
and `render_preview`. Existing documented tools, including `doc_render_preview`, remain
listed. `photocraft://document` and `photocraft://commands` expose live JSON. Direct headless
Render Video supports progress and cancellation using the granted write root; see
listed. `photocraft://document` and `photocraft://commands` expose live JSON. Long commands
that are waited on report progress and stop when the request is cancelled, headless and in
bridge mode (where they may also outlast the bridge's reply timeout), and direct headless
Render Video does too, using the granted write root; see
[`docs/mcp.md`](../../../docs/mcp.md) for arguments, cleanup and transport behavior.
+10 -1
View File
@@ -104,7 +104,9 @@ display_name = "Ran Geerts"
sync_github_name = false
[people.vangel76]
sync_github_name = true
real_name = "Valentin Klein"
display_name = "vangel76"
sync_github_name = false
[people.FinnNk]
sync_github_name = true
@@ -199,6 +201,9 @@ sync_github_name = true
real_name = "Guillem Hermida"
sync_github_name = true
[people.fokas-hoa]
display_name = "Tasos Fokas"
[people.fjamesprice]
sync_github_name = true
@@ -306,3 +311,7 @@ sync_github_name = true
real_name = "Simone Recchia"
display_name = "Simone Recchia"
sync_github_name = false
[people.davidvhk]
real_name = "David Vanhoucke"
sync_github_name = true
+89 -8
View File
@@ -296,7 +296,31 @@ impl<'a> Archive<'a> {
/// Extract an entry by name: decompress, undo the byte predictor and verify the CRC.
pub fn read(&mut self, name: &str) -> Result<Vec<u8>, Error> {
let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone();
self.claim(name)?.decode()
}
/// Extract several entries, decoding them on worker threads. The entries are charged one
/// after the other exactly as the same number of [`read`](Self::read) calls would charge
/// them, and the first error in that order is the one returned.
pub(crate) fn read_many(&mut self, names: &[&str]) -> Result<Vec<Vec<u8>>, Error> {
let mut claimed = Vec::with_capacity(names.len());
let mut refused = None;
for name in names {
match self.claim(name) {
Ok(c) => claimed.push(c),
Err(e) => {
refused = Some(e);
break;
}
}
}
let decoded = decode_all(&claimed).into_iter().collect::<Result<Vec<_>, _>>()?;
refused.map_or(Ok(decoded), Err)
}
/// Check an entry against the limits, count it as extracted and locate its stored bytes.
fn claim(&mut self, name: &str) -> Result<Claimed<'a>, Error> {
let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?;
let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?;
if size > self.limits.max_entry {
return Err(Error::Limit("archive entry too large"));
@@ -315,22 +339,79 @@ impl<'a> Archive<'a> {
let method = e.compression & 3;
let stored_len = if matches!(method, 1 | 2) { e.compressed } else { e.size };
let data = c.take(offset(stored_len)?)?;
let mut out = match method {
1 => inflate(data, size)?,
2 => unzstd(data, size)?,
_ => data.to_vec(),
Ok(Claimed { data, size, compression: e.compression, crc: e.crc })
}
}
/// An entry that is charged and located but not yet decoded.
struct Claimed<'a> {
data: &'a [u8],
size: usize,
compression: u8,
crc: u32,
}
impl Claimed<'_> {
fn decode(&self) -> Result<Vec<u8>, Error> {
let mut out = match self.compression & 3 {
1 => inflate(self.data, self.size)?,
2 => unzstd(self.data, self.size)?,
_ => self.data.to_vec(),
};
if out.len() != size {
if out.len() != self.size {
return Err(Error::Malformed("archive entry size mismatch"));
}
unpredict(&mut out, e.compression)?;
if crc32fast::hash(&out) != e.crc {
unpredict(&mut out, self.compression)?;
if crc32fast::hash(&out) != self.crc {
return Err(Error::Malformed("archive entry checksum mismatch"));
}
Ok(out)
}
}
/// Most threads decoding entries at once; each zstd frame may reserve its declared window.
const MAX_DECODE_THREADS: usize = 8;
/// Fewer entries than this are decoded in place: starting a thread costs more than they do.
const MIN_PARALLEL_ENTRIES: usize = 4;
/// Decode every entry, in parallel when there are enough of them. The results are in order.
fn decode_all(jobs: &[Claimed<'_>]) -> Vec<Result<Vec<u8>, Error>> {
static THREADS: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
// The browser build has no threads to start.
let threads = if jobs.len() < MIN_PARALLEL_ENTRIES || cfg!(target_arch = "wasm32") {
1
} else {
(*THREADS.get_or_init(|| std::thread::available_parallelism().map_or(1, usize::from).min(MAX_DECODE_THREADS))).min(jobs.len())
};
if threads <= 1 {
return jobs.iter().map(Claimed::decode).collect();
}
let next = std::sync::atomic::AtomicUsize::new(0);
// The workers and this thread take the next undecoded entry until none is left.
let work = || {
let mut done = Vec::new();
loop {
let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let Some(job) = jobs.get(i) else { break };
done.push((i, job.decode()));
}
done
};
let mut slots: Vec<Option<Result<Vec<u8>, Error>>> = jobs.iter().map(|_| None).collect();
std::thread::scope(|sc| {
// A thread that cannot be started just leaves its share to the others.
let workers: Vec<_> = (1..threads).filter_map(|_| std::thread::Builder::new().spawn_scoped(sc, work).ok()).collect();
let mut finished = vec![work()];
finished.extend(workers.into_iter().map(|w| w.join().unwrap_or_default()));
for (i, result) in finished.into_iter().flatten() {
if let Some(slot) = slots.get_mut(i) {
*slot = Some(result);
}
}
});
slots.into_iter().map(|r| r.unwrap_or(Err(Error::Malformed("tile decoder stopped")))).collect()
}
/// Read at most `size + 1` bytes so a stream that decodes past its declared size is caught
/// without unbounded allocation.
fn read_bounded(mut r: impl Read, size: usize) -> Result<Vec<u8>, Error> {
+137 -49
View File
@@ -2,7 +2,7 @@
//! (`DyBm.Bckg` → archive entry `c/N`), which is imported as is; other pixels are planar tiles of
//! 256 bytes × 256 rows per channel, each its own archive entry.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use crate::Error;
use crate::model::{Affine, Image, Pixels, Reader};
@@ -123,35 +123,78 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
let scratch = pixels.checked_mul(channels * bps).ok_or(Error::Limit("pixel layer size"))?;
r.admit_pixels(pixels.checked_mul(4).ok_or(Error::Limit("pixel layer size"))?, scratch)?;
let mut planes = Vec::with_capacity(channels);
let mut cache = HashMap::new();
let mut cache = Cache::new();
for c in 1..=channels {
planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), &mut cache)?);
}
let n = (w as usize) * (h as usize);
let mut rgba = vec![0u8; n * 4];
let sample = |p: &Vec<u8>, i: usize| -> f64 {
if bps == 2 {
f64::from(u16::from_le_bytes([p.get(2 * i).copied().unwrap_or(0), p.get(2 * i + 1).copied().unwrap_or(0)])) / 65535.0
} else {
f64::from(p.get(i).copied().unwrap_or(0)) / 255.0
}
};
for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let v: Vec<f64> = planes.iter().map(|p| sample(p, i)).collect();
let (r2, g, b, a) = if channels == 1 {
(v[0], v[0], v[0], 1.0)
} else if channels == 5 {
let k = 1.0 - v[3];
((1.0 - v[0]) * k, (1.0 - v[1]) * k, (1.0 - v[2]) * k, v[4])
} else {
(v[0], v[1], v[2], v[3])
};
px.copy_from_slice(&[r2, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8));
}
let rgba = interleave(&planes, channels, bps, pixels);
let transform = Affine([1.0, 0.0, 0.0, 1.0, f64::from(x0), f64::from(y0)]).then(world);
Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform }))
}
/// Quantize a unit-range sample to a byte: the one conversion every format goes through.
fn quant(v: f64) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
}
/// Interleave the decoded planes (`n` samples of `bps` bytes each) into RGBA8.
///
/// The samples used to be widened to `f64` one pixel at a time. The tables below hold the same
/// expressions evaluated once per input value, so the bytes are identical: an 8-bit sample
/// `x / 255 * 255` rounds back to `x`, a 16-bit one goes through a 64 Ki table, and CMYK's
/// `(1 - c)(1 - k)` through a table over the 256 x 256 `(c, k)` pairs.
pub(crate) fn interleave(planes: &[Vec<u8>], channels: usize, bps: usize, n: usize) -> Vec<u8> {
let mut rgba = vec![0u8; n * 4];
let out = rgba.as_chunks_mut::<4>().0.iter_mut();
let p = |c: usize| planes.get(c).map_or(&[][..], Vec::as_slice);
match (channels, bps) {
(1, 1) => {
for (o, &v) in out.zip(p(0)) {
*o = [v, v, v, 255];
}
}
(4, 1) => {
let (r, g, b, a) = (p(0), p(1), p(2), p(3));
for ((((o, &r), &g), &b), &a) in out.zip(r).zip(g).zip(b).zip(a) {
*o = [r, g, b, a];
}
}
(5, 1) => {
let lut = cmyk_table();
let (c, m, y, k, a) = (p(0), p(1), p(2), p(3), p(4));
for (((((o, &c), &m), &y), &k), &a) in out.zip(c).zip(m).zip(y).zip(k).zip(a) {
let k = usize::from(k) << 8;
*o = [lut[k | usize::from(c)], lut[k | usize::from(m)], lut[k | usize::from(y)], a];
}
}
(_, _) => {
let lut: Vec<u8> = (0..=u16::MAX).map(|v| quant(f64::from(v) / 65535.0)).collect();
let s16 = |c: usize| p(c).as_chunks::<2>().0.iter().map(|b| lut[usize::from(u16::from_le_bytes(*b))]);
if channels == 1 {
for (o, g) in out.zip(s16(0)) {
*o = [g, g, g, 255];
}
} else {
for ((((o, r), g), b), a) in out.zip(s16(0)).zip(s16(1)).zip(s16(2)).zip(s16(3)) {
*o = [r, g, b, a];
}
}
}
}
rgba
}
/// `(1 - c)(1 - k)` quantized, indexed by `k << 8 | c`.
fn cmyk_table() -> Vec<u8> {
let mut lut = Vec::with_capacity(256 * 256);
for k in 0..=255u8 {
for c in 0..=255u8 {
lut.push(quant((1.0 - f64::from(c) / 255.0) * (1.0 - f64::from(k) / 255.0)));
}
}
lut
}
/// The original image file: entry `c/N` is a stream whose root (`Blck`) holds it as `Data`.
fn original(r: &mut Reader, name: &str) -> Result<Vec<u8>, Error> {
let bytes = r.archive.read(name)?;
@@ -167,14 +210,24 @@ fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] {
[prefix[0], prefix[1], prefix[2], b'0' + c as u8]
}
/// Decoded tiles by archive entry name: a tile shared by several grid cells or channels is
/// extracted (and charged to the archive's budget) once.
type Cache<'s> = HashMap<&'s str, Vec<u8>>;
/// A tile to copy into a plane once its entry is decoded: pixel column and row of its corner.
struct Place<'s> {
name: &'s str,
at: (usize, usize),
}
/// One channel of level 0, cropped to (x0, y0, w, h) pixels; `bps` bytes per sample.
fn plane(
r: &mut Reader,
fn plane<'s>(
r: &mut Reader<'s, '_, '_>,
bitmap: ObjId,
c: usize,
bps: usize,
(x0, y0, w, h): (u32, u32, u32, u32),
cache: &mut HashMap<String, Vec<u8>>,
cache: &mut Cache<'s>,
) -> Result<Vec<u8>, Error> {
let s = r.s;
let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
@@ -199,6 +252,15 @@ fn plane(
let mut out = vec![0u8; row * h as usize];
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
let crop = (bx0, py0, bx1, py1);
// First walk the grid: flat cells are filled right away (cells never overlap, so the order
// of the copies does not matter) and the tiles that show are collected. They are extracted
// together below, on several threads, then copied. A walk that fails still extracts the
// tiles before the failing cell, as a tile-by-tile walk would have.
let mut places: Vec<Place<'s>> = Vec::new();
let mut wanted: Vec<&'s str> = Vec::new();
let mut queued: HashSet<&'s str> = HashSet::new();
let mut failure = None;
for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) {
let state = &if let Value::UInt(u) = state { u8::try_from(*u).unwrap_or(255) } else { 255 };
let (ty, tx) = (t / tw.max(1), t % tw.max(1));
@@ -208,30 +270,28 @@ fn plane(
4 => {
let Some(at) = tiles.iter().position(|v| matches!(v, Value::Obj(Some(_)))) else {
r.charge_slots(tiles.len())?;
return Err(Error::Malformed("missing pixel tile"));
failure = Some(Error::Malformed("missing pixel tile"));
break;
};
r.charge_slots(at + 1)?;
let Some(Value::Obj(Some(blck))) = tiles.get(at) else { return Err(Error::Malformed("missing pixel tile")) };
let Some(Value::Obj(Some(blck))) = tiles.get(at) else {
failure = Some(Error::Malformed("missing pixel tile"));
break;
};
let blck = *blck;
tiles = tiles.get(at + 1..).unwrap_or_default();
let (ox, oy) = (tx * TILE, ty * TILE);
if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 {
continue;
}
let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string();
if !cache.contains_key(&name) {
let mut data = r.archive.read(&name)?;
// Older files wrap a tile in a one-field stream: header, then a 64 KiB blob.
if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") {
data = data.get(21..21 + TILE_BYTES).ok_or(Error::Malformed("pixel tile"))?.to_vec();
}
if data.len() != TILE_BYTES {
return Err(Error::Malformed("pixel tile size"));
}
cache.insert(name.clone(), data);
let Some(name) = s.entry(blck, b"Data") else {
failure = Some(Error::Malformed("pixel tile without data"));
break;
};
if !cache.contains_key(name) && queued.insert(name) {
wanted.push(name);
}
let tile = cache.get(&name).ok_or(Error::Malformed("pixel tile"))?;
copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
places.push(Place { name, at: (ox, oy) });
continue;
}
3 => {
@@ -242,22 +302,50 @@ fn plane(
r.warn("pixel layers drawn from an embedded image (left empty)");
None
}
_ => return Err(Error::Malformed("unknown pixel tile state")),
_ => {
failure = Some(Error::Malformed("unknown pixel tile state"));
break;
}
};
if let Some(v) = fill {
copy(&mut out, row, (bx0, py0, bx1, py1), (tx * TILE, ty * TILE), |_, _| v);
copy(&mut out, row, crop, (tx * TILE, ty * TILE), None, v);
}
}
let decoded = r.archive.read_many(&wanted);
// A tile that fails to extract is reported before the failure of the walk after it.
for (name, mut data) in wanted.into_iter().zip(decoded?) {
// Older files wrap a tile in a one-field stream: header, then a 64 KiB blob.
if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") {
data = data.get(21..21 + TILE_BYTES).ok_or(Error::Malformed("pixel tile"))?.to_vec();
}
if data.len() != TILE_BYTES {
return Err(Error::Malformed("pixel tile size"));
}
cache.insert(name, data);
}
if let Some(e) = failure {
return Err(e);
}
for place in &places {
let tile = cache.get(place.name).ok_or(Error::Malformed("pixel tile"))?;
copy(&mut out, row, crop, place.at, Some(tile), 0);
}
Ok(out)
}
/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane.
fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize, usize), (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) {
/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane. `tile` is
/// the tile's bytes, or `None` to fill the overlap with `fill`.
pub(crate) fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize, usize), (ox, oy): (usize, usize), tile: Option<&[u8]>, fill: u8) {
let (xa, xb) = (ox.max(bx0), (ox + TILE).min(bx1));
if xa >= xb {
return;
}
for y in oy.max(py0)..(oy + TILE).min(py1) {
for x in ox.max(bx0)..(ox + TILE).min(bx1) {
if let Some(b) = out.get_mut((y - py0) * row + (x - bx0)) {
*b = at(x - ox, y - oy);
}
let start = (y - py0) * row + (xa - bx0);
let Some(dst) = out.get_mut(start..start + (xb - xa)) else { continue };
match tile.and_then(|t| t.get((y - oy) * TILE + (xa - ox)..(y - oy) * TILE + (xb - ox))) {
Some(src) => dst.copy_from_slice(src),
None => dst.fill(if tile.is_some() { 0 } else { fill }),
}
}
}
+186
View File
@@ -808,6 +808,192 @@ mod spot_colours {
}
}
/// The raster fast paths against the code they replaced.
mod raster_identity {
use crate::raster::{copy, interleave};
/// Deterministic filler (xorshift).
fn bytes(n: usize, mut x: u64) -> Vec<u8> {
(0..n)
.map(|_| {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
(x >> 24) as u8
})
.collect()
}
/// The per-pixel `f64` conversion `decode` used before the tables.
fn reference(planes: &[Vec<u8>], channels: usize, bps: usize, n: usize) -> Vec<u8> {
let sample = |p: &Vec<u8>, i: usize| -> f64 {
if bps == 2 {
f64::from(u16::from_le_bytes([p.get(2 * i).copied().unwrap_or(0), p.get(2 * i + 1).copied().unwrap_or(0)])) / 65535.0
} else {
f64::from(p.get(i).copied().unwrap_or(0)) / 255.0
}
};
let mut rgba = vec![0u8; n * 4];
for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let v: Vec<f64> = planes.iter().map(|p| sample(p, i)).collect();
let (r, g, b, a) = if channels == 1 {
(v[0], v[0], v[0], 1.0)
} else if channels == 5 {
let k = 1.0 - v[3];
((1.0 - v[0]) * k, (1.0 - v[1]) * k, (1.0 - v[2]) * k, v[4])
} else {
(v[0], v[1], v[2], v[3])
};
px.copy_from_slice(&[r, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8));
}
rgba
}
#[test]
fn eight_bit_samples_round_trip() {
// Every byte through every position of the 4-channel and the grey path.
let ramp: Vec<u8> = (0..=255).collect();
let planes = vec![ramp.clone(), ramp.iter().rev().copied().collect(), ramp.clone(), ramp.iter().rev().copied().collect()];
assert_eq!(interleave(&planes, 4, 1, 256), reference(&planes, 4, 1, 256));
assert_eq!(interleave(&planes[..1], 1, 1, 256), reference(&planes[..1], 1, 1, 256));
}
#[test]
fn sixteen_bit_samples_are_exhaustive() {
// All 65536 values in each channel position (rotated so the channels differ).
let all: Vec<u8> = (0..=u16::MAX).flat_map(u16::to_le_bytes).collect();
let rot = |k: usize| -> Vec<u8> { (0..=u16::MAX).flat_map(|v| v.wrapping_add((k * 4099) as u16).to_le_bytes()).collect() };
let planes = vec![all, rot(1), rot(2), rot(3)];
assert_eq!(interleave(&planes, 4, 2, 65536), reference(&planes, 4, 2, 65536));
assert_eq!(interleave(&planes[..1], 1, 2, 65536), reference(&planes[..1], 1, 2, 65536));
}
#[test]
fn cmyk_is_exhaustive_over_colour_and_black() {
// Every (c, k) pair in each of the c, m and y positions; alpha is arbitrary.
let n = 256 * 256;
let k: Vec<u8> = (0..n).map(|i| (i >> 8) as u8).collect();
let c: Vec<u8> = (0..n).map(|i| i as u8).collect();
let m: Vec<u8> = (0..n).map(|i| (i as u8).wrapping_mul(7).wrapping_add(3)).collect();
let y: Vec<u8> = (0..n).map(|i| (i as u8).wrapping_mul(13).wrapping_add(250)).collect();
let planes = vec![c, m, y, k, bytes(n, 9)];
assert_eq!(interleave(&planes, 5, 1, n), reference(&planes, 5, 1, n));
}
#[test]
fn random_planes_match_for_every_format() {
for (channels, bps) in [(1, 1), (1, 2), (4, 1), (4, 2), (5, 1)] {
let n = 5000;
let planes: Vec<Vec<u8>> = (0..channels).map(|c| bytes(n * bps, 1 + c as u64)).collect();
assert_eq!(interleave(&planes, channels, bps, n), reference(&planes, channels, bps, n), "{channels}x{bps}");
}
}
/// The per-byte closure copy `plane` used before the row copies.
fn copy_reference(
out: &mut [u8],
row: usize,
(bx0, py0, bx1, py1): (usize, usize, usize, usize),
(ox, oy): (usize, usize),
at: impl Fn(usize, usize) -> u8,
) {
for y in oy.max(py0)..(oy + 256).min(py1) {
for x in ox.max(bx0)..(ox + 256).min(bx1) {
if let Some(b) = out.get_mut((y - py0) * row + (x - bx0)) {
*b = at(x - ox, y - oy);
}
}
}
}
#[test]
fn tile_copy_matches_the_byte_loop_for_every_crop() {
let tile = bytes(256 * 256, 77);
// Crops that cut the tile on each side, sit inside it, and miss it.
for &(bx0, bx1, py0, py1) in
&[(0, 600, 0, 600), (10, 300, 20, 200), (100, 150, 100, 130), (256, 512, 0, 256), (0, 256, 300, 400), (255, 257, 255, 257), (300, 301, 0, 1)]
{
for &(ox, oy) in &[(0, 0), (256, 0), (0, 256), (256, 256), (512, 256)] {
let row = bx1 - bx0;
let mut old = vec![0xAAu8; row * (py1 - py0)];
let mut new = old.clone();
copy_reference(&mut old, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| tile[y * 256 + x]);
copy(&mut new, row, (bx0, py0, bx1, py1), (ox, oy), Some(&tile), 0);
assert_eq!(old, new, "tile {ox},{oy} crop {bx0}..{bx1} x {py0}..{py1}");
copy_reference(&mut old, row, (bx0, py0, bx1, py1), (ox, oy), |_, _| 0xFF);
copy(&mut new, row, (bx0, py0, bx1, py1), (ox, oy), None, 0xFF);
assert_eq!(old, new, "fill {ox},{oy} crop {bx0}..{bx1} x {py0}..{py1}");
}
}
}
}
/// `Archive::read_many` against the same reads made one at a time.
mod read_many {
use crate::synth::{self, Method};
use crate::{Archive, Error, Limits};
fn entry(i: usize) -> Vec<u8> {
(0..4096 + i * 37).map(|b| (b * 31 + i * 7) as u8).collect()
}
fn archive(count: usize, method: Method) -> Vec<u8> {
let datas: Vec<Vec<u8>> = (0..count).map(entry).collect();
let names: Vec<String> = (0..count).map(|i| format!("d/{i}")).collect();
let entries: Vec<(&str, &[u8], Method)> = names.iter().zip(&datas).map(|(n, d)| (n.as_str(), d.as_slice(), method)).collect();
synth::container(&entries, None)
}
#[test]
fn matches_serial_reads_and_charges_the_same() {
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
for count in [1, 3, 4, 5, 40] {
let file = archive(count, method);
let names: Vec<String> = (0..count).map(|i| format!("d/{i}")).collect();
let names: Vec<&str> = names.iter().map(String::as_str).collect();
let mut one = Archive::open(&file, Limits::default()).unwrap();
let serial: Vec<Vec<u8>> = names.iter().map(|n| one.read(n).unwrap()).collect();
let mut many = Archive::open(&file, Limits::default()).unwrap();
assert_eq!(many.read_many(&names).unwrap(), serial);
assert_eq!(many.extracted(), one.extracted());
assert_eq!(serial[count - 1], entry(count - 1));
}
}
}
#[test]
fn the_limit_stops_at_the_same_entry_as_serial_reads() {
let file = archive(12, Method::Zstd);
let names: Vec<String> = (0..12).map(|i| format!("d/{i}")).collect();
let names: Vec<&str> = names.iter().map(String::as_str).collect();
let limits = Limits { max_total: (0..7).map(|i| entry(i).len()).sum(), ..Limits::default() };
let mut one = Archive::open(&file, limits).unwrap();
let failed = names.iter().position(|n| one.read(n).is_err()).unwrap();
assert_eq!(failed, 7);
let mut many = Archive::open(&file, limits).unwrap();
assert!(matches!(many.read_many(&names), Err(Error::Limit(_))));
assert_eq!(many.extracted(), one.extracted());
}
#[test]
fn the_first_failing_entry_in_order_is_reported() {
let mut file = archive(12, Method::Stored);
// Flip a data byte of entry 2 (checksum failure); entry 9 is missing from the list.
let at = file.windows(4).position(|w| w == b"#Fil").unwrap();
let second = at + 4 + entry(0).len();
let third = second + 4 + entry(1).len();
file[third + 4 + 10] ^= 0xFF;
let names: Vec<String> = (0..12).map(|i| if i == 9 { "gone".to_string() } else { format!("d/{i}") }).collect();
let names: Vec<&str> = names.iter().map(String::as_str).collect();
let mut a = Archive::open(&file, Limits::default()).unwrap();
assert_eq!(a.read_many(&names).unwrap_err(), Error::Malformed("archive entry checksum mismatch"));
// Without the corrupt entry the missing one is what fails.
let intact = archive(12, Method::Stored);
let mut a = Archive::open(&intact, Limits::default()).unwrap();
assert_eq!(a.read_many(&names).unwrap_err(), Error::Malformed("missing archive entry"));
}
}
/// Object lookup and record arrays against the structures they replaced.
mod stream_storage {
use crate::stream::{self, Kind, Object, Tag, Value};
+1 -1
View File
@@ -77,7 +77,7 @@ fn run() -> Result<(), String> {
let mut samples_ms = Vec::with_capacity(o.reps);
for _ in 0..o.reps {
let t0 = Instant::now();
let output = panorama::multiband(cw, ch, &rois, &wts, levels);
let output = panorama::multiband(cw, ch, &rois, &wts, levels).map_err(|e| format!("multiband: {e}"))?;
samples_ms.push(t0.elapsed().as_secs_f64() * 1000.0);
std::hint::black_box(&output);
}
@@ -0,0 +1,162 @@
//! Timing of the Remove Tool's target-mask construction (the dilation of the hole by the patch
//! radius, once per pyramid level) and of the whole fill, on a 24 or 36 MP RGBA f32 image. One
//! JSON line per repetition. Uses only APIs that exist before and after the change, so the same
//! file builds in a base checkout.
//! `cargo run --release -p photocraft-algo --example bench_remove_targets -- --mode phase --mp 24 --hole small --reps 20`
//!
//! * `--mode phase|e2e`: `phase` times the masks alone (`old_loop`, a verbatim copy of the loop
//! the completion used to run, against `remove_dilate`); `e2e` times `remove_with`.
//! * `--hole small|scattered|large|dense`: a 48 px disc; about 20 single cells spread over the
//! image; a centred ellipse (`--large 900x600` sets its extent); half the cells of a 2000x2000
//! square (`phase` only).
use std::time::Instant;
use photocraft_algo::remove::{dilate, hole_radius, remove_with};
use photocraft_raster::Interrupt;
/// The patch radius of the completion's default parameters.
const R: usize = 3;
fn arg<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
}
fn hash(i: u64) -> u64 {
let mut x = i.wrapping_add(0x9e37_79b9_7f4a_7c15);
x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
x ^ (x >> 31)
}
fn fnv(bytes: impl Iterator<Item = u8>) -> u64 {
bytes.fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3))
}
fn texture(w: usize, h: usize) -> Vec<f32> {
let mut img = Vec::with_capacity(w * h * 4);
for i in 0..w * h {
let (x, y) = ((i % w) as f32, (i / w) as f32);
let n = (hash(i as u64) >> 40) as f32 / (1u64 << 24) as f32;
let v = 0.5 + 0.2 * (x * 0.05).sin() * (y * 0.07).cos() + 0.15 * n;
img.extend_from_slice(&[v, v * 0.8 + 0.05 * (x * 0.01).cos(), v * 0.6, 1.0]);
}
img
}
fn hole_mask(kind: &str, w: usize, h: usize, large: (usize, usize)) -> Vec<bool> {
let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0);
let mut m = vec![false; w * h];
match kind {
"small" => (0..w * h).for_each(|i| m[i] = ((i % w) as f32 - cx).hypot((i / w) as f32 - cy) < 24.0),
"scattered" => {
for k in 0..20u64 {
let (x, y) = (hash(k * 2) as usize % w, hash(k * 2 + 1) as usize % h);
m[y * w + x] = true;
}
}
"large" => {
let (a, b) = (large.0 as f32 / 2.0, large.1 as f32 / 2.0);
(0..w * h).for_each(|i| m[i] = (((i % w) as f32 - cx) / a).powi(2) + (((i / w) as f32 - cy) / b).powi(2) < 1.0);
}
"dense" => {
for y in h / 2 - 1000..h / 2 + 1000 {
for x in w / 2 - 1000..w / 2 + 1000 {
m[y * w + x] = hash((y * w + x) as u64) & 1 == 0;
}
}
}
_ => {}
}
m
}
/// The pyramid levels the completion builds for this hole: the same count formula and the same
/// 2x2 OR of the hole.
fn hole_levels(w: usize, h: usize, hole: &[bool]) -> Vec<(usize, usize, Vec<bool>)> {
let n = 2 * R + 1;
let no = hole_radius(w, h, hole).min(1 << 20);
let want = (2.0 * f64::from(no) / n as f64).log2().ceil().max(1.0) as usize;
let (mut lw, mut lh, mut levels) = (w, h, 1);
while levels < want && lw / 2 >= 3 * n && lh / 2 >= 3 * n {
(lw, lh, levels) = (lw.div_ceil(2), lh.div_ceil(2), levels + 1);
}
let mut out = vec![(w, h, hole.to_vec())];
while out.len() < levels {
let Some((pw, ph, prev)) = out.last() else { break };
let (nw, nh) = (pw.div_ceil(2), ph.div_ceil(2));
let next: Vec<bool> = (0..nw * nh)
.map(|i| {
let (fx, fy) = ((i % nw) * 2, (i / nw) * 2);
(0..2).any(|dy| (0..2).any(|dx| fx + dx < *pw && fy + dy < *ph && prev[(fy + dy) * pw + fx + dx]))
})
.collect();
out.push((nw, nh, next));
}
out
}
/// The loop the completion ran per level before the change, verbatim.
fn old_loop(lw: usize, lh: usize, hole: &[bool], r: usize) -> Vec<bool> {
let ri = r as i32;
let mut targets = vec![false; lw * lh];
for (i, _) in hole.iter().enumerate().filter(|(_, m)| **m) {
let (x, y) = ((i % lw) as i32, (i / lw) as i32);
for ty in (y - ri).max(0)..=(y + ri).min(lh as i32 - 1) {
for tx in (x - ri).max(0)..=(x + ri).min(lw as i32 - 1) {
targets[ty as usize * lw + tx as usize] = true;
}
}
}
targets
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let mode = arg(&args, "--mode").unwrap_or("phase");
let mp: usize = arg(&args, "--mp").and_then(|v| v.parse().ok()).unwrap_or(24);
let kind = arg(&args, "--hole").unwrap_or("small");
let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(5);
let large = arg(&args, "--large").and_then(|v| v.split_once('x')).and_then(|(a, b)| Some((a.parse().ok()?, b.parse().ok()?))).unwrap_or((900, 600));
let (w, h) = if mp == 36 { (7360, 4912) } else { (6000, 4000) };
let hole = hole_mask(kind, w, h, large);
let line = |rep: usize, variant: &str, ms: f64, hash: u64| {
println!("{{\"mode\":\"{mode}\",\"mp\":{mp},\"hole\":\"{kind}\",\"rep\":{rep},\"variant\":\"{variant}\",\"ms\":{ms:.3},\"hash\":\"{hash:016x}\"}}")
};
match mode {
"phase" => {
let levels = hole_levels(w, h, &hole);
for rep in 0..reps {
let t = Instant::now();
let old: Vec<Vec<bool>> = levels.iter().map(|(lw, lh, m)| old_loop(*lw, *lh, m, R)).collect();
let old_ms = t.elapsed().as_secs_f64() * 1e3;
let t = Instant::now();
let new: Vec<Vec<bool>> = levels.iter().map(|(lw, lh, m)| dilate(*lw, *lh, m, R)).collect();
let new_ms = t.elapsed().as_secs_f64() * 1e3;
let (ho, hn) = (fnv(old.iter().flatten().map(|b| u8::from(*b))), fnv(new.iter().flatten().map(|b| u8::from(*b))));
assert_eq!(ho, hn, "the masks differ");
line(rep, "old_loop", old_ms, ho);
line(rep, "remove_dilate", new_ms, hn);
}
}
"e2e" => {
if kind == "dense" {
eprintln!("the dense hole is a phase-only scenario");
std::process::exit(2);
}
let img = texture(w, h);
for rep in 0..reps {
let t = Instant::now();
let Ok(out) = remove_with(w, h, 4, &img, &hole, 1, &Interrupt::NONE) else {
eprintln!("cancelled");
std::process::exit(1);
};
let ms = t.elapsed().as_secs_f64() * 1e3;
line(rep, "remove_with", ms, fnv(out.iter().flat_map(|v| v.to_bits().to_le_bytes())));
}
}
_ => {
eprintln!("unknown --mode {mode}");
std::process::exit(2);
}
}
}
+3 -4
View File
@@ -81,16 +81,15 @@ fn main() {
// Refine edge on a 20 px band (selection = the object result as a surface).
let sel = combine_region(None, Some(&obj), SelectionMode::Replace).expect("selection");
let content = sel.content_bounds();
let p = RefineParams { radius: 20.0, smooth: 10.0, feather: 1.0, contrast: 10.0, ..Default::default() };
let t = Instant::now();
let refined = matting::refine_mask(&sampler, &matting::surface_reader(&sel), content, canvas, &p).expect("refined");
let refined = matting::refine_surface(&sampler, &sel, canvas, &p, None).expect("allocation").expect("refined");
println!("refine edge (radius 20): {:.2?}", t.elapsed());
let t = Instant::now();
let _ = matting::refine_mask(&sampler, &matting::surface_reader(&sel), content, canvas, &RefineParams { smart_radius: true, ..p }).expect("refined");
let _ = matting::refine_surface(&sampler, &sel, canvas, &RefineParams { smart_radius: true, ..p }, None).expect("allocation").expect("refined");
println!("refine edge (radius 20, smart): {:.2?}", t.elapsed());
let t = Instant::now();
let dec = matting::decontaminate(&s, &refined, 20.0, 100.0);
let dec = matting::decontaminate_surface(&s, &refined, 20.0, 100.0);
println!("decontaminate: {:.2?} ({} tiles)", t.elapsed(), dec.tile_count());
// Focus area.
+13
View File
@@ -236,6 +236,19 @@ impl GalleryEffect {
}
}
/// A copy whose stored numeric values are held to their declared ranges (a non-finite value
/// becomes the default). A project file stores `values` as plain numbers, so unlike
/// [`Self::set`] nothing has clamped them; geometry sized from them (reach) needs it.
pub(crate) fn clamped(&self) -> GalleryEffect {
let mut out = self.clone();
for p in self.filter.params() {
if let (GalleryParamKind::Range { min, max, default }, Some(v)) = (&p.kind, out.values.get_mut(p.key)) {
*v = if v.is_finite() { v.clamp(*min, *max) } else { *default };
}
}
out
}
/// A bool value.
pub fn flag(&self, key: &str) -> bool {
self.get(key) != 0.0
+8 -3
View File
@@ -407,6 +407,8 @@ fn highlights(p: Px, l: f32, area: f32, intensity: f32) -> Px {
/// Pixels an effect reads around an output pixel.
pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
// Stored values come from a project file unclamped; keep the arithmetic below inside i32.
let e = &e.clamped();
let g = |k: &str| e.get(k);
match e.filter {
F::ColoredPencil => 2,
@@ -528,7 +530,7 @@ fn glass_cell(e: &GalleryEffect) -> f32 {
/// Total reach of a stack of effects.
pub(crate) fn reach(effects: &[GalleryEffect]) -> i32 {
effects.iter().map(effect_reach).sum()
effects.iter().map(effect_reach).fold(0, i32::saturating_add)
}
/// Runs a stack of effects over `out` (the source covers `out` grown by [`reach`]).
@@ -540,8 +542,9 @@ pub(crate) fn run(effects: &[GalleryEffect], src: &Image, out: Rect, ctx: &Ctx)
return src.crop(out);
}
let mut win = Win { r, w, h, c: src.data.chunks_exact(n).map(|px| rgb(rgba(ctx, px))).collect() };
for e in effects {
apply(e, &mut win, ctx);
// The reach that sized `src` comes from the clamped values, so the pixels must use them too.
for e in effects.iter().map(GalleryEffect::clamped) {
apply(&e, &mut win, ctx);
}
finish(&win, src, out, ctx)
}
@@ -562,6 +565,8 @@ pub(crate) struct Prepared {
impl Prepared {
/// `None` for a stack that has no Cutout (it runs tile by tile as usual).
pub(crate) fn new(effects: &[GalleryEffect], src: &Image, ctx: &Ctx) -> Option<Prepared> {
// Held to the declared ranges once here (see `run`), so the traced shapes and the tiles agree.
let effects = &effects.iter().map(GalleryEffect::clamped).collect::<Vec<_>>();
let last = effects.iter().rposition(|e| e.filter == F::Cutout)?;
let r = src.rect;
let (w, h) = (r.width() as usize, r.height() as usize);
+175
View File
@@ -232,3 +232,178 @@ fn direct_36mp_image_with_small_output_is_not_rejected() {
let result = shape_blur(&src, Rect::new(3000, 3000, 3001, 3001), &ctx, 5.0, BlurShape::Circle);
assert_eq!(result, vec![0.25; 4]);
}
/// An asymmetric weighted kernel detects mirroring independently of span accumulation.
#[test]
fn custom_shape_coverage_matches_point_convolution_without_mirroring() {
let mask = Image { rect: Rect::new(0, 0, 3, 3), ch: 1, data: vec![1.0, 0.25, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.0] };
let prepared = crate::shape_blur::Prepared::custom(1.0, &mask, &photocraft_raster::Interrupt::NONE).unwrap();
for (channels, alpha) in [(1, false), (4, true)] {
let src = image(Rect::new(-2, -2, 5, 5), channels, alpha);
let ctx = Ctx { bounds: src.rect, mode: ColorMode::Rgb, alpha };
let out = Rect::new(-3, -3, 6, 6);
let actual = prepared.run(&src, out, &ctx);
let mut expected = Vec::new();
for y in out.y0..out.y1 {
for x in out.x0..out.x1 {
let mut sums = vec![0.0f64; channels];
for ky in 0..3 {
for kx in 0..3 {
let weight = f64::from(mask.data[ky * 3 + kx]);
let (sx, sy) = (x + 1 - kx as i32, y + 1 - ky as i32);
let a = if alpha { src.get(sx, sy, channels - 1) } else { 1.0 };
for (c, sum) in sums.iter_mut().enumerate() {
let v = src.get(sx, sy, c);
*sum += f64::from(if alpha && c + 1 < channels { v * a } else { v }) * weight;
}
}
}
let mut px: Vec<f32> = sums.iter().map(|sum| (sum / 1.75) as f32).collect();
unpremul_px(&mut px, alpha);
expected.extend(px);
}
}
assert_eq!(actual.len(), expected.len());
for (a, e) in actual.iter().zip(expected) {
assert!((a - e).abs() < 1e-6, "{a} != {e}");
}
}
// The top-left kernel corner, rather than its mirror, receives the impulse.
let mut src = Image { rect: Rect::new(0, 0, 7, 7), ch: 1, data: vec![0.0; 49] };
src.data[3 * 7 + 3] = 1.0;
let ctx = Ctx { bounds: src.rect, mode: ColorMode::Grayscale, alpha: false };
let out = prepared.run(&src, src.rect, &ctx);
assert!((out[2 * 7 + 2] - 1.0 / 1.75).abs() < 1e-6);
assert_eq!(out[4 * 7 + 4], 0.0);
}
/// The custom path must agree with the existing square blur, including tiled boundaries.
#[test]
fn custom_square_preserves_builtin_result_and_tile_boundaries() {
let rect = Rect::new(-3, -2, 17, 14);
let src = image(rect, 4, true);
let ctx = Ctx { bounds: rect, mode: ColorMode::Rgb, alpha: true };
let mask = Image { rect: Rect::new(0, 0, 7, 7), ch: 1, data: vec![1.0; 49] };
let p = FilterParams::CustomShapeBlur { radius: 2.5, kernel: Some(std::sync::Arc::new(mask)) };
let actual = kernel(&p, &src, rect, &ctx);
let expected = shape_blur(&src, rect, &ctx, 2.5, BlurShape::Square);
exact(&actual, &expected);
let mut surface = Surface::new(PixelFormat { mode: ColorMode::Rgb, sample: SampleType::F32, alpha: true });
surface.write_region(rect, &src.data);
let tiled = apply_tiled(&surface, &p, rect, rect, None, 5, Some(rect));
let ordinary = apply_tiled(&surface, &FilterParams::ShapeBlur { radius: 2.5, shape: BlurShape::Square }, rect, rect, None, 5, Some(rect));
exact(&tiled.read_region(rect), &ordinary.read_region(rect));
}
/// Oversized and invalid kernel metadata must be rejected before proportional allocation.
#[test]
fn invalid_custom_kernels_are_refused_without_allocating_their_declared_size() {
for mask in [
Image { rect: Rect::new(0, 0, 3, 3), ch: 1, data: vec![0.0; 9] },
Image { rect: Rect::new(0, 0, 3, 3), ch: 1, data: vec![f32::NAN; 9] },
Image { rect: Rect::new(0, 0, 3, 3), ch: 1, data: vec![1.1; 9] },
Image { rect: Rect::new(0, 0, 3, 3), ch: 4, data: vec![1.0; 36] },
Image { rect: Rect::new(0, 0, 3, 3), ch: 1, data: vec![1.0] },
Image { rect: Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX), ch: 1, data: vec![] },
] {
assert!(crate::shape_blur::Prepared::custom(1.0, &mask, &photocraft_raster::Interrupt::NONE).is_none());
}
}
/// Point-by-point weighted convolution of `src` (zero outside it) by a square `mask` of side
/// `2 ri + 1`, normalized by the mask's total coverage: the reference for custom kernels.
fn weighted_reference(mask: &Image, ri: i32, src: &Image, out: Rect, alpha: bool) -> Vec<f32> {
let size = (2 * ri + 1) as usize;
let channels = src.ch;
let total: f64 = mask.data.iter().map(|&v| f64::from(v)).sum();
let mut expected = Vec::new();
for y in out.y0..out.y1 {
for x in out.x0..out.x1 {
let mut sums = vec![0.0f64; channels];
for ky in 0..size {
for kx in 0..size {
let weight = f64::from(mask.data[ky * size + kx]);
let (sx, sy) = (x + ri - kx as i32, y + ri - ky as i32);
let a = if alpha { src.get(sx, sy, channels - 1) } else { 1.0 };
for (c, sum) in sums.iter_mut().enumerate() {
let v = src.get(sx, sy, c);
*sum += f64::from(if alpha && c + 1 < channels { v * a } else { v }) * weight;
}
}
}
let mut px: Vec<f32> = sums.iter().map(|sum| (sum / total) as f32).collect();
unpremul_px(&mut px, alpha);
expected.extend(px);
}
}
expected
}
/// Custom weights reach both dispatch paths: the direct kernel and the tiled filter. An
/// all-ones mask could not tell weights that were ignored or mirrored.
#[test]
fn nonuniform_custom_kernel_matches_weighted_reference_through_kernel_and_tiles() {
let rect = Rect::new(-3, -2, 13, 11);
let src = image(rect, 4, true);
let ctx = Ctx { bounds: rect, mode: ColorMode::Rgb, alpha: true };
// Asymmetric, with distinct weights in every row (5 x 5 for radius 2).
let data: Vec<f32> = (0..25).map(|i| [0.0, 0.125, 0.5, 1.0, 0.25, 0.75][(i * 7 + i / 5) % 6]).collect();
let mask = Image { rect: Rect::new(0, 0, 5, 5), ch: 1, data };
let expected = weighted_reference(&mask, 2, &src, rect, true);
let p = FilterParams::CustomShapeBlur { radius: 2.0, kernel: Some(std::sync::Arc::new(mask)) };
let close = |actual: &[f32], what: &str| {
assert_eq!(actual.len(), expected.len(), "{what}");
for (i, (a, e)) in actual.iter().zip(&expected).enumerate() {
assert!((a - e).abs() < 1e-5, "{what} sample {i}: {a} != {e}");
}
};
close(&kernel(&p, &src, rect, &ctx), "kernel");
let mut surface = Surface::new(PixelFormat { mode: ColorMode::Rgb, sample: SampleType::F32, alpha: true });
surface.write_region(rect, &src.data);
// No extent: tiles read transparency beyond the layer, as the reference's zero extension.
close(&apply_tiled(&surface, &p, rect, rect, None, 4, None).read_region(rect), "apply_tiled");
}
/// A fragmented custom mask (alternating coverage, about size² / 2 runs) would make every
/// output row scan millions of windows; it is refused, while a smooth disc of the same radius
/// and a fully fragmented small kernel are accepted.
#[test]
fn fragmented_large_custom_kernels_are_refused_before_convolution() {
use crate::shape_blur::{Prepared, max_spans};
let none = photocraft_raster::Interrupt::NONE;
let size = 2001usize;
let alternating = Image { rect: Rect::new(0, 0, size as i32, size as i32), ch: 1, data: (0..size * size).map(|i| (i % 2) as f32).collect() };
assert!(size * size / 2 > max_spans(size));
assert!(Prepared::custom(1000.0, &alternating, &none).is_none());
// Through the filter: refused before any tile is convolved.
let mut surface = Surface::new(PixelFormat { mode: ColorMode::Rgb, sample: SampleType::F32, alpha: true });
let r = Rect::new(0, 0, 4, 4);
surface.fill_rect(r, &[0.25, 0.5, 0.75, 1.0]);
let p = FilterParams::CustomShapeBlur { radius: 1000.0, kernel: Some(std::sync::Arc::new(alternating)) };
assert!(crate::apply_tiled_with(&surface, &p, r, r, None, 256, Some(r), &none).is_none());
// A disc of radius 1000 has one run per row.
let c = 1000.0f32;
let disc: Vec<f32> = (0..size * size).map(|i| f32::from(((i % size) as f32 - c).hypot((i / size) as f32 - c) <= c)).collect();
assert!(Prepared::custom(1000.0, &Image { rect: Rect::new(0, 0, size as i32, size as i32), ch: 1, data: disc }, &none).is_some());
// Every kernel of up to 256 x 256 is accepted, however fragmented.
let small = 255usize;
let fragmented =
Image { rect: Rect::new(0, 0, small as i32, small as i32), ch: 1, data: (0..small * small).map(|i| ((i % 4) as f32 + 1.0) / 4.0).collect() };
assert!(Prepared::custom(127.0, &fragmented, &none).is_some());
}
/// Kernel preparation and each tile's convolution poll cancellation, so a large custom kernel
/// does not run a whole tile after Cancel.
#[test]
fn custom_kernel_preparation_and_convolution_stop_when_cancelled() {
use crate::shape_blur::Prepared;
let none = photocraft_raster::Interrupt::NONE;
let cancelled = photocraft_raster::Interrupt::cancel_only(&|| true);
let mask = Image { rect: Rect::new(0, 0, 3, 3), ch: 1, data: vec![1.0, 0.25, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.5] };
assert!(Prepared::custom(1.0, &mask, &cancelled).is_none());
let prepared = Prepared::custom(1.0, &mask, &none).unwrap();
let src = image(Rect::new(0, 0, 6, 5), 4, true);
let ctx = Ctx { bounds: src.rect, mode: ColorMode::Rgb, alpha: true };
assert_eq!(prepared.checked_run(&src, src.rect, &ctx, &none).map(|v| v.len()), Some(6 * 5 * 4));
assert!(prepared.checked_run(&src, src.rect, &ctx, &cancelled).is_none());
}
-6
View File
@@ -65,10 +65,6 @@ pub(super) fn fit(pts: &[Pt], closed: bool, s: &Settings) -> Vec<Pt> {
let mut keep: Vec<usize> = if closed { corners.clone() } else { std::iter::once(0).chain(corners.iter().copied()).chain(std::iter::once(n - 1)).collect() };
keep.sort_unstable();
keep.dedup();
let mut is_corner = vec![false; n];
for &k in &keep {
is_corner[k] = true;
}
if closed && keep.len() < 2 {
// A smooth loop: split it at two far-apart points (not corners) to simplify each half.
let a = keep.first().copied().unwrap_or(0);
@@ -92,8 +88,6 @@ pub(super) fn fit(pts: &[Pt], closed: bool, s: &Settings) -> Vec<Pt> {
verts.push(n - 1);
}
let v: Vec<Pt> = verts.iter().map(|&i| p[i]).collect();
let corner: Vec<bool> = verts.iter().map(|&i| is_corner[i]).collect();
let _ = &corner;
let mut out = v.clone();
if closed {
out.extend(v.first().copied());
+1 -1
View File
@@ -303,7 +303,7 @@ mod tests {
}
#[test]
fn empty_images_stop_before_the_transpose_and_row_passes() {
fn empty_images_trace_to_nothing() {
for r in [Rect::new(0, 0, 0, 4), Rect::new(0, 0, 4, 0), Rect::new(0, 0, 0, 0)] {
let t = trace(&[], r, &settings(4, 2.0, 1.0));
assert!(t.regions.is_empty());
+2 -2
View File
@@ -44,8 +44,8 @@ pub(super) fn components(labels: &[u8], w: usize, h: usize) -> (Vec<u32>, usize)
(comp, n as usize)
}
/// Merges small and sliver components into their neighbours. Returns each pixel's region and
/// each region's mean colour.
/// Merges components smaller than the Edge Simplicity area into the neighbour they share the most
/// border with. Returns each pixel's region and each region's mean colour.
pub(super) fn merge_regions(comp: &[u32], n: usize, c: &[Px], w: usize, h: usize, s: &Settings) -> (Vec<u32>, Vec<Px>) {
let mut area = vec![0u32; n];
let mut sum = vec![[0.0f64; 3]; n];
+275
View File
@@ -0,0 +1,275 @@
//! Filter › Render › Lens Flare.
//!
//! A flare is built from elements placed along its axis: the line from the flare centre (t = 0)
//! through the centre of the image (t = 1), as in a real lens, where the internal reflections line
//! up with the optical centre. Each lens has its own set, measured from Photoshop's output
//! (`lens_flare_data.rs`):
//!
//! - radial elements: a colour profile by distance around a point on the axis (the core, glows,
//! rings and the reflection discs, with their coloured rims);
//! - streaks: straight lines centred on the axis (Movie Prime's horizontal and diagonal streaks, the
//! 105 mm Prime's spike);
//! - rays: thin one-pixel rays out of the centre, drawn without antialiasing as Photoshop does.
//!
//! Sizes are in units of 1/800 of the image width: Photoshop sizes a flare by the width alone, the
//! same in landscape, square and portrait images. Brightness changes the shape of the elements, not
//! just their light (the core grows, the glows widen, at 300 % most lenses wash out to white), so
//! each lens is measured at several brightnesses; between two the flare blends them. The light is
//! screened onto the pixels.
use photocraft_geom::Rect;
use crate::LensType;
use crate::fxutil::{rgba, set_rgba, smoothstep};
use crate::image::Image;
use crate::{Ctx, lens_flare_data as data};
/// A colour profile by distance (in flare units) around a point on the axis; zero outside the
/// first and last knot, linear between knots.
pub(crate) struct Radial {
pub t: f32,
/// Offset across the axis, in flare units.
pub perp: f32,
pub knots: &'static [(f32, [f32; 3])],
}
/// A straight streak centred on the axis: full strength along `half · (1 − taper)`, fading to
/// nothing at `half`; a Gaussian `width` across.
pub(crate) struct Streak {
pub t: f32,
pub perp: f32,
/// Degrees, clockwise from the +x axis (image y points down).
pub angle: f32,
pub half: f32,
pub taper: f32,
pub width: f32,
pub rgb: [f32; 3],
}
/// A one-pixel ray out of the flare centre from radius `r0` to `r1`, fading in and out over
/// `fade` of its length.
pub(crate) struct Ray {
pub angle: f32,
pub r0: f32,
pub r1: f32,
pub fade: f32,
pub rgb: [f32; 3],
}
/// A lens's flare at one measured brightness (percent).
pub(crate) struct Level {
pub brightness: f32,
pub radial: &'static [Radial],
pub streaks: &'static [Streak],
pub rays: &'static [Ray],
}
fn levels(lens: LensType) -> &'static [Level] {
match lens {
LensType::Zoom => data::ZOOM,
LensType::Prime35 => data::PRIME35,
LensType::Prime105 => data::PRIME105,
LensType::MoviePrime => data::MOVIE_PRIME,
}
}
/// The two measured levels around `brightness` and the weight of the second (levels are sorted).
fn bracket(levels: &[Level], brightness: f32) -> Option<(&Level, &Level, f32)> {
let first = levels.first()?;
let last = levels.last()?;
if brightness <= first.brightness {
return Some((first, first, 0.0));
}
let i = levels.partition_point(|l| l.brightness < brightness);
let Some(hi) = levels.get(i) else { return Some((last, last, 0.0)) };
let Some(lo) = i.checked_sub(1).and_then(|j| levels.get(j)) else { return Some((hi, hi, 0.0)) };
let span = hi.brightness - lo.brightness;
Some((lo, hi, if span > 0.0 { (brightness - lo.brightness) / span } else { 0.0 }))
}
/// The profile at distance `d`; `None` outside it.
fn profile(knots: &[(f32, [f32; 3])], d: f32) -> Option<[f32; 3]> {
let (first, last) = (knots.first()?, knots.last()?);
if d < first.0 || d > last.0 {
return None;
}
// The first knot at or beyond `d` (binary search: profiles have up to a few hundred knots).
let i = knots.partition_point(|k| k.0 < d);
let Some(hi) = knots.get(i) else { return Some(last.1) };
let Some(lo) = i.checked_sub(1).and_then(|j| knots.get(j)) else { return Some(hi.1) };
let span = hi.0 - lo.0;
let f = if span > 0.0 { (d - lo.0) / span } else { 0.0 };
Some(std::array::from_fn(|k| lo.1[k] + (hi.1[k] - lo.1[k]) * f))
}
/// Flare geometry in document pixels, and the output tile it is drawn into.
struct Geometry {
centre: (f32, f32),
/// Axis: image centre minus flare centre.
axis: (f32, f32),
/// Unit normal to the axis.
normal: (f32, f32),
/// Pixels per flare unit.
unit: f32,
/// Output tile: origin and size.
origin: (f32, f32),
size: (usize, usize),
}
impl Geometry {
fn point(&self, t: f32, perp: f32) -> (f32, f32) {
(self.centre.0 + t * self.axis.0 + perp * self.unit * self.normal.0, self.centre.1 + t * self.axis.1 + perp * self.unit * self.normal.1)
}
}
/// Adds `weight` times the light of one level into `light` (the output tile, row-major).
fn add_level(level: &Level, weight: f32, g: &Geometry, light: &mut [[f32; 3]]) {
if weight <= 0.0 {
return;
}
let (w, h) = g.size;
let (ox, oy) = g.origin;
// Radial elements, each only over the pixels it can reach.
for e in level.radial {
let Some(reach) = e.knots.last().map(|k| k.0 * g.unit) else { continue };
let p = g.point(e.t, e.perp);
let x0 = ((p.0 - reach - ox).floor().max(0.0) as usize).min(w);
let x1 = ((p.0 + reach - ox).ceil().max(0.0) as usize).min(w);
let y0 = ((p.1 - reach - oy).floor().max(0.0) as usize).min(h);
let y1 = ((p.1 + reach - oy).ceil().max(0.0) as usize).min(h);
for y in y0..y1 {
let fy = oy + y as f32 + 0.5 - p.1;
for x in x0..x1 {
let fx = ox + x as f32 + 0.5 - p.0;
if let Some(v) = profile(e.knots, fx.hypot(fy) / g.unit)
&& let Some(px) = light.get_mut(y * w + x)
{
for (p, v) in px.iter_mut().zip(v) {
*p += weight * v;
}
}
}
}
}
for s in level.streaks {
let p = g.point(s.t, s.perp);
let (sin, cos) = s.angle.to_radians().sin_cos();
let half = s.half.max(1e-3);
let taper = s.taper.clamp(0.01, 1.0);
let width = s.width.max(1e-3);
for (i, px) in light.iter_mut().enumerate() {
let fx = ox + (i % w) as f32 + 0.5 - p.0;
let fy = oy + (i / w) as f32 + 0.5 - p.1;
let across = (-fx * sin + fy * cos) / g.unit / width;
// NaN comes from overflowing geometry and must not reach the pixels.
if across.is_nan() || across.abs() > 4.0 {
continue;
}
let along = ((fx * cos + fy * sin) / g.unit).abs();
let k = weight * (1.0 - smoothstep(half * (1.0 - taper), half, along)) * (-across * across).exp();
for (p, c) in px.iter_mut().zip(s.rgb) {
*p += k * c;
}
}
}
// Rays: walk each ray in short steps and light every pixel it crosses once.
for r in level.rays {
let (sin, cos) = r.angle.to_radians().sin_cos();
let (r0, r1) = (r.r0 * g.unit, r.r1 * g.unit);
let length = (r1 - r0).max(1e-6);
let fade = r.fade.max(1e-3);
// Skip a ray whose segment misses this tile: none of its samples can land inside it.
let (sx, sy) = (g.centre.0 + r0 * cos, g.centre.1 + r0 * sin);
let (ex, ey) = (g.centre.0 + r1 * cos, g.centre.1 + r1 * sin);
if sx.max(ex) < ox - 1.0 || sx.min(ex) > ox + w as f32 + 1.0 || sy.max(ey) < oy - 1.0 || sy.min(ey) > oy + h as f32 + 1.0 {
continue;
}
let steps = ((r1 - r0) / 0.35).ceil().clamp(0.0, 1e6) as usize;
let mut last = None;
for i in 0..steps {
let rad = r0 + i as f32 * 0.35;
let (px, py) = ((g.centre.0 + rad * cos).floor(), (g.centre.1 + rad * sin).floor());
if last == Some((px, py)) {
continue;
}
last = Some((px, py));
let (lx, ly) = (px - ox, py - oy);
if lx < 0.0 || ly < 0.0 || lx >= w as f32 || ly >= h as f32 {
continue;
}
let pos = (rad - r0) / length;
let env = weight * ((pos.min(1.0 - pos)) / fade).clamp(0.0, 1.0);
if let Some(v) = light.get_mut(ly as usize * w + lx as usize) {
for (p, c) in v.iter_mut().zip(r.rgb) {
*p += env * c;
}
}
}
}
}
pub(crate) fn lens_flare(src: &Image, out: Rect, ctx: &Ctx, brightness: f32, centre: (f32, f32), lens: LensType) -> Vec<f32> {
let n = src.ch;
let b = ctx.bounds;
let (bw, bh) = (b.width() as f32, b.height() as f32);
let c = (b.x0 as f32 + centre.0 * bw, b.y0 as f32 + centre.1 * bh);
let mid = (b.x0 as f32 + bw / 2.0, b.y0 as f32 + bh / 2.0);
let axis = (mid.0 - c.0, mid.1 - c.1);
let len = axis.0.hypot(axis.1);
// A centre so far off the canvas that the geometry overflows f32 (e.g. 1e300 from a command)
// lights nothing here; without this, NaN would reach every pixel through the streaks.
if ![c.0, c.1, len].iter().all(|v| v.is_finite()) {
return src.crop(out);
}
// At the image centre the axis has no direction; the offsets across it are tiny anyway.
let normal = if len > 1e-6 { (-axis.1 / len, axis.0 / len) } else { (0.0, 1.0) };
let (w, h) = (out.width() as usize, out.height() as usize);
let g = Geometry { centre: c, axis, normal, unit: (bw / 800.0).max(1e-6), origin: (out.x0 as f32, out.y0 as f32), size: (w, h) };
let mut light = vec![[0.0f32; 3]; w * h];
if let Some((lo, hi, f)) = bracket(levels(lens), brightness.clamp(10.0, 300.0)) {
add_level(lo, 1.0 - f, &g, &mut light);
if !std::ptr::eq(lo, hi) {
add_level(hi, f, &g, &mut light);
}
}
let mut res = src.crop(out);
for (i, px) in res.chunks_exact_mut(n).enumerate() {
let Some(f) = light.get(i) else { break };
let mut c = rgba(ctx, px);
for k in 0..3 {
// `clamp` keeps NaN; a non-finite amount adds no light.
let a = if f[k].is_finite() { f[k].clamp(0.0, 1.0) } else { 0.0 };
// Screen (c + a·(1 − c)) keeps display-referred values below white; a 32-bit value
// already above white is left as is rather than pulled down towards it.
c[k] += a * (1.0 - c[k]).max(0.0);
}
set_rgba(ctx, px, c);
}
res
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn brightness_between_levels_blends_the_two_around_it() {
let lv = levels(LensType::Zoom);
let (a, b) = (&lv[0], &lv[1]);
let mid = (a.brightness + b.brightness) / 2.0;
let (lo, hi, f) = bracket(lv, mid).unwrap();
assert!(std::ptr::eq(lo, a) && std::ptr::eq(hi, b) && (f - 0.5).abs() < 1e-6);
// Exactly on a level, and outside the measured range, one level is used as is.
let (_, hi, f) = bracket(lv, b.brightness).unwrap();
assert!(std::ptr::eq(hi, b) && f == 1.0);
let (lo, hi, f) = bracket(lv, 1.0).unwrap();
assert!(std::ptr::eq(lo, a) && std::ptr::eq(hi, a) && f == 0.0);
let last = lv.last().unwrap();
let (lo, _, f) = bracket(lv, 1000.0).unwrap();
assert!(std::ptr::eq(lo, last) && f == 0.0);
}
}
File diff suppressed because it is too large. Load diff
+50 -7
View File
@@ -43,6 +43,8 @@ pub mod histogram;
mod image;
pub mod inpaint;
pub mod lens;
mod lens_flare;
mod lens_flare_data;
pub mod liquify;
pub mod magnetic;
pub mod matting;
@@ -76,6 +78,7 @@ mod selection_blur;
pub mod separable;
mod shape_blur;
mod sharpen;
mod square_dilate;
pub mod stack;
mod stylize;
mod stylize2;
@@ -504,6 +507,12 @@ pub enum FilterParams {
radius: f32,
shape: BlurShape,
},
/// A custom shape's coverage kernel, resolved by the caller before rendering.
CustomShapeBlur {
radius: f32,
#[serde(skip)]
kernel: Option<std::sync::Arc<Image>>,
},
// ---- Blur Gallery ----
/// Centre fractions, angle degrees, focus/transition as fractions of the bounds' shorter side.
@@ -683,7 +692,7 @@ impl FilterParams {
FilterParams::ReduceNoise { .. } => "Reduce Noise",
FilterParams::SmartBlur { .. } => "Smart Blur",
FilterParams::LensBlur { .. } => "Lens Blur",
FilterParams::ShapeBlur { .. } => "Shape Blur",
FilterParams::ShapeBlur { .. } | FilterParams::CustomShapeBlur { .. } => "Shape Blur",
FilterParams::TiltShift { .. } => "Tilt-Shift",
FilterParams::IrisBlur { .. } => "Iris Blur",
FilterParams::FieldBlur { .. } => "Field Blur",
@@ -728,7 +737,7 @@ fn halo_ext(p: &FilterParams) -> Halo {
// The kernels clamp the radius to 100 px: size the source window by what they use.
FilterParams::SmartBlur { radius, .. } => r(radius.min(100.0)),
FilterParams::LensBlur { radius, .. } => r(radius.min(100.0)),
FilterParams::ShapeBlur { radius, .. } => r(shape_blur::radius(*radius)),
FilterParams::ShapeBlur { radius, .. } | FilterParams::CustomShapeBlur { radius, .. } => r(shape_blur::radius(*radius)),
FilterParams::TiltShift { blur, .. } => r(gallery::reach(*blur)),
FilterParams::IrisBlur { pins } => r(gallery::reach(pins.iter().map(|p| p.blur).fold(0.0, f32::max))),
FilterParams::FieldBlur { pins } => r(gallery::reach(pins.iter().map(|p| p.blur).fold(0.0, f32::max))),
@@ -846,7 +855,7 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
FilterParams::Fibers { variance, strength, seed, foreground, background } => {
render::fibers(src, out, ctx, *variance, *strength, *seed, (*foreground, *background))
}
FilterParams::LensFlare { brightness, center_x, center_y, lens } => render::lens_flare(src, out, ctx, *brightness, (*center_x, *center_y), *lens),
FilterParams::LensFlare { brightness, center_x, center_y, lens } => lens_flare::lens_flare(src, out, ctx, *brightness, (*center_x, *center_y), *lens),
FilterParams::LightingEffects { lights, gloss, metallic, exposure, ambience, texture, height, white_is_high } => render::lighting(
src,
out,
@@ -918,6 +927,10 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
},
),
FilterParams::ShapeBlur { radius, shape } => blur2::shape_blur(src, out, ctx, *radius, *shape),
FilterParams::CustomShapeBlur { radius, kernel } => kernel
.as_ref()
.and_then(|mask| shape_blur::Prepared::custom(*radius, mask, &photocraft_raster::Interrupt::NONE))
.map_or_else(Vec::new, |prepared| prepared.run(src, out, ctx)),
FilterParams::TiltShift { blur, center_x, center_y, angle, focus, transition } => {
gallery::tilt_shift(src, out, ctx, *blur, (*center_x, *center_y), *angle, *focus, *transition)
}
@@ -1034,10 +1047,14 @@ pub fn working_bytes(params: &FilterParams, area: Rect, bounds: Rect, channels:
#[cfg(target_arch = "wasm32")]
let threads = 1u64;
let tile = auto_tile(params, bounds).max(1) as u64;
// Each worker holds one tile's windows at a time, so no more workers than tiles hold them
// at once (a 24 MP image at a 1024 px tile has 24 tiles, fewer than 32 threads).
let tiles = u64::from(area.width()).div_ceil(tile).saturating_mul(u64::from(area.height()).div_ceil(tile)).max(1);
let threads = threads.min(tiles);
let floats = (channels as u64).saturating_mul(4);
// Result groups hold at most RESULT_BUDGET bytes of floats, and never more than the area.
let base = area.size().area().saturating_mul(floats).min(RESULT_BUDGET as u64).saturating_mul(3);
match params.halo_for(bounds) {
let windows = match params.halo_for(bounds) {
Halo::Bounds => base.saturating_add(bounds.union(&area).size().area().saturating_mul(floats)),
Halo::Radius(r) => {
let side = tile.saturating_add((r.max(0) as u64).saturating_mul(2));
@@ -1045,7 +1062,22 @@ pub fn working_bytes(params: &FilterParams, area: Rect, bounds: Rect, channels:
side.saturating_mul(side).saturating_add(tile.saturating_mul(tile).saturating_mul(3)).saturating_mul(floats).saturating_mul(threads),
)
}
}
};
windows.saturating_add(shape_blur_bytes(params, bounds, tile, channels as u64, threads))
}
/// What Shape Blur holds beyond the float windows: each worker's f64 prefix sums over its
/// halo-expanded tile, and for a custom shape the kernel mask kept for the whole run plus its
/// prepared coverage runs (at most [`shape_blur::max_spans`], 20 bytes each).
fn shape_blur_bytes(params: &FilterParams, bounds: Rect, tile: u64, channels: u64, threads: u64) -> u64 {
let (FilterParams::ShapeBlur { .. } | FilterParams::CustomShapeBlur { .. }) = params else { return 0 };
let Halo::Radius(r) = params.halo_for(bounds) else { return 0 };
let side = tile.min(2048).saturating_add((r.max(0) as u64).saturating_mul(2)).saturating_add(1);
let prefix = side.saturating_mul(side).saturating_mul(channels).saturating_mul(8).saturating_mul(threads);
let FilterParams::CustomShapeBlur { kernel: Some(mask), .. } = params else { return prefix };
let size = (mask.rect.width() as usize).max(mask.rect.height() as usize);
let spans = (shape_blur::max_spans(size) as u64).saturating_mul(20);
prefix.saturating_add((mask.data.len() as u64).saturating_mul(4)).saturating_add(spans)
}
/// [`apply`] / [`apply_in`] with an explicit tile size (tests use it to check tile independence).
@@ -1121,7 +1153,11 @@ fn apply_tiled_with_impl(
}
// The public tile override must not create a zero-step loop or an
// unbounded scratch buffer for these checked filters. Automatic tiles are unchanged.
let tile = if matches!(params, FilterParams::ShapeBlur { .. } | FilterParams::UnsharpMask { .. }) { tile.clamp(1, 2048) } else { tile };
let tile = if matches!(params, FilterParams::ShapeBlur { .. } | FilterParams::CustomShapeBlur { .. } | FilterParams::UnsharpMask { .. }) {
tile.clamp(1, 2048)
} else {
tile
};
let fmt = surface.format();
let ctx = Ctx { bounds, mode: fmt.mode, alpha: fmt.alpha };
// Prepare the immutable sample transforms once for all output tiles. If the
@@ -1161,6 +1197,13 @@ fn apply_tiled_with_impl(
}
// Shape rasterization depends only on params, not on a tile's pixels.
let shape = match params {
FilterParams::CustomShapeBlur { radius, kernel } => {
if ctl.cancelled() {
return None;
}
let prepared = shape_blur::Prepared::custom(*radius, kernel.as_ref()?, ctl)?;
Some(prepared)
}
FilterParams::ShapeBlur { radius, shape } => {
if ctl.cancelled() {
return None;
@@ -1237,7 +1280,7 @@ fn apply_tiled_with_impl(
};
let Some(mut data) = (match (&shape, &radial) {
_ if gallery.is_some() => gallery.as_ref().map(|g| g.run(src, *t, &ctx)),
(Some(shape), _) => shape.checked_run(src, *t, &ctx),
(Some(shape), _) => shape.checked_run(src, *t, &ctx, ctl),
(None, Some(plan)) => match plan.filter(src, *t, &ctx, ctl) {
Some(data) => Some(data),
None if ctl.cancelled() => None,
+494 -8
View File
@@ -391,14 +391,7 @@ fn shift_coverage(v: f32, k: f32) -> f32 {
if shifted.is_finite() { shifted.clamp(0.0, 1.0) } else { v }
}
/// Most pixels one refine should cover. Its result is a dense [`Region`] over [`refine_area`]
/// (about two bytes a pixel while it is assembled), so callers refuse a larger refine up front
/// rather than allocate it: 2³⁰ pixels, a 32k × 32k selection.
pub const MAX_REFINE_AREA: u64 = 1 << 30;
/// The area [`refine_mask`] and [`refine_mask_brushed`] compute and allocate their result over:
/// `content` grown by how far `p` can spread it, plus where `brush` adds, within `canvas`. Empty
/// when `content` misses the canvas.
/// Bounds reachable by refinement and brush additions, clipped to the canvas.
pub fn refine_area(content: Rect, canvas: Rect, p: &RefineParams, brush: Option<&EdgeBrush>) -> Rect {
let content = content.intersect(&canvas);
if content.is_empty() {
@@ -410,6 +403,7 @@ pub fn refine_area(content: Rect, canvas: Rect, p: &RefineParams, brush: Option<
/// Refines a selection mask. `mask(r)` returns coverage over `r` (row-major); `content` bounds
/// its non-zero pixels; the result is clipped to `canvas`. `sampler` supplies the guide image
/// (only read when `radius > 0`). Returns `None` when nothing remains selected.
#[cfg(test)]
pub fn refine_mask(sampler: &dyn Sampler, mask: &(dyn Fn(Rect) -> Vec<f32> + Sync), content: Rect, canvas: Rect, p: &RefineParams) -> Option<Region> {
refine_mask_with(sampler, mask, content, canvas, p, None)
}
@@ -417,6 +411,7 @@ pub fn refine_mask(sampler: &dyn Sampler, mask: &(dyn Fn(Rect) -> Vec<f32> + Syn
/// [`refine_mask`] with the Refine Edge Brush: where `brush` adds, coverage comes from the image
/// (the area can reach beyond the edge-detection band); where it restores, the original edge
/// stays. The sampler is read wherever the brush adds, whatever the radius.
#[cfg(test)]
pub fn refine_mask_brushed(
sampler: &dyn Sampler,
mask: &(dyn Fn(Rect) -> Vec<f32> + Sync),
@@ -428,6 +423,8 @@ pub fn refine_mask_brushed(
refine_mask_with(sampler, mask, content, canvas, p, brush)
}
/// Internal helper for dense refinement test oracles.
#[cfg(test)]
fn refine_mask_with(
sampler: &dyn Sampler,
mask: &(dyn Fn(Rect) -> Vec<f32> + Sync),
@@ -497,7 +494,245 @@ fn refine_mask_with(
crate::segment::trim_region(Region { bbox, mask: out })
}
/// Refines sparse coverage without materializing its bounding rectangle.
/// Missing input tiles use the mask's default; output is zero outside the canvas.
pub fn refine_surface(
sampler: &dyn Sampler,
mask: &Surface,
canvas: Rect,
p: &RefineParams,
brush: Option<&EdgeBrush>,
) -> Result<Option<Surface>, photocraft_raster::AllocationError> {
refine_surface_with_interrupt(sampler, mask, canvas, p, brush, &photocraft_raster::Interrupt::NONE)
}
/// Interruptible refinement. Cancellation discards the partial result and returns `Ok(None)`.
/// Callers distinguish cancellation from an empty mask through `interrupt.cancelled()`.
pub fn refine_surface_with_interrupt(
sampler: &dyn Sampler,
mask: &Surface,
canvas: Rect,
p: &RefineParams,
brush: Option<&EdgeBrush>,
interrupt: &photocraft_raster::Interrupt<'_>,
) -> Result<Option<Surface>, photocraft_raster::AllocationError> {
if interrupt.cancelled() {
return Ok(None);
}
use std::collections::{BTreeSet, HashMap};
use std::sync::Arc;
let default = mask.default_pixel().first().copied().unwrap_or(0.0);
let content = if default > 0.0 { canvas } else { mask.content_bounds().intersect(&canvas) };
let bbox = refine_area(content, canvas, p, brush);
if bbox.is_empty() {
return Ok(None);
}
let halo = p.halo().saturating_add(brush.map_or(0, |b| (b.window() as i32).saturating_mul(4)));
// Preserve the dense implementation's block origin, including non-aligned selections.
let mut blocks = BTreeSet::new();
let mut include = |area: Rect| -> Result<(), photocraft_raster::AllocationError> {
let area = area.intersect(&bbox);
if area.is_empty() {
return Ok(());
}
let x0 = (i64::from(area.x0) - i64::from(bbox.x0)) / i64::from(TILE);
let y0 = (i64::from(area.y0) - i64::from(bbox.y0)) / i64::from(TILE);
let x1 = (i64::from(area.x1) - 1 - i64::from(bbox.x0)) / i64::from(TILE);
let y1 = (i64::from(area.y1) - 1 - i64::from(bbox.y0)) / i64::from(TILE);
for y in y0..=y1 {
for x in x0..=x1 {
if interrupt.cancelled() {
return Ok(());
}
blocks.insert((y, x));
if blocks.len() > 1_048_576 {
return Err(photocraft_raster::AllocationError::NotEnoughMemory { bytes: blocks.len().saturating_mul(64) });
}
}
}
Ok(())
};
if default > 0.0 {
include(bbox)?;
} else {
for (tc, _) in mask.tiles() {
if interrupt.cancelled() {
return Ok(None);
}
include(tc.rect().inflate(p.spread()))?;
}
}
if let Some(b) = brush {
for tc in b.tile_coords() {
if interrupt.cancelled() {
return Ok(None);
}
include(tc.rect().inflate(p.spread()))?;
}
}
if interrupt.cancelled() {
return Ok(None);
}
let mut uniform: HashMap<usize, Option<f32>> = HashMap::new();
let mut output = Surface::new(PixelFormat::GRAY8);
let solid = output.solid_tile(&[1.0]);
// Only mixed blocks need expensive filtering. Keep their concurrency and aggregate
// scratch estimate bounded; uniform interiors still take the cheap serial path.
struct Work {
core: Rect,
input: Rect,
painted: Option<Vec<f32>>,
}
let run = |work: &Work| {
let (t, r) = (work.core, work.input);
if interrupt.cancelled() {
return (t, Vec::new());
}
let m = crate::selection::mask_from_surface(Some(mask), r);
let (rw, rh) = (r.width() as usize, r.height() as usize);
let adds = work.painted.as_ref().is_some_and(|v| v.iter().any(|v| *v > 0.0));
// An allocated tile can be mixed while this block's clipped halo is constant.
// Preserve the dense path's local check before sampling the guide image.
let first = m.first().copied().unwrap_or(0.0);
if !adds && (first == 0.0 || first == 1.0) && m.iter().all(|v| *v == first) {
return (t, vec![(first * 255.0).round() as u8; t.width() as usize * t.height() as usize]);
}
let img = (p.uses_image() || adds).then(|| sampler.rgb(r));
let bb = match (&work.painted, brush) {
(Some(v), Some(b)) => Some(BrushBuf { v, window: b.window() }),
_ => None,
};
let refined = refine_buffer_with(img.as_ref(), &m, rw, rh, p, bb);
let mut data = Vec::with_capacity(t.width() as usize * t.height() as usize);
for yy in t.y0..t.y1 {
let start = (yy - r.y0) as usize * rw + (t.x0 - r.x0) as usize;
let end = start + t.width() as usize;
if let Some(row) = refined.get(start..end) {
data.extend(row.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8));
}
}
(t, data)
};
let flush = |pending: &mut Vec<Work>, output: &mut Surface| -> Result<(), photocraft_raster::AllocationError> {
if interrupt.cancelled() {
pending.clear();
return Ok(());
}
#[cfg(not(target_arch = "wasm32"))]
let results: Vec<_> = {
use rayon::prelude::*;
pending.par_iter().map(&run).collect()
};
#[cfg(target_arch = "wasm32")]
let results: Vec<_> = pending.iter().map(&run).collect();
if interrupt.cancelled() {
pending.clear();
return Ok(());
}
for (t, data) in results {
if data.iter().any(|v| *v != 0) {
output.try_write_interleaved(t, &data)?;
for tc in t.tiles() {
if output.tile(tc).is_some_and(|tile| tile.bytes().iter().all(|v| *v == 255)) {
output.put_tiles([(tc, solid.clone())]);
}
}
}
}
pending.clear();
Ok(())
};
let mut pending = Vec::new();
let mut pending_bytes = 0usize;
const MAX_SCRATCH_BYTES: usize = 512 * 1024 * 1024;
// A larger queue lets Rayon balance cheap and expensive diagonal blocks.
// The scratch budget still limits how many blocks can enter a batch.
const MAX_FILTER_BATCH: usize = 24;
for (by, bx) in blocks {
if interrupt.cancelled() {
return Ok(None);
}
let x = (i64::from(bbox.x0) + bx * i64::from(TILE)) as i32;
let y = (i64::from(bbox.y0) + by * i64::from(TILE)) as i32;
let t = Rect::new(x, y, x.saturating_add(TILE).min(bbox.x1), y.saturating_add(TILE).min(bbox.y1));
let r = t.inflate(halo).intersect(&canvas);
let scratch_pixels = (r.width() as usize).checked_mul(r.height() as usize).ok_or(photocraft_raster::AllocationError::SizeOverflow)?;
let scratch_bytes = scratch_pixels.checked_mul(256).ok_or(photocraft_raster::AllocationError::SizeOverflow)?;
let painted = brush
.filter(|b| b.tile_coords().any(|tc| !tc.rect().intersect(&r).is_empty()))
.map(|b| {
if scratch_bytes > MAX_SCRATCH_BYTES {
return Err(photocraft_raster::AllocationError::NotEnoughMemory { bytes: scratch_bytes });
}
Ok(b.read(r))
})
.transpose()?;
let adds = painted.as_ref().is_some_and(|v| v.iter().any(|v| *v > 0.0));
let mut constant = None;
let mut all_uniform = true;
for tc in r.tiles() {
let value = if let Some(tile) = mask.tile(tc) {
let key = Arc::as_ptr(tile) as usize;
*uniform.entry(key).or_insert_with(|| {
let first = mask.sample_channel(tc.rect().x0, tc.rect().y0, 0);
let fmt = mask.format();
let bytes = fmt.bytes_per_pixel();
let binding = tile.bytes();
let first_pixel = binding.get(..bytes)?;
binding.chunks_exact(bytes).all(|p| p == first_pixel).then_some(first)
})
} else {
Some(default)
};
let Some(value) = value else {
all_uniform = false;
break;
};
if constant.is_some_and(|v| v != value) {
all_uniform = false;
break;
}
constant = Some(value);
}
if !adds && all_uniform && matches!(constant, Some(0.0)) {
continue;
}
if !adds && all_uniform && matches!(constant, Some(1.0)) {
let tc = photocraft_geom::TileCoord::containing(t.x0, t.y0);
if tc.rect() == t {
output.put_tiles([(tc, solid.clone())]);
continue;
}
output.fill_rect(t, &[1.0]);
} else {
if scratch_bytes > MAX_SCRATCH_BYTES {
return Err(photocraft_raster::AllocationError::NotEnoughMemory { bytes: scratch_bytes });
}
if !pending.is_empty() && (pending.len() >= MAX_FILTER_BATCH || pending_bytes.saturating_add(scratch_bytes) > MAX_SCRATCH_BYTES) {
flush(&mut pending, &mut output)?;
pending_bytes = 0;
}
pending_bytes = pending_bytes.saturating_add(scratch_bytes);
pending.push(Work { core: t, input: r, painted });
continue;
}
// Canonical tiles can straddle two legacy blocks. Share them as soon as complete,
// keeping solid interiors compact even when the processing origin is unaligned.
for tc in t.tiles() {
if output.tile(tc).is_some_and(|tile| tile.bytes().iter().all(|v| *v == 255)) {
output.put_tiles([(tc, solid.clone())]);
}
}
}
flush(&mut pending, &mut output)?;
if interrupt.cancelled() {
return Ok(None);
}
Ok((output.tile_count() > 0).then_some(output))
}
/// A mask reader over a [`Region`] (zero outside its box).
#[cfg(test)]
pub fn region_reader(reg: &Region) -> impl Fn(Rect) -> Vec<f32> + Sync + '_ {
move |r: Rect| {
let mut v = Vec::with_capacity(r.width() as usize * r.height() as usize);
@@ -511,6 +746,7 @@ pub fn region_reader(reg: &Region) -> impl Fn(Rect) -> Vec<f32> + Sync + '_ {
}
/// A mask reader over a selection surface (channel 0).
#[cfg(test)]
pub fn surface_reader(s: &Surface) -> impl Fn(Rect) -> Vec<f32> + Sync + '_ {
move |r: Rect| crate::selection::mask_from_surface(Some(s), r)
}
@@ -522,6 +758,8 @@ pub fn region_surface(reg: &Region) -> Surface {
s
}
/// Partitions an area into fixed-size tile rectangles.
#[cfg(test)]
fn tiles_of(area: Rect) -> Vec<Rect> {
let mut tiles = Vec::new();
let mut y = area.y0;
@@ -536,6 +774,8 @@ fn tiles_of(area: Rect) -> Vec<Rect> {
tiles
}
/// Maps a function over tile rectangles in parallel on native targets.
#[cfg(test)]
fn par_tiles<T: Send>(tiles: &[Rect], f: impl Fn(&Rect) -> Option<T> + Sync + Send) -> Vec<T> {
#[cfg(not(target_arch = "wasm32"))]
{
@@ -552,6 +792,7 @@ fn par_tiles<T: Send>(tiles: &[Rect], f: impl Fn(&Rect) -> Option<T> + Sync + Se
/// average colour of nearby fully selected pixels (normalised convolution with a box of radius
/// `max(radius, 3)`, widened ×4 where no such pixel is near), by `amount`% scaled by its
/// transparency. Works in the surface's own colour channels (any model / depth).
#[cfg(test)]
pub fn decontaminate(src: &Surface, alpha: &Region, radius: f32, amount: f32) -> Surface {
let fmt = src.format();
let n = fmt.channels();
@@ -610,8 +851,76 @@ pub fn decontaminate(src: &Surface, alpha: &Region, radius: f32, amount: f32) ->
out
}
/// Decontaminates sparse GRAY8 alpha, processing one fringe tile at a time.
pub fn decontaminate_surface(src: &Surface, alpha: &Surface, radius: f32, amount: f32) -> Surface {
let fmt = src.format();
let n = fmt.channels();
let nc = n - fmt.alpha as usize;
let rad = radius.max(3.0).ceil() as usize;
let halo = (rad * 4) as i32;
let amount = amount.clamp(0.0, 100.0) / 100.0;
let soft = |a: f32| a > 0.02 && a < 0.98;
let run = |t: &Rect| {
if !(t.y0..t.y1).any(|y| (t.x0..t.x1).any(|x| soft(alpha.sample_channel(x, y, 0)))) {
return None;
}
let r = t.inflate(halo);
let (rw, rh) = (r.width() as usize, r.height() as usize);
let mut px = Vec::new();
src.read_region_into(r, &mut px);
let al: Vec<f32> = (r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| alpha.sample_channel(x, y, 0))).collect();
let wts: Vec<f32> = (0..rw * rh).map(|i| if al[i] >= 0.98 { if fmt.alpha { px[i * n + n - 1] } else { 1.0 } } else { 0.0 }).collect();
let den = box_mean(&wts, rw, rh, rad);
let den2 = box_mean(&wts, rw, rh, rad * 4);
let mut nums = Vec::with_capacity(nc);
for c in 0..nc {
let v: Vec<f32> = (0..rw * rh).map(|i| wts[i] * px[i * n + c]).collect();
nums.push((box_mean(&v, rw, rh, rad), box_mean(&v, rw, rh, rad * 4)));
}
let (cx, cy, cw, ch) = ((t.x0 - r.x0) as usize, (t.y0 - r.y0) as usize, t.width() as usize, t.height() as usize);
let mut out = Vec::with_capacity(cw * ch * n);
for yy in 0..ch {
for xx in 0..cw {
let i = (cy + yy) * rw + cx + xx;
let a = al[i];
let k = if soft(a) { amount * ((1.0 - a) * 4.0).clamp(0.0, 1.0) } else { 0.0 };
for c in 0..n {
let v = px[i * n + c];
if c >= nc || k == 0.0 {
out.push(v);
continue;
}
let f = if den[i] > 1e-4 {
nums[c].0[i] / den[i]
} else if den2[i] > 1e-4 {
nums[c].1[i] / den2[i]
} else {
v
};
out.push(v + (f - v) * k);
}
}
}
Some((*t, out))
};
let mut out = src.clone();
let mut hard = std::collections::HashMap::new();
for (tc, tile) in alpha.tiles() {
let key = std::sync::Arc::as_ptr(tile) as usize;
let uniform_hard = *hard.entry(key).or_insert_with(|| tile.bytes().iter().all(|v| *v == 0 || *v == 255));
if uniform_hard {
continue;
}
if let Some((t, data)) = run(&tc.rect()) {
out.write_region(t, &data);
}
}
out
}
/// The pixels of `src` with their alpha multiplied by `alpha` (zero outside its box); the result
/// has an alpha channel even if `src` does not.
#[cfg(test)]
pub fn masked_copy(src: &Surface, alpha: &Region) -> Surface {
let fmt = src.format();
let n = fmt.channels();
@@ -644,12 +953,182 @@ pub fn masked_copy(src: &Surface, alpha: &Region) -> Surface {
out
}
/// Copies selected pixels without collecting buffers across the alpha bounds.
/// `alpha` is a zero-default GRAY8 surface, as returned by [`refine_surface`].
/// Explicit `bounds` preserve source colour under zero alpha inside the finite mask extent.
pub fn masked_copy_surface(src: &Surface, alpha: &Surface, bounds: Rect) -> Surface {
let fmt = src.format();
let n = fmt.channels();
let output_fmt = PixelFormat { alpha: true, ..fmt };
let mut out = Surface::new(output_fmt);
let mut default = src.default_pixel();
if !fmt.alpha {
default.push(1.0);
}
let default_tile = out.solid_tile(&default);
let mut full = std::collections::HashMap::new();
for (tc, tile) in alpha.tiles() {
let key = std::sync::Arc::as_ptr(tile) as usize;
let opaque = bounds.contains_rect(&tc.rect()) && *full.entry(key).or_insert_with(|| tile.bytes().iter().all(|v| *v == 255));
if opaque && fmt.alpha {
if let Some(t) = src.tile(*tc) {
out.put_tiles([(*tc, t.clone())]);
} else if default.last().is_some_and(|a| *a > 0.0) {
out.put_tiles([(*tc, default_tile.clone())]);
}
continue;
}
if opaque && src.tile(*tc).is_none() {
out.put_tiles([(*tc, default_tile.clone())]);
continue;
}
let r = tc.rect().intersect(&bounds);
let px = src.read_region(r);
let al = alpha.read_region(r);
let mut data = Vec::with_capacity(al.len().saturating_mul(output_fmt.channels()));
let mut any = false;
for (p, a) in px.chunks_exact(n).zip(al) {
let colors = n - usize::from(fmt.alpha);
data.extend_from_slice(p.get(..colors).unwrap_or(&[]));
let sa = if fmt.alpha { p.last().copied().unwrap_or(0.0) } else { 1.0 };
data.push(sa * a);
any |= sa * a > 0.0;
}
if any {
out.write_region(r, &data);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::segment::ImageSampler;
use photocraft_color::SampleType;
#[test]
fn sparse_refine_matches_dense_at_unaligned_origins() {
let canvas = Rect::new(-15, -9, 540, 280);
let source = Surface::new(PixelFormat::RGBA8);
let sampler = crate::segment::SurfaceSampler(&source);
let mut mask = Surface::new(PixelFormat::GRAY8);
mask.fill_rect(Rect::new(-3, 4, 310, 210), &[1.0]);
mask.fill_rect(Rect::new(64, 50, 130, 90), &[0.25]);
for params in [RefineParams::default(), RefineParams { radius: 4.0, smart_radius: true, smooth: 10.0, feather: 2.0, contrast: 20.0, shift_edge: -15.0 }]
{
let dense = refine_mask(&sampler, &surface_reader(&mask), mask.content_bounds(), canvas, &params).expect("dense");
let sparse = refine_surface(&sampler, &mask, canvas, &params, None).expect("allocation").expect("sparse");
assert_eq!(sparse.to_interleaved(canvas), region_surface(&dense).to_interleaved(canvas));
}
}
#[test]
fn refinement_discards_partial_output_after_cancellation() {
use std::sync::atomic::{AtomicBool, Ordering};
struct CancelGuide<'a>(&'a AtomicBool);
impl Sampler for CancelGuide<'_> {
fn rgba(&self, r: Rect) -> Vec<[f32; 4]> {
self.0.store(true, Ordering::Relaxed);
vec![[0.2, 0.4, 0.7, 1.0]; r.width() as usize * r.height() as usize]
}
}
let cancelled = AtomicBool::new(false);
let cancel = || cancelled.load(Ordering::Relaxed);
let interrupt = photocraft_raster::Interrupt::cancel_only(&cancel);
let canvas = Rect::new(0, 0, 1024, 1024);
let mut mask = Surface::new(PixelFormat::GRAY8);
mask.fill_rect(Rect::new(100, 100, 800, 800), &[1.0]);
let before = mask.clone();
let params = RefineParams { radius: 4.0, ..Default::default() };
let result = refine_surface_with_interrupt(&CancelGuide(&cancelled), &mask, canvas, &params, None, &interrupt).unwrap();
assert!(cancelled.load(Ordering::Relaxed), "guide must trigger cancellation");
assert!(result.is_none(), "partial refinement must be discarded");
assert_eq!(mask, before);
// Multi-batch cancellation: an earlier batch flushes and writes output, and a later
// guide sample triggers cancellation. The partial output must be discarded.
let multi_cancelled = AtomicBool::new(false);
let multi_cancel = || multi_cancelled.load(Ordering::Relaxed);
let multi_interrupt = photocraft_raster::Interrupt::cancel_only(&multi_cancel);
struct MultiBatchGuide<'a> {
cancelled: &'a AtomicBool,
samples: std::sync::atomic::AtomicUsize,
cancel_after: usize,
}
impl Sampler for MultiBatchGuide<'_> {
fn rgba(&self, r: Rect) -> Vec<[f32; 4]> {
let prev = self.samples.fetch_add(1, Ordering::SeqCst);
if prev >= self.cancel_after {
self.cancelled.store(true, Ordering::Relaxed);
}
vec![[0.2, 0.4, 0.7, 1.0]; r.width() as usize * r.height() as usize]
}
}
// 32 mixed blocks along x exceed MAX_FILTER_BATCH (24), forcing at least one completed flush.
let multi_canvas = Rect::new(0, 0, 32 * 256, 256);
let mut multi_mask = Surface::new(PixelFormat::GRAY8);
multi_mask.fill_rect(Rect::new(0, 0, 32 * 256, 128), &[1.0]);
let multi_before = multi_mask.clone();
let multi_guide = MultiBatchGuide { cancelled: &multi_cancelled, samples: std::sync::atomic::AtomicUsize::new(0), cancel_after: 24 };
let multi_result = refine_surface_with_interrupt(&multi_guide, &multi_mask, multi_canvas, &params, None, &multi_interrupt).unwrap();
assert!(multi_cancelled.load(Ordering::Relaxed), "later guide call must trigger cancellation");
assert!(multi_guide.samples.load(Ordering::SeqCst) > 24, "cancellation must occur after the initial batch");
assert!(multi_result.is_none(), "partial output from completed flush must be discarded upon cancellation");
assert_eq!(multi_mask, multi_before);
}
#[test]
fn constant_clipped_halo_does_not_sample_a_mixed_tile_guide() {
struct NoGuide;
impl Sampler for NoGuide {
fn rgba(&self, _: Rect) -> Vec<[f32; 4]> {
panic!("constant halo must not read the guide");
}
}
let canvas = Rect::new(10, 10, 30, 30);
let mut mask = Surface::new(PixelFormat::GRAY8);
mask.fill_rect(canvas, &[1.0]);
let params = RefineParams { radius: 4.0, feather: 1.0, ..Default::default() };
let output = refine_surface(&NoGuide, &mask, canvas, &params, None).unwrap().unwrap();
assert!(output.to_interleaved(canvas).iter().all(|v| *v == 255));
}
#[test]
fn sparse_brushed_refine_matches_dense_for_add_and_restore() {
let canvas = Rect::new(-10, -5, 530, 270);
let source = Surface::with_default(PixelFormat::RGBA8, &[0.2, 0.4, 0.7, 1.0]);
let sampler = crate::segment::SurfaceSampler(&source);
let mut mask = Surface::new(PixelFormat::GRAY8);
mask.fill_rect(Rect::new(12, 17, 300, 230), &[1.0]);
mask.fill_rect(Rect::new(80, 70, 140, 110), &[0.0]);
let strokes = [
EdgeStroke { points: vec![(295.0, 40.0), (320.0, 200.0)], size: 20.0, add: true },
EdgeStroke { points: vec![(310.0, 100.0), (300.0, 160.0)], size: 12.0, add: false },
];
let brush = EdgeBrush::new(&strokes, canvas).unwrap();
let params = RefineParams { radius: 4.0, smart_radius: true, feather: 2.0, smooth: 5.0, ..Default::default() };
let dense = refine_mask_brushed(&sampler, &surface_reader(&mask), mask.content_bounds(), canvas, &params, Some(&brush)).unwrap();
let sparse = refine_surface(&sampler, &mask, canvas, &params, Some(&brush)).unwrap().unwrap();
assert_eq!(sparse.to_interleaved(canvas), region_surface(&dense).to_interleaved(canvas));
}
#[test]
fn sparse_refine_handles_reveal_all_without_input_tiles() {
let canvas = Rect::new(0, 0, 100_000, 100_000);
let source = Surface::new(PixelFormat::RGBA8);
let sampler = crate::segment::SurfaceSampler(&source);
let mask = Surface::with_default(PixelFormat::GRAY8, &[1.0]);
let output = refine_surface(&sampler, &mask, canvas, &RefineParams::default(), None).expect("allocation").expect("selected");
assert_eq!(output.sample_channel(0, 0, 0), 1.0);
assert_eq!(output.sample_channel(99_999, 99_999, 0), 1.0);
assert_eq!(output.sample_channel(100_000, 100_000, 0), 0.0);
assert!(std::sync::Arc::ptr_eq(
output.tile(photocraft_geom::TileCoord::new(10, 10)).unwrap(),
output.tile(photocraft_geom::TileCoord::new(20, 20)).unwrap()
));
}
/// Horizontal blend from `a` (left) to `b` (right) across a Gaussian-blurred edge at x = 40.
fn blurred_edge(w: usize, h: usize, sigma: f32) -> (RgbImage, Vec<f32>) {
let (a, b) = ([0.9f32, 0.8, 0.2], [0.1f32, 0.2, 0.6]);
@@ -813,6 +1292,13 @@ mod tests {
.collect();
let a = Region { bbox: Rect::new(0, 0, 20, 8), mask };
let d = decontaminate(&s, &a, 3.0, 100.0);
let sparse_alpha = region_surface(&a);
let sparse_d = decontaminate_surface(&s, &sparse_alpha, 3.0, 100.0);
for (expected, actual) in d.read_region(a.bbox).iter().zip(sparse_d.read_region(a.bbox)) {
assert!((expected - actual).abs() < 1e-5, "{sample:?}: decontamination changed");
}
let sparse_c = masked_copy_surface(&d, &sparse_alpha, a.bbox);
assert_eq!(sparse_c.to_interleaved(a.bbox), masked_copy(&d, &a).to_interleaved(a.bbox));
let p = d.pixel(10, 4);
assert!(p[0] > 0.9 && p[2] < 0.1, "{sample:?}: {p:?}");
assert_eq!(d.pixel(15, 4), s.pixel(15, 4));
+6
View File
@@ -216,6 +216,12 @@ fn segments(points: &[(f32, f32)], r: f32, clip: Rect) -> impl Iterator<Item = (
}
impl EdgeBrush {
/// Coordinates actually painted by these strokes, including restore coverage.
/// Iterating these avoids visiting the empty space between distant strokes.
pub fn tile_coords(&self) -> impl Iterator<Item = TileCoord> + '_ {
self.tiles.keys().copied()
}
/// Rasterises `strokes` (clipped to `canvas`), tile by tile. `None` when they paint nothing,
/// sweep more than [`MAX_SWEPT_AREA`] or paint more than [`MAX_PAINTED_TILES`] tiles.
pub fn new(strokes: &[EdgeStroke], canvas: Rect) -> Option<EdgeBrush> {
+18 -10
View File
@@ -614,7 +614,7 @@ impl Solver<'_> {
// `FUSE_BAND` of a seam are solved for.
const FUSE_BAND: usize = 16;
let seams: Vec<bool> = (0..w * h).map(|i| div[i * ch..(i + 1) * ch].iter().any(|v| *v != 0.0)).collect();
let band = crate::remove::dilate(w, h, &seams, FUSE_BAND);
let band = crate::square_dilate::dilate_square(w, h, &seams, FUSE_BAND);
let halves: [Vec<usize>; 2] = [0, 1].map(|colour| copied.iter().map(|(p, _, _)| *p).filter(|&p| band[p] && (p % w + p / w) % 2 == colour).collect());
let mut delta = vec![0.0f32; w * h * ch];
let mut next: Vec<f32> = Vec::new();
@@ -846,15 +846,7 @@ pub fn complete_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool],
return Ok(None);
}
let ri = r as i32;
let mut targets = vec![false; lw * lh];
for (i, _) in level.hole.iter().enumerate().filter(|(_, m)| **m) {
let (x, y) = ((i % lw) as i32, (i / lw) as i32);
for ty in (y - ri).max(0)..=(y + ri).min(lh as i32 - 1) {
for tx in (x - ri).max(0)..=(x + ri).min(lw as i32 - 1) {
targets[ty as usize * lw + tx as usize] = true;
}
}
}
let targets = crate::square_dilate::dilate_square(lw, lh, &level.hole, r);
let mut s = Solver { w: lw, h: lh, cha, ch, r: ri, img: level.img.clone(), hole: &level.hole, valid, valid_list, targets, ctl };
let mut nnf = vec![(0i32, 0i32); lw * lh];
let mut cost = vec![f32::INFINITY; lw * lh];
@@ -1012,4 +1004,20 @@ mod tests {
let progress = |_: f32| {};
assert_eq!(complete_with(w, h, 1, &img, &hole, &Params::default(), &Interrupt::new(&cancel, &progress)), Err(Cancelled));
}
/// Every cancellation check point stops the completion: moving or dropping one is a change.
#[test]
fn cancelling_at_each_early_check_is_an_error() {
use std::sync::atomic::{AtomicUsize, Ordering};
let (w, h) = (64, 64);
let img: Vec<f32> = (0..w * h).map(noise).collect();
let hole: Vec<bool> = (0..w * h).map(|i| (20..40).contains(&(i % w)) && (20..40).contains(&(i / w))).collect();
for k in 1..=6 {
let calls = AtomicUsize::new(0);
let cancel = || calls.fetch_add(1, Ordering::Relaxed) + 1 >= k;
let progress = |_: f32| {};
let got = complete_with(w, h, 1, &img, &hole, &Params::default(), &Interrupt::new(&cancel, &progress));
assert_eq!(got, Err(Cancelled), "cancelling at check {k}");
}
}
}
+3 -3
View File
@@ -40,9 +40,9 @@ use crate::transform::Homography;
mod blend;
pub use blend::{Geometry, Photometric, RoiImage, blend_levels, multiband, photometric, seam_labels};
pub use blend::{BAND_BUDGET, Geometry, Photometric, RoiImage, band_bytes_bound, blend_levels, multiband, photometric, seam_labels, try_multiband_batched};
#[cfg(test)]
use blend::{expand, multiband_batched, push_pull, reduce};
use blend::{band_cost, expand, push_pull, reduce};
/// Photomerge layouts.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -929,7 +929,7 @@ mod tests {
.collect()
})
.collect();
let (out, cov) = multiband(w, h, &imgs, &wts, 4);
let (out, cov) = multiband(w, h, &imgs, &wts, 4).unwrap();
let row: Vec<f32> = (0..w).map(|x| out[20 * w + x]).collect();
assert!((row[2] - 0.3).abs() < 0.02 && (row[w - 3] - 0.7).abs() < 0.02, "{row:?}");
let max_jump = row.windows(2).map(|p| (p[1] - p[0]).abs()).fold(0.0f32, f32::max);
+143 -84
View File
@@ -1,4 +1,17 @@
use super::{Normal, Placement, par_map, par_rows};
use photocraft_raster::AllocationError;
fn alloc_plane(len: usize) -> Result<Vec<f32>, AllocationError> {
let bytes = len.checked_mul(std::mem::size_of::<f32>()).ok_or(AllocationError::SizeOverflow)?;
let mut v = Vec::new();
v.try_reserve_exact(len).map_err(|_| AllocationError::NotEnoughMemory { bytes })?;
v.resize(len, 0.0);
Ok(v)
}
fn plane_len(w: usize, h: usize, ch: usize) -> Result<usize, AllocationError> {
w.checked_mul(h).and_then(|n| n.checked_mul(ch)).ok_or(AllocationError::SizeOverflow)
}
// ---------- photometric ----------
@@ -311,29 +324,22 @@ pub fn blend_levels(w: usize, h: usize) -> usize {
n
}
/// `a[i] ⊕ b[i]` for every `i` (the shorter length wins, like `zip`), in parallel on native
/// targets. Purely elementwise, so the result is the serial result bit for bit.
fn par_zip(a: &[f32], b: &[f32], f: impl Fn(f32, f32) -> f32 + Sync + Send) -> Vec<f32> {
/// `a[i] = f(a[i], b[i])` for every `i` (the shorter length wins), in place and in parallel on
/// native targets. Purely elementwise, so the result is the serial result bit for bit.
fn par_zip_assign(a: &mut [f32], b: &[f32], f: impl Fn(f32, f32) -> f32 + Sync + Send) {
const CHUNK: usize = 1 << 14;
let mut out = vec![0.0f32; a.len().min(b.len())];
let fill = |o: &mut [f32], a: &[f32], b: &[f32]| {
for ((o, &a), &b) in o.iter_mut().zip(a).zip(b) {
*o = f(a, b);
let fill = |a: &mut [f32], b: &[f32]| {
for (a, &b) in a.iter_mut().zip(b) {
*a = f(*a, b);
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
out.par_chunks_mut(CHUNK).zip(a.par_chunks(CHUNK)).zip(b.par_chunks(CHUNK)).for_each(|((o, a), b)| fill(o, a, b));
a.par_chunks_mut(CHUNK).zip(b.par_chunks(CHUNK)).for_each(|(a, b)| fill(a, b));
}
#[cfg(target_arch = "wasm32")]
out.chunks_mut(CHUNK).zip(a.chunks(CHUNK)).zip(b.chunks(CHUNK)).for_each(|((o, a), b)| fill(o, a, b));
out
}
/// `f(a[i])` for every `i`, in parallel on native targets (elementwise, so bit for bit serial).
fn par_map1(a: &[f32], f: impl Fn(f32) -> f32 + Sync + Send) -> Vec<f32> {
par_zip(a, a, |x, _| f(x))
a.chunks_mut(CHUNK).zip(b.chunks(CHUNK)).for_each(|(a, b)| fill(a, b));
}
// `reduce`, `expand` and `push_pull` run one pyramid level for one image at a time, and the
@@ -342,11 +348,11 @@ fn par_map1(a: &[f32], f: impl Fn(f32) -> f32 + Sync + Send) -> Vec<f32> {
// the one-thread loops they replace (rows never read each other's output), so the result is
// bit for bit unchanged.
pub(super) fn reduce(w: usize, h: usize, ch: usize, src: &[f32]) -> (usize, usize, Vec<f32>) {
pub(super) fn reduce(w: usize, h: usize, ch: usize, src: &[f32]) -> Result<(usize, usize, Vec<f32>), AllocationError> {
let (nw, nh) = (w.div_ceil(2), h.div_ceil(2));
let k = [1.0f32, 4.0, 6.0, 4.0, 1.0];
// Separable 5-tap binomial, then decimate.
let mut tmp = vec![0.0f32; nw * h * ch];
let mut tmp = alloc_plane(plane_len(nw, h, ch)?)?;
par_rows(&mut tmp, nw, ch, |y, row| {
let line = &src[y * w * ch..(y + 1) * w * ch];
for x in 0..nw {
@@ -361,7 +367,7 @@ pub(super) fn reduce(w: usize, h: usize, ch: usize, src: &[f32]) -> (usize, usiz
}
}
});
let mut out = vec![0.0f32; nw * nh * ch];
let mut out = alloc_plane(plane_len(nw, nh, ch)?)?;
par_rows(&mut out, nw, ch, |y, row| {
// Each output row is five source rows added in tap order: the same sum per element as
// before (`0 + k0·a + k1·b + …`), but over whole rows so it vectorises.
@@ -375,21 +381,21 @@ pub(super) fn reduce(w: usize, h: usize, ch: usize, src: &[f32]) -> (usize, usiz
*o /= 16.0;
}
});
(nw, nh, out)
Ok((nw, nh, out))
}
pub(super) fn expand(sw: usize, sh: usize, ch: usize, src: &[f32], w: usize, h: usize) -> Vec<f32> {
pub(super) fn expand(sw: usize, sh: usize, ch: usize, src: &[f32], w: usize, h: usize) -> Result<Vec<f32>, AllocationError> {
// Bilinear upsampling at half-pixel-aligned positions.
let mut out = vec![0.0f32; w * h * ch];
let mut out = alloc_plane(plane_len(w, h, ch)?)?;
// Source columns and weights depend on x only: worked out once instead of for every row.
// The clamped position is never negative, so truncating is `floor` (and spares the libm call).
let cols: Vec<(usize, usize, f32)> = (0..w)
.map(|x| {
let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sw - 1) as f32);
let x0 = fx as usize;
(x0 * ch, (x0 + 1).min(sw - 1) * ch, fx - x0 as f32)
})
.collect();
let mut cols = Vec::new();
cols.try_reserve_exact(w).map_err(|_| AllocationError::NotEnoughMemory { bytes: w.saturating_mul(std::mem::size_of::<(usize, usize, f32)>()) })?;
cols.extend((0..w).map(|x| {
let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sw - 1) as f32);
let x0 = fx as usize;
(x0 * ch, (x0 + 1).min(sw - 1) * ch, fx - x0 as f32)
}));
par_rows(&mut out, w, ch, |y, row| {
let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sh - 1) as f32);
let y0 = fy as usize;
@@ -404,20 +410,20 @@ pub(super) fn expand(sw: usize, sh: usize, ch: usize, src: &[f32], w: usize, h:
}
}
});
out
Ok(out)
}
/// Fills pixels with zero `wt` from their surroundings (push-pull), so pyramid levels don't
/// pull in black from outside an image.
pub(super) fn push_pull(w: usize, h: usize, ch: usize, px: &mut [f32], wt: &[f32]) {
pub(super) fn push_pull(w: usize, h: usize, ch: usize, px: &mut [f32], wt: &[f32]) -> Result<(), AllocationError> {
if w <= 1 && h <= 1 {
return;
return Ok(());
}
let (nw, nh) = (w.div_ceil(2), h.div_ceil(2));
// Coarse sums. A coarse row is fed by two fine rows; walking them in raster order adds each
// coarse cell's (up to) four fine pixels in the order the serial scatter-add did, from zero.
let mut cpx = vec![0.0f32; nw * nh * ch];
let mut cw = vec![0.0f32; nw * nh];
let mut cpx = alloc_plane(plane_len(nw, nh, ch)?)?;
let mut cw = alloc_plane(plane_len(nw, nh, 1)?)?;
par_rows(&mut cw, nw, 1, |cy, row| {
for y in 2 * cy..(2 * cy + 2).min(h) {
for (x, &a) in wt[y * w..(y + 1) * w].iter().enumerate() {
@@ -454,7 +460,7 @@ pub(super) fn push_pull(w: usize, h: usize, ch: usize, px: &mut [f32], wt: &[f32
}
}
if cw.contains(&0.0) && cw.iter().any(|v| *v > 0.0) {
push_pull(nw, nh, ch, &mut cpx, &cw);
push_pull(nw, nh, ch, &mut cpx, &cw)?;
}
par_rows(px, w, ch, |y, row| {
for x in 0..w {
@@ -468,6 +474,7 @@ pub(super) fn push_pull(w: usize, h: usize, ch: usize, px: &mut [f32], wt: &[f32
}
}
});
Ok(())
}
/// One image's pyramid in [`multiband`]: the grid origin `(gx0, gy0)` at level 0, the Laplacian
@@ -491,50 +498,89 @@ fn band_row(band: &PyramidBand, l: usize, ch: usize, py: usize) -> Option<(usize
/// uses a many-core machine better than one band's row-parallel work, and one at a time made the
/// usual 2–4 image Photomerge 12–36% slower on a 32-thread desktop.
#[cfg(not(target_arch = "wasm32"))]
const BAND_BUDGET: usize = 5 << 30;
pub const BAND_BUDGET: usize = 5 << 30;
/// Without threads the bands of a batch are built one after another anyway, so each is added as
/// soon as it is built (the browser's address space is 4 GB).
#[cfg(target_arch = "wasm32")]
const BAND_BUDGET: usize = 0;
pub const BAND_BUDGET: usize = 0;
/// Multi-band blend of `imgs` into a `w × h` panorama (origin 0,0) with one weight map per image
/// over the same grid as its ROI (`weights[i]` has `w_i × h_i` entries). Returns the colour
/// channels (`ch` per pixel) and the total coverage.
pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], levels: usize) -> (Vec<f32>, Vec<f32>) {
multiband_batched(w, h, imgs, weights, levels, BAND_BUDGET)
/// channels (`ch` per pixel) and the total coverage, or an error when a buffer can't be allocated.
pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], levels: usize) -> Result<(Vec<f32>, Vec<f32>), AllocationError> {
try_multiband_batched(w, h, imgs, weights, levels, BAND_BUDGET, None)
}
/// The grid an image's band is built on: its ROI grown by a margin and aligned down to the
/// coarsest level's grid, clipped to the `w × h` canvas. Returns `(x0, y0, width, height)`.
fn band_grid(im: &RoiImage, w: usize, h: usize, levels: usize) -> (i32, i32, usize, usize) {
let align = 1i32 << (levels.max(1) - 1);
let margin = align * 4;
let gx0 = ((im.x0 - margin).max(0) / align) * align;
let gy0 = ((im.y0 - margin).max(0) / align) * align;
let gx1 = (im.x0 + im.w as i32 + margin).min(w as i32);
let gy1 = (im.y0 + im.h as i32 + margin).min(h as i32);
(gx0, gy0, (gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize)
}
/// Estimated bytes of building one band: its grid's colour and weight planes about three times
/// over (the pyramids are 4/3 of a plane, and the grid copy and `expand`/`reduce` scratch come
/// on top). A safe upper bound: the build itself peaks lower.
pub(super) fn band_cost(im: &RoiImage, w: usize, h: usize, ch: usize, levels: usize) -> usize {
let (_, _, gw, gh) = band_grid(im, w, h, levels);
gw.saturating_mul(gh).saturating_mul(ch + 1).saturating_mul(4 * 3)
}
/// An upper bound on `band_cost` (the estimate batching uses) for any ROI of `w × h` pixels on a `canvas_w × canvas_h`
/// canvas: the grid grows the ROI by the margin on both sides and aligns its start down by up to
/// `align - 1`, and never exceeds the canvas.
pub fn band_bytes_bound(w: usize, h: usize, ch: usize, levels: usize, canvas_w: usize, canvas_h: usize) -> usize {
let align = 1usize << (levels.max(1) - 1).min(30);
let margin = align.saturating_mul(4);
let extent = |n: usize, canvas: usize| n.saturating_add(margin.saturating_mul(2)).saturating_add(align).min(canvas).max(1);
extent(w, canvas_w).saturating_mul(extent(h, canvas_h)).saturating_mul(ch.saturating_add(1)).saturating_mul(4 * 3)
}
/// [`multiband`], building at most `budget` estimated bytes of bands at a time (at least one
/// image). Each pixel still adds the bands in image order, so the result doesn't depend on it.
pub(super) fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], levels: usize, budget: usize) -> (Vec<f32>, Vec<f32>) {
/// With `max_band_budget`, a single band estimated above it is an error instead.
pub fn try_multiband_batched(
w: usize,
h: usize,
imgs: &[RoiImage],
weights: &[Vec<f32>],
levels: usize,
budget: usize,
max_band_budget: Option<usize>,
) -> Result<(Vec<f32>, Vec<f32>), AllocationError> {
let ch = imgs.first().map_or(1, |i| i.ch);
let levels = levels.max(1);
let align = 1i32 << (levels - 1);
let cost = |im: &RoiImage| band_cost(im, w, h, ch, levels);
if let Some(limit) = max_band_budget {
for im in imgs {
let bytes = cost(im);
if bytes > limit {
return Err(AllocationError::NotEnoughMemory { bytes });
}
}
}
// Level sizes of the panorama.
let mut sizes = vec![(w, h)];
for _ in 1..levels {
let Some(&(pw, ph)) = sizes.last() else { break };
sizes.push((pw.div_ceil(2), ph.div_ceil(2)));
}
let mut acc: Vec<Vec<f32>> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh * ch]).collect();
let mut wsum: Vec<Vec<f32>> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh]).collect();
let margin = align * 4;
// Each image on its ROI grown by a margin and aligned to the coarsest level's grid.
let grid = |im: &RoiImage| {
let gx0 = ((im.x0 - margin).max(0) / align) * align;
let gy0 = ((im.y0 - margin).max(0) / align) * align;
let gx1 = (im.x0 + im.w as i32 + margin).min(w as i32);
let gy1 = (im.y0 + im.h as i32 + margin).min(h as i32);
(gx0, gy0, (gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize)
};
// Building one band holds its grid's colour and weight planes about three times over: the
// pyramids are 4/3 of a plane, and the grid copy, coverage and `expand` scratch come on top.
let cost = |im: &RoiImage| {
let (_, _, gw, gh) = grid(im);
gw.saturating_mul(gh).saturating_mul(ch + 1).saturating_mul(4 * 3)
};
let band = |k: usize| -> PyramidBand {
let mut acc: Vec<Vec<f32>> = Vec::with_capacity(sizes.len());
let mut wsum: Vec<Vec<f32>> = Vec::with_capacity(sizes.len());
for &(lw, lh) in &sizes {
let acc_len = lw.checked_mul(lh).and_then(|n| n.checked_mul(ch)).ok_or(AllocationError::SizeOverflow)?;
acc.push(alloc_plane(acc_len)?);
let wsum_len = lw.checked_mul(lh).ok_or(AllocationError::SizeOverflow)?;
wsum.push(alloc_plane(wsum_len)?);
}
let grid = |im: &RoiImage| band_grid(im, w, h, levels);
let band = |k: usize| -> Result<PyramidBand, AllocationError> {
let im = &imgs[k];
let (gx0, gy0, gw, gh) = grid(im);
// Each grid pixel takes the image pixel that lands on it (every image pixel lands on at
@@ -543,7 +589,7 @@ pub(super) fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights:
let (x, y) = (gx as i64 + gx0 as i64 - im.x0 as i64, gy as i64 + gy0 as i64 - im.y0 as i64);
(x >= 0 && y >= 0 && (x as usize) < im.w && (y as usize) < im.h).then(|| y as usize * im.w + x as usize)
};
let mut px = vec![0.0f32; gw * gh * ch];
let mut px = alloc_plane(plane_len(gw, gh, ch)?)?;
par_rows(&mut px, gw, ch, |gy, row| {
for gx in 0..gw {
if let Some(i) = src_of(gx, gy) {
@@ -551,15 +597,7 @@ pub(super) fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights:
}
}
});
let mut cov = vec![0.0f32; gw * gh];
par_rows(&mut cov, gw, 1, |gy, row| {
for (gx, o) in row.iter_mut().enumerate() {
if let Some(i) = src_of(gx, gy) {
*o = im.alpha[i];
}
}
});
let mut wt = vec![0.0f32; gw * gh];
let mut wt = alloc_plane(plane_len(gw, gh, 1)?)?;
par_rows(&mut wt, gw, 1, |gy, row| {
for (gx, o) in row.iter_mut().enumerate() {
if let Some(i) = src_of(gx, gy) {
@@ -567,9 +605,16 @@ pub(super) fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights:
}
}
});
let bin: Vec<f32> = par_map1(&cov, |a| if a > 0.5 { 1.0 } else { 0.0 });
drop(cov);
push_pull(gw, gh, ch, &mut px, &bin);
// Binary coverage straight from the alpha (0 outside the image), without a coverage plane.
let mut bin = alloc_plane(plane_len(gw, gh, 1)?)?;
par_rows(&mut bin, gw, 1, |gy, row| {
for (gx, o) in row.iter_mut().enumerate() {
if let Some(i) = src_of(gx, gy) {
*o = if im.alpha[i] > 0.5 { 1.0 } else { 0.0 };
}
}
});
push_pull(gw, gh, ch, &mut px, &bin)?;
drop(bin);
// Laplacian of the colours, Gaussian of the weights. Planes move into the pyramids rather
// than being copied: with one image built at a time, a copy is a serial pass over it.
@@ -582,17 +627,20 @@ pub(super) fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights:
gau.push(cwt);
break;
}
let (nw, nh, down) = reduce(cw, chh, ch, &cur);
let up = expand(nw, nh, ch, &down, cw, chh);
lap.push((cw, chh, par_zip(&cur, &up, |a, b| a - b)));
let (_, _, wdown) = reduce(cw, chh, 1, &cwt);
let (nw, nh, down) = reduce(cw, chh, ch, &cur)?;
let up = expand(nw, nh, ch, &down, cw, chh)?;
// The Laplacian replaces the level's colours in place (`cur - up`, elementwise).
par_zip_assign(&mut cur, &up, |a, b| a - b);
drop(up);
lap.push((cw, chh, cur));
let (_, _, wdown) = reduce(cw, chh, 1, &cwt)?;
gau.push(cwt);
cw = nw;
chh = nh;
cur = down;
cwt = wdown;
}
(gx0, gy0, lap, gau)
Ok((gx0, gy0, lap, gau))
};
// Build the bands a batch at a time and add each batch before building the next. Each pixel
// adds the bands in the same order as the serial loop (per level, bands one after another;
@@ -601,14 +649,15 @@ pub(super) fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights:
let mut start = 0;
while start < imgs.len() {
let mut end = start + 1;
let mut bytes = cost(&imgs[start]);
let c = cost(&imgs[start]);
let mut bytes = c;
while let Some(im) = imgs.get(end)
&& bytes.saturating_add(cost(im)) <= budget
{
bytes = bytes.saturating_add(cost(im));
end += 1;
}
let bands = par_map(end - start, |j| band(start + j));
let bands = par_map(end - start, |j| band(start + j)).into_iter().collect::<Result<Vec<_>, _>>()?;
for l in 0..levels {
let pw = sizes[l].0;
par_rows(&mut wsum[l], pw, 1, |py, row| {
@@ -660,18 +709,28 @@ pub(super) fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights:
}
});
}
drop(wsum);
// Collapse.
// `levels ≥ 1`, so there is always a coarsest level.
let Some(mut cur) = acc.pop() else { return (vec![0.0; w * h * ch], vec![0.0; w * h]) };
let Some(mut cur) = acc.pop() else {
let total = w.checked_mul(h).and_then(|n| n.checked_mul(ch)).ok_or(AllocationError::SizeOverflow)?;
let cov_total = w.checked_mul(h).ok_or(AllocationError::SizeOverflow)?;
return Ok((alloc_plane(total)?, alloc_plane(cov_total)?));
};
for l in (0..levels - 1).rev() {
let (pw, ph) = sizes[l];
let (sw, sh) = sizes[l + 1];
let up = expand(sw, sh, ch, &cur, pw, ph);
cur = par_zip(&acc[l], &up, |a, b| a + b);
let up = expand(sw, sh, ch, &cur, pw, ph)?;
// Add the upsampled coarser levels into this level's accumulator (`acc + up`, in place).
let mut level = std::mem::take(&mut acc[l]);
par_zip_assign(&mut level, &up, |a, b| a + b);
drop(up);
cur = level;
}
// Coverage: union of the images. Rows are independent; each walks the images in order, so a
// pixel sees the same sequence of `max` calls as in the serial loop.
let mut cov = vec![0.0f32; w * h];
let cov_len = w.checked_mul(h).ok_or(AllocationError::SizeOverflow)?;
let mut cov = alloc_plane(cov_len)?;
par_rows(&mut cov, w, 1, |py, row| {
for im in imgs {
let y = py as i64 - im.y0 as i64;
@@ -688,5 +747,5 @@ pub(super) fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights:
}
}
});
(cur, cov)
Ok((cur, cov))
}
+56 -11
View File
@@ -284,13 +284,13 @@ fn reduce_and_expand_match_the_serial_code() {
for (n, &(w, h)) in SIZES.iter().enumerate() {
for ch in [1usize, 3, 4] {
let src = noise(w * h * ch, n as u64 * 7 + ch as u64);
let (nw, nh, got) = reduce(w, h, ch, &src);
let (nw, nh, got) = reduce(w, h, ch, &src).unwrap();
let (rw, rh, want) = ref_reduce(w, h, ch, &src);
assert_eq!((nw, nh), (rw, rh));
assert_eq!(bits(&got), bits(&want), "reduce {w}x{h}x{ch}");
// Expand back to the original size and to sizes that are not twice the source's.
for (ew, eh) in [(w, h), (2 * nw, 2 * nh), (2 * nw - 1, 2 * nh + 1), (w + 3, h + 5)] {
let got = expand(nw, nh, ch, &want, ew, eh);
let got = expand(nw, nh, ch, &want, ew, eh).unwrap();
let want_up = ref_expand(nw, nh, ch, &want, ew, eh);
assert_eq!(bits(&got), bits(&want_up), "expand {nw}x{nh}x{ch} to {ew}x{eh}");
}
@@ -311,7 +311,7 @@ fn push_pull_matches_the_serial_code() {
};
let px = noise(w * h * ch, 100 + n as u64);
let (mut got, mut want) = (px.clone(), px);
push_pull(w, h, ch, &mut got, &wt);
push_pull(w, h, ch, &mut got, &wt).unwrap();
ref_push_pull(w, h, ch, &mut want, &wt);
assert_eq!(bits(&got), bits(&want), "push_pull {w}x{h}x{ch} variant {variant}");
}
@@ -350,7 +350,7 @@ fn multiband_matches_the_serial_code() {
for ch in [1usize, 3] {
let (imgs, wts) = rois(w, h, ch, n as u64 * 31 + ch as u64);
for levels in [1usize, 2, 3, 5, 7] {
let got = multiband(w, h, &imgs, &wts, levels);
let got = multiband(w, h, &imgs, &wts, levels).unwrap();
let want = ref_multiband(w, h, &imgs, &wts, levels);
assert_eq!(bits(&got.0), bits(&want.0), "colours {w}x{h}x{ch} levels {levels}");
assert_eq!(bits(&got.1), bits(&want.1), "coverage {w}x{h}x{ch} levels {levels}");
@@ -358,7 +358,7 @@ fn multiband_matches_the_serial_code() {
}
}
// No images at all, and zero levels.
assert_eq!(multiband(9, 7, &[], &[], 3).0.len(), ref_multiband(9, 7, &[], &[], 3).0.len());
assert_eq!(multiband(9, 7, &[], &[], 3).unwrap().0.len(), ref_multiband(9, 7, &[], &[], 3).0.len());
}
/// Batching the bands (#7758) changes when they are built, not the result. Budgets from none
@@ -374,7 +374,7 @@ fn multiband_batches_match_the_serial_code() {
for levels in [1usize, 3, 5] {
let want = ref_multiband(w, h, &imgs, &wts, levels);
for &budget in &budgets {
let got = multiband_batched(w, h, &imgs, &wts, levels, budget);
let got = try_multiband_batched(w, h, &imgs, &wts, levels, budget, None).unwrap();
assert_eq!(bits(&got.0), bits(&want.0), "colours {w}x{h}x{ch} levels {levels} budget {budget}");
assert_eq!(bits(&got.1), bits(&want.1), "coverage {w}x{h}x{ch} levels {levels} budget {budget}");
}
@@ -388,15 +388,60 @@ fn multiband_batches_match_the_serial_code() {
#[test]
fn multiband_hashes_recorded_from_the_serial_code() {
let (imgs, wts) = rois(97, 61, 3, 5);
let (px, cov) = multiband(97, 61, &imgs, &wts, 5);
let (px, cov) = multiband(97, 61, &imgs, &wts, 5).unwrap();
assert_eq!(fnv(&[&px, &cov]), 0xf89a5726082ebb6c, "97x61 rgb, 5 levels");
let (imgs, wts) = rois(130, 70, 1, 9);
let (px, cov) = multiband(130, 70, &imgs, &wts, 7);
let (px, cov) = multiband(130, 70, &imgs, &wts, 7).unwrap();
assert_eq!(fnv(&[&px, &cov]), 0xb7d7ddc59d201730, "130x70 grey, 7 levels");
let wt = weights(65, 41, 3);
let mut px = noise(65 * 41 * 3, 4);
push_pull(65, 41, 3, &mut px, &wt);
push_pull(65, 41, 3, &mut px, &wt).unwrap();
assert_eq!(fnv(&[&px]), 0x50ea36a84ca9a68f, "push_pull 65x41 rgb");
let (nw, nh, r) = reduce(65, 41, 3, &px);
assert_eq!(fnv(&[&r, &expand(nw, nh, 3, &r, 65, 41)]), 0x51823acfb4206dd8, "reduce + expand 65x41 rgb");
let (nw, nh, r) = reduce(65, 41, 3, &px).unwrap();
assert_eq!(fnv(&[&r, &expand(nw, nh, 3, &r, 65, 41).unwrap()]), 0x51823acfb4206dd8, "reduce + expand 65x41 rgb");
}
#[test]
fn multiband_rejects_single_band_larger_than_budget() {
let (w, h, ch) = (97, 61, 3);
let (imgs, wts) = rois(w, h, ch, 42);
let err = try_multiband_batched(w, h, &imgs, &wts, 3, 100, Some(100)).unwrap_err();
assert!(matches!(err, photocraft_raster::AllocationError::NotEnoughMemory { bytes } if bytes > 100));
}
/// [`band_bytes_bound`] covers the cost the batching estimates for any ROI, offsets and levels.
#[test]
fn band_bytes_bound_covers_the_band_cost() {
for (n, &(w, h)) in [(1usize, 1usize), (5, 3), (40, 30), (97, 61), (130, 70), (201, 129)].iter().enumerate() {
for ch in [1usize, 3, 4] {
let (imgs, _) = rois(w, h, ch, n as u64 + 3);
for levels in [0usize, 1, 2, 3, 5, 7] {
for im in &imgs {
let cost = band_cost(im, w, h, ch, levels);
let bound = band_bytes_bound(im.w, im.h, ch, levels, w, h);
assert!(bound >= cost, "{w}x{h}x{ch} levels {levels} roi {}x{} at ({}, {}): bound {bound} < cost {cost}", im.w, im.h, im.x0, im.y0);
}
}
}
}
}
#[test]
fn multiband_checks_later_images_and_zero_limit() {
let small = RoiImage { x0: 0, y0: 0, w: 1, h: 1, ch: 1, px: vec![1.0], alpha: vec![1.0] };
let large = RoiImage { x0: 0, y0: 0, w: 20, h: 20, ch: 1, px: vec![1.0; 400], alpha: vec![1.0; 400] };
let limit = band_cost(&small, 40, 40, 1, 1);
let expected = band_cost(&large, 40, 40, 1, 1);
assert!(expected > limit);
let err = try_multiband_batched(40, 40, &[small.clone(), large], &[vec![1.0], vec![1.0; 400]], 1, usize::MAX, Some(limit)).unwrap_err();
assert!(matches!(err, photocraft_raster::AllocationError::NotEnoughMemory { bytes } if bytes == expected));
let err = try_multiband_batched(40, 40, &[small], &[vec![1.0]], 1, usize::MAX, Some(0)).unwrap_err();
assert!(matches!(err, photocraft_raster::AllocationError::NotEnoughMemory { .. }));
}
#[test]
fn pyramid_planes_reject_size_overflow() {
assert!(matches!(reduce(usize::MAX, 3, 3, &[]), Err(photocraft_raster::AllocationError::SizeOverflow)));
assert!(matches!(expand(1, 1, 3, &[0.0; 3], usize::MAX, 2), Err(photocraft_raster::AllocationError::SizeOverflow)));
assert!(matches!(push_pull(usize::MAX, 3, 3, &mut [], &[]), Err(photocraft_raster::AllocationError::SizeOverflow)));
}
+88
View File
@@ -1117,6 +1117,50 @@ pub fn puppet_warp(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp)
puppet_warp_with_mask(src, bounds, w, interp, None).map(|(out, _)| out)
}
/// How many times over a stored mesh's triangles may cover its deformed box, counting each
/// triangle's bounding box: a real mesh tiles the box about twice (a fold adds a layer), a
/// crafted one stacks large triangles to multiply the drawing work.
const MAX_MESH_COVERAGE: f64 = 16.0;
/// Draws `src` (content inside `bounds`) through a mesh solved elsewhere, as a Photoshop file
/// stores its Puppet Warp: `mesh.verts` move to `deformed`, the triangles drawn in their stored
/// order. `None` for a malformed mesh (sizes that don't match, an index out of range, a vertex
/// that isn't finite), one whose deformed box spans more than [`MAX_WARP_PIXELS`] or whose
/// triangles cover that box more than [`MAX_MESH_COVERAGE`] times.
pub fn warp_with_mesh(src: &Surface, bounds: Rect, mesh: &PuppetMesh, deformed: &[[f64; 2]], interp: Interp) -> Option<Surface> {
let n = mesh.verts.len();
if mesh.tris.is_empty() || deformed.len() != n || mesh.tris.iter().flatten().any(|&i| i >= n) {
return None;
}
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for p in mesh.verts.iter().chain(deformed) {
if !(p[0].is_finite() && p[1].is_finite()) {
return None;
}
}
for p in deformed {
(x0, y0, x1, y1) = (x0.min(p[0]), y0.min(p[1]), x1.max(p[0]), y1.max(p[1]));
}
let area = (x1 - x0 + 1.0) * (y1 - y0 + 1.0);
if area > MAX_WARP_PIXELS {
return None;
}
let covered: f64 = mesh
.tris
.iter()
.map(|t| {
let (xs, ys) = (t.map(|i| deformed[i][0]), t.map(|i| deformed[i][1]));
let span = |v: [f64; 3]| v.iter().fold(f64::MIN, |m, &a| m.max(a)) - v.iter().fold(f64::MAX, |m, &a| m.min(a)) + 1.0;
span(xs) * span(ys)
})
.sum();
if covered > MAX_MESH_COVERAGE * area {
return None;
}
let order: Vec<usize> = (0..mesh.tris.len()).collect();
Some(render(src, bounds, mesh, deformed, &order, interp))
}
/// [`puppet_warp`], also moving `mask` (a one-channel layer mask linked to `src`) with the same
/// mesh, as Photoshop moves a linked mask with its layer (#4910). The mask is `None` in the
/// result when none was given.
@@ -1267,6 +1311,50 @@ mod tests {
assert!(!in_mesh(&m, [40.0, 50.0]) && !in_mesh(&m, [60.0, 20.0]));
}
#[test]
fn a_stored_mesh_is_drawn_as_it_is() {
// A Photoshop file's Puppet Warp keeps its solved mesh: two triangles over a 40 × 40 box,
// sheared and moved. A dot lands where the mesh's affine map puts it; pixels outside the
// mesh are dropped.
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true));
for y in 0..48 {
for x in 0..48 {
let dot = (f64::from(x) + 0.5 - 12.0).hypot(f64::from(y) + 0.5 - 20.0) <= 2.0;
s.write_pixel(x, y, &if dot { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] });
}
}
let mesh = PuppetMesh { verts: vec![[0.0, 0.0], [40.0, 0.0], [40.0, 40.0], [0.0, 40.0]], tris: vec![[0, 1, 2], [0, 2, 3]], spacing: 20.0 };
let map = |p: [f64; 2]| [p[0] + 0.25 * p[1] + 3.0, p[1] + 5.0];
let deformed: Vec<[f64; 2]> = mesh.verts.iter().map(|v| map(*v)).collect();
let b = s.content_bounds();
let out = warp_with_mesh(&s, b, &mesh, &deformed, Interp::Bilinear).expect("a valid mesh draws");
let ob = out.content_bounds();
let (mut sw, mut sx, mut sy) = (0.0, 0.0, 0.0);
for (i, px) in out.read_region(ob).as_chunks::<4>().0.iter().enumerate() {
let w = f64::from(px[0] * px[3]);
let (x, y) = (f64::from(ob.x0) + (i % ob.width() as usize) as f64 + 0.5, f64::from(ob.y0) + (i / ob.width() as usize) as f64 + 0.5);
(sw, sx, sy) = (sw + w, sx + w * x, sy + w * y);
}
let want = map([12.0, 20.0]);
assert!((sx / sw - want[0]).abs() < 0.05 && (sy / sw - want[1]).abs() < 0.05, "dot at {:?}, want {want:?}", [sx / sw, sy / sw]);
// The source column x = 44 is outside the mesh: nothing lands right of the sheared box.
assert!(ob.x1 <= 54 && ob.y1 <= 45, "{ob:?}");
// Malformed meshes are refused rather than drawn.
assert!(warp_with_mesh(&s, b, &mesh, &deformed[..3], Interp::Bilinear).is_none());
let bad = PuppetMesh { tris: vec![[0, 1, 4]], ..mesh.clone() };
assert!(warp_with_mesh(&s, b, &bad, &deformed, Interp::Bilinear).is_none());
let mut far = deformed.clone();
far[2] = [f64::NAN, 0.0];
assert!(warp_with_mesh(&s, b, &mesh, &far, Interp::Bilinear).is_none());
far[2] = [1.0e6, 1.0e6];
assert!(warp_with_mesh(&s, b, &mesh, &far, Interp::Bilinear).is_none());
// The same two triangles stacked 40 times: far more drawing than the box needs.
let stacked = PuppetMesh { tris: mesh.tris.repeat(40), ..mesh.clone() };
assert!(warp_with_mesh(&s, b, &stacked, &deformed, Interp::Bilinear).is_none());
let none = PuppetMesh { tris: Vec::new(), ..mesh };
assert!(warp_with_mesh(&s, b, &none, &deformed, Interp::Bilinear).is_none());
}
#[test]
fn no_pins_or_unmoved_pins_are_identity() {
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
+2 -27
View File
@@ -54,34 +54,9 @@ pub fn close_loops(w: usize, h: usize, mask: &[bool]) -> Vec<bool> {
outside.iter().map(|o| !*o).collect()
}
/// `mask` grown by `r` cells (a square structuring element), in two separable passes.
/// `mask` grown by `r` cells (a square structuring element, clipped to the grid).
pub fn dilate(w: usize, h: usize, mask: &[bool], r: usize) -> Vec<bool> {
if r == 0 || mask.len() != w.saturating_mul(h) {
return mask.to_vec();
}
// A pass sets each cell when any cell within `r` along one axis is set (a running count).
let pass = |src: &[bool], horizontal: bool| -> Vec<bool> {
let (lines, len) = if horizontal { (h, w) } else { (w, h) };
let at = |line: usize, k: usize| if horizontal { line * w + k } else { k * w + line };
let mut out = vec![false; src.len()];
for line in 0..lines {
let mut count = 0usize;
for k in 0..len.min(r) {
count += usize::from(src[at(line, k)]);
}
for k in 0..len {
if k + r < len {
count += usize::from(src[at(line, k + r)]);
}
if k > r {
count -= usize::from(src[at(line, k - r - 1)]);
}
out[at(line, k)] = count > 0;
}
}
out
};
pass(&pass(mask, true), false)
crate::square_dilate::dilate_square(w, h, mask, r)
}
/// The largest chessboard distance from a hole cell to the nearest cell outside the hole (cells
+2 -103
View File
@@ -1,11 +1,11 @@
//! Render: Clouds, Difference Clouds, Fibers, Lens Flare, Lighting Effects.
//! Render: Clouds, Difference Clouds, Fibers, Lighting Effects (Lens Flare: `lens_flare.rs`).
use photocraft_geom::Rect;
use crate::fxutil::{MAXC, luma, rgba, set_rgba, smoothstep, xy};
use crate::image::Image;
use crate::noise::hash01;
use crate::{Ctx, LensType, Light, LightKind, TextureChannel};
use crate::{Ctx, Light, LightKind, TextureChannel};
/// Smooth value noise in [0, 1).
fn value_noise(x: f32, y: f32, seed: u32) -> f32 {
@@ -106,107 +106,6 @@ pub(crate) fn fibers(src: &Image, out: Rect, ctx: &Ctx, variance: f32, strength:
res
}
/// One lens-flare ghost: position along the flare→centre axis, radius and tint.
struct Ghost {
t: f32,
r: f32,
tint: [f32; 3],
k: f32,
}
/// Lens Flare: an additive (screen) glow, rays, halo ring and ghost discs
/// along the line through the image centre. Lens types vary the elements.
pub(crate) fn lens_flare(src: &Image, out: Rect, ctx: &Ctx, brightness: f32, centre: (f32, f32), lens: LensType) -> Vec<f32> {
let n = src.ch;
let b = ctx.bounds;
let (bw, bh) = (b.width() as f32, b.height() as f32);
let diag = bw.hypot(bh).max(1.0);
let (cx, cy) = (b.x0 as f32 + centre.0 * bw, b.y0 as f32 + centre.1 * bh);
let (mx, my) = (b.x0 as f32 + bw / 2.0, b.y0 as f32 + bh / 2.0);
let bright = brightness.clamp(10.0, 300.0) / 100.0;
let s = diag / 1000.0;
let (core, glow, ring_r, ring_w, rays, streak) = match lens {
LensType::Zoom => (14.0, 90.0, 0.0, 0.0, 0.5, 0.0),
LensType::Prime35 => (12.0, 70.0, 90.0, 6.0, 0.3, 0.0),
LensType::Prime105 => (22.0, 130.0, 0.0, 0.0, 0.8, 0.0),
LensType::MoviePrime => (10.0, 60.0, 0.0, 0.0, 0.2, 1.0),
};
let ghosts: Vec<Ghost> = match lens {
LensType::Zoom => vec![
Ghost { t: 0.35, r: 10.0, tint: [0.6, 0.8, 1.0], k: 0.18 },
Ghost { t: 0.6, r: 26.0, tint: [0.5, 1.0, 0.6], k: 0.10 },
Ghost { t: 0.85, r: 6.0, tint: [1.0, 0.7, 0.4], k: 0.25 },
Ghost { t: 1.25, r: 40.0, tint: [0.7, 0.5, 1.0], k: 0.08 },
Ghost { t: 1.5, r: 16.0, tint: [1.0, 0.6, 0.5], k: 0.15 },
Ghost { t: 1.9, r: 70.0, tint: [0.5, 0.7, 1.0], k: 0.06 },
],
LensType::Prime35 => vec![Ghost { t: 0.7, r: 18.0, tint: [0.6, 1.0, 0.7], k: 0.12 }, Ghost { t: 1.4, r: 30.0, tint: [1.0, 0.7, 0.5], k: 0.09 }],
LensType::Prime105 => vec![Ghost { t: 0.5, r: 14.0, tint: [1.0, 0.8, 0.5], k: 0.12 }, Ghost { t: 1.6, r: 50.0, tint: [0.6, 0.7, 1.0], k: 0.07 }],
LensType::MoviePrime => vec![
Ghost { t: 0.4, r: 8.0, tint: [0.5, 0.7, 1.0], k: 0.2 },
Ghost { t: 1.0, r: 20.0, tint: [0.4, 0.6, 1.0], k: 0.12 },
Ghost { t: 1.6, r: 12.0, tint: [0.5, 0.8, 1.0], k: 0.16 },
],
};
let mut res = src.crop(out);
for (i, px) in res.chunks_exact_mut(n).enumerate() {
let (x, y) = xy(out, i);
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
let (dx, dy) = (fx - cx, fy - cy);
let d = (dx * dx + dy * dy).sqrt();
let mut f = [0.0f32; 3];
// Hot core and wide glow (warm white).
let g = (-(d / (core * s)).powi(2)).exp() + 0.35 * (-d / (glow * s)).exp();
for (k, w) in [1.0, 0.95, 0.85].iter().enumerate() {
f[k] += g * w;
}
// Rays: angular modulation decaying with distance.
if rays > 0.0 {
let th = dy.atan2(dx);
let m = ((th * 12.0).sin() * 0.5 + 0.5).powi(8) + ((th * 7.0 + 1.3).sin() * 0.5 + 0.5).powi(12) * 0.6;
let rr = rays * m * (-d / (glow * 1.6 * s)).exp();
for v in f.iter_mut() {
*v += rr * 0.6;
}
}
// Anamorphic horizontal streak (movie prime).
if streak > 0.0 {
let st = (-(dy.abs() / (2.5 * s))).exp() * (-(dx.abs() / (450.0 * s))).exp();
f[0] += st * 0.5;
f[1] += st * 0.7;
f[2] += st;
}
// Halo ring with a slight chromatic spread.
if ring_r > 0.0 {
for (k, off) in [1.04f32, 1.0, 0.96].iter().enumerate() {
let rd = (d - ring_r * s * off) / (ring_w * s);
f[k] += 0.18 * (-rd * rd).exp();
}
}
// Ghosts along the axis through the centre.
for gh in &ghosts {
let (gx, gy) = (cx + (mx - cx) * gh.t * 2.0, cy + (my - cy) * gh.t * 2.0);
let gd = ((fx - gx).powi(2) + (fy - gy).powi(2)).sqrt() / (gh.r * s);
if gd < 1.2 {
// Soft-edged disc, slightly brighter at the rim.
let e = smoothstep(1.0, 0.85, gd) * (0.6 + 0.4 * gd);
for (fk, t) in f.iter_mut().zip(gh.tint) {
*fk += e * gh.k * t;
}
}
}
let mut c = rgba(ctx, px);
for k in 0..3 {
let a = (f[k] * bright).max(0.0);
// Screen (c + a·(1 − c)) keeps display-referred values below white; a 32-bit
// value already above white is left as is rather than pulled down towards it.
c[k] += a.min(1.0) * (1.0 - c[k]).max(0.0);
}
set_rgba(ctx, px, c);
}
res
}
pub(crate) struct LightingSpec<'a> {
pub lights: &'a [Light],
pub gloss: f32,
+242 -55
View File
@@ -288,6 +288,21 @@ pub fn scaled_rect(r: Rect, sx: f64, sy: f64) -> Rect {
Rect::new((r.x0 as f64 * sx).floor() as i32, (r.y0 as f64 * sy).floor() as i32, (r.x1 as f64 * sx).ceil() as i32, (r.y1 as f64 * sy).ceil() as i32)
}
/// The largest rectangle a resize of content at `r` by `(sx, sy)` with `filter` stores: the
/// result, or the image between the passes of a two-pass bicubic. That one keeps an axis at its
/// old size, so when both axes shrink it is larger than the result (#9212).
pub fn largest_resize_rect(r: Rect, sx: f64, sy: f64, filter: Resample) -> Rect {
let area = |r: Rect| u64::from(r.width()) * u64::from(r.height());
let (mut cur, mut largest) = (r, Rect::EMPTY);
for p in plan(filter, sx, sy) {
cur = scaled_rect(cur, p.sx, p.sy);
if area(cur) > area(largest) {
largest = cur;
}
}
largest
}
/// Resizes a surface by `(sx, sy)` about the document origin. The surface's
/// default pixel is kept (masks keep their "reveal all" background).
pub fn resize_surface(s: &Surface, sx: f64, sy: f64, filter: Resample) -> Surface {
@@ -346,71 +361,137 @@ fn resize(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Option<Rect>)
/// Runs `filter`'s passes. Between two passes the image is stored in its own format, which
/// clips it (and rounds it at 8 and 16 bits) as Photoshop does; the smaller scale goes first, so
/// the intermediate image is never larger than the result.
/// the intermediate image is never larger than the result when an axis grows, but is when both
/// shrink ([`largest_resize_rect`]).
fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Option<Rect>, budget: usize) -> Surface {
resize_controlled(s, sx, sy, filter, canvas, budget, &photocraft_raster::Interrupt::NONE)
}
/// Resize with cancellation between passes and bounded groups of output bands.
/// A cancelled operation returns no partially resized surface.
pub fn resize_surface_with_interrupt(
s: &Surface,
sx: f64,
sy: f64,
filter: Resample,
ctl: &photocraft_raster::Interrupt,
) -> Result<Surface, photocraft_raster::Cancelled> {
ctl.check()?;
let out = resize_controlled(s, sx, sy, filter, None, READ_BUDGET, ctl);
ctl.check()?;
Ok(out)
}
fn resize_controlled(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Option<Rect>, budget: usize, ctl: &photocraft_raster::Interrupt) -> Surface {
let mut cur: Option<Surface> = None;
let mut canvas = canvas;
for p in plan(filter, sx, sy) {
let next = resize_pass(cur.as_ref().unwrap_or(s), p, canvas, budget);
if ctl.cancelled() {
return Surface::new(s.format());
}
let next = resize_pass(cur.as_ref().unwrap_or(s), p, canvas, budget, ctl);
canvas = canvas.map(|c| scaled_rect(c, p.sx, p.sy));
cur = Some(next);
}
let mut out = cur.unwrap_or_else(|| s.clone());
let mut out = preserve_details(cur.unwrap_or_else(|| s.clone()), filter, sx, sy);
out.prune();
out
}
/// Preserve Details' light sharpening of an enlargement (Lanczos's output, for the other filters
/// nothing).
fn preserve_details(mut out: Surface, filter: Resample, sx: f64, sy: f64) -> Surface {
if filter == Resample::PreserveDetails && (sx > 1.0 || sy > 1.0) {
let dst = out.content_bounds();
let p = crate::FilterParams::UnsharpMask { amount: 30.0, radius: 0.6 * sx.max(sy) as f32, threshold: 0.0 };
let area = crate::output_area(&p, dst, dst, None);
out = crate::apply(&out, &p, area, dst, None);
}
out.prune();
out
}
/// One pass. With `canvas`, the content and the canvas repeat their edge outwards (see
/// [`resize_surface_in_canvas`]).
fn resize_pass(s: &Surface, pass: Pass, canvas: Option<Rect>, budget: usize) -> Surface {
let Pass { sx, sy, kx, ky } = pass;
let edge = canvas.map(|c| s.content_bounds().union(&c)).filter(|a| !a.is_empty());
let fmt = s.format();
let mut out = Surface::with_default(fmt, &s.default_pixel());
let src = s.content_bounds();
let dst = scaled_rect(src, sx, sy);
if dst.is_empty() {
return out;
/// Output rows computed together by [`PassState::band`].
const BAND: usize = 64;
/// One pass prepared: its taps and the constants every band needs. [`PassState::band`] computes
/// one band of output rows from the source, so the parallel loop of [`resize_pass`] and
/// [`ResizeSteps`] run the same code and give the same pixels.
struct PassState {
n: usize,
alpha: bool,
/// The output extent.
dst: Rect,
/// The area every tap is clamped into (the source's content, or with a canvas that and the canvas).
area: Rect,
hx: Vec<Taps>,
vy: Vec<Taps>,
x_lo: i32,
x_hi: i32,
def: Vec<f32>,
/// A source row of nothing but the default pixel, after the horizontal pass.
def_row: Vec<f32>,
rows_per_read: i32,
}
impl PassState {
/// Prepares `pass` over `s`; `None` when the output is empty. With `canvas`, the content and
/// the canvas repeat their edge outwards (see [`resize_surface_in_canvas`]).
fn new(s: &Surface, pass: Pass, canvas: Option<Rect>, budget: usize) -> Option<Self> {
let Pass { sx, sy, kx, ky } = pass;
let edge = canvas.map(|c| s.content_bounds().union(&c)).filter(|a| !a.is_empty());
let fmt = s.format();
let src = s.content_bounds();
let dst = scaled_rect(src, sx, sy);
if dst.is_empty() {
return None;
}
let n = fmt.channels();
let alpha = fmt.alpha;
// With `edge`, every tap is clamped into the edge area, which holds the content.
let area = edge.unwrap_or(src);
let hx = taps(dst.x0, dst.x1, sx, kx, edge.map(|r| (r.x0, r.x1 - 1)), (area.x0, area.x1 - 1));
let vy = taps(dst.y0, dst.y1, sy, ky, edge.map(|r| (r.y0, r.y1 - 1)), (area.y0, area.y1 - 1));
let x_lo = hx.iter().filter(|t| !t.weights.is_empty()).map(|t| t.start).min().unwrap_or(area.x0);
let x_hi = hx.iter().map(|t| t.start + t.weights.len() as i32).max().unwrap_or(area.x0).max(x_lo);
let mut def = s.default_pixel();
premultiply(&mut def, n, alpha);
let dw = dst.width() as usize;
let mut st = Self { n, alpha, dst, area, hx, vy, x_lo, x_hi, def, def_row: Vec::new(), rows_per_read: 1 };
st.def_row = {
let row: Vec<f32> = st.def.iter().copied().cycle().take((x_hi - x_lo) as usize * n).collect();
let mut out = vec![0.0f32; dw * n];
st.horizontal(&row, &mut out);
out
};
// At most one tile row per read anyway, so this always fits an `i32`.
st.rows_per_read = (budget / ((x_hi - x_lo).max(1) as usize * n).max(dw * n)).clamp(1, TILE_SIZE as usize) as i32;
Some(st)
}
let n = fmt.channels();
let alpha = fmt.alpha;
// With `edge`, every tap is clamped into the edge area, which holds the content.
let area = edge.unwrap_or(src);
let hx = taps(dst.x0, dst.x1, sx, kx, edge.map(|r| (r.x0, r.x1 - 1)), (area.x0, area.x1 - 1));
let vy = taps(dst.y0, dst.y1, sy, ky, edge.map(|r| (r.y0, r.y1 - 1)), (area.y0, area.y1 - 1));
let x_lo = hx.iter().filter(|t| !t.weights.is_empty()).map(|t| t.start).min().unwrap_or(area.x0);
let x_hi = hx.iter().map(|t| t.start + t.weights.len() as i32).max().unwrap_or(area.x0).max(x_lo);
let mut def = s.default_pixel();
premultiply(&mut def, n, alpha);
let dw = dst.width() as usize;
const BAND: usize = 64;
let bands: Vec<(usize, usize)> = (0..vy.len()).step_by(BAND).map(|b| (b, (b + BAND).min(vy.len()))).collect();
// Horizontal pass of one source row (`row` spans `x_lo..x_hi`) into `dw` samples.
let horizontal = |row: &[f32], out: &mut [f32]| match n {
1 => horizontal_fixed::<1>(row, out, &hx, x_lo, &def),
2 => horizontal_fixed::<2>(row, out, &hx, x_lo, &def),
3 => horizontal_fixed::<3>(row, out, &hx, x_lo, &def),
4 => horizontal_fixed::<4>(row, out, &hx, x_lo, &def),
5 => horizontal_fixed::<5>(row, out, &hx, x_lo, &def),
_ => horizontal_dynamic(row, out, &hx, x_lo, &def, n),
};
// A source row of nothing but the default pixel, after the horizontal pass.
let def_row = {
let row: Vec<f32> = def.iter().copied().cycle().take((x_hi - x_lo) as usize * n).collect();
let mut out = vec![0.0f32; dw * n];
horizontal(&row, &mut out);
out
};
// At most one tile row per read anyway, so this always fits an `i32`.
let rows_per_read = (budget / ((x_hi - x_lo).max(1) as usize * n).max(dw * n)).clamp(1, TILE_SIZE as usize) as i32;
let run = |&(b0, b1): &(usize, usize)| -> (Rect, Vec<f32>) {
let band = vy.get(b0..b1).unwrap_or(&[]);
/// How many bands of [`BAND`] output rows the pass has.
fn bands(&self) -> usize {
self.vy.len().div_ceil(BAND)
}
/// Horizontal pass of one source row (`row` spans `x_lo..x_hi`) into `dw` samples.
fn horizontal(&self, row: &[f32], out: &mut [f32]) {
let (hx, x_lo, def) = (&self.hx, self.x_lo, &self.def);
match self.n {
1 => horizontal_fixed::<1>(row, out, hx, x_lo, def),
2 => horizontal_fixed::<2>(row, out, hx, x_lo, def),
3 => horizontal_fixed::<3>(row, out, hx, x_lo, def),
4 => horizontal_fixed::<4>(row, out, hx, x_lo, def),
5 => horizontal_fixed::<5>(row, out, hx, x_lo, def),
_ => horizontal_dynamic(row, out, hx, x_lo, def, self.n),
}
}
/// Band `b` of the output: its rectangle and samples, from the pass's source `s`.
fn band(&self, s: &Surface, b: usize) -> (Rect, Vec<f32>) {
let (n, alpha, area, dst, x_lo, x_hi) = (self.n, self.alpha, self.area, self.dst, self.x_lo, self.x_hi);
let dw = dst.width() as usize;
let (b0, b1) = (b * BAND, ((b + 1) * BAND).min(self.vy.len()));
let band = self.vy.get(b0..b1).unwrap_or(&[]);
let y_lo = band.iter().filter(|t| !t.weights.is_empty()).map(|t| t.start).min().unwrap_or(area.y0);
let y_hi = band.iter().map(|t| t.start + t.weights.len() as i32).max().unwrap_or(area.y0).max(y_lo);
let mut res = vec![0.0f32; band.len() * dw * n];
@@ -434,7 +515,7 @@ fn resize_pass(s: &Surface, pass: Pass, canvas: Option<Rect>, budget: usize) ->
if s.has_tiles_in(Rect::new(x_lo, y, x_hi, y1)) {
let mut c0 = y;
while c0 < y1 {
let c1 = (c0 + rows_per_read).min(y1);
let c1 = (c0 + self.rows_per_read).min(y1);
let read = Rect::new(x_lo, c0, x_hi, c1);
s.read_region_into(read, &mut rows);
premultiply(&mut rows, n, alpha);
@@ -442,7 +523,7 @@ fn resize_pass(s: &Surface, pass: Pass, canvas: Option<Rect>, budget: usize) ->
hrows.resize((c1 - c0) as usize * dw * n, 0.0);
let rw = read.width() as usize * n;
for (row, out) in rows.chunks_exact(rw.max(1)).zip(hrows.chunks_exact_mut(dw * n)) {
horizontal(row, out);
self.horizontal(row, out);
}
for (r, h) in (c0..c1).zip(hrows.chunks_exact(dw * n)) {
add(&mut res, r, h);
@@ -456,7 +537,7 @@ fn resize_pass(s: &Surface, pass: Pass, canvas: Option<Rect>, budget: usize) ->
let (a, b) = ((y - t.start).max(0) as usize, (y1 - t.start).max(0) as usize);
let w: f32 = t.weights.get(a.min(t.weights.len())..b.min(t.weights.len())).unwrap_or(&[]).iter().sum();
if w != 0.0 {
for (dv, sv) in d.iter_mut().zip(&def_row) {
for (dv, sv) in d.iter_mut().zip(&self.def_row) {
*dv += sv * w;
}
}
@@ -465,7 +546,7 @@ fn resize_pass(s: &Surface, pass: Pass, canvas: Option<Rect>, budget: usize) ->
y = y1;
}
for (t, d) in band.iter().zip(res.chunks_exact_mut(dw * n)) {
for (dv, sv) in d.iter_mut().zip(&def_row) {
for (dv, sv) in d.iter_mut().zip(&self.def_row) {
*dv += sv * t.outside;
}
}
@@ -476,7 +557,15 @@ fn resize_pass(s: &Surface, pass: Pass, canvas: Option<Rect>, budget: usize) ->
}
unpremultiply(&mut res, n, alpha);
(Rect::new(dst.x0, dst.y0 + b0 as i32, dst.x1, dst.y0 + b1 as i32), res)
};
}
}
/// One pass. With `canvas`, the content and the canvas repeat their edge outwards (see
/// [`resize_surface_in_canvas`]).
fn resize_pass(s: &Surface, pass: Pass, canvas: Option<Rect>, budget: usize, ctl: &photocraft_raster::Interrupt) -> Surface {
let mut out = Surface::with_default(s.format(), &s.default_pixel());
let Some(st) = PassState::new(s, pass, canvas, budget) else { return out };
let bands: Vec<usize> = (0..st.bands()).collect();
// Bands go into the output a group at a time, so the `f32` rows held at once are bounded by
// the group, not by the whole destination (#1544).
#[cfg(not(target_arch = "wasm32"))]
@@ -484,13 +573,16 @@ fn resize_pass(s: &Surface, pass: Pass, canvas: Option<Rect>, budget: usize) ->
#[cfg(target_arch = "wasm32")]
let group = 1;
for chunk in bands.chunks(group) {
if ctl.cancelled() {
return Surface::new(s.format());
}
#[cfg(not(target_arch = "wasm32"))]
let parts: Vec<(Rect, Vec<f32>)> = {
use rayon::prelude::*;
chunk.par_iter().map(run).collect()
chunk.par_iter().map(|&b| st.band(s, b)).collect()
};
#[cfg(target_arch = "wasm32")]
let parts: Vec<(Rect, Vec<f32>)> = chunk.iter().map(run).collect();
let parts: Vec<(Rect, Vec<f32>)> = chunk.iter().map(|&b| st.band(s, b)).collect();
for (r, d) in parts {
out.write_region(r, &d);
}
@@ -498,6 +590,101 @@ fn resize_pass(s: &Surface, pass: Pass, canvas: Option<Rect>, budget: usize) ->
out
}
/// [`resize_surface`] (or, with a canvas, [`resize_surface_in_canvas`]) in resumable steps, for a
/// caller that cannot block, such as the web build with no threads: [`ResizeSteps::step`] computes
/// bands of output rows until the caller says to stop, and returns the surface once they are all
/// done. It runs [`resize_with_budget`]'s passes and [`resize_pass`]'s bands through the same
/// code, so its result equals [`resize_surface`]'s.
pub struct ResizeSteps {
/// The current pass's source: the input, then each pass's output.
src: Surface,
passes: std::vec::IntoIter<Pass>,
total: usize,
canvas: Option<Rect>,
filter: Resample,
scale: (f64, f64),
run: Option<PassRun>,
/// Final pruning must yield too: sparse output can contain many default-filled tiles.
prune: Option<photocraft_raster::PruneCursor>,
}
/// The pass being computed by [`ResizeSteps`].
struct PassRun {
pass: Pass,
state: PassState,
out: Surface,
next: usize,
}
impl ResizeSteps {
/// Resizes `src` by `(sx, sy)` about the document origin, as [`resize_surface`] (no `canvas`)
/// or [`resize_surface_in_canvas`] does.
pub fn new(src: Surface, sx: f64, sy: f64, filter: Resample, canvas: Option<Rect>) -> Self {
let passes = plan(filter, sx, sy);
Self { src, total: passes.len(), passes: passes.into_iter(), canvas, filter, scale: (sx, sy), run: None, prune: None }
}
/// How far along it is, `0.0..=1.0`: whole passes plus the bands done of the current one.
pub fn progress(&self) -> f32 {
let done = self.total.saturating_sub(self.passes.len() + usize::from(self.run.is_some()));
let part = self.run.as_ref().map_or(0.0, |r| r.next as f32 / r.state.bands().max(1) as f32);
((done as f32 + part) / self.total.max(1) as f32).clamp(0.0, 1.0)
}
/// Does at least one unit of work (planning a pass, one band of 64 output rows, or the
/// closing prune batch) and goes on while `more()` returns true. `Some` once the resize is done.
pub fn step(&mut self, more: &mut dyn FnMut() -> bool) -> Option<Surface> {
loop {
match self.run.take() {
None => {
if let Some(pass) = self.passes.next() {
let out = Surface::with_default(self.src.format(), &self.src.default_pixel());
match PassState::new(&self.src, pass, self.canvas, READ_BUDGET) {
Some(state) => self.run = Some(PassRun { pass, state, out, next: 0 }),
None => self.advance(out, pass),
}
} else if let Some(out) = self.finish_step() {
return Some(out);
}
}
Some(mut run) if run.next < run.state.bands() => {
let (r, data) = run.state.band(&self.src, run.next);
run.out.write_region(r, &data);
run.next += 1;
self.run = Some(run);
}
Some(run) => self.advance(run.out, run.pass),
}
if !more() {
return None;
}
}
}
/// The next pass reads this one's output.
fn advance(&mut self, out: Surface, pass: Pass) {
self.canvas = self.canvas.map(|c| scaled_rect(c, pass.sx, pass.sy));
self.src = out;
}
/// The last pass's output, with the same final pruning as [`resize_with_budget`], but at
/// most four tiles per unit so a broad transparent area cannot monopolize the closing poll.
fn finish_step(&mut self) -> Option<Surface> {
if self.prune.is_none() {
let placeholder = Surface::new(self.src.format());
let out = std::mem::replace(&mut self.src, placeholder);
self.src = preserve_details(out, self.filter, self.scale.0, self.scale.1);
self.prune = Some(self.src.prune_cursor());
}
let cursor = self.prune.as_mut()?;
if !self.src.prune_batch(cursor, 4) {
return None;
}
let placeholder = Surface::new(self.src.format());
Some(std::mem::replace(&mut self.src, placeholder))
}
}
/// Moves a surface by whole pixels (default pixel kept).
pub fn translate_surface(s: &Surface, dx: i32, dy: i32) -> Surface {
s.translated(dx, dy, s.content_bounds())
+193
View File
@@ -400,3 +400,196 @@ fn nearest_neighbor_reduction_does_not_average() {
}
}
}
/// #9212: a bicubic shrink of both axes stores the image between its passes with one axis at the
/// old size, larger than the result; single-pass filters and growing axes store nothing larger.
#[test]
fn largest_resize_rect_counts_the_image_between_the_passes() {
let r = Rect::new(0, 0, 100, 80);
for f in [Resample::Bicubic, Resample::BicubicSharper, Resample::BicubicAutomatic, Resample::PreserveDetails] {
assert_eq!(largest_resize_rect(r, 0.5, 0.5, f), Rect::new(0, 0, 50, 80), "{f:?}");
}
// The axis with the smaller scale goes first.
assert_eq!(largest_resize_rect(r, 0.5, 0.25, Resample::Bicubic), Rect::new(0, 0, 100, 20));
for f in [Resample::Bilinear, Resample::Nearest, Resample::Lanczos] {
assert_eq!(largest_resize_rect(r, 0.5, 0.5, f), Rect::new(0, 0, 50, 40), "{f:?}");
}
for f in [Resample::Bicubic, Resample::BicubicAutomatic] {
assert_eq!(largest_resize_rect(r, 2.0, 3.0, f), Rect::new(0, 0, 200, 240), "{f:?} grows");
assert_eq!(largest_resize_rect(r, 0.5, 2.0, f), Rect::new(0, 0, 50, 160), "{f:?} shrinks x, grows y");
}
}
#[test]
fn controlled_resize_matches_pixels_and_discards_cancelled_work() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let src = ramp(depth);
let expected = resize_surface(&src, 1.7, 0.6, Resample::Bicubic);
let actual = resize_surface_with_interrupt(&src, 1.7, 0.6, Resample::Bicubic, &photocraft_raster::Interrupt::NONE).unwrap();
let bounds = expected.content_bounds();
assert_eq!(expected.read_region(bounds), actual.read_region(bounds));
let cancelled = || true;
let progress = |_| {};
let ctl = photocraft_raster::Interrupt::new(&cancelled, &progress);
assert!(resize_surface_with_interrupt(&src, 1.7, 0.6, Resample::Bicubic, &ctl).is_err());
}
}
/// Every band group of a pass asks `ctl` again, not only the start of each pass: counting the
/// calls catches a missing per-group check, which a cancel (that fails at the first call or at the
/// final one alike) cannot.
#[test]
fn controlled_resize_checks_cancellation_per_band_group() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let src = ramp(depth);
let calls = std::sync::atomic::AtomicUsize::new(0);
let cancelled = || {
calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
false
};
let progress = |_| {};
let ctl = photocraft_raster::Interrupt::new(&cancelled, &progress);
assert!(resize_surface_with_interrupt(&src, 1.7, 0.6, Resample::Bicubic, &ctl).is_ok());
let passes = plan(Resample::Bicubic, 1.7, 0.6).len();
let checks = calls.load(std::sync::atomic::Ordering::Relaxed);
assert!(checks > passes + 2, "{checks} cancellation checks for {passes} passes; per-group checks are missing");
}
}
/// A pseudo-random surface of `w`×`h` in a format of `mode`, `sample` and `alpha`.
fn noisy(w: i32, h: i32, mode: ColorMode, sample: SampleType, alpha: bool) -> Surface {
let fmt = PixelFormat::new(mode, sample, alpha);
let n = fmt.channels();
let mut seed = 0x2545_f491u32;
let v: Vec<f32> = (0..w as usize * h as usize * n)
.map(|_| {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
(seed >> 8) as f32 / (1u32 << 24) as f32
})
.collect();
let mut s = Surface::new(fmt);
s.write_region(Rect::new(0, 0, w, h), &v);
s
}
/// Drives `steps` to the end, `per_call` units of work at a time (0: unlimited); returns the
/// surface and how many calls it took.
fn drive(mut steps: ResizeSteps, per_call: usize) -> (Surface, usize) {
let mut calls = 0;
loop {
calls += 1;
let mut left = per_call;
let mut more = || {
left = left.wrapping_sub(1);
per_call == 0 || left > 0
};
if let Some(s) = steps.step(&mut more) {
return (s, calls);
}
assert!(calls < 10_000, "the resize never finished");
}
}
#[test]
fn steps_equal_resize_surface() {
let cases = [
(SampleType::U8, ColorMode::Rgb, true),
(SampleType::U16, ColorMode::Rgb, false),
(SampleType::F32, ColorMode::Rgb, true),
(SampleType::U8, ColorMode::Grayscale, true),
];
for (sample, mode, alpha) in cases {
let src = noisy(150, 130, mode, sample, alpha);
// Up, down, one axis each way, and enough output rows for several bands.
for (sx, sy) in [(0.6, 0.6), (1.7, 1.7), (0.4, 2.2), (1.0, 0.5)] {
for filter in [Resample::Bicubic, Resample::Lanczos, Resample::BicubicAutomatic, Resample::Nearest] {
let expected = resize_surface(&src, sx, sy, filter);
for per_call in [0, 1, 7] {
let (got, _) = drive(ResizeSteps::new(src.clone(), sx, sy, filter, None), per_call);
assert!(got == expected, "{sample:?} {mode:?} alpha={alpha} scale=({sx}, {sy}) {filter:?} per_call={per_call}");
}
}
}
}
}
#[test]
fn steps_equal_resize_surface_in_canvas() {
let src = noisy(90, 70, ColorMode::Rgb, SampleType::U8, true);
let canvas = Rect::new(0, 0, 90, 70);
for (sx, sy) in [(0.7, 0.7), (1.9, 1.3)] {
let expected = resize_surface_in_canvas(&src, sx, sy, Resample::Bicubic, canvas);
let (got, _) = drive(ResizeSteps::new(src.clone(), sx, sy, Resample::Bicubic, Some(canvas)), 1);
assert!(got == expected, "scale=({sx}, {sy})");
}
}
#[test]
fn steps_take_several_calls_and_report_progress() {
let src = noisy(120, 300, ColorMode::Rgb, SampleType::U8, true);
let mut steps = ResizeSteps::new(src, 0.9, 0.9, Resample::Bicubic, None);
assert_eq!(steps.progress(), 0.0);
let mut seen = Vec::new();
let mut calls = 0;
let out = loop {
calls += 1;
if let Some(s) = steps.step(&mut || false) {
break s;
}
seen.push(steps.progress());
};
// Planning, one call per 64-row band (270 rows: five), and the closing call.
assert!(calls >= 6, "{calls}");
assert!(seen.windows(2).all(|w| w[0] <= w[1]), "progress goes back: {seen:?}");
assert!(seen.iter().all(|p| (0.0..=1.0).contains(p)));
assert_eq!(out.content_bounds(), Rect::new(0, 0, 108, 270));
}
#[test]
fn steps_of_an_empty_surface_are_empty() {
let src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true));
let expected = resize_surface(&src, 0.5, 0.5, Resample::Bicubic);
let (got, _) = drive(ResizeSteps::new(src, 0.5, 0.5, Resample::Bicubic, None), 1);
assert!(got == expected);
}
/// A transparent image with distant opaque corners keeps broad resize bounds. Its empty
/// output tiles must be pruned across polls rather than scanning the whole surface at once.
#[test]
fn sparse_resize_final_pruning_yields_between_tile_batches() {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
sparse_resize_final_pruning(sample);
}
}
fn sparse_resize_final_pruning(sample: SampleType) {
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
for (x0, y0) in [(0, 0), (4600, 504)] {
for y in y0..y0 + 8 {
for x in x0..x0 + 8 {
src.write_pixel(x, y, &[1.0, 0.0, 0.0, 1.0]);
}
}
}
let expected = resize_surface(&src, 0.5, 0.5, Resample::Nearest);
let mut steps = ResizeSteps::new(src, 0.5, 0.5, Resample::Nearest, None);
let mut polls = 0;
while !steps.passes.as_slice().is_empty() || steps.run.is_some() {
assert!(steps.step(&mut || false).is_none());
polls += 1;
assert!(polls < 100, "the resize passes did not finish");
}
let unpruned = steps.src.tile_count();
assert!(unpruned >= 9, "the fixture must leave a broad output to scan: {unpruned}");
let mut closing_polls = 0;
let actual = loop {
closing_polls += 1;
if let Some(out) = steps.step(&mut || false) {
break out;
}
assert!(closing_polls < 100, "the final prune did not finish");
};
assert!(closing_polls >= unpruned.div_ceil(4), "{unpruned} tiles were scanned in {closing_polls} closing polls");
assert!(actual == expected, "stepped pruning must preserve pixels and remove the same empty tiles");
assert!(actual.tile_count() < unpruned, "the fixture must include default-filled output tiles");
}
+160 -7
View File
@@ -3,8 +3,9 @@
//! 1. At a reduced working resolution every pixel gets a *sky likelihood*: a colour prior
//! (blue and bright, or bright and unsaturated for overcast / cloud, or a bright warm glow
//! for sunsets) times a smoothness term (sky has little gradient energy).
//! 2. Seeds are likely-sky pixels in the top rows; the sky region grows from them through
//! 4-connected neighbours that stay likely-sky and change colour gradually.
//! 2. Seeds are likely-sky pixels in the top rows, falling back to substantial high-confidence
//! components in the upper half when the top is obstructed. The sky region grows from them
//! through 4-connected neighbours that stay likely-sky and change colour gradually.
//! 3. Sky seen through gaps (between branches) is not connected to the top: likely-sky pixels
//! that fit a colour model (GMM) of the grown sky are added too. Small holes are filled.
//! 4. The binary working-resolution mask is upsampled and refined at full resolution with a
@@ -30,22 +31,31 @@ pub struct SkyParams {
}
impl Default for SkyParams {
/// Default parameters (threshold 50, softness 50).
fn default() -> Self {
Self { threshold: 50.0, softness: 50.0 }
}
}
/// Hermite interpolation between `e0` and `e1` with cubic smoothing.
#[inline]
fn smooth(e0: f32, e1: f32, x: f32) -> f32 {
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// Rec. 601 perceptual luminance of an RGB triple.
#[inline]
fn luma(p: [f32; 3]) -> f32 {
0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]
}
/// Euclidean distance between two RGB colours.
#[inline]
fn rgb_dist(a: [f32; 3], b: [f32; 3]) -> f32 {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
}
/// Colour prior: how sky-like a colour is on its own (0–1).
pub fn colour_prior(p: [f32; 3]) -> f32 {
let [r, g, b] = p;
@@ -79,7 +89,7 @@ pub fn likelihood(img: &RgbImage) -> Vec<f32> {
img.px.iter().zip(&tex).map(|(p, t)| colour_prior(*p) * (1.0 - smooth(0.015, 0.07, *t))).collect()
}
/// Binary sky mask at working resolution (`None` when no sky touches the top of the image).
/// Binary sky mask at working resolution (`None` when no plausible sky region is found).
pub fn sky_mask_low(img: &RgbImage, threshold: f32) -> Option<Vec<bool>> {
let (w, h) = (img.w, img.h);
if w < 2 || h < 2 {
@@ -97,11 +107,22 @@ pub fn sky_mask_low(img: &RgbImage, threshold: f32) -> Option<Vec<bool>> {
stack.push(i);
}
}
let max_step = 0.12f32;
if stack.len() * 20 < top * w {
return None; // less than 5 % of the top edge looks like sky
// An overhang can hide the entire top band. Discard the sparse top seeds before
// looking for more certain, connected sky patches below it.
mask.fill(false);
stack = obstructed_sky_seeds(img, &like, t + 0.1, max_step);
if stack.is_empty() {
return None;
}
for &i in &stack {
if let Some(m) = mask.get_mut(i) {
*m = true;
}
}
}
// Region growing with a gradual-change constraint.
let max_step = 0.12f32;
while let Some(i) = stack.pop() {
let (x, y) = (i % w, i / w);
let n = [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)];
@@ -110,8 +131,7 @@ pub fn sky_mask_low(img: &RgbImage, threshold: f32) -> Option<Vec<bool>> {
continue;
}
let (a, b) = (img.px[i], img.px[j]);
let d = ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt();
if d < max_step {
if rgb_dist(a, b) < max_step {
mask[j] = true;
stack.push(j);
}
@@ -138,6 +158,56 @@ pub fn sky_mask_low(img: &RgbImage, threshold: f32) -> Option<Vec<bool>> {
mask.iter().any(|v| *v).then_some(mask)
}
/// Without border evidence, require a connected patch of at least 0.5% of the image (and
/// 16 pixels). Searching only the upper half avoids using water or blue ground as seeds;
/// counting each component separately keeps scattered bright specks from passing the gate.
/// Adjacent pixels must satisfy the same gradual colour constraint (`max_step`) as region growing.
fn obstructed_sky_seeds(img: &RgbImage, like: &[f32], threshold: f32, max_step: f32) -> Vec<usize> {
let (w, h) = (img.w, img.h);
let Some(area) = w.checked_mul(h) else { return Vec::new() };
let Some(upper) = w.checked_mul(h.div_ceil(2)) else { return Vec::new() };
if img.px.len() < upper {
return Vec::new();
}
let Some(like) = like.get(..upper) else { return Vec::new() };
let minimum = area.div_ceil(200).max(16);
let mut seen = vec![false; upper];
let mut seeds = Vec::new();
let mut component = Vec::new();
for (start, &score) in like.iter().enumerate() {
if score <= threshold || seen.get(start).copied().unwrap_or(true) {
continue;
}
component.clear();
component.push(start);
if let Some(s) = seen.get_mut(start) {
*s = true;
}
let mut cursor = 0;
while let Some(&i) = component.get(cursor) {
cursor += 1;
let x = i % w;
let neighbours = [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), i.checked_sub(w), i.checked_add(w).filter(|j| *j < upper)];
for j in neighbours.into_iter().flatten() {
if like.get(j).is_some_and(|v| *v > threshold)
&& let Some(s) = seen.get_mut(j)
&& !*s
{
let (a, b) = (img.px[i], img.px[j]);
if rgb_dist(a, b) < max_step {
*s = true;
component.push(j);
}
}
}
}
if component.len() >= minimum {
seeds.extend_from_slice(&component);
}
}
seeds
}
/// A neutral region that also covers most of the bottom rows is a studio or product backdrop,
/// not an overcast sky (which never reaches the bottom of a photo); blue sky is never one.
fn is_backdrop(img: &RgbImage, mask: &[bool], rows: usize) -> bool {
@@ -291,6 +361,89 @@ mod tests {
assert!(r.at(30, 10) > 0.9 && r.at(30, 50) < 0.1);
}
#[test]
fn finds_obstructed_blue_and_overcast_sky_in_disconnected_openings() {
for sky in [[0.35, 0.6, 0.95], [0.85, 0.86, 0.88]] {
// A dark arch with two openings: neither sky component touches an image edge.
let img =
RgbImage::from_fn(
160,
100,
|x, y| {
if (15..65).contains(&y) && ((15..65).contains(&x) || (95..145).contains(&x)) { sky } else { [0.12, 0.2, 0.1] }
},
);
for threshold in [0.0, 50.0, 100.0] {
let p = SkyParams { threshold, ..SkyParams::default() };
let r = select_sky(&ImageSampler { img: &img, origin: (0, 0) }, Rect::new(0, 0, 160, 100), p).expect("sky beneath arch");
assert!(r.at(40, 30) > 0.9 && r.at(120, 30) > 0.9, "both disconnected openings: {sky:?}, {threshold}");
assert!(r.at(40, 55) > 0.9, "growth beyond the upper-half seed search");
assert!(r.at(40, 5) < 0.1, "overhead obstruction");
assert!(r.at(80, 30) < 0.1, "arch pillar");
assert!(r.at(40, 85) < 0.1, "ground");
}
}
}
#[test]
fn obstructed_fallback_rejects_small_patches_and_lower_half_objects() {
for colour in [[0.35, 0.6, 0.95], [0.9, 0.9, 0.92]] {
let img = RgbImage::from_fn(160, 100, |x, y| {
if ((20..26).contains(&x) && (20..26).contains(&y)) || ((20..140).contains(&x) && (65..95).contains(&y)) { colour } else { [0.12, 0.2, 0.1] }
});
assert!(sky_mask_low(&img, 50.0).is_none(), "small upper patch and large lower object: {colour:?}");
}
}
#[test]
fn obstructed_neutral_backdrop_is_still_rejected() {
let img = RgbImage::from_fn(160, 100, |_, y| if y < 12 { [0.12, 0.2, 0.1] } else { [0.95, 0.95, 0.94] });
assert!(sky_mask_low(&img, 50.0).is_none());
}
#[test]
fn fallback_requires_individually_large_four_connected_components() {
let (w, h) = (80, 80);
let img = RgbImage { w, h, px: vec![[0.35, 0.6, 0.95]; w * h] };
// Enough total likely pixels to pass the area gate, but no large component.
let mut like: Vec<f32> = (0..w * h).map(|i| if i % w % 4 == 0 && i / w % 4 == 0 { 1.0 } else { 0.0 }).collect();
assert!(obstructed_sky_seeds(&img, &like, 0.5, 0.12).is_empty());
// Adjacent row ends must not join across the horizontal image boundary.
like.fill(0.0);
for y in 10..20 {
for x in 0..2 {
like[y * w + x] = 1.0;
like[(y - 1) * w + w - 1 - x] = 1.0;
}
}
assert!(obstructed_sky_seeds(&img, &like, 0.5, 0.12).is_empty());
}
#[test]
fn obstructed_fallback_excludes_attached_differently_coloured_patch() {
let (w, h) = (160, 100);
// Minimum qualifying component size: area.div_ceil(200).max(16) = 16000 / 200 = 80.
// Sky component is 40x40 = 1600 pixels in rows 15..55, cols 20..60 (qualifies).
// Attached differently coloured patch is 2x5 = 10 pixels in rows 20..25, cols 60..62 (< 80 min).
// Both patches have high likelihood, but their colour distance exceeds max_step (0.12).
let img = RgbImage::from_fn(w, h, |x, y| {
if (15..55).contains(&y) && (20..60).contains(&x) {
[0.35, 0.6, 0.95]
} else if (20..25).contains(&y) && (60..62).contains(&x) {
[0.85, 0.86, 0.88]
} else {
[0.12, 0.2, 0.1]
}
});
let mask = sky_mask_low(&img, 50.0).expect("sky mask");
assert!(mask[30 * w + 40], "qualifying blue sky must be in mask");
for y in 20..25 {
for x in 60..62 {
assert!(!mask[y * w + x], "attached patch at ({x}, {y}) must be excluded from mask");
}
}
}
/// #4634: a near-white studio backdrop around a product is not an overcast sky, but blue sky
/// reaching the bottom (a photo taken upward) still is.
#[test]
+373 -27
View File
@@ -13,20 +13,25 @@
//! S. Süsstrunk, "Frequency-tuned Salient Region Detection", CVPR 2009): distance of the
//! lightly blurred CIELAB image from its mean colour.
//! 4. Otsu's threshold on the saliency map seeds a GrabCut trimap (low-saliency border pixels
//! are definite background); the result is cleaned (small islands dropped).
//! are definite background); the result is cleaned: small islands are dropped unless they lie
//! next to the main part (a head cut off by a neck too thin for the working resolution).
//! 5. A second GrabCut whose background model only knows the backdrop: a colour model fitted
//! to the image's frame marks every pixel it explains as probable background, and the rest
//! (the blob, plus whatever the backdrop cannot account for) starts as probable foreground.
//! Parts of the subject whose colour is close to the backdrop (a cream shirt on a near-white
//! wall) have low saliency and in step 4 land in the background model, which then explains
//! them away; here they start as foreground and the cut keeps them (#2788). Only what
//! touches the first pass's result survives, so the pass cannot pull in unrelated things.
//! them away; here they start as foreground and the cut keeps them (#2788). Only components
//! touching the first pass's result survive, but attached clutter can survive with them.
//! Where the subject runs out of the picture (a torso cut off by the bottom edge), the frame
//! pixels the rest of the frame cannot explain are left out of the backdrop model and left to
//! the cut; otherwise they would teach it the shirt's colour and be fixed as background.
//! 6. Holes are filled only when their colour fits the foreground model better than the
//! background's, so a patch of backdrop enclosed by the subject (between an arm and the
//! torso) stays open; specks still fill. Then the mask is re-cut at full resolution.
//!
//! Works well for a distinct object on a comparatively plain background; it is not a semantic
//! segmenter.
//! segmenter. Nearby clutter and detached subject parts with the same geometry cannot be
//! distinguished by the component filter.
use std::cmp::Ordering;
use std::collections::BinaryHeap;
@@ -82,7 +87,7 @@ pub fn select_subject_with(sampler: &dyn Sampler, canvas: Rect, ctl: &Interrupt)
}
let Some((fg, bg)) = grabcut::grabcut_with(&img, &mut trimap, 6, ctl)? else { return Ok(None) };
let low: Vec<bool> = trimap.iter().map(|v| *v == PR_FG || *v == grabcut::FG).collect();
let low = super::clean_mask(&low, w, h, 0.2, 0.0);
let low = keep_main_and_nearby(&low, w, h);
if !low.iter().any(|v| *v) {
return Ok(None);
}
@@ -94,39 +99,27 @@ pub fn select_subject_with(sampler: &dyn Sampler, canvas: Rect, ctl: &Interrupt)
/// Step 5: GrabCut again with a background model that only knows the backdrop. A colour model
/// is fitted to the image's frame (the outer pixels, minus a margin around `blob` in case the
/// subject runs out of the picture); the frame is definite background, every other pixel it
/// subject runs out of the picture, and minus what [`subject_at_edge`] finds of the subject
/// there); the rest of the frame is definite background, every other pixel it
/// explains well is probable background, and the rest (the blob, and anything the backdrop
/// cannot account for, such as a cream shirt on a near-white wall) starts as probable
/// foreground. `Ok(None)` when the frame cannot be modelled or the cut keeps nothing that
/// touches the blob; `Err(Cancelled)` once `ctl` is cancelled, as in the first pass.
fn backdrop_pass(img: &super::RgbImage, blob: &[bool], ctl: &Interrupt) -> Result<Option<(Vec<bool>, Gmm, Gmm)>, Cancelled> {
let (w, h) = (img.w, img.h);
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize);
for (i, on) in blob.iter().enumerate() {
if *on {
let (x, y) = (i % w, i / w);
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
}
}
if x1 < x0 || y1 < y0 {
return Ok(None);
}
let margin = (((x1 - x0 + 1).max(y1 - y0 + 1) as f32 * 0.15).ceil() as usize).max(4);
let (bx0, by0) = (x0.saturating_sub(margin), y0.saturating_sub(margin));
let (bx1, by1) = ((x1 + margin).min(w - 1), (y1 + margin).min(h - 1));
let Some(near) = margin_box(blob, w, h) else { return Ok(None) };
let (bx0, by0, bx1, by1) = near;
let frame = ((w.min(h) as f32 * 0.06).round() as usize).max(2);
let in_frame = |x: usize, y: usize| x < frame || y < frame || x + frame >= w || y + frame >= h;
let near_blob = |x: usize, y: usize| (bx0..=bx1).contains(&x) && (by0..=by1).contains(&y);
let samples: Vec<[f32; 3]> = (0..w * h).filter(|i| in_frame(i % w, i / w) && !near_blob(i % w, i / w)).map(|i| img.px[i]).collect();
let Some(backdrop) = Gmm::fit(&super::subsample(&samples, 40_000), grabcut::K, super::gmm::DEFAULT_REG) else { return Ok(None) };
// What the frame itself costs under its own model bounds "explained well".
let mut frame_costs: Vec<f32> = samples.iter().map(|p| backdrop.neg_log(*p)).collect();
frame_costs.sort_by(|a, b| a.total_cmp(b));
let Some(limit) = frame_costs.get(frame_costs.len() * 99 / 100).map(|c| c + 4.0) else { return Ok(None) };
let edge = |i: usize| in_frame(i % w, i / w) && !near_blob(i % w, i / w);
// The subject running out of the picture is neither backdrop to learn nor fixed background.
let released = subject_at_edge(img, blob, frame, near, &edge);
let samples: Vec<[f32; 3]> = (0..w * h).filter(|i| edge(*i) && !released[*i]).map(|i| img.px[i]).collect();
let Some((backdrop, limit)) = fit_backdrop(&samples) else { return Ok(None) };
let mut trimap: Vec<u8> = (0..w * h)
.map(|i| {
let (x, y) = (i % w, i / w);
if in_frame(x, y) && !near_blob(x, y) {
if edge(i) && !released[i] {
BG
} else if blob[i] || backdrop.neg_log(img.px[i]) > limit {
PR_FG
@@ -141,6 +134,359 @@ fn backdrop_pass(img: &super::RgbImage, blob: &[bool], ctl: &Interrupt) -> Resul
Ok(low.iter().any(|v| *v).then_some((low, fg, bg)))
}
/// The box around the pixels `blob` marks in a `w` × `h` image, grown on every side by 15% of
/// its longer side (at least four pixels) and clipped to the image, as (x0, y0, x1, y1)
/// inclusive. `None` when `blob` marks nothing.
fn margin_box(blob: &[bool], w: usize, h: usize) -> Option<(usize, usize, usize, usize)> {
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize);
for (i, on) in blob.iter().enumerate() {
if *on {
let (x, y) = (i % w, i / w);
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
}
}
if x1 < x0 || y1 < y0 {
return None;
}
let margin = (((x1 - x0 + 1).max(y1 - y0 + 1) as f32 * 0.15).ceil() as usize).max(4);
Some((x0.saturating_sub(margin), y0.saturating_sub(margin), (x1 + margin).min(w - 1), (y1 + margin).min(h - 1)))
}
/// The connected parts of `mask` that make up the subject: those at least a fifth the size of
/// the largest, and smaller ones (not specks) with pixels close to it. A part of the subject the
/// cut separated from the rest, such as a head whose thin neck the working resolution lost, a
/// tail or something held, would otherwise go missing; clutter further away is still dropped.
fn keep_main_and_nearby(mask: &[bool], w: usize, h: usize) -> Vec<bool> {
let (lab, sizes) = super::components(mask, w, h);
let Some((main, largest)) = sizes.iter().copied().enumerate().skip(1).max_by_key(|(_, n)| *n) else { return vec![false; w * h] };
// Bounding boxes per component: (x0, y0, x1, y1).
let mut bbox = vec![(usize::MAX, usize::MAX, 0usize, 0usize); sizes.len()];
for (i, l) in lab.iter().enumerate() {
if let Some(b) = bbox.get_mut(*l as usize) {
let (x, y) = (i % w, i / w);
*b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y));
}
}
let Some(&(mx0, my0, mx1, my1)) = bbox.get(main) else { return vec![false; w * h] };
let margin = ((mx1 - mx0 + 1).max(my1 - my0 + 1) as f32 * 0.15).ceil() as usize;
let (nx0, ny0, nx1, ny1) = (mx0.saturating_sub(margin), my0.saturating_sub(margin), mx1 + margin, my1 + margin);
// A part is near when some of its pixels lie within the margin, not merely its box: a thin
// diagonal streak from a corner (a cable, a rail) can have a box that reaches the margin.
let mut near = vec![false; sizes.len()];
for y in ny0..=ny1.min(h - 1) {
for x in nx0..=nx1.min(w - 1) {
let label = lab.get(y * w + x).copied().unwrap_or(0) as usize;
if let Some(n) = near.get_mut(label) {
*n = true;
}
}
}
let (big, speck) = ((largest as f32 * 0.2).ceil() as usize, ((largest as f32 * 0.005).ceil() as usize).max(4));
// Only small, nearby components can be vetoed as edge clutter. A strip must be
// narrow relative to the canvas and at least twice as long as it is wide.
let keep: Vec<bool> = sizes
.iter()
.zip(&bbox)
.enumerate()
.map(|(label, (&size, &(x0, y0, x1, y1)))| {
if label == 0 {
return false;
}
if label == main || size >= big {
return true;
}
let (width, height) = (x1 - x0 + 1, y1 - y0 + 1);
let nearby = near.get(label).copied().unwrap_or(false);
let horizontal_strip = (x0 == 0 || x1 + 1 == w) && height < h * 12 / 100 && width >= height * 2;
let vertical_strip = (y0 == 0 || y1 + 1 == h) && width < w * 12 / 100 && height >= width * 2;
let thin_edge = horizontal_strip || vertical_strip;
size >= speck && nearby && !thin_edge
})
.collect();
lab.iter().map(|l| keep.get(*l as usize).copied().unwrap_or(false)).collect()
}
#[cfg(test)]
mod component_tests {
use super::{keep_main_and_nearby, margin_box, subject_at_edge};
use crate::segment::RgbImage;
#[test]
fn nearby_component_margin_and_size_boundaries() {
// The main 20x20 component has margin ceil(20 * .15) = 3 and a four-pixel floor.
// A 30x30 component raises the floor to ceil(900 * .005) = 5.
for (side, x, count, retained) in [(20, 42, 3, false), (20, 42, 4, true), (20, 43, 4, false), (30, 54, 4, false), (30, 54, 5, true), (30, 55, 5, false)]
{
let (w, h) = (80, 80);
let mut mask = vec![false; w * h];
for y in 20..20 + side {
for x in 20..20 + side {
mask[y * w + x] = true;
}
}
for y in 25..25 + count {
mask[y * w + x] = true;
}
let result = keep_main_and_nearby(&mask, w, h);
assert!(result[20 * w + 20]);
assert_eq!(result[25 * w + x], retained, "side {side}, x {x}, size {count}");
}
// A qualifying nearby island is kept whether it is a head or unrelated clutter.
}
fn rotate(x: usize, y: usize, side: usize, turns: usize) -> (usize, usize) {
let (mut x, mut y) = (x, y);
for _ in 0..turns {
(x, y) = (side - 1 - y, x);
}
(x, y)
}
#[test]
fn cropped_heads_and_large_limbs_survive_on_every_edge() {
let side = 240;
for turns in 0..4 {
for (main, part) in [((100, 20, 140, 70), (111, 0, 129, 18)), ((80, 70, 120, 120), (122, 90, 240, 100))] {
let mut mask = vec![false; side * side];
for (x0, y0, x1, y1) in [main, part] {
for y in y0..y1 {
for x in x0..x1 {
let (x, y) = rotate(x, y, side, turns);
mask[y * side + x] = true;
}
}
}
let result = keep_main_and_nearby(&mask, side, side);
let (x, y) = rotate(part.0, part.1, side, turns);
assert!(result[y * side + x], "edge {turns}, part {part:?}");
}
}
}
#[test]
fn small_nearby_elongated_edge_strips_are_excluded() {
let side = 240;
for turns in 0..4 {
let mut mask = vec![false; side * side];
for (x0, y0, x1, y1) in [(70, 80, 170, 180), (112, 0, 120, 70)] {
for y in y0..y1 {
for x in x0..x1 {
let (x, y) = rotate(x, y, side, turns);
mask[y * side + x] = true;
}
}
}
let result = keep_main_and_nearby(&mask, side, side);
let (x, y) = rotate(112, 0, side, turns);
assert!(!result[y * side + x], "edge {turns}");
}
}
#[test]
fn narrow_edge_parts_matching_subject_are_released_on_every_edge() {
let side = 240;
let frame = 12;
for turns in 0..4 {
for (colour, expected) in [([0.8, 0.2, 0.1], true), ([0.1, 0.2, 0.8], false)] {
let mut img = RgbImage::new(side, side);
img.px.fill([0.5, 0.5, 0.5]);
let mut blob = vec![false; side * side];
for y in 60..100 {
for x in 100..140 {
let (x, y) = rotate(x, y, side, turns);
blob[y * side + x] = true;
img.px[y * side + x] = [0.8, 0.2, 0.1];
}
}
for y in 0..frame {
for x in 110..120 {
let (x, y) = rotate(x, y, side, turns);
img.px[y * side + x] = colour;
}
}
let edge = |i: usize| {
let (x, y) = (i % side, i / side);
x < frame || y < frame || x + frame >= side || y + frame >= side
};
let near = margin_box(&blob, side, side).unwrap();
let released = subject_at_edge(&img, &blob, frame, near, &edge);
let (x, y) = rotate(110, 0, side, turns);
assert_eq!(released[y * side + x], expected, "edge {turns}, colour {colour:?}");
}
}
}
#[test]
fn wide_edge_parts_are_released_only_alongside_the_subject() {
// A dark pole along the left edge, next to the shoulder of a figure that runs off the
// bottom edge, stays backdrop although it runs a long way along that edge. A cream torso
// cut off by the bottom edge below a head (the first pass lost it, so the subject's
// colours do not explain it either) is still released.
let side = 240;
let frame = 12;
// (subject, part, part colour, released), boxes as (x0, y0, x1, y1) exclusive.
let cases = [((10, 100, 110, 240), (0, 0, 8, 240), [0.1, 0.1, 0.1], false), ((100, 40, 140, 80), (60, 228, 180, 240), [0.9, 0.85, 0.7], true)];
for turns in 0..4 {
for (subject, part, colour, expected) in cases {
let mut img = RgbImage::new(side, side);
img.px.fill([0.5, 0.5, 0.5]);
let mut blob = vec![false; side * side];
for (x0, y0, x1, y1) in [subject, part] {
for y in y0..y1 {
for x in x0..x1 {
let (x, y) = rotate(x, y, side, turns);
let on = (x0, y0) == (subject.0, subject.1);
blob[y * side + x] = on;
img.px[y * side + x] = if on { [0.8, 0.2, 0.1] } else { colour };
}
}
}
// As in `backdrop_pass`: the frame, minus the blob's box.
let (bx0, by0, bx1, by1) = margin_box(&blob, side, side).unwrap();
let edge = |i: usize| {
let (x, y) = (i % side, i / side);
let in_frame = x < frame || y < frame || x + frame >= side || y + frame >= side;
in_frame && !((bx0..=bx1).contains(&x) && (by0..=by1).contains(&y))
};
let released = subject_at_edge(&img, &blob, frame, (bx0, by0, bx1, by1), &edge);
let (x, y) = rotate(part.0, part.1, side, turns);
assert_eq!(released[y * side + x], expected, "edge {turns}, part {part:?}");
}
}
}
#[test]
fn nearby_means_pixels_within_the_margin_not_just_the_box() {
// The main part covers 100..140 × 20..60, so its margin box is 94..=145 × 14..=65. A
// one-pixel diagonal streak from the corner (a cable, a rail) to (100, 100) has a box
// that overlaps the margin box, but none of its pixels lie in it: it is not kept. A
// compact part whose pixels lie inside the margin is.
let side = 240;
let mut mask = vec![false; side * side];
for y in 20..60 {
for x in 100..140 {
mask[y * side + x] = true;
}
}
for k in 0..=100 {
mask[k * side + k] = true;
}
for y in 30..35 {
for x in 142..146 {
mask[y * side + x] = true;
}
}
let result = keep_main_and_nearby(&mask, side, side);
assert!(result[20 * side + 100]);
assert!(!result[0], "the corner of the streak is kept");
assert!(!result[100 * side + 100], "the end of the streak is kept");
assert!(result[30 * side + 142], "the compact part inside the margin is dropped");
}
#[test]
fn clutter_away_from_the_subject_or_too_small_is_dropped() {
// The main 40x40 part has margin ceil(40 * .15) = 6 and a speck floor of
// ceil(1600 * .005) = 8 pixels. A 5x5 object just past the margin and a 2x3 one inside
// it are clutter; a 3x3 one inside it is kept.
let side = 240;
let mut mask = vec![false; side * side];
for (x0, y0, x1, y1) in [(100, 100, 140, 140), (147, 110, 152, 115), (110, 143, 112, 146), (120, 143, 123, 146)] {
for y in y0..y1 {
for x in x0..x1 {
mask[y * side + x] = true;
}
}
}
let result = keep_main_and_nearby(&mask, side, side);
assert!(result[100 * side + 100]);
assert!(!result[110 * side + 147], "clutter past the margin is kept");
assert!(!result[143 * side + 110], "a speck inside the margin is kept");
assert!(result[143 * side + 120], "a part inside the margin is dropped");
}
}
/// A colour model of `samples` and the cost above which a pixel is not explained by it: what
/// the samples themselves cost under it (99th percentile), plus a margin. `None` without samples.
fn fit_backdrop(samples: &[[f32; 3]]) -> Option<(Gmm, f32)> {
let model = Gmm::fit(&super::subsample(samples, 40_000), grabcut::K, super::gmm::DEFAULT_REG)?;
let mut costs: Vec<f32> = samples.iter().map(|p| model.neg_log(*p)).collect();
costs.sort_by(|a, b| a.total_cmp(b));
let limit = costs.get(costs.len() * 99 / 100)? + 4.0;
Some((model, limit))
}
/// The pixels of the frame (those `edge` accepts, `frame` wide) that belong to the subject
/// running out of the picture, such as a torso cut off by the bottom edge: pixels of a side
/// (its corners included) that the colours of the frame off that side cannot explain. Without
/// this, such a part trains the backdrop model and is fixed as background, so a cream shirt on a
/// near-white wall is lost. A floor or a table runs up the sides too, so the frame off the
/// bottom knows its colour and it stays backdrop; only a band confined to one side's strip is
/// released (and is then left to the cut). Small patches stay backdrop. Narrow patches are
/// released only if their colour is also explained by the retained subject; wide ones whatever
/// their colour, but only where they run alongside `near` (the blob's box with its margin, as
/// [`margin_box`] gives it): a pole or a door frame along a side next to the subject's shoulder
/// stays backdrop, while a torso the first pass lost still lies below the head.
fn subject_at_edge(img: &super::RgbImage, blob: &[bool], frame: usize, near: (usize, usize, usize, usize), edge: &dyn Fn(usize) -> bool) -> Vec<bool> {
let (w, h) = (img.w, img.h);
// Top, bottom, left, right.
let on = |i: usize, s: usize| {
let (x, y) = (i % w, i / w);
[y < frame, y + frame >= h, x < frame, x + frame >= w].get(s).copied().unwrap_or(false)
};
let subject_samples: Vec<[f32; 3]> = img.px.iter().zip(blob).filter_map(|(pixel, on)| on.then_some(*pixel)).collect();
let subject_model = fit_backdrop(&subject_samples);
let mut odd = vec![false; w * h];
for s in 0..4 {
let others: Vec<[f32; 3]> = (0..w * h).filter(|i| edge(*i) && !on(*i, s)).map(|i| img.px[i]).collect();
let Some((model, limit)) = fit_backdrop(&others) else { continue };
for (i, o) in odd.iter_mut().enumerate() {
if !*o && edge(i) && on(i, s) && model.neg_log(img.px[i]) > limit {
*o = true;
}
}
}
// A stray pixel (noise, a dead pixel) is not a subject: only patches of some size count.
let (lab, sizes) = super::components(&odd, w, h);
let min = (frame * frame / 2).max(4);
let mut bounds = vec![(w, h, 0usize, 0usize); sizes.len()];
for (i, &label) in lab.iter().enumerate() {
if label != 0
&& let Some(b) = bounds.get_mut(label as usize)
{
let (x, y) = (i % w, i / w);
*b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y));
}
}
// Count matching pixels per component so a single coincidental pixel cannot release it.
let mut matching = vec![0usize; sizes.len()];
if let Some((model, limit)) = subject_model {
for (i, &label) in lab.iter().enumerate() {
if label != 0
&& model.neg_log(img.px[i]) <= limit
&& let Some(count) = matching.get_mut(label as usize)
{
*count += 1;
}
}
}
lab.iter()
.map(|l| {
let i = *l as usize;
let Some((&size, &(x0, y0, x1, y1))) = sizes.get(i).zip(bounds.get(i)) else { return false };
if *l == 0 || size < min {
return false;
}
// Along the edge, a wide patch must overlap the blob's box: beside the subject, `edge`
// leaves out that box, so a pole along a side shows up only above or below it.
let (nx0, ny0, nx1, ny1) = near;
let wide_on_horizontal_edge = (y0 == 0 || y1 + 1 == h) && x1 - x0 + 1 >= 2 * frame && x0 <= nx1 && x1 >= nx0;
let wide_on_vertical_edge = (x0 == 0 || x1 + 1 == w) && y1 - y0 + 1 >= 2 * frame && y0 <= ny1 && y1 >= ny0;
let matches_subject = matching.get(i).is_some_and(|count| *count >= size.div_ceil(2));
wide_on_horizontal_edge || wide_on_vertical_edge || matches_subject
})
.collect()
}
/// Per-pixel saliency in `0..=1` (see the module docs).
pub fn saliency(img: &super::RgbImage) -> Vec<f32> {
let (w, h) = (img.w, img.h);
+142
View File
@@ -962,6 +962,148 @@ fn select_subject_leaves_a_background_coloured_gap_open() {
assert!(v > 0.9, "IoU {v}");
}
/// A portrait on a light wall shading down the picture: dark hair, a face, a neck and a
/// torso of colour `shirt` running off the bottom edge (as most portraits are cropped). With
/// `floor`, the torso ends on a dark table that fills the bottom quarter, the full width (so it
/// runs up the picture's sides too). Returns (image, truth, the torso).
fn portrait_scene(seed: u64, shirt: [f32; 3], floor: bool) -> (RgbImage, Vec<bool>, Vec<bool>) {
let (w, h) = (240usize, 200usize);
let mut rng = Rng::new(seed);
let mut img = RgbImage::new(w, h);
let mut truth = vec![false; w * h];
let mut torso = vec![false; w * h];
for y in 0..h {
for x in 0..w {
let (fx, fy) = (x as f32, y as f32);
let hair = ((fx - 120.0) / 30.0).powi(2) + ((fy - 52.0) / 27.0).powi(2) <= 1.0;
let face = ((fx - 120.0) / 26.0).powi(2) + ((fy - 66.0) / 32.0).powi(2) <= 1.0;
let neck = (109..131).contains(&x) && (90..112).contains(&y);
// Shoulders widening towards the bottom, wider than the head's box.
let half = 40.0 + (fy - 110.0) * 0.8;
let body = y >= 110 && (fx - 120.0).abs() <= half && (!floor || y < 150);
let table = floor && y >= 150;
let n = 0.012 * rng.normal();
let i = y * w + x;
truth[i] = hair || face || neck || body;
torso[i] = body && !neck;
let wall = 0.95 - 0.07 * fy / h as f32;
img.px[i] = if table {
[0.35, 0.22, 0.12]
} else if face {
[0.88, 0.71, 0.59]
} else if hair {
[0.24, 0.16, 0.12]
} else if neck {
[0.84, 0.67, 0.55]
} else if body {
shirt
} else {
[wall, wall - 0.01, wall - 0.04]
}
.map(|v| (v + n).clamp(0.0, 1.0));
}
}
(img, truth, torso)
}
#[test]
fn select_subject_keeps_a_low_contrast_part_that_runs_off_the_edge() {
// A cream shirt on a near-white wall, cut off by the bottom edge: it used to train the
// backdrop model and be fixed as background there, so only the head was selected.
let (img, truth, torso) = portrait_scene(3, [0.92, 0.85, 0.72], false);
let s = ImageSampler { img: &img, origin: (0, 0) };
let reg = subject::select_subject(&s, Rect::new(0, 0, 240, 200)).unwrap();
let c = coverage(&reg, &torso, 240, 200);
assert!(c > 0.9, "the torso is part of the subject: covered {c}");
let v = iou(&reg, &truth, 240, 200);
assert!(v > 0.9, "IoU {v}");
}
#[test]
fn select_subject_leaves_a_table_along_the_bottom_edge_out() {
// The same figure behind a table that fills the bottom of the picture: the table also runs
// off the edge, but it is not the subject.
let (img, truth, _) = portrait_scene(3, [0.12, 0.2, 0.45], true);
let s = ImageSampler { img: &img, origin: (0, 0) };
let reg = subject::select_subject(&s, Rect::new(0, 0, 240, 200)).unwrap();
let table: Vec<bool> = (0..240 * 200).map(|i| i / 240 >= 150).collect();
let t = coverage(&reg, &table, 240, 200);
assert!(t < 0.1, "the table stays unselected: covered {t}");
let v = iou(&reg, &truth, 240, 200);
assert!(v > 0.9, "IoU {v}");
}
#[test]
fn select_subject_leaves_a_nearby_single_edge_object_out() {
// A horizontal door-frame piece enters only from the left and stops just short of the
// shoulder. It is large enough to be considered by the edge release heuristic.
let (mut img, truth, _) = low_contrast_figure(7);
let (w, h) = (240, 200);
let mut frame = vec![false; w * h];
for y in 48..56 {
for x in 0..77 {
frame[y * w + x] = true;
img.px[y * w + x] = [0.32, 0.24, 0.16];
}
}
let reg = subject::select_subject(&ImageSampler { img: &img, origin: (0, 0) }, Rect::new(0, 0, w as i32, h as i32)).unwrap();
let subject_coverage = coverage(&reg, &truth, w, h);
let frame_coverage = coverage(&reg, &frame, w, h);
assert!(subject_coverage > 0.85, "subject coverage {subject_coverage}");
assert!(frame_coverage < 0.1, "door-frame coverage {frame_coverage}");
}
#[test]
fn select_subject_ignores_a_stray_pixel_at_the_edge() {
// One white pixel in the corner (a dead pixel, a speck of dust) is not the subject running
// out of the picture: it stays unselected and the subject is found as without it.
let canvas = Rect::new(0, 0, 200, 160);
for seed in 1..=8 {
let (mut img, truth) = disc_scene(200, 160, 100.0, 80.0, 45.0, seed);
img.px[0] = [1.0, 1.0, 1.0];
let reg = subject::select_subject(&ImageSampler { img: &img, origin: (0, 0) }, canvas).unwrap();
assert!(reg.at(0, 0) < 0.5, "seed {seed}: the stray pixel is selected");
let v = iou(&reg, &truth, 200, 160);
assert!(v > 0.9, "seed {seed}: IoU {v}");
}
}
#[test]
fn select_subject_keeps_a_small_detached_part_next_to_the_subject() {
// A standing figure whose head is apart from its body (the neck too thin to survive the
// working resolution): the head is under a fifth of the body's size but part of the subject.
let (w, h) = (300usize, 200usize);
let mut rng = Rng::new(9);
let mut img = RgbImage::new(w, h);
let mut truth = vec![false; w * h];
let mut head = vec![false; w * h];
for y in 0..h {
for x in 0..w {
let (fx, fy) = (x as f32, y as f32);
let is_head = ((fx - 150.0) / 9.0).powi(2) + ((fy - 40.0) / 11.0).powi(2) <= 1.0;
let body = (128..172).contains(&x) && (55..170).contains(&y);
let n = 0.012 * rng.normal();
let i = y * w + x;
truth[i] = is_head || body;
head[i] = is_head;
img.px[i] = if is_head {
[0.88, 0.7, 0.58]
} else if body {
[0.75, 0.15, 0.15]
} else {
[0.55, 0.72, 0.9]
}
.map(|v| (v + n).clamp(0.0, 1.0));
}
}
let s = ImageSampler { img: &img, origin: (0, 0) };
let reg = subject::select_subject(&s, Rect::new(0, 0, w as i32, h as i32)).unwrap();
let c = coverage(&reg, &head, w, h);
assert!(c > 0.9, "the head is part of the subject: covered {c}");
let v = iou(&reg, &truth, w, h);
assert!(v > 0.9, "IoU {v}");
}
#[test]
fn antialias_blends_edge_pixels_with_their_neighbourhood() {
// A lone selected pixel keeps most of its coverage and its neighbours get half of a ninth;
+182
View File
@@ -778,6 +778,8 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec<f32> {
}
mod distance;
#[cfg(test)]
mod ellipse_aa_edge_tests;
mod inverse;
mod lasso;
mod marquee;
@@ -925,5 +927,185 @@ pub fn polygon_window(points: &[(f32, f32)], canvas: Rect, window: Rect, anti_al
m
}
/// Matches candidate colors against a set of selected colors within an $L_\\infty$
/// tolerance per channel.
///
/// Rather than checking against a single 4D axis-aligned bounding box (which would
/// falsely include intermediate colors when disparate hues like red and blue are selected),
/// this indexes selected colors in a bounding-volume hierarchy (BVH). Candidate colors
/// outside the expanded bounds of subtrees are pruned in $O(1)$ time. Fully transparent
/// pixels (`a == 0`) match each other whatever their hidden RGB, as in [`magic_wand`].
#[derive(Clone, Debug)]
pub struct ColorMatcher {
has_transparent: bool,
tree: ColorBvh,
tol: f32,
}
#[derive(Clone, Debug)]
struct ColorBvh {
points: Vec<[f32; 4]>,
nodes: Vec<ColorNode>,
}
#[derive(Clone, Debug)]
enum ColorNode {
Leaf { start: usize, end: usize },
Branch { min: [f32; 4], max: [f32; 4], left: usize, right: usize },
}
impl ColorBvh {
fn build(mut points: Vec<[f32; 4]>) -> Result<Self, &'static str> {
let capacity = Self::node_count(points.len());
let mut nodes = Self::reserve_nodes(capacity)?;
if !points.is_empty() {
Self::build_nodes(&mut points, 0, &mut nodes);
}
Ok(Self { points, nodes })
}
fn node_count(len: usize) -> usize {
if len == 0 {
0
} else if len <= 16 {
1
} else {
1 + Self::node_count(len / 2) + Self::node_count(len - len / 2)
}
}
fn reserve_nodes(capacity: usize) -> Result<Vec<ColorNode>, &'static str> {
let mut nodes = Vec::new();
nodes.try_reserve_exact(capacity).map_err(|_| "not enough memory to index selection colors")?;
Ok(nodes)
}
// Reserve the exact node count before recursion so pushes cannot allocate.
fn build_nodes(points: &mut [[f32; 4]], start: usize, nodes: &mut Vec<ColorNode>) -> usize {
if points.len() <= 16 {
let index = nodes.len();
nodes.push(ColorNode::Leaf { start, end: start + points.len() });
return index;
}
let (mut min, mut max) = ([f32::MAX; 4], [f32::MIN; 4]);
for p in points.iter() {
for c in 0..4 {
min[c] = min[c].min(p[c]);
max[c] = max[c].max(p[c]);
}
}
let mut axis = 0;
for c in 1..4 {
if max[c] - min[c] > max[axis] - min[axis] {
axis = c;
}
}
let mid = points.len() / 2;
points.select_nth_unstable_by(mid, |a, b| a[axis].total_cmp(&b[axis]));
let (left_points, right_points) = points.split_at_mut(mid);
let left = Self::build_nodes(left_points, start, nodes);
let right = Self::build_nodes(right_points, start + mid, nodes);
let index = nodes.len();
nodes.push(ColorNode::Branch { min, max, left, right });
index
}
fn matches_node(&self, index: usize, q: &[f32; 4], tol: f32) -> bool {
match self.nodes.get(index) {
Some(ColorNode::Leaf { start, end }) => {
self.points.get(*start..*end).is_some_and(|pts| pts.iter().any(|p| (0..4).all(|c| (q[c] - p[c]).abs() <= tol + 1e-6)))
}
Some(ColorNode::Branch { min, max, left, right }) => {
for c in 0..4 {
let d = if q[c] < min[c] {
min[c] - q[c]
} else if q[c] > max[c] {
q[c] - max[c]
} else {
0.0
};
if d > tol + 1e-6 {
return false;
}
}
self.matches_node(*left, q, tol) || self.matches_node(*right, q, tol)
}
None => false,
}
}
fn matches(&self, q: &[f32; 4], tol: f32) -> bool {
self.nodes.len().checked_sub(1).is_some_and(|root| self.matches_node(root, q, tol))
}
}
/// Bytes of memory the index is charged per distinct colour: the dedupe set entry and its
/// table slack, the colour in the point list, and its share of the tree nodes. It is an
/// approximate average, not a worst case: while the set rehashes or the point list doubles,
/// the peak runs to roughly 70–90 bytes per colour.
const COLOR_INDEX_BYTES: usize = 64;
/// The most memory the index may be charged. It scales with the distinct colours rather than the
/// canvas, so a noisy 24 MP photo, or a float document where every pixel is its own colour,
/// still builds; it only keeps the index within the sampled pixels' own budget. On 32-bit
/// (the web build) 1 GiB still admits every distinct colour of a 24 MP photo.
const COLOR_INDEX_BUDGET: usize = if cfg!(target_pointer_width = "64") { 4 << 30 } else { 1 << 30 };
impl ColorMatcher {
/// Creates a color matcher from selected RGBA colors and an $L_\\infty$ tolerance in $[0, 1]$.
pub fn new(selected_colors: impl IntoIterator<Item = [f32; 4]>, tol: f32) -> Result<Self, &'static str> {
Self::with_budget(selected_colors, tol, COLOR_INDEX_BUDGET)
}
// Bound the index by `budget` bytes, charged at `COLOR_INDEX_BYTES` per distinct colour, and
// fail without altering the selection.
fn with_budget(selected_colors: impl IntoIterator<Item = [f32; 4]>, tol: f32, budget: usize) -> Result<Self, &'static str> {
let limit = budget / COLOR_INDEX_BYTES;
let mut has_transparent = false;
let mut seen = std::collections::HashSet::new();
let mut opaque_or_semi = Vec::new();
for c in selected_colors {
// A transparent color also stays in the tree: a nearly transparent query is compared
// by its RGB and alpha like any other.
has_transparent |= c[3] == 0.0;
let norm = |x: f32| if x == 0.0 { 0.0f32.to_bits() } else { x.to_bits() };
let key = [norm(c[0]), norm(c[1]), norm(c[2]), norm(c[3])];
if !seen.contains(&key) {
if seen.len() >= limit {
return Err("the selection contains too many distinct colors to match; select a smaller area");
}
seen.try_reserve(1).map_err(|_| "not enough memory to index selection colors")?;
opaque_or_semi.try_reserve(1).map_err(|_| "not enough memory to index selection colors")?;
seen.insert(key);
opaque_or_semi.push(c);
}
}
drop(seen);
opaque_or_semi.sort_unstable_by(|a, b| {
a[0].total_cmp(&b[0]).then_with(|| a[1].total_cmp(&b[1])).then_with(|| a[2].total_cmp(&b[2])).then_with(|| a[3].total_cmp(&b[3]))
});
opaque_or_semi.dedup_by(|a, b| a == b);
// The tree keeps this list; give back the slack its doubling left.
opaque_or_semi.shrink_to_fit();
Ok(Self { has_transparent, tree: ColorBvh::build(opaque_or_semi)?, tol: tol.max(0.0) })
}
/// Returns `true` if no colors (including transparent pixels) were provided.
#[inline]
pub fn is_empty(&self) -> bool {
!self.has_transparent && self.tree.nodes.is_empty()
}
/// Returns `true` if `q` falls within `tol` of any selected color.
#[inline]
pub fn matches(&self, q: [f32; 4]) -> bool {
if q[3] == 0.0 && self.has_transparent {
return true;
}
self.tree.matches(&q, self.tol)
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,100 @@
//! The Elliptical Marquee's anti-aliased edge against the reference measurement (#11391):
//! document 400 × 350, ellipse 100,100–300,250, the patch x 127–132, y 119–124 on the 45° part
//! of its top-left edge. Grids hold what a black fill of the selection reads there (255 outside,
//! 0 fully selected), as the issue's tables do.
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use super::ellipse_surface;
const AREA: Rect = Rect { x0: 0, y0: 0, x1: 400, y1: 350 };
const ELLIPSE: Rect = Rect { x0: 100, y0: 100, x1: 300, y1: 250 };
const PATCH: (i32, i32) = (127, 119);
/// The reference with Anti-alias on.
const SOFT: [[u8; 6]; 6] = [
[255, 255, 255, 255, 255, 152],
[255, 255, 255, 212, 106, 0],
[255, 255, 172, 64, 0, 0],
[247, 137, 26, 0, 0, 0],
[105, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
];
/// Anti-alias off: the reference and PhotoCraft before #11391 agree.
const HARD: [[u8; 6]; 6] =
[[255, 255, 255, 255, 255, 255], [255, 255, 255, 255, 0, 0], [255, 255, 255, 0, 0, 0], [255, 255, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0]];
/// The grey a black fill of selection `s` gives at `(x, y)` on white.
fn grey(s: &Surface, x: i32, y: i32) -> u8 {
let v = s.pixel(x, y).first().copied().unwrap_or(0.0);
(255.0 - v * 255.0).round() as u8
}
fn patch(s: &Surface) -> [[u8; 6]; 6] {
std::array::from_fn(|j| std::array::from_fn(|i| grey(s, PATCH.0 + i as i32, PATCH.1 + j as i32)))
}
#[test]
fn the_anti_aliased_patch_matches_the_reference_within_three_levels() {
let got = patch(&ellipse_surface(ELLIPSE, AREA, true));
for (j, (row, want)) in got.iter().zip(&SOFT).enumerate() {
for (i, (g, w)) in row.iter().zip(want).enumerate() {
assert!(g.abs_diff(*w) <= 3, "{},{}: {g} vs reference {w}\n{got:?}", PATCH.0 + i as i32, PATCH.1 + j as i32);
}
}
}
#[test]
fn the_other_three_quadrants_mirror_the_measured_one() {
let s = ellipse_surface(ELLIPSE, AREA, true);
// Symmetric about x = 200 and y = 175: pixel x mirrors to 399 - x, y to 349 - y.
for y in PATCH.1..PATCH.1 + 6 {
for x in PATCH.0..PATCH.0 + 6 {
let here = s.pixel(x, y);
assert_eq!(s.pixel(399 - x, y), here, "{x},{y} mirrored across x");
assert_eq!(s.pixel(x, 349 - y), here, "{x},{y} mirrored across y");
assert_eq!(s.pixel(399 - x, 349 - y), here, "{x},{y} mirrored through the centre");
}
}
// A few partial pixels, so the mirror is not of 0s and 1s alone.
assert_eq!(grey(&s, 399 - 129, 122), grey(&s, 129, 122));
assert!((1..255).contains(&grey(&s, 399 - 129, 349 - 121)));
}
#[test]
fn the_hard_edge_is_unchanged() {
assert_eq!(patch(&ellipse_surface(ELLIPSE, AREA, false)), HARD);
}
/// Fully inside reads exactly 1 and outside exactly 0, so Add / Subtract / Intersect combine as
/// before; the edge is soft only in a band at most √2 px wide.
#[test]
fn inside_is_exactly_selected_and_outside_exactly_empty() {
let s = ellipse_surface(ELLIPSE, AREA, true);
// Every pixel whose square lies inside, here all of the axes but the end pixels (whose
// corners poke out by a sliver).
for x in 101..299 {
assert_eq!(s.pixel(x, 175), vec![1.0], "{x},175 on the horizontal axis");
}
for y in 101..249 {
assert_eq!(s.pixel(200, y), vec![1.0], "200,{y} on the vertical axis");
}
for (x, y) in [(100, 175), (299, 175), (200, 100), (200, 249)] {
assert!(s.pixel(x, y)[0] > 0.99, "{x},{y}: the axis ends are all but full");
}
for (x, y) in [(99, 175), (300, 175), (200, 99), (200, 250), (100, 100), (299, 249), (0, 0)] {
assert_eq!(s.pixel(x, y), vec![0.0], "{x},{y} outside");
}
let partial = (0..350).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| (1..255).contains(&grey(&s, x, y))).count();
let (hard_in, soft_in) = {
let h = ellipse_surface(ELLIPSE, AREA, false);
let sum = |s: &Surface| s.read_region(AREA).iter().map(|v| f64::from(*v)).sum::<f64>();
(sum(&h), sum(&s))
};
// The perimeter (≈ 556 px) times a band 1 to √2 px across, and the hard ellipse's area.
assert!((556..787).contains(&partial), "{partial} partial pixels");
let exact = std::f64::consts::PI * 100.0 * 75.0;
assert!((soft_in - exact).abs() < 0.002 * exact && (hard_in - exact).abs() < 0.002 * exact, "{soft_in} {hard_in} vs {exact}");
}
+71 -19
View File
@@ -30,14 +30,22 @@ fn decode8(b: u8) -> f32 {
}
/// An ellipse inscribed in a rectangle, tested in `f32` exactly as the Elliptical Marquee
/// always has (so its anti-aliased edge keeps the same bytes).
/// always has (so its hard edge keeps the same bytes).
struct Ellipse {
cx: f32,
cy: f32,
rx: f32,
ry: f32,
/// At most 3 px across: the anti-aliased edge keeps its 4×4 sub-samples (#11391 measured
/// only larger ellipses).
small: bool,
/// `1 / rx`, `1 / ry`, `1 / rx²` and `1 / ry²` for [`Ellipse::ramp`].
inv: [f64; 4],
}
/// The widest an ellipse is across and still [`Ellipse::small`].
const SMALL: u32 = 3;
impl Ellipse {
fn new(r: Rect) -> Self {
Self {
@@ -45,42 +53,82 @@ impl Ellipse {
cy: (i64::from(r.y0) + i64::from(r.y1)) as f32 / 2.0,
rx: r.width() as f32 / 2.0,
ry: r.height() as f32 / 2.0,
small: r.width().min(r.height()) <= SMALL,
inv: {
let (rx, ry) = (f64::from(r.width()) / 2.0, f64::from(r.height()) / 2.0);
[1.0 / rx, 1.0 / ry, 1.0 / (rx * rx), 1.0 / (ry * ry)]
},
}
}
/// The anti-aliased edge's coverage of pixel `(x, y)`, as Photoshop draws it (#11391): with
/// `F` the normalised radius at the pixel's centre, `(F - 1) / (|∂F/∂x| + |∂F/∂y|)` is the
/// centre's distance to the edge measured in the pixel's own width across the edge, and the
/// coverage ramps linearly over that width. An axis-aligned edge gets the pixel's exact
/// area; a 45° edge ramps over √2 px. 1 exactly at and around the centre.
#[inline]
fn ramp(&self, x: i32, y: i32) -> f64 {
let [irx, iry, irx2, iry2] = self.inv;
let (dx, dy) = (f64::from(x) + 0.5 - f64::from(self.cx), f64::from(y) + 0.5 - f64::from(self.cy));
let (u, v) = (dx * irx, dy * iry);
let f = (u * u + v * v).sqrt();
// `f · (|∂F/∂x| + |∂F/∂y|)`; 0 only at the centre itself.
let across = dx.abs() * irx2 + dy.abs() * iry2;
if across <= 0.0 {
return 1.0;
}
(0.5 - (f - 1.0) * f / across).clamp(0.0, 1.0)
}
#[inline]
fn inside(&self, x: f32, y: f32) -> bool {
let (dx, dy) = ((x - self.cx) / self.rx, (y - self.cy) / self.ry);
dx * dx + dy * dy <= 1.0
}
/// Whether every point of pixel `(x, y)` that the coverage samples is inside: its four
/// corners (anti-aliased) or its centre. Along a row the pixels where this holds form one
/// run: each test is monotonic in the distance from the centre (IEEE rounding is monotonic).
/// Whether pixel `(x, y)` is fully selected: its centre is inside (hard edge), its four
/// corners are (a small anti-aliased ellipse), or its [`Ellipse::ramp`] is 1. Along a row
/// the pixels where this holds form one run: each test grows with the distance from the
/// centre; [`row_span`] checks both ends of the run.
#[inline]
fn full(&self, x: i32, y: i32, aa: bool) -> bool {
let (fx, fy) = (x as f32, y as f32);
if !aa {
return self.inside(fx + 0.5, fy + 0.5);
}
self.inside(fx, fy) && self.inside(fx + 1.0, fy) && self.inside(fx, fy + 1.0) && self.inside(fx + 1.0, fy + 1.0)
if self.small {
return self.inside(fx, fy) && self.inside(fx + 1.0, fy) && self.inside(fx, fy + 1.0) && self.inside(fx + 1.0, fy + 1.0);
}
self.ramp(x, y) >= 1.0
}
/// Coverage of pixel `(x, y)` in sixteenths: 16 when [`Ellipse::full`], else (anti-aliased)
/// the number of its 4×4 sub-samples inside.
/// The byte pixel `(x, y)` stores: `one` when [`Ellipse::full`], 0 outside a hard edge, and
/// otherwise the fraction of its 4×4 sub-samples inside (small) or its [`Ellipse::ramp`].
#[inline]
fn coverage(&self, x: i32, y: i32, aa: bool) -> u8 {
fn coverage(&self, x: i32, y: i32, aa: bool, enc: &[u8; 17]) -> u8 {
let one = enc[16];
if aa && !self.small {
// One ramp: a full pixel's is 1, which encodes as `one`.
// `encode8`'s rounding, inline: this runs for every edge pixel.
return (self.ramp(x, y) as f32 * 255.0 + 0.5) as u8;
}
if self.full(x, y, aa) {
return 16;
return one;
}
if !aa {
return 0;
}
let (fx, fy) = (x as f32, y as f32);
(0..16u8).filter(|i| self.inside(fx + ((i % 4) as f32 + 0.5) / 4.0, fy + ((i / 4) as f32 + 0.5) / 4.0)).count() as u8
let n = (0..16u8).filter(|i| self.inside(fx + ((i % 4) as f32 + 0.5) / 4.0, fy + ((i / 4) as f32 + 0.5) / 4.0)).count();
enc.get(n).copied().unwrap_or(one)
}
}
/// The bytes for 0/16 … 16/16 coverage.
fn sixteenths() -> [u8; 17] {
std::array::from_fn(|n| encode8(n as f32 / 16.0))
}
/// One row of the ellipse: nothing outside `lo..hi`, fully selected in `full_lo..full_hi`, and
/// the edge pixels in between (`lo..full_lo`, `full_hi..hi`) need the exact coverage test.
#[derive(Clone, Copy)]
@@ -94,7 +142,7 @@ struct RowSpan {
/// Bounds row `y` of `e` within `cut.x0..cut.x1` from the exact (f64) ellipse, widened by a
/// margin that covers the f32 tests' rounding. The bounds are then checked with the exact tests
/// (the fully selected run at both ends, nothing just outside); any doubt evaluates the whole row.
fn row_span(e: &Ellipse, y: i32, cut: Rect, aa: bool) -> RowSpan {
fn row_span(e: &Ellipse, y: i32, cut: Rect, aa: bool, enc: &[u8; 17]) -> RowSpan {
let exact = RowSpan { lo: cut.x0, full_lo: cut.x1, full_hi: cut.x1, hi: cut.x1 };
let (cx, cy, rx, ry) = (f64::from(e.cx), f64::from(e.cy), f64::from(e.rx), f64::from(e.ry));
// Every point the tests sample in this row lies in `y..y + 1`.
@@ -118,14 +166,15 @@ fn row_span(e: &Ellipse, y: i32, cut: Rect, aa: bool) -> RowSpan {
if inner < 0.0 || full_lo >= full_hi {
(full_lo, full_hi) = (hi, hi);
}
let empty = |x: i32| e.coverage(x, y, aa) == 0;
let empty = |x: i32| e.coverage(x, y, aa, enc) == 0;
let ok = (lo <= cut.x0 || empty(lo - 1)) && (hi >= cut.x1 || empty(hi)) && (full_lo >= full_hi || (e.full(full_lo, y, aa) && e.full(full_hi - 1, y, aa)));
if ok { RowSpan { lo, full_lo, full_hi, hi } } else { exact }
}
/// The Elliptical Marquee's shape: the ellipse inscribed in `r`, cut to `clip`, as a GRAY8
/// selection. Anti-aliased edge pixels hold the fraction of their 4×4 sub-samples inside;
/// without anti-aliasing a pixel is selected when its centre is inside.
/// selection. Anti-aliased edge pixels ramp across the edge as in Photoshop ([`Ellipse::ramp`];
/// an ellipse at most 3 px across keeps the fraction of 4×4 sub-samples inside); without
/// anti-aliasing a pixel is selected when its centre is inside.
pub fn ellipse_surface(r: Rect, clip: Rect, anti_alias: bool) -> Surface {
let mut s = Surface::new(PixelFormat::GRAY8);
let cut = r.intersect(&clip);
@@ -133,9 +182,9 @@ pub fn ellipse_surface(r: Rect, clip: Rect, anti_alias: bool) -> Surface {
return s;
}
let e = Ellipse::new(r);
let enc: [u8; 17] = std::array::from_fn(|n| encode8(n as f32 / 16.0));
let enc = sixteenths();
let one = encode8(1.0);
let rows = par_map(cut.height() as usize, |i| row_span(&e, cut.y0 + i as i32, cut, anti_alias));
let rows = par_map(cut.height() as usize, |i| row_span(&e, cut.y0 + i as i32, cut, anti_alias, &enc));
let span = |y: i32| rows.get(usize::try_from(y - cut.y0).ok()?);
let full = s.solid_tile(&[1.0]);
let blank = s.solid_tile(&[0.0]);
@@ -159,11 +208,11 @@ pub fn ellipse_surface(r: Rect, clip: Rect, anti_alias: bool) -> Surface {
d.fill(one);
}
for x in (sp.lo.max(tr.x0)..sp.full_lo.min(tr.x1)).chain(sp.full_hi.max(tr.x0)..sp.hi.min(tr.x1)) {
let n = e.coverage(x, y, anti_alias);
let n = e.coverage(x, y, anti_alias, &enc);
if n > 0
&& let Some(d) = tile.get_or_insert_with(|| Tile::clone(&blank)).bytes_mut().get_mut(at(x))
{
*d = enc.get(usize::from(n)).copied().unwrap_or(one);
*d = n;
}
}
}
@@ -302,7 +351,8 @@ pub fn combine_sparse(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f32)
mod tests {
use super::*;
/// The Elliptical Marquee as it was: every pixel tested and written one at a time.
/// The Elliptical Marquee one pixel at a time: every pixel tested and written on its own (the
/// anti-aliased ramp through [`Ellipse::ramp`]).
fn old_ellipse(r: Rect, area: Rect, aa: bool) -> Surface {
let cut = r.intersect(&area);
let mut shape = Surface::new(PixelFormat::GRAY8);
@@ -318,6 +368,8 @@ mod tests {
let corners = [(fx, fy), (fx + 1.0, fy), (fx, fy + 1.0), (fx + 1.0, fy + 1.0)].iter().filter(|(a, b)| inside(*a, *b)).count();
let cov = if !aa {
if inside(fx + 0.5, fy + 0.5) { 1.0 } else { 0.0 }
} else if r.width().min(r.height()) > SMALL {
Ellipse::new(r).ramp(x, y) as f32
} else if corners == 4 {
1.0
} else {
+140
View File
@@ -1,3 +1,95 @@
#[test]
fn color_matcher_bvh_and_transparency() {
let red = [1.0, 0.0, 0.0, 1.0];
let blue = [0.0, 0.0, 1.0, 1.0];
let matcher = ColorMatcher::new([red, blue], 10.0 / 255.0).unwrap();
assert!(!matcher.is_empty());
assert!(matcher.matches([0.98, 0.02, 0.0, 1.0]));
assert!(matcher.matches([0.0, 0.02, 0.98, 1.0]));
assert!(!matcher.matches([0.0, 0.0, 0.0, 1.0]));
assert!(!matcher.matches([0.0, 1.0, 0.0, 1.0]));
assert!(!matcher.matches([1.0, 1.0, 0.0, 1.0]));
assert!(!matcher.matches([0.5, 0.0, 0.5, 1.0]));
assert!(!matcher.matches([0.0, 0.0, 0.0, 0.0]));
let matcher_trans = ColorMatcher::new([red, [0.0, 0.0, 0.0, 0.0]], 10.0 / 255.0).unwrap();
assert!(matcher_trans.matches([0.0, 0.0, 0.0, 0.0]));
assert!(matcher_trans.matches([0.5, 0.2, 0.1, 0.0]));
assert!(matcher_trans.matches([0.98, 0.0, 0.0, 1.0]));
assert!(!matcher_trans.matches([0.0, 1.0, 0.0, 1.0]));
let empty = ColorMatcher::new([], 10.0 / 255.0).unwrap();
assert!(empty.is_empty());
assert!(!empty.matches(red));
// Only an exactly transparent pair ignores RGB: a faint pixel is compared like any other.
let faint = [0.0, 0.0, 0.0, 1e-6];
assert!(!matcher_trans.matches([0.5, 0.2, 0.1, 1e-6]));
assert!(matcher_trans.matches(faint));
let matcher_faint = ColorMatcher::new([faint], 10.0 / 255.0).unwrap();
assert!(matcher_faint.matches([0.0, 0.0, 0.0, 0.0]));
assert!(!matcher_faint.matches([0.5, 0.0, 0.0, 0.0]));
}
#[test]
fn color_matcher_preserves_distinct_faint_32f_colors_at_zero_tolerance() {
let zero = [0.0, 0.0, 0.0, 0.0];
let faint = [0.0, 0.0, 0.0, 8e-6];
let matcher = ColorMatcher::new([zero, faint], 0.0).unwrap();
assert!(matcher.matches(zero));
assert!(matcher.matches(faint));
assert!(!matcher.matches([0.0, 0.0, 0.0, 2e-5]));
}
#[test]
fn color_matcher_coalesces_exact_repeated_colors() {
let red = [1.0, 0.0, 0.0, 1.0];
let transparent = [0.0, 0.0, 0.0, 0.0];
let colors = std::iter::repeat_n(red, 10_000).chain(std::iter::repeat_n(transparent, 10_000));
let matcher = ColorMatcher::new(colors, 0.0).unwrap();
assert!(matcher.matches(red));
assert!(matcher.matches(transparent));
assert!(!matcher.matches([0.0, 1.0, 0.0, 1.0]));
}
#[test]
fn color_matcher_splits_colors_that_are_all_nearly_equal() {
// Distinct colours a few ulps apart: no spread to split on usefully, but still no leaf past 16.
let mut c = 0.5f32;
let selected: Vec<[f32; 4]> = (0..200)
.map(|_| {
c = c.next_up();
[c, 0.25, 0.75, 1.0]
})
.collect();
let matcher = ColorMatcher::new(selected.iter().copied(), 0.0).unwrap();
assert!(matcher.tree.nodes.iter().any(|node| matches!(node, ColorNode::Branch { .. })), "more than 16 colours must not share one leaf");
for p in &selected {
assert!(matcher.matches(*p));
}
assert!(!matcher.matches([0.6, 0.25, 0.75, 1.0]));
}
#[test]
fn color_matcher_tree_matches_brute_force() {
// More than 16 distinct colors, so the tree has nodes and not just one leaf.
let selected: Vec<[f32; 4]> = (0..200)
.map(|i| {
let t = i as f32 / 199.0;
[t, (t * 7.0).fract(), (t * 13.0).fract(), if i % 25 == 0 { 0.0 } else { 1.0 }]
})
.collect();
let tol = 0.05;
let matcher = ColorMatcher::new(selected.iter().copied(), tol).unwrap();
for i in 0..2000 {
let t = i as f32 / 1999.0;
let q = [(t * 3.0).fract(), (t * 5.0).fract(), (t * 11.0).fract(), if i % 7 == 0 { 0.0 } else { 1.0 }];
let want = selected.iter().any(|p| (q[3] == 0.0 && p[3] == 0.0) || (0..4).all(|c| (q[c] - p[c]).abs() <= tol + 1e-6));
assert_eq!(matcher.matches(q), want, "{q:?}");
}
}
use super::*;
fn img(w: usize, h: usize, f: impl Fn(usize, usize) -> [f32; 4]) -> Vec<[f32; 4]> {
@@ -655,6 +747,45 @@ fn combine_region_matches_combine() {
assert!(combine_region(None, None, SelectionMode::Subtract).is_none());
}
#[test]
fn color_matcher_bounds_tightly_clustered_leaves() {
let colors: Vec<_> = (0..1000).map(|i| [i as f32 * 1e-10, 0.0, 0.0, 1.0]).collect();
let matcher = ColorMatcher::new(colors.iter().copied(), 0.0).unwrap();
for node in &matcher.tree.nodes {
if let ColorNode::Leaf { start, end } = node {
assert!(end - start <= 16);
}
}
assert!(matcher.matches(colors[999]));
assert!(!matcher.matches([0.1, 0.0, 0.0, 1.0]));
}
#[test]
fn color_matcher_budget_fires_at_the_distinct_color_it_is_sized_for() {
let colors: Vec<_> = (0..5).map(|i| [i as f32 / 8.0, 0.0, 0.0, 1.0]).collect();
// Room for exactly `n` distinct colours: the n-th still fits, the (n + 1)-th does not.
for n in 1..=5 {
let budget = n * COLOR_INDEX_BYTES;
assert!(ColorMatcher::with_budget(colors[..n].iter().copied(), 0.0, budget).is_ok(), "{n} colours in {budget} bytes");
if n < 5 {
assert!(ColorMatcher::with_budget(colors[..=n].iter().copied(), 0.0, budget).is_err(), "{} colours in {budget} bytes", n + 1);
}
}
// A budget short of one colour's charge admits none, and duplicates are never charged.
assert!(ColorMatcher::with_budget([colors[0]], 0.0, COLOR_INDEX_BYTES - 1).is_err());
let repeated = std::iter::repeat_n(colors[0], 1000);
assert!(ColorMatcher::with_budget(repeated, 0.0, COLOR_INDEX_BYTES).is_ok());
}
#[test]
fn color_matcher_default_budget_admits_a_noisy_float_canvas() {
// Every pixel of a 2000x1000 float canvas is its own colour: past the old 1M-colour cap.
let colors = (0..2_000_000u32).map(|i| [i as f32 * 1e-7, (i % 1013) as f32 * 1e-3, 0.5, 1.0]);
let matcher = ColorMatcher::new(colors, 0.0).unwrap();
assert!(matcher.matches([1_999_999.0 * 1e-7, (1_999_999 % 1013) as f32 * 1e-3, 0.5, 1.0]));
assert!(!matcher.matches([0.9, 0.9, 0.9, 1.0]));
}
#[test]
fn polygon_window_keeps_full_canvas_fractional_coverage() {
for canvas in [Rect::new(0, 0, 110_000, 12), Rect::new(-123, -7, 109_877, 5)] {
@@ -687,3 +818,12 @@ fn feather_leaves_everything_beyond_its_reach_at_zero() {
assert!(out[reach + 1..].iter().all(|v| *v == 0.0), "radius {radius}: nonzero past reach {reach}");
}
}
#[test]
fn color_matcher_tree_reservation_failure_is_reported() {
assert!(ColorBvh::reserve_nodes(usize::MAX).is_err());
for n in 0..1024 {
let tree = ColorBvh::build(vec![[0.0; 4]; n]).unwrap();
assert_eq!(tree.nodes.len(), ColorBvh::node_count(n));
}
}
+76 -10
View File
@@ -2,6 +2,7 @@
//! in the same span order as the scalar implementation, but contiguous slices
//! let the compiler vectorize without architecture-specific code or unsafe.
use photocraft_geom::Rect;
use photocraft_raster::Interrupt;
use crate::{
BlurShape, Ctx,
@@ -16,20 +17,78 @@ pub(crate) fn radius(r: f32) -> f32 {
if r.is_finite() { r.clamp(0.0, 1000.0) } else { 0.0 }
}
/// The most coverage runs a custom kernel of side `size` may hold. Every output row adds one
/// window per run, so a fragmented mask (alternating or ever-changing coverage, up to `size²`
/// runs) would cost as much as a direct convolution. Smooth anti-aliased shapes need about one
/// run per edge pixel plus one per interior stretch (a radius-1000 disc about 8k, 4 per row);
/// this allows 64 per row on average and never limits kernels of up to 256 × 256.
pub(crate) fn max_spans(size: usize) -> usize {
size.saturating_mul(64).max(1 << 16)
}
pub(crate) struct Prepared {
spans: Spans,
weights: Option<Vec<f64>>,
}
impl Prepared {
pub(crate) fn new(r: f32, shape: BlurShape) -> Self {
Self { spans: Spans::new(radius(r), shape_inside(shape)) }
Self { spans: Spans::new(radius(r), shape_inside(shape)), weights: None }
}
/// Coverage of a custom shape, centred in a (2 ceil(radius) + 1) square. `None` for a
/// malformed or empty mask, a cancelled `ctl`, or one too fragmented to convolve (see
/// [`max_spans`]).
pub(crate) fn custom(r: f32, mask: &Image, ctl: &Interrupt) -> Option<Self> {
let ri = radius(r).ceil() as i32;
let size = usize::try_from(ri.checked_mul(2)?.checked_add(1)?).ok()?;
if mask.ch != 1
|| mask.rect.width() as usize != size
|| mask.rect.height() as usize != size
|| mask.data.len() != size.checked_mul(size)?
|| mask.data.iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v))
{
return None;
}
let mut rows = Vec::new();
let mut weights = Vec::new();
let mut count = 0.0;
let limit = max_spans(size);
for (y, row) in mask.data.chunks_exact(size).enumerate() {
if ctl.cancelled() || rows.len() > limit {
return None;
}
rows.try_reserve(size).ok()?;
weights.try_reserve(size).ok()?;
let mut x = 0;
while let Some(&coverage) = row.get(x) {
let start = x;
x += 1;
while row.get(x) == Some(&coverage) {
x += 1;
}
if coverage > 0.0 {
// Convolution samples source(x-offset), so an impulse draws the selected
// kernel in its original orientation rather than a mirrored shape.
rows.push((ri - y as i32, ri - x as i32 + 1, ri - start as i32));
weights.push(f64::from(coverage));
count += (x - start) as f64 * f64::from(coverage);
}
}
}
if rows.len() > limit {
return None;
}
(count > 0.0).then_some(Self { spans: Spans { rows, count }, weights: Some(weights) })
}
pub(crate) fn run(&self, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
self.checked_run(src, out, ctx).unwrap_or_default()
self.checked_run(src, out, ctx, &Interrupt::NONE).unwrap_or_default()
}
pub(crate) fn checked_run(&self, src: &Image, out: Rect, ctx: &Ctx) -> Option<Vec<f32>> {
/// `None` for malformed input, an allocation that does not fit, or a cancelled `ctl`
/// (polled per input and output row).
pub(crate) fn checked_run(&self, src: &Image, out: Rect, ctx: &Ctx, ctl: &Interrupt) -> Option<Vec<f32>> {
let n = src.ch;
if n == 0 || n > crate::fxutil::MAXC || out.is_empty() {
return Some(Vec::new());
@@ -51,13 +110,16 @@ impl Prepared {
if prefix_len > MAX_SAMPLES || output_len > MAX_SAMPLES {
return None;
}
let prefix = Prefix::build(src, ctx, n, stride, prefix_len)?;
let prefix = Prefix::build(src, ctx, n, stride, prefix_len, ctl)?;
let mut result = Vec::new();
result.try_reserve_exact(output_len).ok()?;
let mut acc = Vec::new();
acc.try_reserve_exact(row_len).ok()?;
acc.resize(row_len, 0.0f64);
for y in out.y0..out.y1 {
if ctl.cancelled() {
return None;
}
self.accumulate_row(&prefix, src, out, y, &mut acc)?;
for px in acc.chunks_exact(n) {
let mut sample = [0.0f32; crate::fxutil::MAXC];
@@ -76,7 +138,7 @@ impl Prepared {
fn accumulate_row(&self, prefix: &Prefix, src: &Image, out: Rect, y: i32, acc: &mut [f64]) -> Option<()> {
let x = i64::from(out.x0) - i64::from(src.rect.x0);
acc.fill(0.0);
for &(dy, a, b) in &self.spans.rows {
for (index, &(dy, a, b)) in self.spans.rows.iter().enumerate() {
let yy = i64::from(y) - i64::from(src.rect.y0) + i64::from(dy);
let Ok(yy) = usize::try_from(yy) else {
continue;
@@ -85,7 +147,8 @@ impl Prepared {
continue;
}
let row = prefix.row(yy)?;
prefix.add_window(row, acc, x + i64::from(a), x + i64::from(b) + 1, out.width() as i64)?;
let weight = self.weights.as_ref().and_then(|w| w.get(index)).copied().unwrap_or(1.0);
prefix.add_window(row, acc, x + i64::from(a), x + i64::from(b) + 1, out.width() as i64, weight)?;
}
Some(())
}
@@ -102,7 +165,7 @@ struct Prefix {
}
impl Prefix {
fn build(src: &Image, ctx: &Ctx, n: usize, stride: usize, len: usize) -> Option<Self> {
fn build(src: &Image, ctx: &Ctx, n: usize, stride: usize, len: usize, ctl: &Interrupt) -> Option<Self> {
let w = src.rect.width() as usize;
let h = src.rect.height() as usize;
let input_row = w.checked_mul(n)?;
@@ -111,6 +174,9 @@ impl Prefix {
data.resize(len, 0.0f64);
if input_row != 0 {
for (s, d) in src.data.chunks_exact(input_row).zip(data.chunks_exact_mut(stride)) {
if ctl.cancelled() {
return None;
}
let mut sum = [0.0f64; crate::fxutil::MAXC];
for (px, dst) in s.chunks_exact(n).zip(d.get_mut(n..)?.chunks_exact_mut(n)) {
let alpha = if ctx.alpha { *px.last()? } else { 1.0 };
@@ -131,7 +197,7 @@ impl Prefix {
}
/// Add the `[lo, hi)` window difference of `row` for `acc.len() / n` output pixels.
fn add_window(&self, row: &[f64], acc: &mut [f64], lo: i64, hi: i64, end: i64) -> Option<()> {
fn add_window(&self, row: &[f64], acc: &mut [f64], lo: i64, hi: i64, end: i64, weight: f64) -> Option<()> {
let (n, w) = (self.n, self.w);
let row_len = acc.len();
if lo >= 0 && hi + end <= w as i64 {
@@ -140,7 +206,7 @@ impl Prefix {
let low = row.get(start_lo..start_lo.checked_add(row_len)?)?;
let high = row.get(start_hi..start_hi.checked_add(row_len)?)?;
for ((acc, hi), lo) in acc.iter_mut().zip(high).zip(low) {
*acc += hi - lo;
*acc += if weight == 1.0 { hi - lo } else { (hi - lo) * weight };
}
} else {
// Direct kernel callers may omit the halo or request pixels outside src.
@@ -149,7 +215,7 @@ impl Prefix {
let low = (lo + i as i64).clamp(0, w as i64) as usize * n;
let high = (hi + i as i64).clamp(0, w as i64) as usize * n;
for ((acc, hi), lo) in px.iter_mut().zip(row.get(high..high + n)?).zip(row.get(low..low + n)?) {
*acc += hi - lo;
*acc += if weight == 1.0 { hi - lo } else { (hi - lo) * weight };
}
}
}
+337
View File
@@ -0,0 +1,337 @@
//! Grow a boolean mask by a clipped square: shared by the Remove Tool and the non-local completion.
/// `mask` grown by `r` cells with a square structuring element, clipped to the `w × h` grid: a
/// cell is set when any mask cell lies within chessboard distance `r`. A mask of the wrong size
/// comes back unchanged.
///
/// Work stays proportional to the mask's extent, not the grid's: a mask cell costs `(2r+1)²`
/// stores directly, which is the cheapest for a small or sparse mask; a bulky one is grown by two
/// running-count passes over its own expanded bounding box instead.
pub(crate) fn dilate_square(w: usize, h: usize, mask: &[bool], r: usize) -> Vec<bool> {
if r == 0 || mask.len() != w.saturating_mul(h) {
return mask.to_vec();
}
let Some(b) = bounds(w, mask) else {
return vec![false; mask.len()];
};
let mut out = vec![false; mask.len()];
match choose(w, h, &b, r) {
Path::Direct => direct(w, h, mask, r, &b, &mut out),
Path::Separable => {
separable(w, h, mask, r, &b, &mut out);
}
}
out
}
/// The tight box around the set cells (inclusive corners) and how many there are. A mask of at
/// most `KEEP` set cells also keeps their indices (`cells.len() == count`), so the direct path
/// needn't scan the grid a second time.
struct Bounds {
x0: usize,
y0: usize,
x1: usize,
y1: usize,
count: usize,
cells: Vec<usize>,
}
/// Most set cells whose indices `bounds` keeps.
const KEEP: usize = 1 << 12;
#[derive(Debug, PartialEq, Eq)]
enum Path {
Direct,
Separable,
}
/// `None` when no cell is set. `mask` has `w` columns.
fn bounds(w: usize, mask: &[bool]) -> Option<Bounds> {
let mut b = Bounds { x0: usize::MAX, y0: usize::MAX, x1: 0, y1: 0, count: 0, cells: Vec::new() };
// Most cells are unset: skip 64 at a time with a vectorised test before looking at any.
for (c, chunk) in mask.chunks(64).enumerate().filter(|(_, chunk)| chunk.iter().any(|m| *m)) {
for (j, _) in chunk.iter().enumerate().filter(|(_, m)| **m) {
let i = c * 64 + j;
let (x, y) = (i % w, i / w);
// Row-major order: `y` never decreases.
(b.x0, b.y0, b.x1, b.y1, b.count) = (b.x0.min(x), b.y0.min(y), b.x1.max(x), y, b.count + 1);
if b.count <= KEEP {
b.cells.push(i);
} else if !b.cells.is_empty() {
b.cells = Vec::new();
}
}
}
(b.count > 0).then_some(b)
}
/// The box around the set cells grown by `r` and clipped to the grid: `(x0, x1, y0, y1)`, inclusive.
fn expanded(w: usize, h: usize, b: &Bounds, r: usize) -> (usize, usize, usize, usize) {
(b.x0.saturating_sub(r), b.x1.saturating_add(r).min(w - 1), b.y0.saturating_sub(r), b.y1.saturating_add(r).min(h - 1))
}
/// Cells of scratch the separable path allocates: the rows that can hold set cells, across the
/// expanded box. Never more than the expanded box's area.
#[cfg(test)]
fn scratch_cells(w: usize, h: usize, b: &Bounds, r: usize) -> usize {
let (ex0, ex1, _, _) = expanded(w, h, b, r);
(b.y1 - b.y0 + 1).saturating_mul(ex1 - ex0 + 1)
}
/// The cheaper way to grow the mask: the direct squares when they cost no more than two passes
/// over the expanded box.
fn choose(w: usize, h: usize, b: &Bounds, r: usize) -> Path {
let side = r.saturating_mul(2).saturating_add(1);
let direct = b.count.saturating_mul(side.saturating_mul(side));
let (ex0, ex1, ey0, ey1) = expanded(w, h, b, r);
let separable = (ex1 - ex0 + 1).saturating_mul(ey1 - ey0 + 1).saturating_mul(2);
if direct <= separable { Path::Direct } else { Path::Separable }
}
/// Set the clipped `(2r+1)²` square around every set cell.
fn direct(w: usize, h: usize, mask: &[bool], r: usize, b: &Bounds, out: &mut [bool]) {
let mut stamp = |x: usize, y: usize| {
let (sx, ex) = (x.saturating_sub(r), x.saturating_add(r).min(w - 1));
for ty in y.saturating_sub(r)..=y.saturating_add(r).min(h - 1) {
out[ty * w + sx..=ty * w + ex].fill(true);
}
};
if b.cells.len() == b.count {
b.cells.iter().for_each(|i| stamp(i % w, i / w));
return;
}
for y in b.y0..=b.y1 {
for (dx, _) in mask[y * w + b.x0..=y * w + b.x1].iter().enumerate().filter(|(_, m)| **m) {
stamp(b.x0 + dx, y);
}
}
}
/// Two running-count passes (a cell is set when any cell within `r` along one axis is set),
/// confined to the expanded box. Returns the scratch cells it allocated.
fn separable(w: usize, h: usize, mask: &[bool], r: usize, b: &Bounds, out: &mut [bool]) -> usize {
let (ex0, ex1, ey0, ey1) = expanded(w, h, b, r);
let (bw, eh, rows) = (ex1 - ex0 + 1, ey1 - ey0 + 1, b.y1 - b.y0 + 1);
// Horizontal: every set cell lies in `x0..=x1`, so clipping a row's window at the box instead
// of the grid only drops cells that are unset anyway. Only the rows that hold set cells.
let mut horiz = vec![false; rows * bw];
for ry in 0..rows {
let src = &mask[(b.y0 + ry) * w + ex0..=(b.y0 + ry) * w + ex1];
let dst = &mut horiz[ry * bw..(ry + 1) * bw];
let mut count = 0usize;
for &s in &src[..bw.min(r)] {
count += usize::from(s);
}
for (k, d) in dst.iter_mut().enumerate() {
count += src.get(k.saturating_add(r)).map_or(0, |s| usize::from(*s));
count -= k.checked_sub(r).and_then(|j| j.checked_sub(1)).and_then(|j| src.get(j)).map_or(0, |s| usize::from(*s));
*d = count > 0;
}
}
// Vertical, row-major: the expanded box's rows form a sequence of length `eh` whose element
// `k` is the scratch row of output row `ey0 + k`, or all unset above and below the set rows.
let row = |k: usize| if k < eh { (ey0 + k).checked_sub(b.y0).filter(|j| *j < rows).map(|j| &horiz[j * bw..(j + 1) * bw]) } else { None };
let mut counts = vec![0usize; bw];
for k in 0..eh.min(r) {
if let Some(src) = row(k) {
counts.iter_mut().zip(src).for_each(|(c, s)| *c += usize::from(*s));
}
}
for k in 0..eh {
if let Some(src) = row(k.saturating_add(r)) {
counts.iter_mut().zip(src).for_each(|(c, s)| *c += usize::from(*s));
}
if let Some(src) = k.checked_sub(r).and_then(|j| j.checked_sub(1)).and_then(row) {
counts.iter_mut().zip(src).for_each(|(c, s)| *c -= usize::from(*s));
}
let dst = &mut out[(ey0 + k) * w + ex0..(ey0 + k) * w + ex1 + 1];
dst.iter_mut().zip(&counts).for_each(|(d, c)| *d = *c > 0);
}
horiz.len()
}
#[cfg(test)]
mod tests {
use super::*;
/// The loop the non-local completion used before this module: the oracle.
fn reference(w: usize, h: usize, mask: &[bool], r: usize) -> Vec<bool> {
let ri = r as i32;
let mut targets = vec![false; w * h];
for (i, _) in mask.iter().enumerate().filter(|(_, m)| **m) {
let (x, y) = ((i % w) as i32, (i / w) as i32);
for ty in (y - ri).max(0)..=(y + ri).min(h as i32 - 1) {
for tx in (x - ri).max(0)..=(x + ri).min(w as i32 - 1) {
targets[ty as usize * w + tx as usize] = true;
}
}
}
targets
}
fn forced(w: usize, h: usize, mask: &[bool], r: usize, path: &Path) -> Vec<bool> {
let mut out = vec![false; w * h];
if let Some(b) = bounds(w, mask) {
match path {
Path::Direct => direct(w, h, mask, r, &b, &mut out),
Path::Separable => {
separable(w, h, mask, r, &b, &mut out);
}
}
}
out
}
/// `dilate_square` and both paths, forced, against the oracle.
fn check(w: usize, h: usize, mask: &[bool], r: usize, what: &str) {
let want = reference(w, h, mask, r);
assert!(dilate_square(w, h, mask, r) == want, "{what}: dilate_square {w}x{h} r={r}");
assert!(forced(w, h, mask, r, &Path::Direct) == want, "{what}: direct {w}x{h} r={r}");
assert!(forced(w, h, mask, r, &Path::Separable) == want, "{what}: separable {w}x{h} r={r}");
}
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn mask(&mut self, n: usize, per_mille: u64) -> Vec<bool> {
(0..n).map(|_| self.next() % 1000 < per_mille).collect()
}
}
#[test]
fn every_mask_on_small_grids_matches_the_oracle() {
for w in 1..=3usize {
for h in 1..=3usize {
for bits in 0..1u32 << (w * h) {
let mask: Vec<bool> = (0..w * h).map(|i| bits >> i & 1 == 1).collect();
for r in [1, 2, 3, 5] {
check(w, h, &mask, r, "exhaustive");
}
}
}
}
}
fn cases(w: usize, h: usize) -> Vec<(&'static str, Vec<bool>)> {
let mut rng = Rng(0x9e37_79b9_7f4a_7c15 ^ (w * 31 + h) as u64);
let at = |x: usize, y: usize| y * w + x;
let single = |x: usize, y: usize| {
let mut m = vec![false; w * h];
m[at(x, y)] = true;
m
};
let (cx, cy, mx, my) = (w / 2, h / 2, w - 1, h - 1);
let mut two = single(0, 0);
two[at(mx, my)] = true;
let blob = |x0: usize, y0: usize| -> Vec<bool> {
(0..w * h).map(|i| (x0..x0 + 3.min(w - x0)).contains(&(i % w)) && (y0..y0 + 3.min(h - y0)).contains(&(i / w))).collect()
};
let mut corners = blob(0, 0);
corners.iter_mut().zip(blob(w.saturating_sub(3), h.saturating_sub(3))).for_each(|(a, b)| *a |= b);
vec![
("empty", vec![false; w * h]),
("centre", single(cx, cy)),
("top-left", single(0, 0)),
("top-right", single(mx, 0)),
("bottom-left", single(0, my)),
("bottom-right", single(mx, my)),
("top edge", single(cx, 0)),
("bottom edge", single(cx, my)),
("left edge", single(0, cy)),
("right edge", single(mx, cy)),
("sparse", rng.mask(w * h, 5)),
("dense", rng.mask(w * h, 700)),
("full", vec![true; w * h]),
("two corners", two),
("two blobs", corners),
("edge blob", blob(0, cy.saturating_sub(1))),
("horizontal line", (0..w * h).map(|i| i / w == cy).collect()),
("vertical line", (0..w * h).map(|i| i % w == cx).collect()),
]
}
#[test]
fn named_cases_match_the_oracle() {
for (w, h) in [(64, 48), (1, 97), (97, 1), (7, 3)] {
for (what, mask) in cases(w, h) {
for r in [1, 2, 3, 5, 16, 200] {
check(w, h, &mask, r, what);
}
}
}
}
/// More set cells than `bounds` keeps (`KEEP`): it drops its cell list, and the direct path
/// rescans the box instead.
#[test]
fn a_mask_with_more_cells_than_are_kept_matches_the_oracle() {
let (w, h) = (200, 200);
let mask = Rng(0x2545_f491_4f6c_dd1d).mask(w * h, 130);
let Some(b) = bounds(w, &mask) else { panic!("cells are set") };
assert!(b.count > KEEP && b.cells.is_empty(), "{} cells, {} kept", b.count, b.cells.len());
check(w, h, &mask, 1, "more than KEEP");
}
#[test]
fn the_remove_tool_dilate_is_unchanged() {
for (w, h) in [(64, 48), (1, 97), (7, 3)] {
for (what, mask) in cases(w, h) {
for r in [1, 3, 16] {
assert!(crate::remove::dilate(w, h, &mask, r) == reference(w, h, &mask, r), "{what} {w}x{h} r={r}");
}
assert!(crate::remove::dilate(w, h, &mask, 0) == mask, "{what}: r = 0");
}
}
assert_eq!(dilate_square(4, 4, &[true; 5], 2), vec![true; 5], "a mask of the wrong size");
assert_eq!(dilate_square(0, 0, &[], 2), Vec::<bool>::new());
}
#[test]
fn a_small_or_sparse_mask_takes_the_direct_path_however_big_the_grid() {
let (w, h) = (600, 400);
let mut one = vec![false; w * h];
one[200 * w + 300] = true;
let Some(b) = bounds(w, &one) else { panic!("one cell is set") };
assert_eq!(choose(w, h, &b, 3), Path::Direct);
let mut two = vec![false; w * h];
two[0] = true;
two[w * h - 1] = true;
let Some(b) = bounds(w, &two) else { panic!("two cells are set") };
assert_eq!((b.x0, b.y0, b.x1, b.y1), (0, 0, w - 1, h - 1), "the box is the whole grid");
assert_eq!(choose(w, h, &b, 3), Path::Direct);
}
#[test]
fn a_bulky_mask_takes_the_separable_path_with_bounded_scratch() {
let (w, h) = (240, 240);
let mut rng = Rng(7);
let mut mask = vec![false; w * h];
for y in 20..220 {
for x in 20..220 {
mask[y * w + x] = rng.next().is_multiple_of(2);
}
}
let Some(b) = bounds(w, &mask) else { panic!("the patch has set cells") };
assert_eq!(choose(w, h, &b, 3), Path::Separable);
let (ex0, ex1, ey0, ey1) = expanded(w, h, &b, 3);
let scratch = scratch_cells(w, h, &b, 3);
assert!(scratch <= (ex1 - ex0 + 1) * (ey1 - ey0 + 1), "scratch stays inside the expanded box");
assert!(scratch < w * h, "and is smaller than the grid when the mask is inside it");
let mut out = vec![false; w * h];
assert_eq!(separable(w, h, &mask, 3, &b, &mut out), scratch);
}
#[test]
fn huge_radii_do_not_overflow() {
let mask = [false, true, false, false];
assert_eq!(dilate_square(2, 2, &mask, usize::MAX), vec![true; 4]);
assert_eq!(forced(2, 2, &mask, usize::MAX, &Path::Separable), vec![true; 4]);
}
}
+49
View File
@@ -16,6 +16,55 @@ fn relight_admission_tracks_the_document_dependent_working_set() {
assert!(working_bytes(&params, tiny, tiny, 4) < 64 << 20, "{}", working_bytes(&params, tiny, tiny, 4));
}
/// Shape Blur admits each worker's f64 prefix sums over its halo-expanded tile, and a custom
/// shape also the kernel mask it keeps for the whole run and its worst-case coverage runs.
#[test]
fn shape_blur_admission_counts_prefix_sums_and_the_retained_kernel() {
let area = Rect::new(0, 0, 4096, 4096);
let radius = 100.0f32;
let plain = FilterParams::ShapeBlur { radius, shape: BlurShape::Circle };
let Halo::Radius(reach) = plain.halo_for(area) else { panic!("Shape Blur has a radius halo") };
let threads = rayon::current_num_threads().max(1) as u64;
let tile = auto_tile(&plain, area) as u64;
let window = tile + 2 * reach as u64;
// The float windows every radius filter admits (4 channels of f32), then the f64 prefix sums.
let windows = (area.size().area() * 16).min(RESULT_BUDGET as u64) * 3 + (window * window + tile * tile * 3) * 16 * threads;
let prefix = (window + 1) * (window + 1) * 4 * 8 * threads;
assert_eq!(working_bytes(&plain, area, area, 4), windows + prefix);
let size = (2 * shape_blur::radius(radius).ceil() as usize + 1) as i32;
let mask = Image { rect: Rect::new(0, 0, size, size), ch: 1, data: vec![1.0; (size * size) as usize] };
let custom = FilterParams::CustomShapeBlur { radius, kernel: Some(std::sync::Arc::new(mask)) };
let kernel = (size * size) as u64 * 4 + shape_blur::max_spans(size as usize) as u64 * 20;
assert_eq!(working_bytes(&custom, area, area, 4), working_bytes(&plain, area, area, 4) + kernel);
// Unresolved, it holds no kernel yet.
assert_eq!(working_bytes(&FilterParams::CustomShapeBlur { radius, kernel: None }, area, area, 4), working_bytes(&plain, area, area, 4));
}
/// Only as many workers as there are tiles hold a tile's windows at once: a 24 MP Shape Blur
/// at radius 500 (24 tiles of 1024 px) counts 24 workers' windows, not one per thread.
#[test]
fn radius_filter_admission_counts_no_more_workers_than_tiles() {
let area = Rect::new(0, 0, 6000, 4000);
let plain = FilterParams::ShapeBlur { radius: 500.0, shape: BlurShape::Heart };
let Halo::Radius(reach) = plain.halo_for(area) else { panic!("Shape Blur has a radius halo") };
let tile = auto_tile(&plain, area) as u64;
let tiles = 6000u64.div_ceil(tile) * 4000u64.div_ceil(tile);
let workers = (rayon::current_num_threads().max(1) as u64).min(tiles);
let window = tile + 2 * reach as u64;
let windows = (area.size().area() * 16).min(RESULT_BUDGET as u64) * 3 + (window * window + tile * tile * 3) * 16 * workers;
let prefix = (window + 1) * (window + 1) * 4 * 8 * workers;
assert_eq!(working_bytes(&plain, area, area, 4), windows + prefix);
// One tile: one worker, whatever the thread count.
let small = Rect::new(0, 0, 200, 200);
let one = FilterParams::ShapeBlur { radius: 10.0, shape: BlurShape::Circle };
let Halo::Radius(r) = one.halo_for(small) else { panic!("Shape Blur has a radius halo") };
let t = auto_tile(&one, small) as u64;
let w = t + 2 * r as u64;
let single = (small.size().area() * 16).min(RESULT_BUDGET as u64) * 3 + (w * w + t * t * 3) * 16 + (w + 1) * (w + 1) * 4 * 8;
assert_eq!(working_bytes(&one, small, small, 4), single);
}
fn fmt(s: SampleType) -> PixelFormat {
PixelFormat::new(ColorMode::Rgb, s, true)
}
+76
View File
@@ -435,6 +435,82 @@ fn lens_flare_is_brightest_at_its_centre() {
assert!(out.pixel(38, 2)[0] >= 0.2 - 1e-6, "screen never darkens");
}
/// A Lens Flare on black over `r` (its reference bounds), as straight RGB.
fn flare_on_black(r: Rect, lens: LensType) -> Vec<f32> {
let s = flat(SampleType::F32, r, BLACK);
let p = FilterParams::LensFlare { brightness: 100.0, center_x: 0.3, center_y: 0.3, lens };
apply(&s, &p, output_area(&p, s.content_bounds(), r, None), r, None).read_region(r)
}
#[test]
fn lens_flare_scales_with_the_image() {
// Sizes are fractions of the image diagonal: the same flare on a twice-as-large image, halved,
// is the small one (up to the one-pixel rays, which stay one pixel wide).
for lens in [LensType::Zoom, LensType::Prime35, LensType::Prime105, LensType::MoviePrime] {
let (small, big) = (Rect::new(0, 0, 200, 150), Rect::new(0, 0, 400, 300));
let a = flare_on_black(small, lens);
let b = flare_on_black(big, lens);
let mut diff = 0.0f64;
for y in 0..150usize {
for x in 0..200usize {
for k in 0..3 {
let at = |xx: usize, yy: usize| f64::from(b[(yy * 400 + xx) * 4 + k]);
let halved = (at(2 * x, 2 * y) + at(2 * x + 1, 2 * y) + at(2 * x, 2 * y + 1) + at(2 * x + 1, 2 * y + 1)) / 4.0;
diff += (halved - f64::from(a[(y * 200 + x) * 4 + k])).abs();
}
}
}
let mean = diff / (200.0 * 150.0 * 3.0);
assert!(mean < 0.01, "{lens:?}: mean difference {mean}");
}
}
#[test]
fn every_lens_flare_lens_looks_different() {
let r = Rect::new(0, 0, 160, 120);
let lenses = [LensType::Zoom, LensType::Prime35, LensType::Prime105, LensType::MoviePrime];
let flares: Vec<Vec<f32>> = lenses.iter().map(|l| flare_on_black(r, *l)).collect();
for i in 0..flares.len() {
for j in i + 1..flares.len() {
let d: f32 = flares[i].iter().zip(&flares[j]).map(|(a, b)| (a - b).abs()).sum::<f32>() / flares[i].len() as f32;
assert!(d > 0.01, "{:?} and {:?} are nearly the same ({d})", lenses[i], lenses[j]);
}
}
}
#[test]
fn lens_flare_with_an_overflowing_centre_leaves_finite_pixels() {
// A centre of 1e300 from a command becomes infinite in f32; NaN must not reach the pixels.
let src = flat(SampleType::F32, R, [0.2, 0.3, 0.4, 1.0]);
for lens in [LensType::Zoom, LensType::Prime35, LensType::Prime105, LensType::MoviePrime] {
for (cx, cy) in [(f32::INFINITY, f32::NEG_INFINITY), (1e38, -1e38), (f32::NAN, 0.5), (5.0, -5.0)] {
let out = run(&src, &FilterParams::LensFlare { brightness: 100.0, center_x: cx, center_y: cy, lens });
let px = out.read_region(R);
assert!(px.iter().all(|v| v.is_finite()), "{lens:?} at ({cx}, {cy})");
assert!(px.iter().zip(src.read_region(R)).all(|(a, b)| *a >= b - 1e-6), "{lens:?}: screen never darkens");
}
}
}
#[test]
fn lens_flare_between_two_measured_brightnesses_blends_their_light() {
// 75 % and 100 % are measured levels; 87.5 % is halfway, so its light is their mean. On black
// 32-bit the result is the light itself; at 0 and 1 the clamp makes the blend nonlinear.
let black = flat(SampleType::F32, R, [0.0, 0.0, 0.0, 1.0]);
for lens in [LensType::Zoom, LensType::Prime35, LensType::Prime105, LensType::MoviePrime] {
let at = |brightness: f32| run(&black, &FilterParams::LensFlare { brightness, center_x: 0.25, center_y: 0.5, lens }).read_region(R);
let (lo, mid, hi) = (at(75.0), at(87.5), at(100.0));
let mut checked = 0;
for ((l, m), h) in lo.iter().zip(&mid).zip(&hi) {
if [*l, *m, *h].iter().all(|v| *v > 0.0 && *v < 1.0) {
assert!((m - 0.5 * (l + h)).abs() < 1e-4, "{lens:?}: {m} is not the mean of {l} and {h}");
checked += 1;
}
}
assert!(checked >= 100, "{lens:?}: too few unclipped values ({checked})");
}
}
#[test]
fn lens_flare_never_darkens_32_bit_values_above_one() {
// #5785: the screen formula turned 2.0 into 1.5 (and 1.0 at the core).
@@ -0,0 +1,76 @@
//! A project file stores a Filter Gallery effect's values as plain numbers, bypassing the clamp that
//! `GalleryEffect::set` applies, so the reach arithmetic must survive any `f32` in them.
use photocraft_algo::{FilterParams, GalleryEffect, GalleryFilter, GalleryParamKind, Halo, apply};
use photocraft_color::PixelFormat;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
const EXTREME: [f32; 6] = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, 3.0e9];
/// The reach in pixels; a whole-image effect (`Halo::Bounds`) has no radius to check.
fn radius(effects: Vec<GalleryEffect>) -> i32 {
match (FilterParams::FilterGallery { effects }).halo() {
Halo::Radius(r) => r,
_ => 0,
}
}
fn with_all_ranges(filter: GalleryFilter, v: f32) -> GalleryEffect {
let mut e = GalleryEffect::new(filter);
for p in filter.params() {
if matches!(p.kind, GalleryParamKind::Range { .. }) {
e.values.insert(p.key.to_string(), v);
}
}
e
}
#[test]
fn gallery_reach_survives_extreme_stored_values() {
for &filter in GalleryFilter::ALL {
for v in EXTREME {
let r = radius(vec![with_all_ranges(filter, v)]);
assert!(r >= 0, "{filter:?} with every range at {v}: reach {r}");
}
}
}
#[test]
fn gallery_reach_of_a_stack_survives_extreme_stored_values() {
for &filter in GalleryFilter::ALL {
let stack = vec![with_all_ranges(filter, f32::MAX), with_all_ranges(filter, f32::MAX), with_all_ranges(filter, f32::MAX)];
let r = radius(stack);
assert!(r >= 0, "{filter:?} stacked three times: reach {r}");
}
}
/// Every range value at `scale` times its declared maximum (past the range when `scale` > 1).
fn scaled_to_max(filter: GalleryFilter, scale: f32) -> GalleryEffect {
let mut e = GalleryEffect::new(filter);
for p in filter.params() {
if let GalleryParamKind::Range { max, .. } = p.kind {
e.values.insert(p.key.to_string(), max * scale);
}
}
e
}
/// The tile halo comes from the clamped values, so the pixels must be drawn from them too: a stored
/// value past its range renders exactly as the clamped one, with no seam where tiles meet.
#[test]
fn gallery_pixels_use_the_same_clamped_values_as_the_reach() {
let mut src = Surface::new(PixelFormat::RGBA8);
src.fill_rect(Rect::new(0, 0, 160, 160), &[0.2, 0.5, 0.8, 1.0]);
src.fill_rect(Rect::new(30, 40, 120, 100), &[0.9, 0.3, 0.1, 1.0]);
src.fill_rect(Rect::new(70, 60, 90, 130), &[0.1, 0.9, 0.4, 1.0]);
let area = Rect::new(0, 0, 160, 160);
for &filter in GalleryFilter::ALL {
let run = |e: GalleryEffect| apply(&src, &FilterParams::FilterGallery { effects: vec![e] }, area, area, None).read_region(area);
assert_eq!(
run(scaled_to_max(filter, 3.0)),
run(scaled_to_max(filter, 1.0)),
"{filter:?}: a stored value past its range must render as the clamped one"
);
}
}
+1
View File
@@ -1,5 +1,6 @@
//! The crate's integration tests, linked into one test binary.
mod gallery_reach_extreme;
mod liquify_freeze_formats;
mod liquify_source_footprint;
mod retouch_blur_exact;
+15
View File
@@ -6,6 +6,15 @@ edition.workspace = true
license.workspace = true
rust-version.workspace = true
[features]
default = []
gpu = [
"dep:photocraft-gpu",
"dep:wgpu",
"dep:pollster",
"photocraft-gpu/native",
]
[lints]
workspace = true
@@ -28,7 +37,13 @@ getrandom = { workspace = true }
cap-std = "4"
clap = { workspace = true }
# Optional native GPU rendering backend
photocraft-gpu = { workspace = true, optional = true }
wgpu = { version = "30", default-features = false, features = ["std", "wgsl"], optional = true }
pollster = { version = "1", optional = true }
[dev-dependencies]
photocraft-color = { workspace = true }
photocraft-geom = { workspace = true }
# Synthetic camera raws (#7789).
photocraft-raw = { workspace = true, features = ["testgen"] }
+15 -25
View File
@@ -1,8 +1,8 @@
//! Client for the desktop app's JSON-lines control protocol
//! (`docs/control-protocol.md`): one JSON request per line, replies matched
//! by `id`. Keeps one connection, reconnecting after a failure — or before
//! a call, once the connection has been idle long enough that the app
//! (which closes idle control connections after
//! by `id`. Keeps one connection, reconnecting after a failure or a call
//! dropped before its reply — or before a call, once the connection has been
//! idle long enough that the app (which closes idle control connections after
//! [`IO_TIMEOUT`](crate::security::IO_TIMEOUT)) may already have closed it.
use std::sync::atomic::{AtomicU64, Ordering};
@@ -148,41 +148,31 @@ impl BridgeClient {
*guard = Some((conn, Instant::now()));
}
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let Some((conn, _)) = guard.as_mut() else {
// Out of the cache until answered: a call dropped mid-exchange (a cancelled wait) closes
// its connection, so no later call shares a socket with a half-sent request or a reply
// the app is still working on.
let Some((mut conn, _)) = guard.take() else {
return Err(Failure::NotSent(format!("not connected to {}", self.addr)));
};
let outcome = tokio::time::timeout(self.timeout, exchange(conn, id, method, params)).await;
let outcome = tokio::time::timeout(self.timeout, exchange(&mut conn, id, method, params)).await;
match outcome {
Ok(Ok(Err(error @ AutomationError::BadRequest(_)))) => {
// An oversized frame leaves unread bytes. Drop this connection and
// report the budget failure without retrying a possibly completed edit.
*guard = None;
Err(Failure::Definite(error))
}
// An oversized frame leaves unread bytes. Drop this connection and
// report the budget failure without retrying a possibly completed edit.
Ok(Ok(Err(error @ AutomationError::BadRequest(_)))) => Err(Failure::Definite(error)),
Ok(Ok(Ok(v))) => {
if let Some((_, last_used)) = guard.as_mut() {
*last_used = Instant::now();
}
*guard = Some((conn, Instant::now()));
Ok(v)
}
Ok(Ok(Err(e))) => {
// The app answered: the operation did not apply — except the app's own
// deadline reply, which fires while the command may still be running.
if let Some((_, last_used)) = guard.as_mut() {
*last_used = Instant::now();
}
*guard = Some((conn, Instant::now()));
Err(Failure::Definite(e))
}
// A failed write or lost reply does not prove the edit was not applied.
// Reconnect on the next call; only a vouched read may resend (#1007).
Ok(Err(e)) => {
*guard = None;
Err(Failure::Transport(e.to_string()))
}
Err(_) => {
*guard = None;
Err(Failure::TimedOut(()))
}
Ok(Err(e)) => Err(Failure::Transport(e.to_string())),
Err(_) => Err(Failure::TimedOut(())),
}
}
}
+117 -8
View File
@@ -5,7 +5,7 @@ use std::path::Path;
use photocraft_doc::Document;
use photocraft_format::{PcraftWriter, SaveOptions};
use photocraft_io::ExportOptions;
use photocraft_io::{BandRenderer, ExportOptions};
use crate::AutomationError;
@@ -103,6 +103,43 @@ pub fn save(
format_override: Option<&str>,
opts: &ExportOptions,
writer: Option<&mut PcraftWriter>,
) -> Result<Vec<String>, AutomationError> {
save_with_renderer(doc, path, format_override, opts, writer, None)
}
/// [`save`] with an optional custom [`BandRenderer`].
pub fn save_with_renderer(
doc: &Document,
path: &Path,
format_override: Option<&str>,
opts: &ExportOptions,
writer: Option<&mut PcraftWriter>,
mut renderer: Option<&mut dyn BandRenderer>,
) -> Result<Vec<String>, AutomationError> {
let mut kept = Vec::new();
let res = save_with_renderer_keeping(doc, path, format_override, opts, writer, renderer.as_mut().map(|r| &mut **r as &mut dyn BandRenderer), &mut kept);
if res.is_err()
&& let Some(r) = renderer
{
// Callers without their own store still see the reason through the renderer.
for warning in kept {
r.push_diagnostic(warning);
}
}
res
}
/// [`save_with_renderer`] that hands the export warnings of an encode whose write then failed
/// to the caller in `kept`, independent of what the renderer does with
/// [`BandRenderer::push_diagnostic`] (the default is a no-op).
pub fn save_with_renderer_keeping(
doc: &Document,
path: &Path,
format_override: Option<&str>,
opts: &ExportOptions,
writer: Option<&mut PcraftWriter>,
renderer: Option<&mut dyn BandRenderer>,
kept: &mut Vec<String>,
) -> Result<Vec<String>, AutomationError> {
let ext = format_override
.map(|f| f.trim_start_matches('.').to_ascii_lowercase())
@@ -113,34 +150,106 @@ pub fn save(
let mut local = PcraftWriter::new();
let w = writer.unwrap_or(&mut local);
w.save_path(&named, path, &native_options(&named, opts)?)?;
return Ok(Vec::new());
let mut warnings = Vec::new();
if let Some(r) = renderer {
warnings.push("native .pcraft format uses CPU compositor for previews".into());
warnings.extend(r.drain_diagnostics());
}
return Ok(warnings);
}
let Some(renderer) = renderer else {
let r = photocraft_io::export_with_renderer(doc, &ext, opts, &photocraft_raster::Interrupt::NONE, None)?;
// Crash-safe: a failed write never destroys the previous file.
photocraft_format::atomic_write(path, &r.bytes).map_err(|e| AutomationError::Io(e.to_string()))?;
return Ok(r.warnings);
};
let r = photocraft_io::export_with_renderer(doc, &ext, opts, &photocraft_raster::Interrupt::NONE, Some(&mut *renderer))?;
if let Err(e) = photocraft_format::atomic_write(path, &r.bytes) {
// Encoding drained the renderer's diagnostics into `r.warnings`; hand them to the
// caller so it can still report the adapter or fallback reason alongside the write error.
kept.extend(r.warnings);
return Err(AutomationError::Io(e.to_string()));
}
let r = photocraft_io::export(doc, &ext, opts)?;
// Crash-safe: a failed write never destroys the previous file.
photocraft_format::atomic_write(path, &r.bytes).map_err(|e| AutomationError::Io(e.to_string()))?;
Ok(r.warnings)
}
/// Encode a document for a capability-scoped write. Unlike [`save`], this
/// never obtains ambient filesystem authority and emits `.pcraft` as a ZIP.
pub fn save_bytes(doc: &Document, name: &str, format_override: Option<&str>, opts: &ExportOptions) -> Result<(Vec<u8>, Vec<String>), AutomationError> {
save_bytes_with_renderer(doc, name, format_override, opts, None)
}
/// [`save_bytes`] with an optional custom [`BandRenderer`].
pub fn save_bytes_with_renderer(
doc: &Document,
name: &str,
format_override: Option<&str>,
opts: &ExportOptions,
renderer: Option<&mut dyn BandRenderer>,
) -> Result<(Vec<u8>, Vec<String>), AutomationError> {
let ext = format_override
.map(|format| format.trim_start_matches('.').to_ascii_lowercase())
.or_else(|| Path::new(name).extension().map(|extension| extension.to_string_lossy().to_ascii_lowercase()))
.ok_or_else(|| AutomationError::BadRequest(format!("cannot tell the format of `{name}`; pass a format")))?;
if ext == photocraft_format::EXTENSION {
let named = named_for_output(doc, name);
return Ok((photocraft_format::save_to_bytes(&named, &native_options(&named, opts)?)?, Vec::new()));
// Encode before draining: if the options check or the encode fails, the queued
// diagnostics stay with the renderer for the caller to report alongside the error.
let bytes = photocraft_format::save_to_bytes(&named, &native_options(&named, opts)?)?;
let mut warnings = Vec::new();
if let Some(r) = renderer {
warnings.push("native .pcraft format uses CPU compositor for previews".into());
warnings.extend(r.drain_diagnostics());
}
return Ok((bytes, warnings));
}
let result = photocraft_io::export(doc, &ext, opts)?;
let result = photocraft_io::export_with_renderer(doc, &ext, opts, &photocraft_raster::Interrupt::NONE, renderer)?;
Ok((result.bytes, result.warnings))
}
/// Flattened document as PNG, scaled to fit `max_side` (0 = full size).
pub fn render_png(doc: &Document, max_side: u32) -> Result<Vec<u8>, AutomationError> {
render_png_with_renderer(doc, max_side, None)
}
/// [`render_png`] with an optional custom [`BandRenderer`]. Preview fallback warnings are
/// queued on the renderer; use [`render_png_with_warnings`] to receive them directly.
pub fn render_png_with_renderer(doc: &Document, max_side: u32, mut renderer: Option<&mut dyn BandRenderer>) -> Result<Vec<u8>, AutomationError> {
let (png, warnings) = render_png_with_warnings(doc, max_side, renderer.as_mut().map(|r| &mut **r as &mut dyn BandRenderer))?;
if let Some(r) = renderer {
for w in warnings {
r.push_diagnostic(w);
}
}
Ok(png)
}
/// [`render_png_with_renderer`] that returns the preview fallback warnings with the bytes.
pub fn render_png_with_warnings(doc: &Document, max_side: u32, mut renderer: Option<&mut dyn BandRenderer>) -> Result<(Vec<u8>, Vec<String>), AutomationError> {
let side = if max_side == 0 { doc.size.width.max(doc.size.height) } else { max_side };
let (target_w, target_h) = photocraft_compose::thumbnail_size(doc, side.max(1));
let mut warnings = Vec::new();
if let Some(r) = renderer.as_mut()
&& (target_w, target_h) == (doc.size.width, doc.size.height)
{
// 8-bit RGBA whatever the document's depth, like the CPU preview path: a 16-bit
// document's preview must not be twice as large as the one it previews.
let res = photocraft_io::export_preview_png_with_renderer(doc, Some(&mut **r));
match res {
Ok(res) => return Ok((res.bytes, res.warnings)),
Err(photocraft_io::IoError::Cancelled) => return Err(AutomationError::Other("rendering was cancelled".into())),
Err(e) => return Err(e.into()),
}
}
if renderer.is_some() {
warnings.push("scaled previews and thumbnails use CPU compositor; GPU rendering applies to full-size previews".into());
}
let img = photocraft_compose::thumbnail(doc, side.max(1));
let image = photocraft_codecs::Image::from_u8(img.width, img.height, photocraft_codecs::ChannelLayout::Rgba, img.pixels)
.map_err(|e| AutomationError::Other(e.to_string()))?;
photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).map_err(|e| AutomationError::Other(e.to_string()))
let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).map_err(|e| AutomationError::Other(e.to_string()))?;
Ok((png, warnings))
}
+211
View File
@@ -0,0 +1,211 @@
//! Native GPU renderer adapter and session integration for headless automation.
use std::sync::Arc;
use photocraft_doc::{DocId, Document, Rect};
use photocraft_gpu::context::{NativeContext, NativeContextOptions};
use photocraft_gpu::{Compositor, readback};
use photocraft_io::{BandRenderer, IoError, RenderBand, RenderError};
use crate::AutomationError;
/// Headless GPU renderer implementing [`BandRenderer`] with device and compositor reuse.
pub struct GpuRenderer {
context: Option<Arc<NativeContext>>,
compositor: Option<Compositor>,
last_doc_id: Option<DocId>,
options: NativeContextOptions,
max_staging_bytes: usize,
init_error: Option<String>,
diagnostics: Vec<String>,
adapter_reported: bool,
}
impl GpuRenderer {
/// Create and immediately initialize a GPU renderer.
pub fn new() -> Result<Self, AutomationError> {
let mut r = Self::lazy();
r.ensure_init().map_err(|e| AutomationError::Other(format!("failed to initialize GPU renderer: {e}")))?;
Ok(r)
}
/// Create a GPU renderer that lazily requests an adapter on first render.
pub fn lazy() -> Self {
Self {
context: None,
compositor: None,
last_doc_id: None,
options: NativeContextOptions::default(),
max_staging_bytes: readback::DEFAULT_MAX_STAGING_BYTES,
init_error: None,
diagnostics: Vec::new(),
adapter_reported: false,
}
}
/// Create with custom [`NativeContextOptions`].
pub fn with_options(options: NativeContextOptions) -> Self {
Self {
context: None,
compositor: None,
last_doc_id: None,
options,
max_staging_bytes: readback::DEFAULT_MAX_STAGING_BYTES,
init_error: None,
diagnostics: Vec::new(),
adapter_reported: false,
}
}
/// Set maximum in-flight staging bytes for bounded readback.
pub fn set_max_staging_bytes(&mut self, bytes: usize) {
self.max_staging_bytes = bytes;
}
fn push_diag(&mut self, msg: String) {
const MAX_DIAGNOSTICS: usize = 16;
if self.diagnostics.len() < MAX_DIAGNOSTICS && !self.diagnostics.contains(&msg) {
self.diagnostics.push(msg);
}
}
fn ensure_init(&mut self) -> Result<(), RenderError> {
if let Some(err) = &self.init_error {
return Err(RenderError::Unavailable(err.clone()));
}
if self.context.is_none() {
match NativeContext::request(&self.options) {
Ok(ctx) => match ctx.create_compositor() {
Ok(comp) => {
if !self.adapter_reported {
self.adapter_reported = true;
let is_software = ctx.is_software();
let adapter_type = if is_software { "software rasterizer" } else { "hardware" };
let msg = format!("GPU adapter: {} ({:?}, {adapter_type})", ctx.adapter_name(), ctx.backend());
self.push_diag(msg);
}
self.context = Some(Arc::new(ctx));
self.compositor = Some(comp);
}
Err(e) => {
let msg = format!("could not build GPU compositor: {e}");
self.push_diag(msg.clone());
self.init_error = Some(msg.clone());
return Err(RenderError::Unavailable(msg));
}
},
Err(e) => {
let msg = format!("no GPU adapter available: {e}");
self.push_diag(msg.clone());
self.init_error = Some(msg.clone());
return Err(RenderError::Unavailable(msg));
}
}
}
Ok(())
}
/// The underlying GPU context if initialized.
pub fn context(&self) -> Option<&Arc<NativeContext>> {
self.context.as_ref()
}
/// Whether this renderer is healthy.
pub fn is_healthy(&self) -> bool {
self.context.as_ref().is_some_and(|c| c.is_healthy())
}
/// Whether the active adapter is a software rasterizer.
pub fn is_software(&self) -> bool {
self.context.as_ref().is_some_and(|c| c.is_software())
}
/// Adapter name reported by the driver.
pub fn adapter_name(&self) -> Option<&str> {
self.context.as_ref().map(|c| c.adapter_name())
}
/// Invalidate compositor caches for a closed or modified document.
pub fn forget_doc(&mut self, doc: DocId) {
if let Some(comp) = &mut self.compositor {
comp.forget_doc(doc);
}
if self.last_doc_id == Some(doc) {
self.last_doc_id = None;
}
}
/// Invalidate all compositor caches (resident pages, effect maps, pattern textures).
pub fn reset_cache(&mut self) {
if let Some(comp) = &mut self.compositor {
comp.reset_cache();
}
self.last_doc_id = None;
}
}
impl BandRenderer for GpuRenderer {
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> {
let max_staging = self.max_staging_bytes;
self.ensure_init()?;
let ctx = self.context.clone().ok_or_else(|| RenderError::Unavailable("GPU context missing".into()))?;
if !ctx.is_healthy() {
let fault = ctx.health().fault().map_or_else(|| "GPU device lost".into(), |f| f.to_string());
return Err(RenderError::Device(fault));
}
let comp = self.compositor.as_mut().ok_or_else(|| RenderError::Unavailable("GPU compositor missing".into()))?;
if let Err(unsupported) = comp.supports(doc) {
self.push_diag(format!("GPU rendering unsupported for document: {}", unsupported.0));
return Err(RenderError::Unsupported(unsupported.0));
}
if self.last_doc_id != Some(doc.id) {
if let Some(prev) = self.last_doc_id {
comp.forget_doc(prev);
}
self.last_doc_id = Some(doc.id);
}
let device = ctx.device();
let queue = ctx.queue();
readback::render_bands(comp, device, queue, doc, rect, band_rows, max_staging, ctl, |band_rect, pixels| sink(RenderBand { rect: band_rect, pixels }))
.map_err(|e| {
let render_err = match e {
readback::ReadbackError::Unsupported(s) => RenderError::Unsupported(s),
readback::ReadbackError::DeviceLost(s) => RenderError::Device(s),
readback::ReadbackError::AllocationFailed(s) => RenderError::Unavailable(s),
readback::ReadbackError::MappingFailed(s) => RenderError::Device(s),
readback::ReadbackError::Cancelled => RenderError::Cancelled,
readback::ReadbackError::Execution(s) => RenderError::Execution(s),
readback::ReadbackError::Sink(io) => RenderError::sink(io),
};
if render_err.is_retryable() {
self.push_diag(format!("GPU readback error: {render_err}"));
}
render_err
})
}
fn drain_diagnostics(&mut self) -> Vec<String> {
std::mem::take(&mut self.diagnostics)
}
fn push_diagnostic(&mut self, diagnostic: String) {
self.push_diag(diagnostic);
}
fn forget_document(&mut self, doc: DocId) {
self.forget_doc(doc);
}
}
+244 -16
View File
@@ -4,6 +4,7 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use photocraft_engine::jobs::{JobEvent, JobId, JobOutcome, Started};
use photocraft_engine::{Session, command_specs, file_cmds};
use photocraft_format::PcraftWriter;
use photocraft_io::ExportOptions;
@@ -15,7 +16,7 @@ use crate::{AuthorizedWorkspace, AutomationError, files};
fn untrusted(filesystem: Filesystem) -> Headless {
let mut session = Session::new();
session.authorize = Some(authorize_engine_step);
Headless { session, writers: HashMap::new(), filesystem }
Headless { session, writers: HashMap::new(), filesystem, renderer: None, pending_diagnostics: Vec::new(), waited: HashMap::new() }
}
enum Filesystem {
@@ -30,6 +31,29 @@ pub struct Headless {
/// Incremental `.pcraft` writers per document id.
writers: HashMap<u64, PcraftWriter>,
filesystem: Filesystem,
/// Jobs a caller waits on ([`Headless::command_begin`]), with their outcome once they end.
/// Kept here because `jobs.list` holds only the last few jobs that ended.
waited: HashMap<JobId, Option<JobEvent>>,
renderer: Option<Box<dyn photocraft_io::BandRenderer>>,
/// Export warnings kept when a write fails, and preview fallback warnings: the next
/// [`Headless::drain_diagnostics`] still reports them, whatever the renderer does with
/// [`photocraft_io::BandRenderer::push_diagnostic`].
pending_diagnostics: Vec<String>,
}
/// A waited job's state, from [`Headless::job_poll`].
#[derive(Debug)]
pub enum JobPoll {
/// Still running: progress 0..1 and the job's message.
Running(f32, String),
/// Ended: the command's result, or why it failed or was cancelled.
Ended(Result<Value, AutomationError>),
}
/// What admission decided: run the command, or Render Video already ran.
enum Admitted {
Run(Value),
Done(Value),
}
impl Default for Headless {
@@ -48,7 +72,14 @@ impl Headless {
/// not a remote automation client, supplies these host paths. Nested
/// `actions.play` steps are not re-checked: this caller is already trusted.
pub fn trusted_local() -> Self {
Self { session: Session::new(), writers: HashMap::new(), filesystem: Filesystem::TrustedLocal }
Self {
session: Session::new(),
writers: HashMap::new(),
filesystem: Filesystem::TrustedLocal,
renderer: None,
pending_diagnostics: Vec::new(),
waited: HashMap::new(),
}
}
/// Create an automation session with capability-scoped file access.
@@ -57,11 +88,59 @@ impl Headless {
untrusted(Filesystem::Workspace(workspace))
}
/// Configure a custom [`photocraft_io::BandRenderer`] for flat exports and previews.
pub fn with_renderer(mut self, renderer: Box<dyn photocraft_io::BandRenderer>) -> Self {
self.renderer = Some(renderer);
self
}
/// Set or clear the active [`photocraft_io::BandRenderer`].
pub fn set_renderer(&mut self, renderer: Option<Box<dyn photocraft_io::BandRenderer>>) {
self.renderer = renderer;
}
/// Take the active renderer out of this session.
pub fn take_renderer(&mut self) -> Option<Box<dyn photocraft_io::BandRenderer>> {
self.renderer.take()
}
/// Mutable reference to the active renderer if any.
pub fn renderer_mut(&mut self) -> Option<&mut dyn photocraft_io::BandRenderer> {
self.renderer.as_mut().map(|b| &mut **b as &mut dyn photocraft_io::BandRenderer)
}
/// Drain diagnostics from the active renderer if any, and the export warnings a failed
/// capability-scoped write kept. Each is returned once.
pub fn drain_diagnostics(&mut self) -> Vec<String> {
let mut drained = std::mem::take(&mut self.pending_diagnostics);
if let Some(renderer) = self.renderer.as_mut() {
drained.extend(renderer.drain_diagnostics());
}
drained
}
#[cfg(all(feature = "gpu", not(target_arch = "wasm32")))]
/// Enable the native GPU renderer backend with lazy device initialization.
pub fn enable_gpu(&mut self) {
self.renderer = Some(Box::new(crate::gpu::GpuRenderer::lazy()));
}
#[cfg(all(feature = "gpu", not(target_arch = "wasm32")))]
/// Builder method to enable the native GPU renderer backend.
pub fn with_gpu(mut self) -> Self {
self.enable_gpu();
self
}
/// Apply background jobs that finished since the last request, so every request (save,
/// export, inspect, preview, `session.list`, commands) sees their result. Cheap when no job
/// runs. The JSON-lines server and the MCP server call it before each request.
pub fn sync_jobs(&mut self) {
self.session.poll_jobs();
for event in self.session.poll_jobs() {
if let Some(slot) = self.waited.get_mut(&event.id) {
*slot = Some(event);
}
}
}
fn doc_index(&self, index: Option<usize>) -> Result<usize, AutomationError> {
@@ -298,14 +377,36 @@ impl Headless {
Filesystem::Denied => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
Filesystem::TrustedLocal => {
let writer = self.writers.entry(doc.id.0).or_default();
files::save(&doc, &target, format, opts, Some(writer))?
// Export warnings of a failed write stay in `pending_diagnostics`, whatever the
// renderer does with `push_diagnostic`.
files::save_with_renderer_keeping(
&doc,
&target,
format,
opts,
Some(writer),
self.renderer.as_mut().map(|b| &mut **b as &mut dyn photocraft_io::BandRenderer),
&mut self.pending_diagnostics,
)?
}
Filesystem::Workspace(workspace) => {
// Only the target is granted: a linked smart object's stored path is document
// data, so PSD export writes that layer as pixels instead of embedding the file.
let opts = ExportOptions { read_linked_files: false, ..opts.clone() };
let (bytes, warnings) = files::save_bytes(&doc, target_text, format, &opts)?;
workspace.write(target_text, &bytes)?;
let (bytes, warnings) = files::save_bytes_with_renderer(
&doc,
target_text,
format,
&opts,
self.renderer.as_mut().map(|b| &mut **b as &mut dyn photocraft_io::BandRenderer),
)?;
if let Err(error) = workspace.write(target_text, &bytes) {
// Keep the export diagnostics for the caller, as the local save path does:
// they are reported with the write error and by the next drain, even when the
// renderer's `push_diagnostic` is the default no-op.
self.pending_diagnostics.extend(warnings);
return Err(error);
}
warnings
}
};
@@ -323,6 +424,8 @@ impl Headless {
if let Some(st) = this.session.active_mut() {
st.saved_to(target.to_string_lossy().into_owned());
}
// History Options › Automatically Create New Snapshot When Saving (#2966).
photocraft_engine::history_options_cmds::snapshot_on_save(&mut this.session);
Ok(())
})?;
}
@@ -344,13 +447,16 @@ impl Headless {
Ok(photocraft_engine::inspect::document(&self.session.documents()[i]))
}
pub fn render_png(&self, index: Option<usize>, max_side: u32) -> Result<Vec<u8>, AutomationError> {
pub fn render_png(&mut self, index: Option<usize>, max_side: u32) -> Result<Vec<u8>, AutomationError> {
let i = self.doc_index(index)?;
let doc = &self.session.documents()[i].doc;
if !matches!(&self.filesystem, Filesystem::TrustedLocal) {
crate::budgets::check_preview(doc.size.width, doc.size.height, max_side)?;
}
let png = files::render_png(doc, max_side)?;
let (png, warnings) = files::render_png_with_warnings(doc, max_side, self.renderer.as_mut().map(|b| &mut **b as &mut dyn photocraft_io::BandRenderer))?;
// Kept here so `drain_diagnostics` returns them whatever the renderer does with
// `push_diagnostic`.
self.pending_diagnostics.extend(warnings);
if !matches!(&self.filesystem, Filesystem::TrustedLocal) {
crate::budgets::check_png(png.len())?;
}
@@ -368,7 +474,13 @@ impl Headless {
pub fn close(&mut self, index: Option<usize>) -> Result<Value, AutomationError> {
let i = self.doc_index(index)?;
if let Some(d) = self.session.close(i) {
self.writers.remove(&d.doc.id.0);
let id = d.doc.id;
self.writers.remove(&id.0);
// Release this document's GPU caches now instead of keeping them until another
// document renders or the server exits.
if let Some(r) = self.renderer.as_mut() {
r.forget_document(id);
}
}
// The closed document's buffers went to the scratch pool: hand them back to the OS.
photocraft_raster::scratch::release();
@@ -387,22 +499,111 @@ impl Headless {
/// Photomerge, …) runs in the background and this returns `{"job": id}` at once (poll with
/// `jobs.list`, stop with `jobs.cancel`). Other commands finish before returning either way.
pub fn command_start(&mut self, id: &str, params: Value, wait: bool) -> Result<Value, AutomationError> {
let params = match self.admit(id, params)? {
Admitted::Run(params) => params,
Admitted::Done(value) => return Ok(value),
};
// Commands such as `file.close`, `file.closeAll` and `file.closeOthers` remove
// documents directly through the session, so release their GPU caches here too.
let before = self.document_ids();
let result = if wait {
self.session.execute(id, params).map_err(AutomationError::from)
} else {
self.session.start(id, params).map_err(AutomationError::from).map(|started| match started {
Started::Done(v) => v,
Started::Job(job) => json!({"job": job.0, "pending": true}),
})
};
self.forget_removed_documents(&before);
result
}
/// Start a command for a caller that waits without holding the session: a job-capable
/// command runs in the background and returns its job, to follow with [`Self::job_poll`]
/// and stop with [`Self::job_abandon`]. Other commands finish here.
pub fn command_begin(&mut self, id: &str, params: Value) -> Result<Started, AutomationError> {
let params = match self.admit(id, params)? {
Admitted::Run(params) => params,
Admitted::Done(value) => return Ok(Started::Done(value)),
};
let before = self.document_ids();
let started = self.session.start(id, params);
self.forget_removed_documents(&before);
let started = started?;
if let Started::Job(job) = started {
self.waited.insert(job, None);
}
Ok(started)
}
/// Where a job from [`Self::command_begin`] is. Once it has ended this returns its result
/// and forgets the job. Never blocks.
pub fn job_poll(&mut self, job: JobId) -> JobPoll {
// A finished job stays in the running list until it is applied.
self.sync_jobs();
if let Some(info) = self.session.job(job) {
return JobPoll::Running(info.progress, info.message);
}
let outcome = self.waited.remove(&job).flatten();
JobPoll::Ended(match outcome {
Some(event) => match event.outcome {
JobOutcome::Done(value) => Ok(value),
JobOutcome::Failed(error) => Err(event.error.map(AutomationError::from).unwrap_or(AutomationError::Other(error))),
JobOutcome::Cancelled => Err(photocraft_engine::EngineError::Cancelled.into()),
},
// Ended without this session seeing it end (not expected): `wait_job` reads the
// recent-jobs list and does not block for a job that is no longer running.
None => self.session.wait_job(job).map_err(Into::into),
})
}
/// Stop waiting on `job`: cancel it if it is still running (the document stays unchanged)
/// and forget it. Returns how the job really ended: `Cancelled` only when this call cancelled
/// it. A job that ended first (another request applied it) keeps its result, and one that
/// cannot be cancelled (printing) is reported as still running.
pub fn job_abandon(&mut self, job: JobId) -> Result<Value, AutomationError> {
if self.session.cancel_job(job) {
self.waited.remove(&job);
return Err(photocraft_engine::EngineError::Cancelled.into());
}
match self.job_poll(job) {
JobPoll::Ended(result) => result,
JobPoll::Running(..) => {
self.waited.remove(&job);
Err(AutomationError::Other(format!("job {} cannot be cancelled and is still running; check jobs.list for its outcome", job.0)))
}
}
}
/// Default the params, authorize an untrusted command and apply finished jobs. Render Video
/// runs here for an untrusted session, with its own write capability.
fn admit(&mut self, id: &str, params: Value) -> Result<Admitted, AutomationError> {
let params = if params.is_null() { json!({}) } else { params };
if !matches!(&self.filesystem, Filesystem::TrustedLocal) {
if id == "file.export.renderVideo" {
return self.render_video(params, |_, _| true);
return self.render_video(params, |_, _| true).map(Admitted::Done);
}
authorize_engine_command(id, &params)?;
}
// Background jobs that finished since the last request (or batch step) are applied first.
self.sync_jobs();
if wait {
return Ok(self.session.execute(id, params)?);
Ok(Admitted::Run(params))
}
fn document_ids(&self) -> Vec<photocraft_doc::DocId> {
self.session.documents().iter().map(|st| st.doc.id).collect()
}
/// Release the writer and GPU caches of every document a command removed.
fn forget_removed_documents(&mut self, before: &[photocraft_doc::DocId]) {
let after: std::collections::HashSet<_> = self.document_ids().into_iter().collect();
let removed: Vec<_> = before.iter().copied().filter(|id| !after.contains(id)).collect();
for id in removed {
self.writers.remove(&id.0);
if let Some(r) = self.renderer.as_mut() {
r.forget_document(id);
}
}
Ok(match self.session.start(id, params)? {
photocraft_engine::jobs::Started::Done(v) => v,
photocraft_engine::jobs::Started::Job(job) => json!({"job": job.0, "pending": true}),
})
}
}
@@ -451,3 +652,30 @@ fn exported(dir: &str, written: photocraft_engine::Result<Vec<String>>, outputs:
}
}
}
#[cfg(test)]
mod waited_error_tests {
use super::*;
#[test]
fn waited_job_keeps_typed_failure() {
let mut headless = Headless::trusted_local();
let id = JobId(123);
let error = photocraft_engine::EngineError::PsdHardBudget { name: "large.psd".into(), required: 9000, limit: 4000, free_memory: None };
headless.waited.insert(
id,
Some(JobEvent {
id,
command: "file.open".into(),
label: "Open".into(),
document: None,
outcome: JobOutcome::Failed(error.to_string()),
error: Some(error),
}),
);
assert!(matches!(
headless.job_poll(id),
JobPoll::Ended(Err(AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required: 9000, limit: 4000, .. })))
));
}
}
+5
View File
@@ -31,6 +31,11 @@ pub use headless::Headless;
pub use server::{Backend, PhotocraftMcp};
pub use workspace::AuthorizedWorkspace;
#[cfg(all(feature = "gpu", not(target_arch = "wasm32")))]
pub mod gpu;
#[cfg(all(feature = "gpu", not(target_arch = "wasm32")))]
pub use gpu::GpuRenderer;
#[derive(Debug, thiserror::Error)]
pub enum AutomationError {
#[error("{0}")]
+29 -7
View File
@@ -17,7 +17,7 @@
//! `doc.importDataSets {path, delimiter?, index?}` (#3216: the same treatment for the export
//! and CSV half of #2810), `doc.save {path?, format?, quality?, index?}`, `doc.inspect {index?}`,
//! `doc.render {index?, maxSide?, path?}` (writes a PNG to `path`, else
//! returns it base64-encoded), `doc.select {index}`, `doc.close {index?}`
//! returns it base64-encoded; adds `warnings` when a renderer fell back), `doc.select {index}`, `doc.close {index?}`
//! - `batch {steps: [{command, params?, wait?} | {method, params?}], stopOnError?}`
//! - `methods`: this list.
@@ -79,12 +79,16 @@ fn attach_memory_error_details(obj: &mut Value, err: &AutomationError) {
}
}
}
AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit })
| AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required, limit, .. }) => {
AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit, free_memory })
| AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required, limit, free_memory, .. }) => {
if let Some(m) = obj.as_object_mut() {
m.insert("required".into(), json!(required));
m.insert("hard".into(), json!(limit));
m.insert("hardLimit".into(), json!(limit));
if let Some(free) = free_memory {
m.insert("available".into(), json!(free));
m.insert("availableRam".into(), json!(free));
}
}
}
_ => {}
@@ -180,6 +184,9 @@ impl Headless {
let tiff_bool = |key| photocraft_engine::file_cmds::tiff_bool_param(&p, key, "doc.save").map_err(|e| bad(e.to_string()));
opts.tiff_transparency = tiff_bool("tiffTransparency")?;
opts.encode.tiff_pyramid = tiff_bool("tiffPyramid")?.unwrap_or(false);
if let Some(maximize) = photocraft_engine::file_cmds::maximize_compatibility_param(&p, "doc.save").map_err(|e| bad(e.to_string()))? {
opts.psd_maximize_compatibility = maximize;
}
match p.get("quality") {
None | Some(Value::Null) => {}
Some(v) => {
@@ -221,16 +228,24 @@ impl Headless {
}
};
let png = self.render_png(index_of(&p)?, max)?;
match str_of(&p, "path") {
// A GPU to CPU fallback is reported with this reply, not on the next save.
let warnings = self.drain_diagnostics();
let mut reply = match str_of(&p, "path") {
Some(path) => {
self.write_render(&PathBuf::from(path), &png)?;
Ok(json!({"path": path, "bytes": png.len()}))
json!({"path": path, "bytes": png.len()})
}
None => Ok(json!({
None => json!({
"mime": "image/png",
"base64": base64::engine::general_purpose::STANDARD.encode(&png),
})),
}),
};
if !warnings.is_empty()
&& let Some(object) = reply.as_object_mut()
{
object.insert("warnings".into(), json!(warnings));
}
Ok(reply)
}
"doc.select" => {
let i = index_of(&p)?.ok_or_else(|| bad("doc.select needs `index`"))?;
@@ -314,6 +329,13 @@ pub fn respond(h: &Mutex<Headless>, line: &str) -> Value {
// poisoned lock is safe to keep using, the same as the MCP server does.
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| h.lock().unwrap_or_else(PoisonError::into_inner).handle(method, params)))
.unwrap_or_else(|_| Err(AutomationError::Other(format!("internal error: `{method}` panicked"))));
// A renderer whose diagnostics panic must not lose the reply either.
let diagnostics =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| h.lock().unwrap_or_else(PoisonError::into_inner).drain_diagnostics())).unwrap_or_default();
for diagnostic in diagnostics {
// A broken stderr pipe must not panic the server before it writes the reply.
let _ = writeln!(std::io::stderr(), "warning: {diagnostic}");
}
match r {
Ok(v) => json!({"id": id, "ok": true, "result": v}),
Err(e) => {
+72 -1
View File
@@ -116,6 +116,36 @@ fn poisoned_session_keeps_serving() {
assert_eq!(r["ok"], true, "{r}");
}
/// A renderer whose diagnostics panic must not cost the request its reply.
#[test]
fn a_panicking_renderer_diagnostic_drain_still_writes_the_reply() {
struct PanickyDiagnostics;
impl photocraft_io::BandRenderer for PanickyDiagnostics {
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> {
Ok(())
}
fn drain_diagnostics(&mut self) -> Vec<String> {
panic!("diagnostics blew up");
}
}
let h = session();
h.lock().unwrap().set_renderer(Some(Box::new(PanickyDiagnostics)));
let r = respond(&h, r#"{"id":7,"method":"doc.new","params":{"width":8,"height":8}}"#);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(r["id"], 7);
// The session keeps serving afterwards.
let r = respond(&h, r#"{"id":8,"method":"doc.new","params":{"width":8,"height":8}}"#);
assert_eq!(r["ok"], true, "{r}");
}
#[test]
fn batch_stops_on_error_unless_told_not_to() {
let h = session();
@@ -273,6 +303,28 @@ fn doc_save_rejects_a_bad_quality_instead_of_ignoring_or_clamping_it() {
std::fs::remove_dir_all(dir).unwrap();
}
/// #8064: `doc.save` takes `maximizeCompatibility` for a layered PSD's merged image (false: the
/// placeholder, flagged in resource 1057); anything but a boolean writes nothing.
#[test]
fn doc_save_takes_maximize_compatibility() {
let dir = std::env::temp_dir().join(format!("pc-rpc-save-maximize-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut h = Headless::trusted_local();
h.handle("doc.new", json!({"width": 64, "height": 32})).unwrap();
h.handle("engine.execute", json!({"command": "layer.new.layer"})).unwrap();
let real_merged = |p: &std::path::Path| photocraft_psd::PsdFile::from_bytes(&std::fs::read(p).unwrap()).unwrap().has_real_merged_data();
for (name, maximize, real) in [("default.psd", json!(null), true), ("off.psd", json!(false), false), ("on.psd", json!(true), true)] {
let path = dir.join(name);
h.handle("doc.save", json!({"path": path.to_string_lossy(), "maximizeCompatibility": maximize})).unwrap();
assert_eq!(real_merged(&path), Some(real), "{name}");
}
let bad = dir.join("bad.psd");
let err = h.handle("doc.save", json!({"path": bad.to_string_lossy(), "maximizeCompatibility": 0})).unwrap_err().to_string();
assert!(err.contains("maximizeCompatibility"), "{err}");
assert!(!bad.exists(), "a bad value writes nothing");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn automation_preview_rejects_oversized_full_size_and_numeric_wraparound() {
let mut h = Headless::new();
@@ -1062,7 +1114,12 @@ fn approve_high_memory_rejects_invalid_types_and_formats_structured_error() {
// fields, and only the fields an approval request has: no soft limit and no capacity, since
// no approval can raise the ceiling.
let mut hard_obj = json!({"id": 2, "ok": false, "error": "test"});
let hard = crate::AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { name: "big.psd".into(), required: 9000, limit: 8000 });
let hard = crate::AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget {
name: "big.psd".into(),
required: 9000,
limit: 8000,
free_memory: None,
});
super::attach_memory_error_details(&mut hard_obj, &hard);
assert_eq!(hard_obj["required"], 9000);
assert_eq!(hard_obj["hard"], 8000);
@@ -1070,6 +1127,20 @@ fn approve_high_memory_rejects_invalid_types_and_formats_structured_error() {
for absent in ["soft", "softLimit", "available", "availableRam"] {
assert!(hard_obj.get(absent).is_none(), "a hard refusal has no {absent}: {hard_obj}");
}
// When measured free memory set the limit, the refusal also reports it.
let mut low_obj = json!({"id": 3, "ok": false, "error": "test"});
let low = crate::AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget {
name: "big.psd".into(),
required: 9000,
limit: 8000,
free_memory: Some(10_000),
});
super::attach_memory_error_details(&mut low_obj, &low);
assert_eq!(low_obj["hardLimit"], 8000);
assert_eq!(low_obj["available"], 10_000);
assert_eq!(low_obj["availableRam"], 10_000);
assert!(low_obj.get("soft").is_none(), "a hard refusal has no soft limit: {low_obj}");
}
/// #10897: `doc.place` forwards the strict one-shot high-memory approval on both routes: the
+340 -80
View File
@@ -1,6 +1,7 @@
//! The MCP server (rmcp). Tool names use `snake_case` with underscores (dots
//! are not valid in every client's tool-name grammar).
use std::io::Write as _;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, PoisonError};
@@ -8,7 +9,8 @@ use base64::Engine as _;
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{CallToolResult, ContentBlock as Content};
use rmcp::{ErrorData as McpError, ServerHandler, ServiceExt, tool, tool_router};
use rmcp::service::RequestContext;
use rmcp::{ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, tool, tool_router};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
@@ -111,6 +113,11 @@ pub struct ExportLayersParams {
/// Encoder quality, 0..12, for the formats that take one.
#[serde(default)]
pub quality: Option<f64>,
/// PSD/PSB: write the real merged image (Photoshop's Maximize Compatibility); false writes
/// Photoshop's white placeholder (Bitmap, Indexed Color and Duotone documents still write the
/// real image). Unset: the engine's Preferences › File Handling decides.
#[serde(default, rename = "maximizeCompatibility")]
pub maximize_compatibility: Option<bool>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
@@ -212,6 +219,13 @@ pub struct SaveParams {
/// The save may omit it; check the manifest's `full_composite` member. Off by default.
#[serde(default, rename = "pcraftFullComposite")]
pub pcraft_full_composite: Option<bool>,
/// PSD/PSB: write the real merged image (Photoshop's Maximize Compatibility). false writes
/// Photoshop's white placeholder instead: 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. Unset: the real image (in bridge mode, the app's
/// Preferences › File Handling decides).
#[serde(default, rename = "maximizeCompatibility")]
pub maximize_compatibility: Option<bool>,
#[serde(default)]
pub index: Option<usize>,
}
@@ -348,7 +362,8 @@ pub struct MenuParams {
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct UiSetParams {
/// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, colorPanel
/// Fields for the control method `ui.set`: tool, moveShowTransform (bool, the Move tool's
/// Show Transform Controls option), panels, dock, dockTabs, dockWidth, colorPanel
/// ({background: bool} picks which swatch the Color panel edits), maskTarget,
/// vectorMaskTarget, filterMaskTarget (paint the active smart object's filter mask), selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio,
/// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark, softLight, softDark), brushSection, brushTab,
@@ -367,15 +382,22 @@ pub struct UiSetParams {
/// combine, subtract, intersect or exclude: how the next Pen path joins the selected shape
/// layer or path), shapeStroke (an object patching width, align, cap, join, miterLimit,
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
/// `shape.edit` names and units; colour comes from `tools.setColors`. frameShape (rectangle,
/// ellipse, triangle, polygon or custom: the Frame tool's shape), frameSides (3..100, the
/// polygon's sides), frameTarget (frame, content or both: what the selected frame's handles
/// edit). camTransformOnDrop (bool,
/// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the
/// active layer's storage tiles). layerFilter (an object patching by ("kind", "name", "mode",
/// "color", "smartObject" or "artboard", which lists the artboards that hold the selected
/// layers), kinds (a list of layer kinds), name (matches part of the layer name), mode (a blend
/// mode name), color (a layer colour label, or "none") and smart (a list of "linked", "missing"
/// and "embedded") for the Layers panel filter). strokeTrace (true, false, "stderr" or "off":
/// the stroke latency trace at runtime; a CSV file is set at launch with --stroke-trace, not
/// over this channel). updatesOpen (bool, the Help › Check for Updates window).
/// active layer's storage tiles). layerFilter (an object patching by ("kind", "name", "effect",
/// "mode", "attribute", "color", "smartObject" or "artboard", which lists the artboards that
/// hold the selected layers), kinds (a list of layer kinds), name (matches part of the layer
/// name), effect ("bevelEmboss", "stroke", "innerShadow", "innerGlow", "satin", "overlay",
/// "outerGlow" or "dropShadow"), mode (a blend mode name), attribute ("visible", "locked",
/// "empty", "linked", "clipped", "layerMask", "vectorMask", "layerEffects",
/// "advancedBlending", or "notVisible" … "noAdvancedBlending"), color (a layer colour label,
/// or "none") and smart (a list of "linked", "missing" and "embedded") for the Layers panel
/// filter). strokeTrace (true, false, "stderr" or "off": the stroke latency trace at runtime; a
/// CSV file is set at launch with --stroke-trace, not over this channel). updatesOpen (bool,
/// the Help › Check for Updates window).
/// histogramPanel (an object patching view ("compact", "expanded" or "allChannels"), channel
/// ("composite", "red", "green", "blue", "cyan", "magenta", "yellow", "black", "a", "b",
/// "luminosity" or "colors"), showChannelsInColor, showStatistics and source ("entireImage",
@@ -504,8 +526,9 @@ fn png_result(png: &[u8], note: String) -> CallToolResult {
}
/// Adds the typed byte counts of a memory refusal to an error object: an approval request keeps
/// its soft limit, hard limit and (when known) capacity; a hard refusal keeps only its required
/// and hard amounts, because no approval can raise that ceiling.
/// its soft limit, hard limit and (when known) capacity; a hard refusal keeps its required and
/// hard amounts, because no approval can raise that ceiling, and adds the free memory when
/// measured memory set the limit.
fn attach_memory_error_details(obj: &mut Value, err: &AutomationError) {
let Some(obj) = obj.as_object_mut() else { return };
match err {
@@ -521,11 +544,15 @@ fn attach_memory_error_details(obj: &mut Value, err: &AutomationError) {
obj.insert("availableRam".into(), json!(avail));
}
}
AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit })
| AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required, limit, .. }) => {
AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit, free_memory })
| AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required, limit, free_memory, .. }) => {
obj.insert("required".into(), json!(required));
obj.insert("hard".into(), json!(limit));
obj.insert("hardLimit".into(), json!(limit));
if let Some(free) = free_memory {
obj.insert("available".into(), json!(free));
obj.insert("availableRam".into(), json!(free));
}
}
_ => {}
}
@@ -574,8 +601,12 @@ impl PhotocraftMcp {
Self::with_backend(Backend::Headless(Arc::new(Mutex::new(Headless::new()))))
}
pub fn with_headless(headless: Headless) -> Self {
Self::with_backend(Backend::Headless(Arc::new(Mutex::new(headless))))
}
pub fn headless_with_workspace(workspace: AuthorizedWorkspace) -> Self {
Self::with_backend(Backend::Headless(Arc::new(Mutex::new(Headless::with_workspace(workspace)))))
Self::with_headless(Headless::with_workspace(workspace))
}
pub fn bridge(addr: &str, token: &str) -> Result<Self, AutomationError> {
@@ -779,6 +810,9 @@ impl PhotocraftMcp {
if let Some(v) = p.quality {
opts.insert("quality".into(), json!(v));
}
if let Some(v) = p.maximize_compatibility {
opts.insert("maximizeCompatibility".into(), json!(v));
}
let opts = Value::Object(opts);
// Bridge mode runs the app on this machine, which writes its own paths like any command.
let mut bridge_params = opts.clone();
@@ -861,7 +895,7 @@ impl PhotocraftMcp {
}
#[tool(description = "Save the document. `.pcraft` is the lossless native format (incremental); other extensions \
(psd, png, jpg, tif, webp, exr, …) export. Without `path` only a PSD, PSB or .pcraft document is written back to its own file. `pcraftFullComposite` requests `composite/full.png`, which may be omitted; check the manifest's `full_composite` member. Returns warnings about anything the format cannot hold.")]
(psd, png, jpg, tif, webp, exr, …) export. Without `path` only a PSD, PSB or .pcraft document is written back to its own file. `pcraftFullComposite` requests `composite/full.png`, which may be omitted; check the manifest's `full_composite` member. Returns warnings about anything the format cannot hold.")]
async fn doc_save(&self, Parameters(p): Parameters<SaveParams>) -> Result<CallToolResult, McpError> {
self.save_impl(p).await
}
@@ -898,7 +932,17 @@ impl PhotocraftMcp {
#[tool(description = "Render the flattened document and return it as a PNG image (bridge mode: window screenshot).")]
async fn doc_render_preview(&self, Parameters(p): Parameters<PreviewParams>) -> Result<CallToolResult, McpError> {
let max = p.max_side.unwrap_or(1024);
if let Some(r) = self.headless_op(move |h| h.render_png(p.index, max)).await {
if let Some(r) = self
.headless_op(move |h| {
let res = h.render_png(p.index, max);
for d in h.drain_diagnostics() {
// Ignore a failed stderr write: the completed render must still be returned.
let _ = writeln!(std::io::stderr(), "warning: {d}");
}
res
})
.await
{
return Ok(match r {
Ok(png) => png_result(&png, format!("flattened preview (max side {max})")),
Err(e) => fail(e),
@@ -965,9 +1009,11 @@ impl PhotocraftMcp {
#[tool(description = "Run an engine command by id with JSON params (see command_list). Returns the command's JSON result. \
Long commands (filters, Content-Aware Fill/Scale, Photomerge, brush import) wait to finish unless `wait` is false: \
then they run as a background job and the result is {job: id} (see jobs_list, jobs_cancel).")]
async fn command_run(&self, Parameters(p): Parameters<RunParams>) -> Result<CallToolResult, McpError> {
self.run_command(p.id, p.params.unwrap_or_else(|| json!({})), p.wait.unwrap_or(true)).await
then they run as a background job and the result is {job: id} (see jobs_list, jobs_cancel). A waited long \
command reports progress, cancelling the request cancels it, and other calls are served meanwhile.")]
async fn command_run(&self, Parameters(p): Parameters<RunParams>, context: RequestContext<RoleServer>) -> Result<CallToolResult, McpError> {
let mut waiter = Waiter::new(Some(context));
to_result(self.step(p.id, p.params.unwrap_or_else(|| json!({})), p.wait.unwrap_or(true), &mut waiter, (0, 1)).await)
}
#[tool(description = "List background jobs: running ones with progress (0-1), message and elapsed time, then the last few \
@@ -985,49 +1031,8 @@ impl PhotocraftMcp {
#[tool(description = "Run several engine commands in one call, in order (fewer round trips). Returns {completed, failed, \
results:[{ok, result|error}]}; stops at the first error unless stop_on_error is false. \
Not an atomic transaction: earlier successful steps stay applied (no rollback).")]
async fn command_batch(&self, Parameters(p): Parameters<BatchParams>) -> Result<CallToolResult, McpError> {
if p.steps.len() > MAX_BATCH_STEPS {
return Ok(fail(format!("batch contains {} steps; maximum is {MAX_BATCH_STEPS}", p.steps.len())));
}
let stop = p.stop_on_error.unwrap_or(true);
let steps: Vec<Value> =
p.steps.into_iter().map(|s| json!({"command": s.id, "params": s.params.unwrap_or_else(|| json!({})), "wait": s.wait.unwrap_or(true)})).collect();
let args = json!({"steps": steps, "stopOnError": stop});
// The reply travels as escaped JSON text, so steps are charged their escaped size.
if let Some(r) = self.headless_op(move |h| h.batch_with_budget(&args, BatchReplyBudget::escaped())).await {
return to_result(r);
}
let Some(b) = self.bridge_client() else {
return Ok(no_backend());
};
let mut results = Vec::new();
let mut failed = 0;
let mut reply_budget = BatchReplyBudget::escaped();
for s in &steps {
let response = b.call("engine.execute", s.clone()).await;
let was_error = response.is_err();
let result = match response {
Ok(value) => json!({"ok": true, "result": value}),
Err(error) => {
let mut obj = json!({"ok": false, "error": error.to_string()});
attach_memory_error_details(&mut obj, &error);
obj
}
};
if let Err(error) = reply_budget.charge(&result) {
failed += 1;
results.push(json!({"ok": false, "error": format!("batch response budget exceeded; current step may have completed: {error}")}));
break;
}
results.push(result);
if was_error {
failed += 1;
if stop {
break;
}
}
}
Ok(ok_json(&json!({"completed": results.len() - failed, "failed": failed, "results": results})))
async fn command_batch(&self, Parameters(p): Parameters<BatchParams>, context: RequestContext<RoleServer>) -> Result<CallToolResult, McpError> {
self.batch(p, Waiter::new(Some(context))).await
}
// ----- live-GUI tools (bridge mode) -----
@@ -1102,14 +1107,60 @@ fn bridge_only_headless(name: &str) -> Result<CallToolResult, McpError> {
impl PhotocraftMcp {
async fn run_command(&self, id: String, params: Value, wait: bool) -> Result<CallToolResult, McpError> {
let (id2, params2) = (id.clone(), params.clone());
if let Some(r) = self.headless_op(move |h| h.command_start(&id2, params2, wait)).await {
return to_result(r);
to_result(self.step(id, params, wait, &mut Waiter::new(None), (0, 1)).await)
}
/// Run one command in either backend; `at` is its place in a batch, for progress.
async fn step(&self, id: String, params: Value, wait: bool, waiter: &mut Waiter, at: (usize, usize)) -> Result<Value, AutomationError> {
let none = || Err(AutomationError::Other("internal error: no headless session or app bridge".into()));
match &*self.backend {
Backend::Headless(_) if wait => self.headless_wait(id, params, waiter, at).await.unwrap_or_else(none),
Backend::Headless(_) => self.headless_op(move |h| h.command_start(&id, params, false)).await.unwrap_or_else(none),
Backend::Bridge(b) if wait => self.bridge_wait(b, id, params, waiter, at).await,
Backend::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params, "wait": false})).await,
}
let Some(b) = self.bridge_client() else {
return Ok(no_backend());
};
to_result(b.call("engine.execute", json!({"command": id, "params": params, "wait": wait})).await)
}
async fn batch(&self, p: BatchParams, mut waiter: Waiter) -> Result<CallToolResult, McpError> {
if p.steps.len() > MAX_BATCH_STEPS {
return Ok(fail(format!("batch contains {} steps; maximum is {MAX_BATCH_STEPS}", p.steps.len())));
}
let stop = p.stop_on_error.unwrap_or(true);
let total = p.steps.len();
let mut results = Vec::new();
let mut failed = 0;
// The reply travels as escaped JSON text, so steps are charged their escaped size.
let mut reply_budget = BatchReplyBudget::escaped();
for (i, s) in p.steps.into_iter().enumerate() {
// A cancelled request runs no further steps; the client no longer reads the reply.
if waiter.is_cancelled() {
break;
}
let response = self.step(s.id, s.params.unwrap_or_else(|| json!({})), s.wait.unwrap_or(true), &mut waiter, (i, total)).await;
let was_error = response.is_err();
let result = match response {
Ok(value) => json!({"ok": true, "result": value}),
Err(error) => {
let mut obj = json!({"ok": false, "error": error.to_string()});
attach_memory_error_details(&mut obj, &error);
obj
}
};
if let Err(error) = reply_budget.charge(&result) {
failed += 1;
results.push(json!({"ok": false, "error": format!("batch response budget exceeded; current step may have completed: {error}")}));
break;
}
results.push(result);
if was_error {
failed += 1;
if stop {
break;
}
}
waiter.report((i + 1) as f64, total as f64, "").await;
}
Ok(ok_json(&json!({"completed": results.len() - failed, "failed": failed, "results": results})))
}
/// `jobs.list` / `jobs.cancel` in either backend; `read` picks the bridge's retry rule.
@@ -1150,9 +1201,10 @@ impl PhotocraftMcp {
if let Some(b) = self.bridge_client() {
// The bridge forwards to the running app's `app.save`, which takes only an optional
// path (without one it writes back to the document's own layered file, as headless
// does), the Targa bits, the DDS options and the TIFF compression, transparency and
// pyramid, so the other options are headless-only. Saying so beats saving with
// defaults while the caller believes their quality or format was applied.
// does), the Targa bits, the DDS options, the TIFF compression, transparency and
// pyramid, and Maximize Compatibility, so the other options are headless-only. Saying
// so beats saving with defaults while the caller believes their quality or format was
// applied.
let unsupported: Vec<&str> = [
p.format.is_some().then_some("format"),
p.quality.is_some().then_some("quality"),
@@ -1190,6 +1242,9 @@ impl PhotocraftMcp {
if p.tiff_pyramid {
params["tiffPyramid"] = json!(true);
}
if let Some(maximize) = p.maximize_compatibility {
params["maximizeCompatibility"] = json!(maximize);
}
return to_result(b.call("app.save", params).await);
}
let Some(r) = self
@@ -1207,13 +1262,21 @@ impl PhotocraftMcp {
}
opts.encode.tiff_pyramid = p.tiff_pyramid;
opts.encode.dds = photocraft_io::DdsOptions { compression: dds_compression.unwrap_or_default(), mipmaps: p.dds_mipmaps };
if let Some(maximize) = p.maximize_compatibility {
opts.psd_maximize_compatibility = maximize;
}
if let Some(q) = p.quality {
opts.encode.jpeg_quality = q;
opts.encode.webp_quality = q;
opts.encode.webp_lossless = false;
}
let path = p.path.map(PathBuf::from);
h.save(p.index, path.as_deref(), p.format.as_deref(), &opts)
let res = h.save(p.index, path.as_deref(), p.format.as_deref(), &opts);
for d in h.drain_diagnostics() {
// Ignore a failed stderr write: the completed save must still be returned.
let _ = writeln!(std::io::stderr(), "warning: {d}");
}
res
})
.await
else {
@@ -1227,6 +1290,8 @@ mod conventions;
mod progress;
mod transport;
use progress::Waiter;
/// Used by the render helper in tests and the CLI.
pub fn render_document_png(doc: &photocraft_doc::Document, max_side: u32) -> Result<Vec<u8>, AutomationError> {
files::render_png(doc, max_side)
@@ -1312,10 +1377,7 @@ mod tests {
[(json!({"radius":"big"}), "radius"), (json!({"radus":3}), "radus"), (json!({"radius":5000}), "radius"), (json!({"radius":-4}), "radius")]
{
for wait in [true, false] {
let result = mcp
.command_run(Parameters(RunParams { id: "filter.blur.gaussianBlur".into(), params: Some(params.clone()), wait: Some(wait) }))
.await
.unwrap();
let result = mcp.run_command("filter.blur.gaussianBlur".into(), params.clone(), wait).await.unwrap();
assert_eq!(result.is_error, Some(true));
let error = &result.content.first().and_then(|c| c.as_text()).unwrap().text;
assert!(error.contains("filter.blur.gaussianBlur") && error.contains(key), "{error}");
@@ -1342,7 +1404,7 @@ mod tests {
let step = |id: &str, params: Value| RunParams { id: id.into(), params: Some(params), wait: None };
let mut steps: Vec<RunParams> = (0..MAX_BATCH_STEPS - 1).map(|_| step("command.list", json!({}))).collect();
steps.push(step("file.new", json!({"width": 8, "height": 8})));
let result = mcp.command_batch(Parameters(BatchParams { steps, stop_on_error: Some(false) })).await.unwrap();
let result = mcp.batch(BatchParams { steps, stop_on_error: Some(false) }, Waiter::new(None)).await.unwrap();
assert_ne!(result.is_error, Some(true), "{:?}", result.content.first().and_then(|c| c.as_text()).map(|t| &t.text[..200.min(t.text.len())]));
let text = &result.content.first().and_then(|c| c.as_text()).unwrap().text;
let reply: Value = serde_json::from_str(text).unwrap();
@@ -1354,6 +1416,184 @@ mod tests {
assert_eq!(docs, 0, "the step after the budget ran out did not run");
}
/// rmcp may drop a cancelled request's future without polling it again; its job must still
/// stop and leave the document unchanged (#8129).
#[tokio::test(flavor = "multi_thread")]
async fn dropping_a_waited_command_cancels_its_job() {
let mcp = PhotocraftMcp::headless();
mcp.headless_op(|h| {
h.command_run("file.new", json!({"width": 6000, "height": 6000}))?;
h.command_run("filter.noise.addNoise", json!({"amount": 50}))
})
.await
.unwrap()
.unwrap();
let before = mcp.headless_op(|h| h.inspect(None)).await.unwrap().unwrap();
let mut waiter = Waiter::new(None);
let waited = mcp.headless_wait("filter.blur.gaussianBlur".into(), json!({"radius": 1000}), &mut waiter, (0, 1));
assert!(tokio::time::timeout(std::time::Duration::from_millis(100), waited).await.is_err(), "the blur ended before it was dropped");
let t = std::time::Instant::now();
loop {
let (running, cancelled) =
mcp.headless_op(|h| Ok((h.session.has_jobs(), h.session.jobs_with_recent().iter().any(|j| j.state == "cancelled")))).await.unwrap().unwrap();
if !running {
assert!(cancelled, "the job ended without being cancelled");
break;
}
assert!(t.elapsed().as_secs() < 30, "the dropped job kept running");
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert_eq!(mcp.headless_op(|h| h.inspect(None)).await.unwrap().unwrap(), before);
}
/// Dropped while its start still waits for the session lock (held here as a long synchronous
/// command would): the job that starts afterwards is cancelled too, not left running.
#[tokio::test(flavor = "multi_thread")]
async fn a_wait_dropped_before_its_job_starts_cancels_the_job() {
let mcp = PhotocraftMcp::headless();
mcp.headless_op(|h| {
h.command_run("file.new", json!({"width": 6000, "height": 6000}))?;
h.command_run("filter.noise.addNoise", json!({"amount": 50}))
})
.await
.unwrap()
.unwrap();
let before = mcp.headless_op(|h| h.inspect(None)).await.unwrap().unwrap();
let Backend::Headless(session) = &*mcp.backend else { panic!("headless") };
let (session, (locked, is_locked)) = (session.clone(), std::sync::mpsc::channel());
let holder = std::thread::spawn(move || {
let _held = session.lock().unwrap();
locked.send(()).unwrap();
std::thread::sleep(std::time::Duration::from_millis(300));
});
is_locked.recv().unwrap();
let mut waiter = Waiter::new(None);
let waited = mcp.headless_wait("filter.blur.gaussianBlur".into(), json!({"radius": 1000}), &mut waiter, (0, 1));
assert!(tokio::time::timeout(std::time::Duration::from_millis(50), waited).await.is_err(), "the start waits for the lock");
holder.join().unwrap();
let t = std::time::Instant::now();
loop {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let (started, running) = mcp.headless_op(|h| Ok((!h.session.jobs_with_recent().is_empty(), h.session.has_jobs()))).await.unwrap().unwrap();
if started && !running {
break;
}
assert!(t.elapsed().as_secs() < 30, "the job kept running");
}
let cancelled = mcp.headless_op(|h| Ok(h.session.jobs_with_recent().iter().any(|j| j.state == "cancelled"))).await.unwrap().unwrap();
assert!(cancelled, "the job ended without being cancelled");
assert_eq!(mcp.headless_op(|h| h.inspect(None)).await.unwrap().unwrap(), before);
}
/// Cancellation notifications only cancel the token: the handler remains alive while
/// its blocking start waits for the session lock.
#[tokio::test(flavor = "multi_thread")]
async fn cancelled_queued_commands_never_start() {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let mcp = PhotocraftMcp::headless();
mcp.headless_op(|h| h.command_run("file.new", json!({"width": 8, "height": 8}))).await.unwrap().unwrap();
let before = mcp.headless_op(|h| h.inspect(None)).await.unwrap().unwrap();
let Backend::Headless(session) = &*mcp.backend else { panic!("headless") };
let session = session.clone();
let (locked, acquired) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel();
let holder = std::thread::spawn(move || {
let _held = session.lock().unwrap();
locked.send(()).unwrap();
released.recv().unwrap();
});
acquired.recv().unwrap();
let (mut input, server_in) = tokio::io::duplex(65536);
let (server_out, output) = tokio::io::duplex(65536);
let server = tokio::spawn(mcp.clone().serve_io(server_in, server_out));
let mut lines = BufReader::new(output).lines();
async fn send(input: &mut tokio::io::DuplexStream, value: Value) {
input.write_all(format!("{value}\n").as_bytes()).await.unwrap();
}
send(&mut input, json!({"jsonrpc":"2.0", "id":1, "method":"initialize", "params":{"protocolVersion":"2025-06-18", "capabilities":{}, "clientInfo":{"name":"test", "version":"1"}}})).await;
lines.next_line().await.unwrap().unwrap();
send(&mut input, json!({"jsonrpc":"2.0", "method":"notifications/initialized"})).await;
for (id, command, params) in [(2, "layer.new.layer", json!({})), (3, "filter.blur.gaussianBlur", json!({"radius": 10}))] {
send(
&mut input,
json!({"jsonrpc":"2.0", "id":id, "method":"tools/call", "params":{"name":"command_run", "arguments":{"id":command, "params":params}}}),
)
.await;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
for id in [2, 3] {
send(&mut input, json!({"jsonrpc":"2.0", "method":"notifications/cancelled", "params":{"requestId":id}})).await;
}
send(&mut input, json!({"jsonrpc":"2.0", "id":4, "method":"ping"})).await;
loop {
let reply: Value = serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
if reply["id"] == 4 {
break;
}
}
release.send(()).unwrap();
holder.join().unwrap();
// Let both blocked starts acquire the mutex before inspecting their effects.
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let (after, jobs) = mcp.headless_op(|h| Ok((h.inspect(None)?, h.session.jobs_with_recent()))).await.unwrap().unwrap();
assert_eq!(after, before);
assert!(jobs.is_empty(), "cancelled queued job started: {jobs:?}");
drop(input);
server.await.unwrap().unwrap();
}
/// The same over the bridge (#8130): a dropped wait sends `jobs.cancel` for the app's job.
#[tokio::test(flavor = "multi_thread")]
async fn dropping_a_bridged_wait_cancels_the_apps_job() {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let (cancels, mut cancelled) = tokio::sync::mpsc::unbounded_channel();
let polled = Arc::new(tokio::sync::Notify::new());
let polling = polled.clone();
// A fake app whose job 3 never ends and whose poll never replies. Dropping the wait
// discards that connection, so the cancellation must arrive on a fresh one (#8130).
let app = tokio::spawn(async move {
loop {
let (socket, _) = listener.accept().await.unwrap();
let (read, mut write) = socket.into_split();
let mut lines = tokio::io::BufReader::new(read).lines();
let (auth, _) = crate::security::authentication_reply(&lines.next_line().await.unwrap().unwrap(), TOKEN);
write.write_all(format!("{auth}\n").as_bytes()).await.unwrap();
while let Ok(Some(line)) = lines.next_line().await {
let request: Value = serde_json::from_str(&line).unwrap();
let result = match request["method"].as_str().unwrap() {
"engine.execute" => json!({"job": 3, "pending": true}),
"jobs.list" => {
polling.notify_one();
continue;
}
"jobs.cancel" => json!({"cancelled": 1}),
method => panic!("unexpected method {method}"),
};
write.write_all(format!("{}\n", json!({"id": request["id"], "ok": true, "result": result})).as_bytes()).await.unwrap();
if request["method"] == "jobs.cancel" {
cancels.send(request).unwrap();
return;
}
}
}
});
let mcp = PhotocraftMcp::with_backend(Backend::Bridge(Arc::new(BridgeClient::new(addr, TOKEN).unwrap())));
let mut waited = Box::pin(mcp.run_command("filter.blur.gaussianBlur".into(), json!({"radius": 4}), true));
tokio::select! {
result = &mut waited => panic!("the running job answered before its wait was dropped: {result:?}"),
ready = tokio::time::timeout(std::time::Duration::from_secs(10), polled.notified()) => ready.expect("the app received the poll"),
}
drop(waited);
let cancel = tokio::time::timeout(std::time::Duration::from_secs(10), cancelled.recv()).await.unwrap().unwrap();
assert_eq!((&cancel["method"], &cancel["params"]), (&json!("jobs.cancel"), &json!({"job": 3})));
tokio::time::timeout(std::time::Duration::from_secs(10), app).await.unwrap().unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn mcp_doc_open_schema_and_structured_error() {
let schema = &PhotocraftMcp::tool_router().map["doc_open"].attr.input_schema;
@@ -1401,7 +1641,12 @@ mod tests {
assert!(serde_json::from_value::<PlaceParams>(json!({"path": "file.psd", "approve_high_memory": invalid})).is_err(), "{invalid}");
}
let err = AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { name: "huge.psd".into(), required: 9_000, limit: 8_000 });
let err = AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget {
name: "huge.psd".into(),
required: 9_000,
limit: 8_000,
free_memory: None,
});
let res = to_result(Err(err)).unwrap();
assert_eq!(res.is_error, Some(true));
let text = &res.content.first().and_then(|c| c.as_text()).unwrap().text;
@@ -1412,6 +1657,21 @@ mod tests {
for absent in ["soft", "softLimit", "available", "availableRam"] {
assert!(v.get(absent).is_none(), "a hard refusal has no {absent}: {v}");
}
// With measured free memory behind the limit, the refusal reports it too.
let err = AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget {
name: "huge.psd".into(),
required: 9_000,
limit: 8_000,
free_memory: Some(10_000),
});
let res = to_result(Err(err)).unwrap();
let text = &res.content.first().and_then(|c| c.as_text()).unwrap().text;
let v: Value = serde_json::from_str(text).unwrap();
assert_eq!(v["hardLimit"], 8_000);
assert_eq!(v["available"], 10_000);
assert_eq!(v["availableRam"], 10_000);
assert!(v.get("soft").is_none(), "a hard refusal has no soft limit: {v}");
}
/// The MCP server applies finished jobs before every tool call too (#503).
+6 -3
View File
@@ -17,11 +17,14 @@ fn annotated(mut tool: Tool) -> Tool {
let title = chars.next().map(|c| c.to_uppercase().collect::<String>() + chars.as_str()).unwrap_or_default();
let read = matches!(
tool.name.as_ref(),
"command_list" | "doc_inspect" | "session_list" | "render_preview" | "doc_render_preview" | "ui_inspect" | "ui_screenshot"
"command_list" | "doc_inspect" | "session_list" | "render_preview" | "doc_render_preview" | "ui_inspect" | "ui_screenshot" | "jobs_list"
);
let additive = matches!(tool.name.as_ref(), "doc_new" | "doc_open" | "doc_place");
// Changes state without discarding anything: the scoped exports refuse to overwrite a file,
// and switching the active document edits nothing.
let additive = matches!(tool.name.as_ref(), "doc_new" | "doc_open" | "doc_place" | "doc_select" | "doc_export_layers" | "doc_export_data_sets");
let idempotent = read || tool.name == "doc_select";
tool.title = Some(title.clone());
tool.annotations = Some(ToolAnnotations::from_raw(Some(title), Some(read), Some(!read && !additive), Some(read), Some(false)));
tool.annotations = Some(ToolAnnotations::from_raw(Some(title), Some(read), Some(!read && !additive), Some(idempotent), Some(false)));
Arc::make_mut(&mut tool.input_schema).insert("additionalProperties".into(), json!(false));
tool
}
+270 -2
View File
@@ -1,5 +1,9 @@
//! Render Video runs on the existing blocking worker; rmcp remains available for ping/cancel.
use super::{CallToolResult, PhotocraftMcp, Value, fail, no_backend, ok_json};
//! Progress and cancellation for calls that wait. Render Video runs on the existing blocking
//! worker; other long commands run as engine jobs that the server polls ([`Waiter`]): the headless
//! session's own, or the app's over the bridge.
use super::{Arc, AutomationError, Backend, BridgeClient, CallToolResult, PhotocraftMcp, PoisonError, Value, fail, json, no_backend, ok_json};
use crate::headless::JobPoll;
use photocraft_engine::jobs::{JobId, Started};
use rmcp::RoleServer;
use rmcp::model::ProgressNotificationParam;
use rmcp::service::RequestContext;
@@ -41,3 +45,267 @@ impl PhotocraftMcp {
}
}
}
/// Where a waited call reports progress and learns that it was cancelled. Without a request
/// (a direct call in a test) nothing is reported and nothing cancels.
pub(super) struct Waiter {
context: Option<RequestContext<RoleServer>>,
last: Option<f64>,
sent_at: Option<Instant>,
}
impl Waiter {
pub(super) fn new(context: Option<RequestContext<RoleServer>>) -> Self {
Waiter { context, last: None, sent_at: None }
}
pub(super) fn is_cancelled(&self) -> bool {
self.context.as_ref().is_some_and(|c| c.ct.is_cancelled())
}
async fn cancelled(&self) {
match &self.context {
Some(c) => c.ct.cancelled().await,
None => std::future::pending().await,
}
}
/// Send `progress` of `total` when the request has a progress token. At most every 100ms,
/// and only when it grew (MCP requires progress to increase).
pub(super) async fn report(&mut self, progress: f64, total: f64, message: &str) {
let Some(context) = &self.context else { return };
let Some(token) = context.meta.get_progress_token() else { return };
let due = self.sent_at.is_none_or(|t| t.elapsed() >= Duration::from_millis(100));
if !due || !progress.is_finite() || self.last.is_some_and(|n| progress <= n) || context.ct.is_cancelled() {
return;
}
self.last = Some(progress);
self.sent_at = Some(Instant::now());
let mut note = ProgressNotificationParam::new(token, progress).with_total(total);
if !message.is_empty() {
note = note.with_message(message);
}
let _ = context.peer.notify_progress(note).await;
}
}
/// Cancels a waited job when its waiter goes away without collecting it: rmcp may drop the
/// request future on cancellation, and nothing else would stop the job.
struct Abandon {
backend: Arc<Backend>,
job: Option<JobId>,
}
impl Drop for Abandon {
fn drop(&mut self) {
let Some(job) = self.job.take() else { return };
let runtime = tokio::runtime::Handle::try_current();
match &*self.backend {
Backend::Headless(session) => {
let session = session.clone();
let abandon = move || drop(session.lock().unwrap_or_else(PoisonError::into_inner).job_abandon(job));
match runtime {
// A synchronous command can hold the lock for a while: don't block a runtime thread.
Ok(runtime) => drop(runtime.spawn_blocking(abandon)),
Err(_) => abandon(),
}
}
// Without a runtime there is no way to reach the app; the job then runs to the end.
Backend::Bridge(client) => {
if let Ok(runtime) = runtime {
let client = client.clone();
drop(runtime.spawn(async move { client.call("jobs.cancel", json!({"job": job.0})).await }));
}
}
}
}
}
impl PhotocraftMcp {
/// Headless: run command `id` for a caller that waits (#8129). A job-capable command runs as
/// a background job, so the session serves other calls meanwhile; its progress goes to
/// `waiter` as `step.0 + fraction` of `step.1`, and cancelling the request cancels the job,
/// leaving the document unchanged. `None` in bridge mode.
pub(super) async fn headless_wait(&self, id: String, params: Value, waiter: &mut Waiter, step: (usize, usize)) -> Option<Result<Value, AutomationError>> {
if !matches!(&*self.backend, Backend::Headless(_)) {
return None;
}
let backend = self.backend.clone();
let cancel = waiter.context.as_ref().map(|context| context.ct.clone());
// The guard is armed under the lock that starts the job: when the request is dropped while
// the start still waits for the lock, the closure's output is dropped unread and cancels it.
let mut guard = match self
.headless_op(move |h| {
if cancel.as_ref().is_some_and(|token| token.is_cancelled()) {
return Err(photocraft_engine::EngineError::Cancelled.into());
}
Ok(match h.command_begin(&id, params)? {
Started::Job(job) if cancel.as_ref().is_some_and(|token| token.is_cancelled()) => {
return h.job_abandon(job).map(Err);
}
Started::Job(job) => Ok(Abandon { backend, job: Some(job) }),
Started::Done(value) => Err(value),
})
})
.await?
{
Ok(Ok(guard)) => guard,
Ok(Err(value)) => return Some(Ok(value)),
Err(error) => return Some(Err(error)),
};
let Some(job) = guard.job else { return Some(Err(AutomationError::Other("internal error: a started job has no id".into()))) };
let mut delay = Duration::from_millis(1);
loop {
match self.headless_op(move |h| Ok(h.job_poll(job))).await? {
Ok(JobPoll::Running(fraction, message)) => {
let fraction = if fraction.is_finite() { f64::from(fraction.clamp(0.0, 1.0)) } else { 0.0 };
waiter.report(step.0 as f64 + fraction, step.1 as f64, &message).await;
}
Ok(JobPoll::Ended(result)) => {
guard.job = None;
return Some(result);
}
Err(error) => return Some(Err(error)),
}
tokio::select! {
biased;
() = waiter.cancelled() => {
// Cancel before answering, so the next call already sees the document unchanged.
// A job that ended first is answered with its result, not as cancelled.
guard.job = None;
return Some(self.headless_op(move |h| Ok(h.job_abandon(job))).await?.and_then(|result| result));
}
() = tokio::time::sleep(delay) => {}
}
delay = (delay * 2).min(Duration::from_millis(50));
}
}
/// Bridge: run command `id` for a caller that waits (#8130). An engine command starts with
/// `wait: false`; when the app runs it as a background job, `jobs.list` is polled until the job
/// ends, so no single reply has to arrive within the client's call timeout. Progress and
/// cancellation as in [`Self::headless_wait`]. Only the start is an edit, and it is sent once
/// (#1007); a failed poll reports the job instead of guessing its outcome.
pub(super) async fn bridge_wait(
&self,
client: &BridgeClient,
id: String,
params: Value,
waiter: &mut Waiter,
step: (usize, usize),
) -> Result<Value, AutomationError> {
// A UI command can start a job without naming it in its reply: the app waits on those.
if photocraft_engine::commands::find(&id).is_none() {
return client.call("engine.execute", json!({"command": id, "params": params, "wait": true})).await;
}
let started = client.call("engine.execute", json!({"command": id, "params": params, "wait": false})).await?;
let pending = started.get("pending").and_then(Value::as_bool) == Some(true);
let Some(job) = started.get("job").and_then(Value::as_u64).filter(|_| pending) else {
return Ok(started);
};
let mut guard = Abandon { backend: self.backend.clone(), job: Some(JobId(job)) };
let mut delay = Duration::from_millis(10);
loop {
// Race the poll against a cancel: an app slow to answer must not hold it until the call times out.
let polled = tokio::select! {
biased;
listed = client.call_read("jobs.list", json!({})) => Some(listed),
() = waiter.cancelled() => None,
};
let listed = match polled {
Some(Ok(listed)) => listed,
Some(Err(error)) if !waiter.is_cancelled() => {
// The app may be fine and the job still running: leave it, and say how to find it.
guard.job = None;
return Err(AutomationError::Bridge(format!(
"lost track of job {job} ({id}): {error}; it may still be running and apply its result: check jobs_list, or stop it with jobs_cancel"
)));
}
// Cancelled during the poll, which may also have failed: the app may answer a
// fresh connection.
_ => return Err(bridge_cancel(client, &mut guard, job, &id).await),
};
let entry = listed.get("jobs").and_then(Value::as_array).and_then(|jobs| jobs.iter().find(|j| j.get("id").and_then(Value::as_u64) == Some(job)));
let Some(entry) = entry else {
guard.job = None;
return Err(AutomationError::Bridge(format!(
"job {job} ({id}) ended but jobs.list no longer shows it (it keeps the last few); inspect the document for its result"
)));
};
let text = |key: &str| entry.get(key).and_then(Value::as_str).unwrap_or_default().to_owned();
match entry.get("state").and_then(Value::as_str) {
Some("running") => {
let fraction = entry.get("progress").and_then(Value::as_f64).filter(|f| f.is_finite()).map_or(0.0, |f| f.clamp(0.0, 1.0));
waiter.report(step.0 as f64 + fraction, step.1 as f64, &text("message")).await;
}
state => {
guard.job = None;
// The same results the app's own waited reply gives.
return match state {
Some("done") => Ok(entry.get("result").cloned().unwrap_or(Value::Null)),
Some("failed") => Err(AutomationError::App(Some(text("error")).filter(|e| !e.is_empty()).unwrap_or_else(|| "unknown error".into()))),
Some("cancelled") => Err(AutomationError::App("cancelled".into())),
_ => Err(AutomationError::Bridge(format!("job {job} ({id}) has an unknown state: {entry}"))),
};
}
}
tokio::select! {
biased;
() = waiter.cancelled() => return Err(bridge_cancel(client, &mut guard, job, &id).await),
() = tokio::time::sleep(delay) => {}
}
delay = (delay * 2).min(Duration::from_millis(200));
}
}
}
/// Cancel bridged `job` (`id`) before answering a cancelled wait. Failed calls leave the guard
/// armed for one best-effort cleanup attempt on drop.
async fn bridge_cancel(client: &BridgeClient, guard: &mut Abandon, job: u64, id: &str) -> AutomationError {
let cancel = client.call("jobs.cancel", json!({"job": job})).await;
// A failed or abandoned call keeps the guard armed for one cleanup attempt on drop.
// Only an acknowledged cancellation proves that cleanup is no longer needed.
// A definite refusal is not improved by retrying; malformed replies and transport
// failures leave the guard armed because the outcome is uncertain.
if cancel.as_ref().is_ok_and(|value| cancellation_acknowledged(value) || refusal(value, job).is_some()) {
guard.job = None;
}
cancel_answer(job, id, cancel)
}
/// The answer to a bridged wait that was cancelled, from the app's reply to `jobs.cancel`. The app
/// refuses to cancel a job that already ended, whose result may then be applied: say so rather
/// than report a cancellation.
fn cancel_answer(job: u64, id: &str, reply: Result<Value, AutomationError>) -> AutomationError {
match reply {
Ok(value) if refusal(&value, job).is_some() => {
let reason = refusal(&value, job).unwrap_or("unknown reason");
AutomationError::Bridge(format!("job {job} ({id}) was not cancelled: {reason}; check jobs_list for its outcome"))
}
Ok(value) if cancellation_acknowledged(&value) => photocraft_engine::EngineError::Cancelled.into(),
Ok(value) => AutomationError::Bridge(format!("cancellation of job {job} ({id}) was not confirmed: {value}; check jobs_list for its outcome")),
Err(error) => AutomationError::Bridge(format!("job {job} ({id}) cancellation was not confirmed: {error}; check jobs_list for its outcome")),
}
}
fn cancellation_acknowledged(value: &Value) -> bool {
value.get("cancelled").and_then(Value::as_u64) == Some(1)
}
fn refusal(value: &Value, job: u64) -> Option<&str> {
value.get("notCancelled")?.as_array()?.iter().find(|entry| entry.get("job").and_then(Value::as_u64) == Some(job))?.get("reason")?.as_str()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_refused_cancel_is_not_reported_as_cancelled() {
let cancelled = cancel_answer(7, "filter.blur.gaussianBlur", Ok(json!({"cancelled": 1})));
assert!(matches!(cancelled, AutomationError::Engine(photocraft_engine::EngineError::Cancelled)), "{cancelled}");
let refused = cancel_answer(7, "filter.blur.gaussianBlur", Err(AutomationError::App("job 7 is not running".into())));
assert!(matches!(&refused, AutomationError::Bridge(m) if m.contains("not confirmed") && m.contains("job 7 is not running")), "{refused}");
let refused = cancel_answer(7, "filter.blur.gaussianBlur", Ok(json!({"cancelled": 0, "notCancelled": [{"job": 7, "reason": "committing"}]})));
assert!(matches!(&refused, AutomationError::Bridge(m) if m.contains("job 7") && m.contains("committing") && m.contains("jobs_list")), "{refused}");
}
}
+1
View File
@@ -494,6 +494,7 @@ fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
| "brush.presets.importAbr"
| "brush.presets.importImage"
| "gradient.presets.importGrd"
| "shape.presets.importCsh"
| "plugin.install"
| "swatches.import"
| "style.presets.importAsl"
+82 -1
View File
@@ -572,6 +572,18 @@ fn registry_filesystem_path_params_are_classified() {
assert!(unclassified.is_empty(), "filesystem path parameters in the command registry need an automation policy: {unclassified:?}");
}
/// Shape Blur uses inline vector geometry without granting a headless caller filesystem access.
#[test]
fn shape_blur_accepts_inline_geometry_and_rejects_filesystem_strings() {
let mut headless = crate::headless::Headless::new();
headless.command_run("file.new", serde_json::json!({"width":8,"height":8})).unwrap();
headless
.command_run("filter.blur.shapeBlur", serde_json::json!({"radius":5,"shapePath":{"subpaths":[{"knots":[[0,0],[1,0],[1,1],[0,1]],"closed":true}]}}))
.unwrap();
let error = headless.command_run("filter.blur.shapeBlur", serde_json::json!({"radius":5,"shapePath":"/outside/shape.svg"})).unwrap_err();
assert!(error.to_string().contains("nonempty vector path"), "{error}");
}
#[test]
fn native_vector_preview_updates_are_not_an_untrusted_automation_capability() {
let params = serde_json::json!({"document":1,"layer":1,"contentsId":1,"fingerprint":"00".repeat(32),"extension":"ai","sourceBase64":"JVBERi0=","previewSvg":"<svg/>"});
@@ -698,6 +710,7 @@ fn svg_update_keeps_inline_and_builtin_smart_filters_working() {
}
}
/// Distinguish filesystem parameters from descriptions of inline vector paths.
fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
// These commands return settings or installed-profile paths; they do not accept a host
// filesystem path as an automation input.
@@ -707,7 +720,8 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
// These registry descriptions refer to document vector paths, not host files.
if matches!(
id,
"filter.blurGallery.pathBlur"
"filter.blur.shapeBlur"
| "filter.blurGallery.pathBlur"
| "filter.render.flame"
| "shape.create"
| "shape.edit"
@@ -718,7 +732,9 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
| "path.info"
| "path.addToSelection"
| "path.subtractFromSelection"
| "path.intersectWithSelection"
| "path.set"
| "actions.insertPath"
| "path.transform"
| "path.moveAnchors"
| "path.moveHandle"
@@ -731,6 +747,7 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
| "path.clippingPath.set"
| "path.rename"
| "path.stroke"
| "path.toShape"
| "type.attachToPath"
| "type.fillPath"
| "type.detachFromPath"
@@ -747,6 +764,9 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
| "layer.combineShapes.intersectShapeAreas"
| "layer.combineShapes.excludeOverlappingShapes"
| "layer.combineShapes.mergeShapeComponents"
| "layer.new.frame"
// `"stepName":"Transform Path"` names the History step, not a host file.
| "edit.transform"
) || id.starts_with("layer.newFillLayer.")
|| id.starts_with("layer.newAdjustmentLayer.")
{
@@ -1054,3 +1074,64 @@ fn absolute_paths_are_checked_against_the_authority_s_own_root() {
let none = AuthorizedWorkspace::default();
assert!(none.read(&target.to_string_lossy()).is_err());
}
#[test]
fn csh_import_requires_inline_data_in_automation() {
let id = "shape.presets.importCsh";
for path in ["/outside/set.csh", "set.csh", "../set.csh", ""] {
let params = serde_json::json!({"path": path});
assert!(authorize_engine_command(id, &params).is_err(), "{path}");
assert!(authorize_desktop_engine_command(id, &params).is_err(), "{path}");
}
// The importer gives inline data precedence, even when a path is also supplied.
for params in [serde_json::json!({"data": "QUJD"}), serde_json::json!({"data": "QUJD", "path": "/outside/set.csh"})] {
assert!(authorize_engine_command(id, &params).is_ok());
assert!(authorize_desktop_engine_command(id, &params).is_ok());
}
let mut headless = crate::headless::Headless::new();
let error = headless.command_run(id, serde_json::json!({"path": "/outside/set.csh"})).unwrap_err();
assert!(error.to_string().contains("uses ambient filesystem paths and is disabled"), "{error}");
}
/// A one-shape CSH set (version 2) named `name`, with a single open one-knot subpath.
fn one_shape_csh(name: &str) -> Vec<u8> {
use photocraft_psd::path::{PathData, PsdKnot, PsdSubpath};
let mut bytes = b"cush".to_vec();
bytes.extend_from_slice(&2u32.to_be_bytes());
bytes.extend_from_slice(&1u32.to_be_bytes());
let units: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
bytes.extend_from_slice(&(units.len() as u32).to_be_bytes());
for unit in units {
bytes.extend_from_slice(&unit.to_be_bytes());
}
while !bytes.len().is_multiple_of(4) {
bytes.push(0);
}
bytes.extend_from_slice(&1u32.to_be_bytes());
let mut body = vec![2, b'i', b'd'];
for bound in [0i32, 0, 100, 100] {
body.extend_from_slice(&bound.to_be_bytes());
}
let knot = PsdKnot { linked: true, pre: (0.25, 0.25), anchor: (0.5, 0.5), post: (0.75, 0.75) };
body.extend_from_slice(&PathData::from_subpaths(&[PsdSubpath { closed: false, operation: 0, knots: vec![knot] }], false).to_bytes());
while !body.len().is_multiple_of(4) {
body.push(0);
}
bytes.extend_from_slice(&(body.len() as u32).to_be_bytes());
bytes.extend_from_slice(&body);
bytes
}
/// The headless import itself reads inline `data` before `path`: a valid set imports even when
/// the path beside it cannot be read (and would be refused as an ambient path on its own).
#[test]
fn csh_import_with_inline_data_ignores_an_unreadable_path() {
use base64::Engine as _;
let data = base64::engine::general_purpose::STANDARD.encode(one_shape_csh("Inline"));
let mut headless = crate::headless::Headless::new();
let params = serde_json::json!({"data": data, "path": "/outside/missing-set.csh", "group": "Inline Set"});
let result = headless.command_run("shape.presets.importCsh", params).unwrap();
assert_eq!(result["count"], 1, "{result}");
assert_eq!(result["names"], serde_json::json!(["Inline"]), "{result}");
assert_eq!(result["group"], "Inline Set", "{result}");
}
+36
View File
@@ -164,3 +164,39 @@ async fn a_timed_out_call_is_not_retried() {
assert!(!error.contains("may have completed"), "a read timeout never claims an applied edit: {error}");
assert_eq!(requests.lock().unwrap().len(), 1, "a timed-out call is not resent");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_call_dropped_before_its_reply_does_not_reuse_its_connection() {
// Connection 1 never answers. A call dropped while waiting on it (a cancelled MCP wait) must
// close it: on the same socket the next request would queue behind the unanswered one.
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let accepted = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let count = std::sync::Arc::clone(&accepted);
tokio::spawn(async move {
while let Ok((mut socket, _)) = listener.accept().await {
let silent = count.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
tokio::spawn(async move {
authenticate(&mut socket).await;
let (read, mut write) = socket.into_split();
let mut lines = BufReader::new(read).lines();
while let Ok(Some(line)) = lines.next_line().await {
let Ok(request) = serde_json::from_str::<Value>(&line) else { continue };
if !silent {
let reply = json!({"id": request["id"], "ok": true, "result": {"served": true}});
if write.write_all(format!("{reply}\n").as_bytes()).await.is_err() {
return;
}
}
}
});
}
});
let bridge = BridgeClient::new(&addr, CONTROL_TOKEN).unwrap().with_timeout(Duration::from_secs(30));
let dropped = tokio::time::timeout(Duration::from_millis(300), bridge.call_read("jobs.list", json!({}))).await;
assert!(dropped.is_err(), "the silent connection answered: {dropped:?}");
let next = tokio::time::timeout(Duration::from_secs(10), bridge.call("jobs.cancel", json!({"job": 7}))).await;
assert_eq!(next.expect("the next call waited on the dropped call's connection").unwrap()["served"], true);
assert_eq!(accepted.load(std::sync::atomic::Ordering::SeqCst), 2, "the next call opened a fresh connection");
}
+284
View File
@@ -0,0 +1,284 @@
//! Tests for the headless GPU backend, capability isolation, cache mutation, and CPU/GPU parity.
#[cfg(all(feature = "gpu", not(target_arch = "wasm32")))]
mod tests {
use std::fs;
use photocraft_codecs::decode;
use photocraft_color::{BlendMode, Color, ColorMode, SampleType};
use photocraft_doc::{Document, Layer, LayerContent};
use photocraft_geom::{Rect, Size};
use photocraft_io::BandRenderer;
use photocraft_raster::Surface;
use photocraft_automation::workspace::AuthorizedWorkspace;
use photocraft_automation::{GpuRenderer, Headless};
fn make_test_doc(w: u32, h: u32, depth: SampleType) -> Document {
let mut doc = Document::with_background("test_doc", Size::new(w, h), ColorMode::Rgb, depth, Color::WHITE);
let mut s = Surface::new(doc.pixel_format());
s.fill_rect(Rect::new(10, 10, w as i32 - 10, h as i32 - 10), &[0.2, 0.5, 0.8, 0.8]);
let mut top = Layer::new("top", LayerContent::Raster(s));
top.opacity = 0.75;
top.blend = BlendMode::Multiply;
doc.layers.push(top);
doc
}
#[test]
fn headless_with_gpu_renders_png_full_and_scaled() {
let mut h = Headless::trusted_local().with_gpu();
let doc = make_test_doc(120, 80, SampleType::U8);
h.session.open_document(doc, None);
// Full size render (max_side = 0)
let full_png = h.render_png(None, 0).expect("full render_png");
let full_img = decode(&full_png).expect("decode full");
assert_eq!((full_img.width(), full_img.height()), (120, 80));
let diagnostics = h.drain_diagnostics();
// Scaled preview (max_side = 40)
let thumb_png = h.render_png(None, 40).expect("thumbnail render_png");
let thumb_img = decode(&thumb_png).expect("decode thumbnail");
assert_eq!((thumb_img.width(), thumb_img.height()), (40, 27));
// Skip when this host has no usable GPU: then the full-size render legitimately used
// the CPU path instead, and there is no GPU result to check.
if diagnostics.iter().any(|d| d.contains("no GPU adapter available") || d.contains("could not build GPU compositor")) {
return;
}
assert!(diagnostics.iter().any(|d| d.contains("GPU adapter:")), "GPU was not initialized: {diagnostics:?}");
assert!(!diagnostics.iter().any(|d| d.contains("custom renderer unavailable")), "full-size render fell back to CPU: {diagnostics:?}");
}
#[test]
fn capability_scoped_writes_preserved_with_gpu() {
let dir = std::env::temp_dir().join(format!("photocraft-cap-test-{}", std::process::id()));
let _ = fs::create_dir_all(&dir);
let workspace = AuthorizedWorkspace::new(Some(&dir), Some(&dir)).expect("authorized workspace");
let mut h = Headless::with_workspace(workspace).with_gpu();
let doc = make_test_doc(64, 64, SampleType::U8);
h.session.open_document(doc, None);
let valid_target = dir.join("out.png");
let res = h.save(None, Some(&valid_target), Some("png"), &Default::default());
assert!(res.is_ok(), "authorized write must succeed: {:?}", res);
assert!(valid_target.exists());
// Attempt write outside authorized workspace
let invalid_target = std::env::temp_dir().join("outside_unauthorized.png");
let denied = h.save(None, Some(&invalid_target), Some("png"), &Default::default());
assert!(denied.is_err(), "write outside authorized workspace must be denied");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn cache_invalidation_across_mutations_and_distinct_docs() {
let mut h = Headless::trusted_local().with_gpu();
// 1. Render Doc 1
let doc1 = make_test_doc(64, 64, SampleType::U8);
h.session.open_document(doc1, None);
let png1_initial = h.render_png(Some(0), 0).expect("initial render");
let diagnostics = h.drain_diagnostics();
if diagnostics.iter().any(|d| d.contains("no GPU adapter available") || d.contains("could not build GPU compositor")) {
return;
}
assert!(diagnostics.iter().any(|d| d.contains("GPU adapter:")), "GPU was not initialized: {diagnostics:?}");
assert!(!diagnostics.iter().any(|d| d.contains("custom renderer unavailable")), "initial render fell back to CPU: {diagnostics:?}");
let img1_initial = decode(&png1_initial).expect("decode initial");
// 2. Mutate Doc 1 in place (change top layer opacity to 0.1)
h.session
.edit("Change layer opacity", |doc, _| {
doc.layers[1].opacity = 0.1;
Ok(())
})
.expect("edit opacity");
let png1_mutated = h.render_png(Some(0), 0).expect("mutated render");
let mutated_diagnostics = h.drain_diagnostics();
assert!(!mutated_diagnostics.iter().any(|d| d.contains("custom renderer unavailable")), "mutated render fell back to CPU: {mutated_diagnostics:?}");
let img1_mutated = decode(&png1_mutated).expect("decode mutated");
assert_ne!(img1_initial.data(), img1_mutated.data(), "mutating document must invalidate cache and produce fresh pixels");
// 3. Open Doc 2 with distinct contents
let mut doc2 = Document::with_background("doc2", Size::new(64, 64), ColorMode::Rgb, SampleType::U8, Color::BLACK);
let mut s2 = Surface::new(doc2.pixel_format());
s2.fill_rect(Rect::new(0, 0, 64, 64), &[1.0, 0.0, 0.0, 1.0]);
doc2.layers.push(Layer::new("red", LayerContent::Raster(s2)));
h.session.open_document(doc2, None);
let png2 = h.render_png(Some(1), 0).expect("doc2 render");
let doc2_diagnostics = h.drain_diagnostics();
assert!(!doc2_diagnostics.iter().any(|d| d.contains("custom renderer unavailable")), "second-document render fell back to CPU: {doc2_diagnostics:?}");
let img2 = decode(&png2).expect("decode doc2");
assert_ne!(img2.data(), img1_mutated.data(), "distinct document must not return stale pixels from previous doc");
}
#[test]
fn cpu_gpu_direct_parity_and_matrix_verification() {
let mut gpu_renderer = match GpuRenderer::new() {
Ok(r) => r,
Err(e) => {
eprintln!("SKIPPED: GpuRenderer unavailable ({e}); no GPU adapter present");
return;
}
};
let adapter = gpu_renderer.adapter_name().unwrap_or("unknown");
let is_software = gpu_renderer.is_software();
println!("GPU parity verified using adapter '{adapter}' (software: {is_software})");
// Matrix: 8-bit output x Alpha modes (opaque background, translucent document).
// U16 and F32 are deliberately excluded: band readback uploads half-precision textures,
// so both must reject and let the export fall back to the CPU (checked below).
for with_alpha in [false, true] {
let doc = if with_alpha {
let mut d = Document::new("translucent", Size::new(96, 96), ColorMode::Rgb, SampleType::U8);
let mut s = Surface::new(d.pixel_format());
s.fill_rect(Rect::new(10, 10, 86, 86), &[0.3, 0.6, 0.9, 0.5]);
d.layers.push(Layer::new("trans", LayerContent::Raster(s)));
d
} else {
make_test_doc(96, 96, SampleType::U8)
};
// Independent reference CPU compositor oracle
let mut cpu_pixels = Vec::new();
photocraft_compose::render_bands(&doc, doc.bounds(), 16, |buf| {
cpu_pixels.extend_from_slice(&buf.px);
Ok::<(), std::convert::Infallible>(())
})
.expect("CPU reference compose failed");
// Direct GPU renderer invocation (MUST NOT fall back to CPU)
let mut gpu_pixels = Vec::new();
gpu_renderer
.render_bands(&doc, doc.bounds(), 16, &photocraft_raster::Interrupt::NONE, &mut |band| {
gpu_pixels.extend_from_slice(&band.pixels);
Ok::<(), photocraft_io::IoError>(())
})
.expect("direct GPU render_bands failed");
assert_eq!(cpu_pixels.len(), gpu_pixels.len());
let tol = 1.0 / 255.0;
for (i, (c, g)) in cpu_pixels.iter().zip(&gpu_pixels).enumerate() {
for ch in 0..4 {
assert!((c[ch] - g[ch]).abs() <= tol, "alpha {} pixel {} ch {} differs: cpu={}, gpu={}", with_alpha, i, ch, c[ch], g[ch]);
}
}
}
// U16 and F32 need the CPU compositor: band readback would quantise them to half.
for depth in [SampleType::U16, SampleType::F32] {
let doc = make_test_doc(64, 64, depth);
let res = gpu_renderer.render_bands(&doc, doc.bounds(), 16, &photocraft_raster::Interrupt::NONE, &mut |_| Ok::<(), photocraft_io::IoError>(()));
assert!(
matches!(res, Err(photocraft_io::RenderError::Unsupported(_))),
"{depth:?} documents must be rejected so the export falls back to CPU: {res:?}"
);
}
}
#[test]
fn high_depth_exports_fall_back_to_the_cpu_compositor() {
let mut gpu_renderer = match GpuRenderer::new() {
Ok(r) => r,
Err(e) => {
eprintln!("SKIPPED: GpuRenderer unavailable ({e}); no GPU adapter present");
return;
}
};
let opts = photocraft_io::ExportOptions::default();
for depth in [SampleType::U16, SampleType::F32] {
let doc = make_test_doc(64, 64, depth);
let expected = photocraft_io::export_flat(&doc, photocraft_codecs::Format::Png, &opts).expect("CPU export");
let res = photocraft_io::export_flat_with_renderer(
&doc,
photocraft_codecs::Format::Png,
&opts,
&photocraft_raster::Interrupt::NONE,
Some(&mut gpu_renderer),
)
.expect("the export must retry on the CPU");
assert_eq!(expected.bytes, res.bytes, "{depth:?} exports must keep the CPU compositor's precision");
assert!(res.warnings.iter().any(|w| w.contains("falling back to CPU")), "{depth:?} export must report its CPU fallback: {:?}", res.warnings);
}
}
#[test]
fn gpu_renderer_diagnostics_and_bounded_storage() {
let mut r = match GpuRenderer::new() {
Ok(r) => r,
Err(e) => {
eprintln!("SKIPPED: GpuRenderer unavailable ({e}); no GPU adapter present");
return;
}
};
let diags = r.drain_diagnostics();
assert!(!diags.is_empty(), "adapter status must be reported once in diagnostics");
assert!(diags[0].contains("GPU adapter:"), "unexpected diagnostic: {:?}", diags);
// After draining, second drain should be empty
assert!(r.drain_diagnostics().is_empty());
// Push diagnostics up to limit and verify bounded dedup storage
for i in 0..30 {
r.push_diagnostic(format!("test diagnostic {i}"));
}
let drained = r.drain_diagnostics();
assert!(drained.len() <= 16, "diagnostics must be capped to bounded limit, got {}", drained.len());
}
#[test]
fn gpu_renderer_preserves_sink_errors_and_cancellation() {
let mut gpu_renderer = match GpuRenderer::new() {
Ok(r) => r,
Err(e) => {
eprintln!("SKIPPED: GpuRenderer unavailable ({e}); no GPU adapter present");
return;
}
};
let doc = make_test_doc(64, 64, SampleType::U8);
// 1. Cancellation preservation
let cancel_res =
gpu_renderer.render_bands(&doc, doc.bounds(), 16, &photocraft_raster::Interrupt::NONE, &mut |_| Err(photocraft_io::IoError::Cancelled));
assert!(matches!(cancel_res, Err(photocraft_io::RenderError::Cancelled)), "cancellation must be preserved: {cancel_res:?}");
// 2. Sink I/O error preservation
let sink_res = gpu_renderer.render_bands(&doc, doc.bounds(), 16, &photocraft_raster::Interrupt::NONE, &mut |_| {
Err(photocraft_io::IoError::Read("simulated sink error".into()))
});
assert!(matches!(sink_res, Err(photocraft_io::RenderError::Sink(_))), "sink error must be preserved as RenderError::Sink: {sink_res:?}");
}
#[test]
fn gpu_renderer_bounded_readback_staging() {
let mut gpu_renderer = match GpuRenderer::new() {
Ok(r) => r,
Err(e) => {
eprintln!("SKIPPED: GpuRenderer unavailable ({e}); no GPU adapter present");
return;
}
};
let doc = make_test_doc(128, 128, SampleType::U8);
gpu_renderer.set_max_staging_bytes(32 * 1024);
let mut received_rows = 0;
let res = gpu_renderer.render_bands(&doc, doc.bounds(), 0, &photocraft_raster::Interrupt::NONE, &mut |band| {
received_rows += band.rect.height();
Ok(())
});
assert!(res.is_ok(), "bounded staging readback must succeed: {res:?}");
assert_eq!(received_rows, 128);
}
}
+2
View File
@@ -4,6 +4,7 @@ mod agent_tasks;
mod agent_tasks_extended;
mod agent_tasks_more;
mod bridge;
mod gpu_backend;
mod inactive_save;
mod krita;
mod layered_save_dirty;
@@ -14,4 +15,5 @@ mod native_full_composite;
mod native_save_name;
mod openraster;
mod raw_defaults;
mod renderer_save;
mod smart_filter_authorization;
+123 -3
View File
@@ -513,6 +513,70 @@ async fn headless_doc_save_writes_tiff_options() {
cleanup(&dir);
}
/// #8064: headless `doc_save`/`doc_export` take `maximizeCompatibility` for a layered PSD/PSB's
/// merged image: false writes Photoshop's placeholder and says so in resource 1057; unset, the
/// real image. A value that isn't a boolean is refused before anything is written.
#[tokio::test(flavor = "multi_thread")]
async fn headless_doc_save_takes_maximize_compatibility() {
let dir = tmp("maximize-compat");
let client = connect(headless_in(&dir)).await;
call(&client, "doc_new", json!({"width": 64, "height": 32, "background": "white"})).await;
let r = call(&client, "command_run", json!({"id": "layer.new.layer", "params": {}})).await;
assert_ne!(r.is_error, Some(true), "{}", text(&r));
for (tool, params, real) in [
("doc_export", json!({"path": "default.psd"}), true),
("doc_export", json!({"path": "off.psd", "maximizeCompatibility": false}), false),
("doc_save", json!({"path": "off.psb", "maximizeCompatibility": false}), false),
("doc_save", json!({"path": "on.psd", "maximizeCompatibility": true}), true),
] {
let r = call(&client, tool, params.clone()).await;
assert_ne!(r.is_error, Some(true), "{params}: {}", text(&r));
let bytes = std::fs::read(dir.join(params["path"].as_str().unwrap())).unwrap();
assert_eq!(photocraft_psd::PsdFile::from_bytes(&bytes).unwrap().has_real_merged_data(), Some(real), "{params}");
}
let r = call(&client, "doc_export", json!({"path": "bad.psd", "maximizeCompatibility": "no"})).await;
assert_eq!(r.is_error, Some(true), "{}", text(&r));
assert!(!dir.join("bad.psd").exists());
client.cancel().await.unwrap();
cleanup(&dir);
}
/// #8064: `doc_export_layers` takes `maximizeCompatibility` too, for PSD/PSB layer files.
#[tokio::test(flavor = "multi_thread")]
async fn headless_doc_export_layers_takes_maximize_compatibility() {
let dir = tmp("export-layers-maximize-compat");
let client = connect(headless_in(&dir)).await;
call(&client, "doc_new", json!({"width": 16, "height": 16, "background": "white", "name": "card"})).await;
for (sub, params, real) in
[("on", json!({"dir": "on", "format": "psd"}), true), ("off", json!({"dir": "off", "format": "psd", "maximizeCompatibility": false}), false)]
{
let result = json_of(&call(&client, "doc_export_layers", params.clone()).await);
assert_eq!(result["count"], 1, "{params}: {result}");
let file = result["files"][0].as_str().unwrap();
assert!(file.starts_with(sub), "{file}");
let bytes = std::fs::read(dir.join(file)).unwrap();
assert_eq!(photocraft_psd::PsdFile::from_bytes(&bytes).unwrap().has_real_merged_data(), Some(real), "{params}");
}
let r = call(&client, "doc_export_layers", json!({"dir": "bad", "format": "psd", "maximizeCompatibility": "no"})).await;
assert_eq!(r.is_error, Some(true), "{}", text(&r));
client.cancel().await.unwrap();
cleanup(&dir);
}
/// #8064: `maximizeCompatibility` reaches the app's `app.save` through the bridge; unset, nothing
/// is sent and the app's preference decides.
#[tokio::test(flavor = "multi_thread")]
async fn bridge_doc_save_forwards_maximize_compatibility() {
let (addr, app) = fake_app().await;
let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await;
let saved = json_of(&call(&client, "doc_save", json!({"path": "out.psd", "maximizeCompatibility": false})).await);
assert_eq!(saved, json!({"saved": {"path": "out.psd", "maximizeCompatibility": false}}));
let saved = json_of(&call(&client, "doc_save", json!({"path": "out.psd"})).await);
assert_eq!(saved, json!({"saved": {"path": "out.psd"}}));
client.cancel().await.unwrap();
app.abort();
}
#[tokio::test(flavor = "multi_thread")]
async fn bridge_response_budget_drops_connection_without_retrying_the_operation() {
use photocraft_automation::{BridgeClient, budgets::MAX_RESPONSE_BYTES};
@@ -1305,10 +1369,14 @@ async fn command_batch_step_without_waiting_starts_a_background_job() {
async fn bridge_command_batch_forwards_each_steps_wait() {
let (addr, app) = fake_app().await;
let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await;
let r = json_of(&call(&client, "command_batch", json!({"steps": [{"id": "filter.blur.gaussianBlur", "wait": false}, {"id": "layer.new.layer"}]})).await);
assert_eq!(r["completed"], 2, "{r}");
let steps = json!([{"id": "filter.blur.gaussianBlur", "wait": false}, {"id": "layer.new.layer"}, {"id": "view.zoomIn"}]);
let r = json_of(&call(&client, "command_batch", json!({"steps": steps})).await);
assert_eq!(r["completed"], 3, "{r}");
assert_eq!(r["results"][0]["result"]["wait"], false, "{r}");
assert_eq!(r["results"][1]["result"]["wait"], true, "{r}");
// A waited engine command starts without waiting too: the bridge polls a job it starts (#8130).
assert_eq!(r["results"][1]["result"]["wait"], false, "{r}");
// A UI command can't be polled, so the app waits on it.
assert_eq!(r["results"][2]["result"]["wait"], true, "{r}");
client.cancel().await.unwrap();
app.abort();
}
@@ -1390,6 +1458,58 @@ async fn convention_tools_have_titles_hints_and_strict_schemas() {
client.cancel().await.unwrap();
}
/// The hints say what each tool does, not just that hints exist (#8129): `jobs_list` was
/// advertised as destructive. A new tool fails here until it is classified.
#[tokio::test(flavor = "multi_thread")]
async fn convention_tool_hints_match_what_each_tool_does() {
// (read-only, destructive, idempotent)
const READ: (bool, bool, bool) = (true, false, true);
const ADDS: (bool, bool, bool) = (false, false, false);
const EDITS: (bool, bool, bool) = (false, true, false);
let expected = [
("command_list", READ),
("doc_inspect", READ),
("session_list", READ),
("render_preview", READ),
("doc_render_preview", READ),
("ui_inspect", READ),
("ui_screenshot", READ),
("jobs_list", READ),
("doc_new", ADDS),
("doc_open", ADDS),
("doc_place", ADDS),
("doc_export_layers", ADDS),
("doc_export_data_sets", ADDS),
("doc_select", (false, false, true)),
("doc_save", EDITS),
("doc_replace_smart_object", EDITS),
("doc_export", EDITS),
("doc_close", EDITS),
("doc_import_data_sets", EDITS),
("command_run", EDITS),
("command_batch", EDITS),
("jobs_cancel", EDITS),
("ui_pointer", EDITS),
("ui_menu_invoke", EDITS),
("ui_set", EDITS),
("control_call", EDITS),
];
let client = connect(PhotocraftMcp::headless()).await;
let tools = client.list_all_tools().await.unwrap();
assert_eq!(tools.len(), expected.len(), "classify every tool here");
for tool in tools {
let value = serde_json::to_value(&tool).unwrap();
let hints = &value["annotations"];
let actual = (hints["readOnlyHint"] == true, hints["destructiveHint"] == true, hints["idempotentHint"] == true);
let want = expected.iter().find(|(name, _)| *name == tool.name).map(|(_, hints)| *hints);
assert_eq!(Some(actual), want, "{}: {hints}", tool.name);
assert_eq!(hints["openWorldHint"], false, "{}", tool.name);
let description = tool.description.as_deref().unwrap_or_default();
assert!(!description.contains(" "), "{}: a run of spaces in {description:?}", tool.name);
}
client.cancel().await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn convention_unknown_arguments_name_the_key_without_side_effects() {
let client = connect(PhotocraftMcp::headless()).await;
+283
View File
@@ -0,0 +1,283 @@
//! A failed renderer-aware save must never replace the previous file, and closing a document
//! must release its renderer caches.
use std::fs;
use std::sync::{Arc, Mutex};
use photocraft_automation::workspace::AuthorizedWorkspace;
use photocraft_automation::{Headless, files};
use photocraft_color::{Color, ColorMode, SampleType};
use photocraft_doc::{DocId, Document, Rect, Size};
use photocraft_io::{BandRenderer, CpuRenderer, ExportOptions, IoError, RenderBand, RenderError};
struct FailingRenderer;
impl BandRenderer for FailingRenderer {
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> {
Err(RenderError::Execution("forced renderer failure".into()))
}
}
#[test]
fn failed_renderer_save_preserves_prior_file() {
let dir = std::env::temp_dir().join(format!("photocraft-renderer-save-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let target = dir.join("existing.png");
let original = b"prior file contents that must survive a failed save";
fs::write(&target, original).unwrap();
let mut doc = Document::with_background("test", Size::new(32, 24), ColorMode::Rgb, SampleType::U8, Color::WHITE);
let mut top = doc.layers[0].clone();
top.name = "top".into();
doc.layers.push(top);
let mut renderer = FailingRenderer;
let res = files::save_with_renderer(&doc, &target, Some("png"), &ExportOptions::default(), None, Some(&mut renderer));
assert!(res.is_err(), "the renderer failure must fail the save: {res:?}");
assert_eq!(fs::read(&target).unwrap(), original, "a failed save must leave the previous file intact");
let _ = fs::remove_dir_all(&dir);
}
struct ForgetRecordingRenderer {
forgotten: Arc<Mutex<Vec<DocId>>>,
}
impl BandRenderer for ForgetRecordingRenderer {
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> {
Ok(())
}
fn forget_document(&mut self, doc: DocId) {
self.forgotten.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(doc);
}
}
#[test]
fn closing_a_document_forgets_its_renderer_caches() {
let forgotten = Arc::new(Mutex::new(Vec::new()));
let mut h = Headless::trusted_local();
h.session.open_document(Document::with_background("close", Size::new(16, 16), ColorMode::Rgb, SampleType::U8, Color::WHITE), None);
let id = h.session.documents()[0].doc.id;
h.set_renderer(Some(Box::new(ForgetRecordingRenderer { forgotten: forgotten.clone() })));
h.close(Some(0)).expect("close document");
let forgotten = forgotten.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone();
assert_eq!(forgotten, vec![id], "closing a document must release its renderer caches");
}
#[test]
fn closing_a_document_through_a_command_forgets_its_renderer_caches() {
let forgotten = Arc::new(Mutex::new(Vec::new()));
let mut h = Headless::trusted_local();
for name in ["first", "second"] {
h.session.open_document(Document::with_background(name, Size::new(16, 16), ColorMode::Rgb, SampleType::U8, Color::WHITE), None);
}
let closed = h.session.documents()[0].doc.id;
h.set_renderer(Some(Box::new(ForgetRecordingRenderer { forgotten: forgotten.clone() })));
// `file.close` removes the document through the session, not `Headless::close`.
h.command_run("file.close", serde_json::json!({"document": 0})).expect("close command");
let forgotten = forgotten.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone();
assert_eq!(forgotten, vec![closed], "a command that closes a document must release its renderer caches");
}
struct DiagnosingRenderer {
diagnostics: Vec<String>,
}
impl BandRenderer for DiagnosingRenderer {
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.push_diagnostic("renderer diagnostic survives a failed write".into());
let mut cpu = CpuRenderer;
cpu.render_bands(doc, rect, band_rows, ctl, sink)
}
fn drain_diagnostics(&mut self) -> Vec<String> {
std::mem::take(&mut self.diagnostics)
}
fn push_diagnostic(&mut self, diagnostic: String) {
if !self.diagnostics.contains(&diagnostic) {
self.diagnostics.push(diagnostic);
}
}
}
#[test]
fn failed_atomic_write_keeps_renderer_diagnostics_for_the_caller() {
let dir = std::env::temp_dir().join(format!("photocraft-renderer-write-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
// A directory at the destination makes the final atomic rename fail after encoding.
let target = dir.join("target.png");
fs::create_dir(&target).unwrap();
let mut doc = Document::with_background("test", Size::new(32, 24), ColorMode::Rgb, SampleType::U8, Color::WHITE);
let mut top = doc.layers[0].clone();
top.name = "top".into();
doc.layers.push(top);
let mut renderer = DiagnosingRenderer { diagnostics: Vec::new() };
let res = files::save_with_renderer(&doc, &target, Some("png"), &ExportOptions::default(), None, Some(&mut renderer));
assert!(res.is_err(), "a write over a directory must fail: {res:?}");
let diagnostics = renderer.drain_diagnostics();
assert!(diagnostics.iter().any(|d| d.contains("failed write")), "diagnostics drained before the failed write must be queued again: {diagnostics:?}");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn failed_workspace_write_keeps_export_diagnostics_for_the_caller() {
let dir = std::env::temp_dir().join(format!("photocraft-workspace-save-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let root = dir.join("root");
fs::create_dir_all(&root).unwrap();
let workspace = AuthorizedWorkspace::new(Some(&root), Some(&root)).expect("authorized workspace");
let mut h = Headless::with_workspace(workspace);
let mut doc = Document::with_background("test", Size::new(32, 24), ColorMode::Rgb, SampleType::U8, Color::WHITE);
let mut top = doc.layers[0].clone();
top.name = "top".into();
doc.layers.push(top);
h.session.open_document(doc, None);
h.set_renderer(Some(Box::new(DiagnosingRenderer { diagnostics: Vec::new() })));
// Outside the authorized root: the export succeeds, then its write is refused.
let outside = dir.join("outside.png");
let res = h.save(None, Some(&outside), Some("png"), &Default::default());
assert!(res.is_err(), "a write outside the workspace must fail: {res:?}");
let diagnostics = h.drain_diagnostics();
assert!(
diagnostics.iter().any(|d| d.contains("failed write")),
"export diagnostics drained before the failed workspace write must be queued again: {diagnostics:?}"
);
let _ = fs::remove_dir_all(&dir);
}
/// Overrides only `drain_diagnostics`: [`BandRenderer::push_diagnostic`] keeps its default no-op,
/// as most custom renderers leave it.
struct DrainOnlyRenderer;
impl BandRenderer for DrainOnlyRenderer {
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> {
let mut cpu = CpuRenderer;
cpu.render_bands(doc, rect, band_rows, ctl, sink)
}
fn drain_diagnostics(&mut self) -> Vec<String> {
Vec::new()
}
}
#[test]
fn failed_workspace_write_keeps_export_warnings_with_a_drain_only_renderer() {
let dir = std::env::temp_dir().join(format!("photocraft-workspace-warnings-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let root = dir.join("root");
fs::create_dir_all(&root).unwrap();
let workspace = AuthorizedWorkspace::new(Some(&root), Some(&root)).expect("authorized workspace");
let mut h = Headless::with_workspace(workspace);
let mut doc = Document::with_background("test", Size::new(32, 24), ColorMode::Rgb, SampleType::U8, Color::WHITE);
let mut top = doc.layers[0].clone();
top.name = "top".into();
doc.layers.push(top);
h.session.open_document(doc, None);
h.set_renderer(Some(Box::new(DrainOnlyRenderer)));
// Outside the authorized root: the export succeeds, then its write is refused.
let outside = dir.join("outside.png");
let res = h.save(None, Some(&outside), Some("png"), &Default::default());
assert!(res.is_err(), "a write outside the workspace must fail: {res:?}");
let diagnostics = h.drain_diagnostics();
assert!(
diagnostics.iter().any(|d| d.contains("flattened")),
"the export warnings must survive a failed workspace write when the renderer ignores pushed diagnostics: {diagnostics:?}"
);
let again = h.drain_diagnostics();
assert!(again.is_empty(), "the export warnings must drain exactly once: {again:?}");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn failed_local_write_keeps_export_warnings_with_a_drain_only_renderer() {
let dir = std::env::temp_dir().join(format!("photocraft-local-warnings-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
// A directory at the destination makes the final atomic rename fail after encoding.
let target = dir.join("target.png");
fs::create_dir(&target).unwrap();
let mut h = Headless::trusted_local();
let mut doc = Document::with_background("test", Size::new(32, 24), ColorMode::Rgb, SampleType::U8, Color::WHITE);
let mut top = doc.layers[0].clone();
top.name = "top".into();
doc.layers.push(top);
h.session.open_document(doc, None);
h.set_renderer(Some(Box::new(DrainOnlyRenderer)));
let res = h.save(None, Some(&target), Some("png"), &Default::default());
assert!(res.is_err(), "a write over a directory must fail: {res:?}");
let diagnostics = h.drain_diagnostics();
assert!(
diagnostics.iter().any(|d| d.contains("flattened")),
"the export warnings must survive a failed local write when the renderer ignores pushed diagnostics: {diagnostics:?}"
);
assert!(h.drain_diagnostics().is_empty(), "the export warnings must drain exactly once");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn scaled_preview_fallback_warning_is_drained_and_the_rpc_reply_carries_it() {
let mut h = Headless::trusted_local();
h.session.open_document(Document::with_background("preview", Size::new(64, 48), ColorMode::Rgb, SampleType::U8, Color::WHITE), None);
h.set_renderer(Some(Box::new(DrainOnlyRenderer)));
// A scaled preview composes on the CPU; the warning must not depend on `push_diagnostic`.
let png = h.render_png(None, 16).expect("scaled preview");
assert!(png.starts_with(b"\x89PNG"));
let diagnostics = h.drain_diagnostics();
assert!(diagnostics.iter().any(|d| d.contains("scaled previews")), "preview fallback warning must be drained: {diagnostics:?}");
let reply = h.handle("doc.render", serde_json::json!({"maxSide": 16})).expect("doc.render");
assert!(reply.get("base64").is_some(), "existing reply fields stay: {reply}");
let warnings = reply.get("warnings").and_then(|w| w.as_array()).expect("warnings array");
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("scaled previews"))), "{reply}");
assert!(h.drain_diagnostics().is_empty(), "the reply consumed the warnings");
// No renderer, no fallback, no `warnings` key.
let mut plain = Headless::trusted_local();
plain.session.open_document(Document::with_background("plain", Size::new(64, 48), ColorMode::Rgb, SampleType::U8, Color::WHITE), None);
let reply = plain.handle("doc.render", serde_json::json!({"maxSide": 16})).expect("doc.render");
assert!(reply.get("warnings").is_none(), "{reply}");
}
Loaded 100 of 1032 files, more files were not shown because too many files have changed in this diff. Show more