Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 86953e0ebf security: enforce HTTPS on curl downloads (SonarCloud S6506)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
2026-10-21 17:07:12 -04:00
872 changed files with 4984 additions and 57264 deletions

No files matched your search

+15 -18
View File
@@ -37,8 +37,7 @@ concurrency:
jobs:
# Which OSes the test job runs on. main, manual runs and PRs labelled `full-ci` get Linux and
# Windows. Other PRs get Linux, plus Windows when they touch code whose behaviour differs by
# platform: the app shell, UI, text and fonts, GPU, pen input, native saves, build scripts or
# the lockfile. Format changes must exercise the Windows-only publication barriers (#8034).
# platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile.
# macOS is tested only by release.yml.
platforms:
timeout-minutes: 10
@@ -79,7 +78,7 @@ jobs:
else
echo "web=false" >> "$GITHUB_OUTPUT"
fi
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif|format)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
echo "legs=$all" >> "$GITHUB_OUTPUT"
else
echo "legs=[$linux]" >> "$GITHUB_OUTPUT"
@@ -114,7 +113,7 @@ jobs:
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
libgl1-mesa-dev libgtk-3-dev
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
@@ -123,7 +122,7 @@ jobs:
# A failing test still saves: the cache holds compiled dependencies, not test results.
# The Windows legs share one key (same dependencies); the integration leg saves it,
# because it also runs Clippy and so leaves Clippy's dependency metadata in it.
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration') }}
cache-on-failure: true
@@ -252,9 +251,9 @@ jobs:
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
libgl1-mesa-dev libgtk-3-dev
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- uses: Swatinem/rust-cache@v2
with:
shared-key: test
save-if: false
@@ -307,8 +306,8 @@ jobs:
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: corpus
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -329,10 +328,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- uses: Swatinem/rust-cache@v2
with:
key: wasm32
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -352,10 +351,10 @@ jobs:
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- uses: Swatinem/rust-cache@v2
with:
key: ci-web
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -420,17 +419,15 @@ jobs:
- { crate: format, name: load }
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@2c76c034f9e32a5d6e2c104dc4df23a22476db04 # nightly
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2
with:
workspaces: crates/${{ matrix.target.crate }}/fuzz
- name: Install cargo-fuzz
run: cargo install cargo-fuzz --locked
- name: Fuzz
working-directory: crates/${{ matrix.target.crate }}
env:
FUZZ_SECONDS: ${{ inputs.fuzz_seconds }}
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time="$FUZZ_SECONDS"
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time=${{ inputs.fuzz_seconds }}
- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v4
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: dtolnay/rust-toolchain@stable
- name: Install mdBook
run: cargo install mdbook --version 0.4.52 --locked
- name: Build documentation
+1 -3
View File
@@ -32,7 +32,5 @@ jobs:
with:
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
cargo-build-extra-args: "--features heif"
# The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a device at
# once, so photocraft-gpu's tests run one at a time, like ui-egui's integration tests.
custom-test-command: "CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui --exclude photocraft-gpu && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-gpu --locked -- --test-threads=1 && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1"
custom-test-command: "CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1"
run-package-check: true
+2 -2
View File
@@ -44,9 +44,9 @@ jobs:
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- uses: Swatinem/rust-cache@v2
with:
key: perf-release
-1
View File
@@ -70,7 +70,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
+1 -6
View File
@@ -25,23 +25,19 @@ 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/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/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) |
| `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`, `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` 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`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.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` and `marquee-{row,column}.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/{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/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | 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 |
| `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/text-warp.svg` | Create Warped Text glyph (Type tool options bar): a T above a bowed arc, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/align-edge-*.svg`, `assets/icons/distribute-*.svg` | Move tool options-bar glyphs: Align (six edges/centres) and Distribute (edges, centres, spacing), a guide line or edge lines with bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `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 |
@@ -120,7 +116,6 @@ next to the files:
| `corpus/heif/` (9 files, 0.1 MB) | HEIC/HEIF test files: `heic-rs/` checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by macOS `sips`, each `.ref.png` Apple's decode); `pillow-heif/` the 10-bit RGBA `RGBA_10__29x100.heif` and its source `RGBA_16__29x100.png` | Thomas Braun (heic-rs); Pillow-Heif contributors | [heic-rs `tests/fixtures`](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), [pillow-heif `tests/images`](https://github.com/bigcat88/pillow_heif/tree/master/tests/images) | MIT OR Apache-2.0 (heic-rs); BSD-3-Clause (pillow-heif) |
| `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause |
| `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI |
| `corpus/atn/` (16 files, 0.9 MB) | Photoshop Actions sets, one folder per repository: `sbaril/` (Frequency Separation, Firefly, Dimension, Harmonize, Photo, Quick Object Extraction, Generative Upscale), `bjango/` (Bjango Actions, Adjustment Actions, Scale Actions), `bkwld/` (Save layers for web), `atntools/` (test.atn), `trimmer/` (Trimmer), `alpha-utility/` (AlphaUtilityAdditions), `gif-creator/` (BannerActions) | Stéphane Baril; Bjango and Marc Edwards; BKWLD; Maurits Rijk; damsmcfly; UCLA Game Lab; overbyte | [sbaril/Photoshop-Scripts](https://github.com/sbaril/Photoshop-Scripts), [bjango/Bjango-Actions](https://github.com/bjango/Bjango-Actions), [BKWLD/photoshop-actions](https://github.com/BKWLD/photoshop-actions), [mrijk/atntools](https://github.com/mrijk/atntools), [damsmcfly/photoshop-cleaner-trimmer](https://github.com/damsmcfly/photoshop-cleaner-trimmer), [uclagamelab/AlphaUtilityAdditions](https://github.com/uclagamelab/AlphaUtilityAdditions), [overbyte/photoshop-gif-creator-script](https://github.com/overbyte/photoshop-gif-creator-script), pinned in [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | MIT (sbaril, BKWLD, atntools, gif-creator), BSD (bjango, per `Help/License.md`), BSD-2-Clause (alpha-utility), Unlicense (trimmer); each licence file sits next to its files |
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
| `corpus/pixls/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
Generated
+35 -37
View File
@@ -4638,11 +4638,10 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"arboard",
"async-io",
"chrono",
"core-graphics2",
"dispatch2",
"eframe",
@@ -4684,7 +4683,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4694,7 +4693,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4707,7 +4706,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"base64 0.22.1",
"cap-std",
@@ -4717,8 +4716,8 @@ dependencies = [
"photocraft-doc",
"photocraft-engine",
"photocraft-format",
"photocraft-geom",
"photocraft-io",
"photocraft-raw",
"rmcp",
"rustix 1.1.5",
"schemars",
@@ -4730,14 +4729,13 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
"photocraft-engine",
"photocraft-format",
"photocraft-io",
"photocraft-raw",
"serde_json",
"tokio",
"winresource",
@@ -4745,7 +4743,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"moxcms",
"rayon",
@@ -4753,7 +4751,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"exr",
"flate2",
@@ -4774,7 +4772,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"photocraft-cms",
"serde",
@@ -4782,7 +4780,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"criterion",
"photocraft-color",
@@ -4797,7 +4795,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4808,10 +4806,9 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"blake3",
"chrono",
"craft-print",
"exr",
"flate2",
@@ -4847,7 +4844,7 @@ dependencies = [
[[package]]
name = "photocraft-format"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"blake3",
"crc32fast",
@@ -4870,14 +4867,14 @@ dependencies = [
[[package]]
name = "photocraft-geom"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"half",
"log",
@@ -4895,14 +4892,14 @@ dependencies = [
[[package]]
name = "photocraft-heif"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"blake3",
"craft-docpath",
@@ -4932,18 +4929,18 @@ dependencies = [
[[package]]
name = "photocraft-ipp"
version = "0.14.0"
version = "0.12.0"
[[package]]
name = "photocraft-ops"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4954,7 +4951,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4969,7 +4966,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4980,7 +4977,7 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4991,7 +4988,7 @@ dependencies = [
[[package]]
name = "photocraft-raw"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"flate2",
"image",
@@ -5006,7 +5003,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -5018,15 +5015,18 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"memory-stats",
"photocraft-color",
"photocraft-geom",
"photocraft-raster",
"serde_json",
]
[[package]]
name = "photocraft-text"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"flate2",
"parley",
@@ -5041,7 +5041,7 @@ dependencies = [
[[package]]
name = "photocraft-ui-egui"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"base64 0.22.1",
"craft-print",
@@ -5076,7 +5076,6 @@ dependencies = [
"serde",
"serde_json",
"sys-locale",
"tempfile",
"toml",
"unicode-bidi",
"winsafe",
@@ -5084,7 +5083,7 @@ dependencies = [
[[package]]
name = "photocraft-vector"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -5095,7 +5094,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"eframe",
"egui",
@@ -5117,7 +5116,7 @@ dependencies = [
[[package]]
name = "photocraft-web-boot"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"flate2",
"js-sys",
@@ -8713,9 +8712,8 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.14.0"
version = "0.12.0"
dependencies = [
"chrono",
"serde",
"serde_json",
"toml",
+1 -3
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.14.0"
version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -59,8 +59,6 @@ egui = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
criterion = { version = "0.5", default-features = false }
getrandom = "0.3"
+2 -6
View File
@@ -7,10 +7,6 @@
</a>
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png" width="128" height="128" alt="PhotoCraft icon: an engraved nine-tailed fox on a blue tile">
</p>
<h1 align="center">PhotoCraft</h1>
<p align="center">
@@ -221,7 +217,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
PhotoCraft's PSD support is a standalone crate written from Adobe's public specification and tested against a corpus of real-world files.
- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/it/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/it/corpus.rs`).
- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/corpus.rs`).
- **Pixels that match:** a composite oracle compares our render with Photoshop's own merged image, covering gradient interpolation (Classic, Perceptual and Linear), layer effects, shape strokes, clipping and fill opacity.
- **Large documents:** PSB, 16 and 32-bit files, and CMYK and Lab documents open natively.
@@ -343,7 +339,7 @@ sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests wi
**New to PhotoCraft?** Download it from the [PhotoCraft page on getartcraft.com](https://getartcraft.com/apps/photocraft). That's the easiest way to install it.
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) have earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
### Windows
+3 -6
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-11 · **Last updated:** 2026-10-13 · **Change:** 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)
@@ -104,7 +104,7 @@ because engine messages are still English. Detail: [localization-parity](docs/lo
| Korean | `ko` | 100% | partial |
| Vietnamese | `vi` | 0% | none |
Also shipped: Traditional Chinese, Russian (every dynamic label, [notes](docs/localization-ru.md)), Ukrainian, Czech, Polish, Greek, Dutch, Italian (8).
Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dutch, Italian (8).
## Upcoming
@@ -122,7 +122,6 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Entry |
|---|---|
| 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 to the craftrules standard (`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 |
| 2026-10-08 | 0.5.0. Affinity import; Magnetic Lasso, Pattern Stamp, Mixer Brush, Pencil, Patch, Vertical Type on the toolbar |
@@ -136,9 +135,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 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 |
| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
| 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) |
| 2026-10-10 | minor | Readiness by audience with hours per tier |
| 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added |
-4
View File
@@ -31,10 +31,6 @@ photocraft-codecs = { workspace = true }
serde_json = { workspace = true }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
[dev-dependencies]
# Synthetic camera raws (#7789).
photocraft-raw = { workspace = true, features = ["testgen"] }
[build-dependencies]
# Icon + VERSIONINFO for photocraft-cli.exe; build.rs only uses it when targeting Windows.
winresource = "0.1"
+9 -67
View File
@@ -15,27 +15,21 @@ pub const USAGE: &str = "\
photocraft-cli: headless PhotoCraft
USAGE:
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression <none|lzw|zip>] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
An <out> that is the <in> file is refused, as the result would replace the original;
--in-place allows it.
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data);
--tiff-compression compresses it (none, the default; lzw; zip).
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
--tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as
its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24.
--dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16.
--dds-compression sets a DDS texture's compression: none (uncompressed, the default), bc1
(DXT1: alpha on or off), bc2 (DXT3) or bc3 (DXT5). --dds-mipmaps also writes the full mip chain.
--jpeg-format picks a JPEG's scans, as JPEG Options does: baseline (the default), optimized
(Huffman tables fitted to the image; RGB is written 4:2:0 and keeps the standard tables, so
only Gray and CMYK shrink) or progressive.
photocraft-cli info <file> [--compact]
Print the document as JSON (size, mode, depth, layer tree).
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tiff-compression <none|lzw|zip>] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
Open a file, run engine commands in order, save the result. Each --params
applies to the preceding --cmd. Prints each command's JSON result.
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression <none|lzw|zip>] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a
set: its action --action names plays, as below. JSON steps are [id, params] pairs,
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
@@ -79,44 +73,20 @@ struct Subcommand {
const SUBCOMMANDS: &[Subcommand] = &[
Subcommand {
name: "convert",
values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--tiff-compression", "--jpeg-format"],
bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
bare: &["--tiff-layers", "--dds-mipmaps"],
run: convert,
},
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
Subcommand {
name: "run",
values: &[
"--new",
"--cmd",
"--params",
"--out",
"--format",
"--quality",
"--tga-bits",
"--dpx-bits",
"--dds-compression",
"--tiff-compression",
"--jpeg-format",
],
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
bare: &["--tiff-layers", "--dds-mipmaps"],
run: run_cmds,
},
Subcommand {
name: "batch",
values: &[
"--actions",
"--action",
"--in",
"--out",
"--format",
"--quality",
"--tga-bits",
"--dpx-bits",
"--dds-compression",
"--tiff-compression",
"--jpeg-format",
],
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
run: batch,
},
@@ -256,18 +226,6 @@ fn export_opts(a: &Args) -> Result<ExportOptions, String> {
o.encode.dpx_bits =
Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?);
}
if let Some(c) = a.get("--tiff-compression") {
o.encode.tiff_compression = match c {
"none" => photocraft_codecs::TiffCompression::None,
"lzw" => photocraft_codecs::TiffCompression::Lzw,
"zip" => photocraft_codecs::TiffCompression::Deflate,
_ => return Err(format!("bad --tiff-compression `{c}`: expected none, lzw or zip")),
};
}
if let Some(f) = a.get("--jpeg-format") {
o.encode.jpeg_format =
photocraft_codecs::JpegFormat::parse(f).ok_or_else(|| format!("bad --jpeg-format `{f}`: expected baseline, optimized or progressive"))?;
}
if let Some(c) = a.get("--dds-compression") {
o.encode.dds.compression =
photocraft_io::DdsCompression::from_name(c).ok_or_else(|| format!("bad --dds-compression `{c}`: expected none, bc1, bc2 or bc3"))?;
@@ -293,28 +251,12 @@ fn automation_workspace(args: &Args) -> Result<AuthorizedWorkspace, String> {
.map_err(|error| error.to_string())
}
/// An output that is the input file itself, however it is spelt (relative, with `./`, through a
/// hard or symbolic link), would replace the original, and a lossy re-encode can't be undone: it
/// is refused unless `--in-place` asks for it, as `batch` refuses an `--out` that is its `--in`
/// folder (#7437). The paths are compared when the command starts and the save opens `output`
/// again later, so another process swapping in a link meanwhile goes unnoticed: this guards
/// against mistakes, it is not a security boundary.
fn refuse_overwriting_input(a: &Args, input: &str, output: &str) -> R {
if !a.has("--in-place") && photocraft_engine::file_cmds::same_file(input, output) {
return Err(format!("{output} is the input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it"));
}
Ok(())
}
fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
let [input, output] = a.positional.as_slice() else {
return Err("convert needs <in> <out>".into());
};
let opts = export_opts(a)?;
refuse_overwriting_input(a, input, output)?;
let mut o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
// 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);
let o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
warn_all(err, &o.warnings);
warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document));
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?;
@@ -563,33 +563,6 @@ fn tga_bits_choose_the_targa_depth() {
}
}
/// `--jpeg-format` picks the JPEG's scans: progressive writes a progressive frame (SOF2). One it
/// doesn't name is refused and nothing is written.
#[test]
fn jpeg_format_chooses_the_scans() {
let d = tmp("jpeg-format");
let a = d.join("a.png");
write_png(&a, 16, 8, 3);
let marker = |b: &[u8], m: u8| b.windows(2).any(|w| w == [0xFF, m]);
for (format, progressive) in [(None, false), (Some("baseline"), false), (Some("optimized"), false), (Some("progressive"), true)] {
let out = d.join("a.jpg");
let mut convert = bin();
convert.arg("convert").arg(&a).arg(&out);
if let Some(f) = format {
convert.args(["--jpeg-format", f]);
}
ok(&mut convert);
assert_eq!(marker(&std::fs::read(&out).unwrap(), 0xC2), progressive, "--jpeg-format {format:?}");
}
for bad in ["Progressive", "1", ""] {
let out = d.join("bad.jpg");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--jpeg-format", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--jpeg-format {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --jpeg-format `{bad}`")), "--jpeg-format {bad}");
assert!(!out.exists(), "--jpeg-format {bad} wrote a file");
}
}
/// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before
/// anything is written, unless `--in-place` asks for it.
#[test]
@@ -636,42 +609,6 @@ fn batch_refuses_to_write_over_its_inputs() {
assert!(after.iter().zip(&before).all(|(a, b)| a.0 == b.0 && a.1 != b.1), "both inputs replaced");
}
/// #7437: `convert` to its own input file, however the output is spelt, is refused before
/// anything is read or written, unless `--in-place` asks for it (as `batch` does, #492).
#[test]
fn convert_refuses_to_write_over_its_input() {
let d = tmp("convert-in-place");
write_png(&d.join("in.png"), 8, 4, 5);
let before = std::fs::read(d.join("in.png")).unwrap();
let convert = |out: &Path, extra: &[&str]| bin().current_dir(&d).args(["convert", "in.png"]).arg(out).args(extra).output().unwrap();
let mut outs = vec![PathBuf::from("in.png"), PathBuf::from("./in.png"), d.join("in.png"), d.join(".").join("in.png")];
std::fs::hard_link(d.join("in.png"), d.join("hard.png")).unwrap();
outs.push(PathBuf::from("hard.png"));
#[cfg(unix)]
{
std::os::unix::fs::symlink(d.join("in.png"), d.join("soft.png")).unwrap();
outs.push(PathBuf::from("soft.png"));
}
for out in &outs {
for extra in [&[][..], &["--format", "png"][..], &["--quality", "80"][..]] {
let o = convert(out, extra);
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(1), "{} {extra:?}: {err}", out.display());
assert!(err.contains("is the input file") && err.contains("--in-place"), "{err}");
assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before, "{} {extra:?} changed the original", out.display());
}
}
// Another file converts as before, leaving the original alone.
ok(bin().current_dir(&d).args(["convert", "in.png", "out.jpg"]));
assert!(d.join("out.jpg").is_file());
assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before);
// `--in-place` opts in: the result replaces the original, which still opens.
ok(bin().current_dir(&d).args(["convert", "in.png", "in.png", "--in-place"]));
let (out, _) = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"]));
let info: Value = serde_json::from_str(&out).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(4)), "{info}");
}
#[test]
fn commands_listing() {
let (out, _) = ok(bin().arg("commands"));
@@ -859,136 +796,3 @@ fn batch_plays_a_photoshop_actions_file() {
assert!(err.contains("Generative Fill"), "{err}");
assert!(!o.status.success());
}
/// `photocraft_cli::run` in-process: `(exit code, stdout, stderr)`.
fn invoke(args: &[&str]) -> (i32, String, String) {
let (mut out, mut err) = (Vec::new(), Vec::new());
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let code = photocraft_cli::run(&args, &mut out, &mut err);
(code, String::from_utf8(out).unwrap(), String::from_utf8(err).unwrap())
}
const SUBCOMMANDS: [&str; 8] = ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"];
/// Pins top-level dispatch: help goes to stdout (exit 0), no arguments is a usage error on stderr.
#[test]
fn top_level_help_version_and_no_arguments() {
let (code, out, err) = invoke(&[]);
assert_eq!((code, out.is_empty(), err.is_empty()), (2, true, false));
for help in ["--help", "-h", "help"] {
let (code, out, err) = invoke(&[help]);
assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{help}");
}
for version in ["--version", "version"] {
let (code, out, err) = invoke(&[version]);
assert_eq!((code, err.as_str()), (0, ""), "{version}");
assert!(out.starts_with("photocraft-cli "), "{version}: {out}");
}
let (code, out, err) = invoke(&["frobnicate"]);
assert_eq!((code, out.is_empty()), (2, true));
assert!(err.contains("frobnicate"), "{err}");
}
/// `<sub> --help` / `-h` prints help on stdout and returns without running the subcommand
/// (`mcp` and `serve` would otherwise block on stdin).
#[test]
fn every_subcommand_help_returns_on_stdout() {
for sub in SUBCOMMANDS {
for help in ["--help", "-h"] {
let (code, out, err) = invoke(&[sub, help]);
assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{sub} {help}: {err}");
// `commands` would also exit 0 with output if it ran; its list names `file.new`, help doesn't.
assert!(!out.contains("file.new"), "{sub} {help} ran instead of printing help: {out}");
}
}
}
/// Every subcommand refuses an unknown flag (bare or `=value`) with exit 2, naming it on stderr.
#[test]
fn every_subcommand_rejects_an_unknown_flag() {
for sub in SUBCOMMANDS {
for flag in [&["--fromat"][..], &["--fromat=jpg"][..], &["--fromat", "jpg"][..]] {
let args: Vec<&str> = [sub].into_iter().chain(flag.iter().copied()).collect();
let (code, out, err) = invoke(&args);
assert_eq!((code, out.is_empty()), (2, true), "{args:?}: {err}");
assert!(err.contains("--fromat"), "{args:?}: {err}");
}
}
}
/// `--flag=value`, a bare flag given a value, a value flag without one, and repeated flags.
#[test]
fn flag_value_forms_and_repeats() {
let ids = |out: &str| out.lines().filter_map(|l| l.split_whitespace().next()).map(str::to_owned).collect::<Vec<_>>();
let (code, eq, err) = invoke(&["commands", "--filter=file.new"]);
assert_eq!(code, 0, "{err}");
let (_, spaced, _) = invoke(&["commands", "--filter", "file.new"]);
assert_eq!(eq, spaced);
assert!(ids(&eq).contains(&"file.new".to_string()), "{eq}");
// The last of a repeated value flag wins, whichever order they come in.
let (code, out, err) = invoke(&["commands", "--filter", "zzz-no-match", "--filter", "file.new"]);
assert_eq!(code, 0, "{err}");
assert_eq!(out, eq);
let (code, out, err) = invoke(&["commands", "--filter", "file.new", "--filter", "zzz-no-match"]);
assert_eq!(code, 0, "{err}");
assert!(out.is_empty(), "{out}");
// A bare flag takes no value; a value flag needs one.
let (code, out, err) = invoke(&["commands", "--json=1"]);
assert_eq!((code, out.is_empty()), (2, true));
assert!(err.contains("--json"), "{err}");
let (code, out, err) = invoke(&["commands", "--filter"]);
assert_eq!((code, out.is_empty()), (2, true));
assert!(err.contains("--filter"), "{err}");
}
/// Each `--params` applies to the `--cmd` before it; a `--cmd` without one gets `{}`; a
/// `--params` ahead of every `--cmd` fails naming `--params`.
#[test]
fn run_pairs_params_with_the_preceding_cmd() {
let (code, out, err) = invoke(&[
"run",
"--new",
r#"{"width":8,"height":8}"#,
"--cmd",
"layer.new.layer",
"--params",
r#"{"name":"First"}"#,
"--cmd",
"layer.new.layer",
"--cmd",
"layer.new.layer",
"--params",
r#"{"name":"Third"}"#,
"--cmd",
"document.inspect",
]);
assert_eq!(code, 0, "{err}");
let rows: Vec<Value> = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(rows.len(), 4, "{out}");
let names: Vec<&str> = rows[3]["result"]["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect();
let count = |n: &str| names.iter().filter(|x| **x == n).count();
assert_eq!((count("First"), count("Third")), (1, 1), "each --params applies to its own --cmd only: {names:?}");
// Three new layers plus the background; the middle one (no --params) has a default name.
assert_eq!(names.len(), 4, "{names:?}");
// Layers list top first: Third, the unnamed middle one, First, Background. Pairing --params
// with --cmd by position (params[i] -> cmd[i]) would name the middle one Third instead.
assert_eq!((names[0], names[2]), ("Third", "First"), "{names:?}");
let (code, _, err) = invoke(&["run", "--new", "{}", "--params", "{}", "--cmd", "layer.new.layer"]);
assert_ne!(code, 0);
assert!(err.contains("--params"), "{err}");
}
/// #7789: `convert` opens a developed raw at Raw Defaults › Resolution (default 300 ppi), as
/// `run`, `info` and the app do; it wrote 72.
#[test]
fn convert_opens_a_raw_at_the_raw_defaults_resolution() {
use photocraft_raw::testgen::{DngSpec, mosaic, scene};
let d = tmp("convert-raw");
let (w, h) = (24, 16);
let (raw, psd) = (d.join("IMG_0001.dng"), d.join("IMG_0001.psd"));
std::fs::write(&raw, DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build()).unwrap();
ok(bin().args(["convert"]).arg(&raw).arg(&psd));
let doc = photocraft_io::import("IMG_0001.psd", &std::fs::read(&psd).unwrap()).unwrap().document;
assert_eq!(doc.resolution_dpi, 300.0);
std::fs::remove_dir_all(d).unwrap();
}
-4
View File
@@ -1,4 +0,0 @@
//! The crate's integration tests, linked into one test binary.
mod cli;
mod orientation;
+5 -12
View File
@@ -17,12 +17,11 @@ use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .act, .ase), which go to the preset libraries.
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico",
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm", "ilbm", "heic",
"heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "af", "afdesign",
"afphoto", "afpub",
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef",
"nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
@@ -323,12 +322,6 @@ impl WebShell {
}
impl eframe::App for WebShell {
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
// A tap reaches eframe as Pointer Events and Touch Events: keep one press per tap, or the
// twin press closes the menu the first one opened (#2095).
photocraft_ui_egui::touch_twins::drop_twins(ctx, raw_input);
}
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
if !dropped.is_empty() {
@@ -391,8 +384,8 @@ impl eframe::App for WebShell {
fn services(inbox: Inbox, place_inbox: Inbox, 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| {
photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| {
photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
})),
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
photocraft_io::export(doc, path, &settings.io_options()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
+2 -4
View File
@@ -33,7 +33,6 @@ eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11",
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
@@ -51,12 +50,11 @@ core-graphics2 = { version = "=0.6.1", default-features = false, features = ["di
# safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
# Safe AppKit calls only: turn off AppKit's title bar dragging (mac_window.rs) and find where the
# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position), and play the
# system alert sound for Beep When Done (services.rs, NSBeep). Already in
# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position). Already in
# Cargo.lock (winit, photocraft-tablet); pinned exactly to the same versions.
objc2 = "=0.6.4"
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSGeometry"] }
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSGraphics", "NSResponder", "NSView", "NSWindow"] }
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSResponder", "NSView", "NSWindow"] }
# The macOS menu bar (mac_menu.rs): a standalone native menu crate (from the tauri-apps org, but
# not Tauri: no webview, no JS), AppKit menus behind a safe API (no GTK with default features off). Pinned exactly: review the diff before bumping.
muda = { version = "=0.21.1", default-features = false }
-20
View File
@@ -34,13 +34,6 @@ pub fn sets_runtime_icon(exe: Option<&std::path::Path>) -> bool {
!(cfg!(target_os = "macos") && exe.is_some_and(in_app_bundle))
}
/// Value for [`egui::ViewportBuilder::with_icon`]: [`window_icon`] when [`sets_runtime_icon`]
/// is true; otherwise [`egui::IconData::default`] so eframe does not substitute its own icon
/// on the Dock after the window opens (#7253).
pub fn viewport_icon(exe: Option<&std::path::Path>) -> egui::IconData {
if sets_runtime_icon(exe) { window_icon() } else { egui::IconData::default() }
}
/// Whether `exe` is an app bundle's executable: `…/Name.app/Contents/MacOS/binary`.
fn in_app_bundle(exe: &std::path::Path) -> bool {
let name = |p: Option<&std::path::Path>| p.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned());
@@ -87,19 +80,6 @@ mod tests {
assert_eq!(sets_runtime_icon(Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))), !cfg!(target_os = "macos"));
}
/// #7253: omitting `with_icon` made eframe paint its default glyph on the Dock once the window loaded.
#[test]
fn viewport_icon_blocks_eframe_default_when_runtime_icon_is_skipped() {
use std::path::Path;
let bundled = Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"));
if sets_runtime_icon(bundled) {
let icon = viewport_icon(bundled);
assert!(icon.width >= 256 && icon.width == icon.height);
} else {
assert_eq!(viewport_icon(bundled), egui::IconData::default());
}
}
#[test]
fn window_icon_decodes() {
let icon = window_icon();
+1 -22
View File
@@ -25,7 +25,7 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRe
return rx;
}
};
eprintln!("photocraft: control server listening on {}", listening_addr(&listener, port));
eprintln!("photocraft: control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || {
let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
let token = Arc::new(token);
@@ -47,16 +47,6 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRe
rx
}
/// The address `listener` is bound to: with `--control 0` the OS picks the port, and a client
/// needs that one, not the requested 0 (#6427). Falls back to the requested port if the OS
/// can't report it.
fn listening_addr(listener: &TcpListener, requested_port: u16) -> String {
match listener.local_addr() {
Ok(addr) => addr.to_string(),
Err(_) => format!("127.0.0.1:{requested_port}"),
}
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE);
}
@@ -141,17 +131,6 @@ mod tests {
use super::*;
use std::io::BufRead;
/// #6427: with port 0 the log names the port the OS bound, which a client can connect to.
#[test]
fn port_zero_logs_the_bound_port() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let bound = listener.local_addr().unwrap();
assert_ne!(bound.port(), 0);
let logged = listening_addr(&listener, 0);
assert_eq!(logged, format!("127.0.0.1:{}", bound.port()));
TcpStream::connect(&logged).unwrap();
}
/// #4965: a connection that keeps sending without authenticating is closed at the deadline
/// (releasing its connection permit), and nothing it sent is dispatched.
#[test]
+25 -68
View File
@@ -12,10 +12,10 @@
//! - macOS: Metal → CPU
//!
//! The choice that worked is remembered in `performance.gpuBackend` (Preferences › Performance,
//! where it can be reset). With `auto`, Intel and Qualcomm (Windows on ARM) adapters on Windows
//! use DX12, and when DXGI lists an Intel adapter the instance leaves Vulkan out, so the first
//! start doesn't load the Intel Vulkan driver either. `WGPU_BACKEND` still overrides everything,
//! and `--safe-gpu` forces the CPU path for one launch.
//! where it can be reset). With `auto`, Intel adapters on Windows use DX12, and when DXGI lists an
//! Intel adapter the instance leaves Vulkan out, so the first start doesn't load the Intel Vulkan
//! driver either. `WGPU_BACKEND` still overrides everything, and `--safe-gpu` forces the CPU path
//! for one launch.
//!
//! "CPU" composites on the CPU and draws the window with a software adapter where the platform
//! has one (WARP on Windows, llvmpipe over GL elsewhere). On Linux a GL adapter that can't present
@@ -35,10 +35,6 @@ use serde_json::{Value, json};
pub const MARKER_FILE: &str = "gpu-starting.json";
/// PCI vendor id of Intel.
pub const INTEL: u32 = 0x8086;
/// Qualcomm's vendor id as its Vulkan driver reports it.
pub const QUALCOMM: u32 = 0x5143;
/// Qualcomm's vendor id as DXGI (DX12) reports it: "QCOM".
pub const QUALCOMM_DXGI: u32 = 0x4D4F_4351;
/// Largest marker read (a marker is a few hundred bytes).
const MARKER_MAX: u64 = 64 << 10;
@@ -248,18 +244,6 @@ pub fn dx12_compiler(env: Option<&str>, exe_dir: Option<&Path>) -> wgpu::Dx12Com
}
}
/// The GPU makers whose Windows Vulkan driver `auto` avoids, so DX12 comes first: Intel's
/// (`igvk64.dll` crashed while the device was created, #4) and Qualcomm's Adreno driver on
/// Windows on ARM (it lost the device mid-session and the window went with it; DX12 on the same
/// machine didn't, #7537). The name is for System Info.
fn dx12_first_on_windows(vendor: u32) -> Option<&'static str> {
match vendor {
INTEL => Some("Intel"),
QUALCOMM | QUALCOMM_DXGI => Some("Qualcomm"),
_ => None,
}
}
/// What adapter selection needs to know about an adapter.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Candidate {
@@ -274,10 +258,9 @@ pub struct Candidate {
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
/// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for
/// Intel and Qualcomm on Windows ([`dx12_first_on_windows`]). For `cpu`, any GL adapter comes
/// before Vulkan, which is only the fallback for presenting the window (see [`backends`]). On
/// Windows GL comes after every other adapter, WARP included: GL there (VMware's SVGA3D, GL-only
/// old GPUs) drew a black window (#5191, #1788).
/// Intel on Windows. For `cpu`, any GL adapter comes before Vulkan, which is only the fallback
/// for presenting the window (see [`backends`]). On Windows GL comes after every other adapter,
/// WARP included: GL there (VMware's SVGA3D, GL-only old GPUs) drew a black window (#5191, #1788).
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
let fallback = u8::from((backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan) || (os == Os::Windows && a.backend == wgpu::Backend::Gl));
let ty = match a.device_type {
@@ -293,7 +276,7 @@ fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
_ => ty,
};
let be = match a.backend {
wgpu::Backend::Dx12 if os == Os::Windows && dx12_first_on_windows(a.vendor).is_some() => 0,
wgpu::Backend::Dx12 if os == Os::Windows && a.vendor == INTEL => 0,
wgpu::Backend::Vulkan | wgpu::Backend::Metal => 1,
wgpu::Backend::Dx12 => 2,
wgpu::Backend::Gl => 3,
@@ -346,18 +329,15 @@ pub fn wanted_adapter(env: Option<&str>, pref: &str) -> Option<String> {
[env.unwrap_or(""), pref].iter().map(|s| s.trim()).find(|s| !s.is_empty()).map(|s| s.chars().take(256).collect())
}
/// The maker's name when picking `chosen` applied the Windows DX12 default of
/// [`dx12_first_on_windows`]: a Vulkan adapter of the same maker was available, or, for Intel,
/// `vulkan_left_out` ([`windows_auto_backends`] left Vulkan out of the instance).
pub fn dx12_default_applied(adapters: &[Candidate], chosen: usize, os: Os, vulkan_left_out: bool) -> Option<&'static str> {
let c = adapters.get(chosen)?;
if os != Os::Windows || c.backend != wgpu::Backend::Dx12 {
return None;
}
let maker = dx12_first_on_windows(c.vendor)?;
let applied =
(vulkan_left_out && c.vendor == INTEL) || adapters.iter().any(|a| a.backend == wgpu::Backend::Vulkan && dx12_first_on_windows(a.vendor) == Some(maker));
applied.then_some(maker)
/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the
/// same vendor was available, or `vulkan_left_out`: [`windows_auto_backends`] left Vulkan out of
/// the instance).
pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os, vulkan_left_out: bool) -> bool {
let Some(c) = adapters.get(chosen) else { return false };
os == Os::Windows
&& c.vendor == INTEL
&& c.backend == wgpu::Backend::Dx12
&& (vulkan_left_out || adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan))
}
/// wgpu compiles indirect-call validation shaders inside `request_device`; Metal's compiler
@@ -369,8 +349,8 @@ fn skips_indirect_validation(os: Os, intel: bool) -> bool {
}
/// Configure eframe's wgpu setup for `plan`: instance backends, the DX12 shader compiler, adapter
/// selection (Intel and Qualcomm on Windows, software adapters for `cpu`) and the marker update
/// once the adapter is chosen. `note` receives a remark for System Info (e.g. the DX12 default).
/// selection (Intel on Windows, software adapters for `cpu`) and the marker update once the
/// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default).
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted: Option<String>, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
if let egui_wgpu::WgpuSetup::CreateNew(create) = setup {
if let Some(b) = backends(plan, os) {
@@ -416,11 +396,9 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted:
*note.lock().unwrap_or_else(PoisonError::into_inner) =
Some("CPU image rendering; no software graphics adapter is available, so the window uses hardware graphics".into());
}
if backend == GpuBackend::Auto
&& let Some(maker) = dx12_default_applied(&cands, i, os, vulkan_left_out)
{
if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os, vulkan_left_out) {
*note.lock().unwrap_or_else(PoisonError::into_inner) =
Some(format!("{maker} graphics on Windows: using DirectX 12 (the {maker} Vulkan driver is known to crash)"));
Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into());
}
adapters.get(i).cloned().ok_or_else(|| "no graphics adapter found".to_string())
}));
@@ -794,13 +772,13 @@ mod tests {
let intel = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::IntegratedGpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
let i = pick(&intel, Auto, Os::Windows).unwrap();
assert_eq!(intel[i].backend, B::Dx12);
assert_eq!(dx12_default_applied(&intel, i, Os::Windows, false), Some("Intel"));
assert!(intel_dx12_applied(&intel, i, Os::Windows, false));
// Elsewhere (and for other vendors) Vulkan stays first.
assert_eq!(intel[pick(&intel, Auto, Os::Other).unwrap()].backend, B::Vulkan);
let nv = [cand(0x10de, T::DiscreteGpu, B::Dx12), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
let i = pick(&nv, Auto, Os::Windows).unwrap();
assert_eq!(nv[i].backend, B::Vulkan);
assert_eq!(dx12_default_applied(&nv, i, Os::Windows, false), None);
assert!(!intel_dx12_applied(&nv, i, Os::Windows, false));
// Intel iGPU + discrete GPU: the discrete one wins (high performance), on Vulkan.
let hybrid = [intel[0], intel[1], nv[1]];
assert_eq!(pick(&hybrid, Auto, Os::Windows), Some(2));
@@ -811,27 +789,6 @@ mod tests {
assert_eq!(pick(&[], Auto, Os::Windows), None);
}
/// #7537: on Windows on ARM the Adreno Vulkan driver lost the device mid-session and took the
/// window with it; DX12 on the same machine didn't. DXGI and Vulkan report different vendor ids.
#[test]
fn qualcomm_on_windows_defaults_to_dx12() {
use wgpu::{Backend as B, DeviceType as T};
let adreno = [cand(QUALCOMM, T::IntegratedGpu, B::Vulkan), cand(QUALCOMM_DXGI, T::IntegratedGpu, B::Dx12)];
let i = pick(&adreno, Auto, Os::Windows).unwrap();
assert_eq!(adreno[i].backend, B::Dx12);
assert_eq!(dx12_default_applied(&adreno, i, Os::Windows, false), Some("Qualcomm"));
// Only Vulkan offered: still Vulkan, and no note.
assert_eq!(pick(&adreno[..1], Auto, Os::Windows), Some(0));
assert_eq!(dx12_default_applied(&adreno, 0, Os::Windows, false), None);
// An Intel Vulkan adapter doesn't make a Qualcomm DX12 pick "the default".
let mixed = [cand(INTEL, T::IntegratedGpu, B::Vulkan), adreno[1]];
assert_eq!(dx12_default_applied(&mixed, 1, Os::Windows, false), None);
// Vulkan left out of the instance (an Intel adapter was listed) speaks only for Intel.
assert_eq!(dx12_default_applied(&adreno[1..], 0, Os::Windows, true), None);
// Elsewhere Vulkan stays first.
assert_eq!(adreno[pick(&adreno, Auto, Os::Other).unwrap()].backend, B::Vulkan);
}
/// #4: an instance with Vulkan loads the Intel Vulkan driver, which crashed at startup, before
/// the selector picks Intel's DX12 adapter. With an Intel adapter in the DX12 list, `auto` on
/// Windows creates the instance without Vulkan.
@@ -867,8 +824,8 @@ mod tests {
let listed = [cand(INTEL, T::IntegratedGpu, B::Dx12), cand(WARP, T::Cpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
let i = pick(&listed, Auto, Os::Windows).unwrap();
assert_eq!(i, 0);
assert_eq!(dx12_default_applied(&listed, i, Os::Windows, true), Some("Intel"));
assert_eq!(dx12_default_applied(&listed, i, Os::Windows, false), None);
assert!(intel_dx12_applied(&listed, i, Os::Windows, true));
assert!(!intel_dx12_applied(&listed, i, Os::Windows, false));
}
#[test]
+17 -40
View File
@@ -91,36 +91,27 @@ fn matches(name: &str, target: &str) -> bool {
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
/// logging never panics and never silently resets to the epoch for a convertible instant.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
// Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
// past `i64::MAX` seconds saturates rather than falling back to the epoch.
let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
let (y, t) = utc_parts(secs, since.subsec_nanos());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
}
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
/// so month, day and time of day come from chrono and only the year is carried, which keeps every
/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch).
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days + 719_468;
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + u64::from(m <= 2);
(y, m, d)
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -425,20 +416,6 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// Past chrono's year range still converts via the 400-year cycle (the old code did too,
// through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
// SystemTime can't hold this instant, so it is only checked where it exists.
// `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
// 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
}
#[test]
+5 -14
View File
@@ -102,7 +102,9 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool {
/// different offset every launch (#419). A later launch restores the saved position instead
/// (`main` turns `centered` off, #4826). Wayland compositors place windows themselves.
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
let viewport = egui::ViewportBuilder::default().with_icon(app_icon::viewport_icon(std::env::current_exe().ok().as_deref()));
let viewport = egui::ViewportBuilder::default();
// Not inside a macOS app bundle: its Dock icon is the bundle's .icns (`app_icon`, #5849).
let viewport = if app_icon::sets_runtime_icon(std::env::current_exe().ok().as_deref()) { viewport.with_icon(app_icon::window_icon()) } else { viewport };
eframe::NativeOptions {
viewport: viewport
.with_app_id(APP_ID)
@@ -524,13 +526,6 @@ fn main() -> eframe::Result {
#[cfg(not(target_os = "macos"))]
let single_instance = {
let new_instance_env = std::env::var("PHOTOCRAFT_NEW_INSTANCE").ok();
// A variable documented as the equivalent of a per-launch flag counts as that flag: a
// launch that sets only `PHOTOCRAFT_PRESENT_MODE` (say) has per-process options too, so it
// keeps its own process instead of handing its files over and being ignored.
// Folded in here, inside the block that only non-macOS builds compile, so the binding
// above keeps its `mut` off macOS, where nothing is folded in.
let mut launch_options = launch_options;
launch_options.extend_from_slice(&single_instance::env_launch_options());
let context = single_instance::LaunchContext {
per_user_settings: app_dirs::current().mode == app_dirs::Mode::Platform,
new_instance_flag: new_instance,
@@ -791,9 +786,6 @@ fn main() -> eframe::Result {
if let Some(rs) = cc.wgpu_render_state.clone() {
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
app.perf.gpu_info.set_available(&rs.available_adapters);
// Every egui texture uploads through this adapter, whichever canvas draws:
// Iris Xe on DX12 shows stale memory in all-zero texels (texel_guard.rs).
photocraft_ui_egui::texel_guard::install(&cc.egui_ctx, &rs.adapter.get_info());
let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu;
if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu {
// Keep an earlier note, such as a requested adapter that couldn't be used.
@@ -811,9 +803,8 @@ fn main() -> eframe::Result {
{
app.set_wgpu(rs);
} else {
// The window still draws with wgpu: record its errors instead of panicking,
// and notice when the device is lost (#3438).
app.watch_window_device(&rs.device);
// The window still draws with wgpu: record its errors instead of panicking.
let _ = photocraft_ui_egui::gpu_canvas::DeviceHealth::watch(&rs.device);
}
}
let fallback_reason = std::env::var("PHOTOCRAFT_GPU_STARTUP_FAILURE").ok().or_else(|| {
+4 -16
View File
@@ -156,18 +156,6 @@ fn file_uri_path(uri: &str) -> Option<PathBuf> {
Some(PathBuf::from(std::ffi::OsString::from_vec(out)))
}
/// Which file type's extensions belong on the name the dialog returned: the one it reported, or,
/// when the dialog offered only one type, that one. It reports no pick then, and the name it was
/// given is the stem (`services::dialog_file_name`), so a bare `Swatches` has to come back as
/// `Swatches.aco`. `None` when the answer names no type this dialog offered.
fn chosen_type(filters: &[Filter], picked: Option<usize>) -> Option<usize> {
match picked {
Some(i) if i < filters.len() => Some(i),
None if filters.len() == 1 => Some(0),
_ => None,
}
}
/// Save As on Linux: the portal's dialog, with the name given the extension of the file type
/// picked. When that changes the name to one that already exists, the dialog opens again under
/// the new name, so the portal asks before replacing it (it asked only about the typed name).
@@ -175,10 +163,10 @@ pub fn save_as(conn: &zbus::blocking::Connection, mut req: SaveRequest) -> Resul
loop {
let Some(saved) = save_file(conn, &req)? else { return Ok(None) };
let picked = saved.filter.as_deref().and_then(|label| req.filters.iter().position(|(l, _)| l == label));
// A typed extension is the user's own: `with_chosen_type` keeps one it recognises.
let exts: &[String] = match chosen_type(&req.filters, picked) {
Some(i) => &req.filters[i].1,
None => &[],
// With one type offered there is nothing to pick, and a typed extension is the user's.
let exts: &[String] = match picked {
Some(i) if req.filters.len() > 1 => &req.filters[i].1,
_ => &[],
};
let path = crate::services::with_chosen_type(&saved.path, req.suggested.as_deref(), exts);
if path == saved.path || !path.exists() {
-30
View File
@@ -235,33 +235,3 @@ fn a_single_file_type_keeps_the_typed_extension() {
assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.ase")));
daemon.kill().ok();
}
/// The same single type, with the name left as the dialog shows it: the bare stem comes back, and
/// that one type gives it its extension instead of a file named `Swatches` being written.
#[test]
fn a_single_file_type_gives_the_bare_name_its_extension() {
let typed: &'static str = "file:///tmp/photocraft-none/Swatches";
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "Color Swatches")))]) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let req = SaveRequest { filters: vec![("Color Swatches".into(), vec!["aco".into()])], ..request("Swatches.aco") };
assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.aco")));
daemon.kill().ok();
}
/// Which type's extensions the returned name gets. The portal cases above need a `dbus-daemon`,
/// so the rule itself is checked here too, which needs none.
#[test]
fn the_type_that_gives_the_extension_is_the_one_picked() {
let one: Vec<Filter> = vec![("Color Swatches".into(), vec!["aco".into()])];
// One type offered: the dialog reports no pick, and it was given the stem.
assert_eq!(chosen_type(&one, None), Some(0));
assert_eq!(chosen_type(&one, Some(0)), Some(0));
let many: Vec<Filter> = request("test").filters;
assert_eq!(chosen_type(&many, Some(1)), Some(1));
// Several types and no pick: nothing to add, as before.
assert_eq!(chosen_type(&many, None), None);
// An answer naming a type this dialog never offered is not a pick either.
assert_eq!(chosen_type(&many, Some(99)), None);
}
+87 -197
View File
@@ -1,8 +1,10 @@
//! Native platform services: file dialogs (rfd), filesystem, codecs.
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image};
use photocraft_doc::Document;
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType as CS};
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{Document, Layer, Size};
use photocraft_format::RecoveryStore;
use photocraft_geom::Rect;
use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recoverable, Services};
use std::cell::{Cell, RefCell};
use std::future::Future;
@@ -15,13 +17,12 @@ use std::sync::Arc;
mod recovery_tests;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .act, .ase) and keyboard shortcut sets
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico",
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm",
"ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase",
"kys", "af", "afdesign", "afphoto", "afpub",
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2",
"cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
@@ -66,12 +67,10 @@ const OPEN_FILTERS: &[(&str, &[&str])] = &[
("Radiance HDR", &["hdr"]),
("FITS", &["fits", "fit", "fts"]),
("DPX and Cineon", &["dpx", "cin"]),
("Amiga IFF", &["iff", "lbm", "ilbm"]),
("DICOM", &["dcm", "dicom", "dc3"]),
("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]),
("Brushes", &["abr"]),
("Gradients", &["grd"]),
("Swatches", &["aco", "act", "ase"]),
("Swatches", &["aco", "ase"]),
("Keyboard Shortcuts", &["kys"]),
];
@@ -103,12 +102,11 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("DPX", &["dpx"]),
// Game and 3D textures; DDS Options asks for the compression and mipmaps (#1327).
("DDS", &["dds"]),
("Cineon", &["cin"]),
];
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
/// never swaps their extension for a document format's.
const OTHER_SAVE_FILTERS: &[(&str, &[&str])] = &[("Color Swatches", &["aco"]), ("Color Table", &["act"]), ("Swatch Exchange", &["ase"])];
const OTHER_SAVE_FILTERS: &[(&str, &[&str])] = &[("Color Swatches", &["aco"]), ("Swatch Exchange", &["ase"])];
/// Lists the save dialog's file types with the `suggested` type first (added if unlisted), so the dialog keeps that extension instead of .psd.
fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
@@ -200,10 +198,10 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
};
if multiple {
let picked = dialog.pick_files();
Box::pin(async move { picked.await.map(|files| answer(files.iter().map(|f| f.path()), false, FileDialogAnswer::Paths)) })
Box::pin(async move { picked.await.map(|files| FileDialogAnswer::Paths(files.iter().map(path_of).collect())) })
} else {
let picked = dialog.pick_file();
Box::pin(async move { picked.await.map(|file| answer([file.path()], false, FileDialogAnswer::Paths)) })
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) })
}
}
FileDialogRequest::Folder { initial_dir } => {
@@ -211,7 +209,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
dialog = dialog.set_directory(dir);
}
let picked = dialog.pick_folder();
Box::pin(async move { picked.await.map(|folder| answer_one(folder.path(), true, FileDialogAnswer::FolderTo)) })
Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) })
}
FileDialogRequest::Save { suggested } => {
let filters = save_filters(&suggested);
@@ -246,10 +244,10 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
.map_err(|e| e.to_string())
.and_then(|conn| crate::portal_save::save_as(&conn, req));
match portal {
Ok(path) => path.map(|p| answer_one(&p, true, FileDialogAnswer::SaveTo)),
Ok(path) => path.map(|p| FileDialogAnswer::SaveTo(p.to_string_lossy().into_owned())),
Err(e) => {
log::info!("portal save dialog unavailable ({e}), using rfd");
dialog.save_file().await.map(|file| answer_one(file.path(), true, FileDialogAnswer::SaveTo))
dialog.save_file().await.map(|file| FileDialogAnswer::SaveTo(path_of(&file)))
}
}
})
@@ -258,7 +256,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
{
let _ = filters;
let picked = dialog.save_file();
Box::pin(async move { picked.await.map(|file| answer_one(file.path(), true, FileDialogAnswer::SaveTo)) })
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) })
}
}
};
@@ -272,22 +270,8 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
}
}
/// The dialog's answer for the chosen `paths`, or the error for one whose name isn't valid
/// Unicode, which a lossy copy would turn into another file's name (#6601). `saving`: the paths
/// are to be written to.
fn answer<'a>(paths: impl IntoIterator<Item = &'a Path>, saving: bool, make: impl FnOnce(Vec<String>) -> FileDialogAnswer) -> FileDialogAnswer {
match paths.into_iter().map(|p| photocraft_ui_egui::file_open::path_text(p, saving)).collect() {
Ok(paths) => make(paths),
Err(e) => FileDialogAnswer::Unavailable(e),
}
}
/// [`answer`] for a dialog that picks one path.
fn answer_one(path: &Path, saving: bool, make: impl FnOnce(String) -> FileDialogAnswer) -> FileDialogAnswer {
match photocraft_ui_egui::file_open::path_text(path, saving) {
Ok(path) => make(path),
Err(e) => FileDialogAnswer::Unavailable(e),
}
fn path_of(file: &rfd::FileHandle) -> String {
file.path().to_string_lossy().into_owned()
}
/// Runs `future` to completion on this thread, sleeping until it is woken.
@@ -346,44 +330,6 @@ fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> R
photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string())
}
/// Whether `a` and `b` reach the same file: the same device and inode on Unix, the same final path
/// on Windows (where it holds the name's letter case as stored). False when either can't be read.
fn same_file(a: &Path, b: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
match (std::fs::metadata(a), std::fs::metadata(b)) {
(Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(),
_ => false,
}
}
#[cfg(not(unix))]
{
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
}
/// Whether `a` and `b` name one entry in one folder: `same_file` finds they reach the same file
/// in the same folder, and the folder doesn't list both names. Another spelling of a name on a
/// file system that ignores letter case is the same entry; a hard link is another entry, which a
/// save replaces while the first name keeps the old file. False when the folder can't be read.
fn same_entry(a: &Path, b: &Path, same_file: impl Fn(&Path, &Path) -> bool) -> bool {
let folder = |p: &Path| p.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")).to_path_buf();
if !same_file(a, b) || !same_file(&folder(a), &folder(b)) {
return false;
}
let (Some(x), Some(y)) = (a.file_name(), b.file_name()) else { return false };
if x == y {
return true;
}
let Ok(entries) = std::fs::read_dir(folder(a)) else { return false };
let listed: Vec<_> = entries.filter_map(|e| e.ok().map(|e| e.file_name())).collect();
!(listed.iter().any(|n| n == x) && listed.iter().any(|n| n == y))
}
fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions {
settings.io_options()
}
@@ -441,13 +387,12 @@ fn with_store<R>(store: &SharedRecovery, f: impl FnOnce(&mut RecoveryStore) -> R
fn recovery_services(dir: Option<PathBuf>) -> Services {
let recover_dir = dir.clone();
let store: SharedRecovery = Rc::new(RefCell::new(dir.map(RecoveryStore::new)));
let (s1, s2, s4, s5) = (store.clone(), store.clone(), store.clone(), store.clone());
let (s1, s2, s4) = (store.clone(), store.clone(), store.clone());
Services {
autosave: Some(Box::new(move |doc: &Arc<Document>, revision: u64, path: Option<&str>| {
with_store(&s1, |s| s.autosave_checked(doc, revision, path.map(str::to_string)))?
})),
autosave_results: Some(Box::new(move || with_store(&s4, |s| s.take_completed()).unwrap_or_default())),
flush_autosaves: Some(Box::new(move || with_store(&s5, RecoveryStore::flush).unwrap_or_default())),
discard_autosave: Some(Box::new(move |id: u64| {
let _ = with_store(&s2, |s| s.discard(id));
})),
@@ -470,17 +415,6 @@ fn recovery_services(dir: Option<PathBuf>) -> Services {
}
}
/// The OS clipboard read and write run under the crash guard, like Open and Export (#7582): a
/// panic in arboard, or in decoding a copied file, becomes the paste's (or copy's) error and the
/// app keeps its documents.
fn guarded_clipboard_get(mut read: impl FnMut() -> Result<Option<(u32, u32, Vec<u8>)>, String> + 'static) -> photocraft_ui_egui::ClipboardGetFn {
Box::new(move || crate::crash_guard::guard("Paste", &mut read))
}
fn guarded_clipboard_set(mut write: impl FnMut(u32, u32, &[u8]) -> Result<(), String> + 'static) -> photocraft_ui_egui::ClipboardSetFn {
Box::new(move |w: u32, h: u32, px: &[u8]| crate::crash_guard::guard("Copy", || write(w, h, px)))
}
/// The pixels to paste when the clipboard holds copied files rather than an image (Copy in
/// Files, Finder or Explorer puts paths on the clipboard, #338): the first file that decodes,
/// upright, as RGBA8. Files that aren't images are skipped after reading only their header.
@@ -593,9 +527,9 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
}) as photocraft_ui_egui::AutomationCommandFn
});
Services {
import: Some(Box::new(|name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions| {
import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| {
crate::crash_guard::guard("Open", || {
photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
})
})),
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
@@ -610,7 +544,6 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
let meta = std::fs::metadata(path).ok()?;
Some(photocraft_ui_egui::FileStamp { len: meta.len(), modified: meta.modified().ok() })
})),
same_file: Some(Box::new(|a: &str, b: &str| same_entry(Path::new(a), Path::new(b), same_file))),
automation_read,
automation_write,
automation_open_pcraft,
@@ -626,7 +559,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
beep: Some(platform_beep),
clipboard_set_image: Some({
let clip = clip.clone();
guarded_clipboard_set(move |w: u32, h: u32, px: &[u8]| {
Box::new(move |w: u32, h: u32, px: &[u8]| {
let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?;
let cb = match slot.as_mut() {
Some(c) => c,
@@ -638,7 +571,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
clipboard_get_image: Some({
let clip = clip.clone();
let unreadable = clip_unreadable.clone();
guarded_clipboard_get(move || {
Box::new(move || {
let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?;
let cb = match slot.as_mut() {
Some(c) => c,
@@ -702,19 +635,77 @@ fn platform_beep() {
{
let _ = winsafe::MessageBeep(winsafe::co::MBP::OK);
}
// The user's alert sound, at the alert volume set in System Settings ▸ Sound. The terminal
// bell below is silent for an app started from the Finder or the Dock.
#[cfg(target_os = "macos")]
objc2_app_kit::NSBeep();
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
#[cfg(not(target_os = "windows"))]
{
// No beep API among the other platforms' dependencies: write the terminal bell (audible
// when launched from a terminal).
// No beep API among this app's other platforms' dependencies: write the terminal bell
// (audible when launched from a terminal, which is the same cue macOS terminals use).
use std::io::Write as _;
let _ = std::io::stdout().write_all(b"\x07").and_then(|()| std::io::stdout().flush());
}
}
/// Flat-image import via photocraft-codecs (kept for reference/tests; the app uses photocraft-io).
#[allow(dead_code)]
pub fn import_flat(name: &str, bytes: &[u8]) -> Result<Document, String> {
let img = photocraft_codecs::decode(bytes).map_err(|e| e.to_string())?;
let (w, h) = (img.width(), img.height());
let depth = match img.sample_type() {
CS::U8 => SampleType::U8,
CS::U16 => SampleType::U16,
_ => SampleType::F32,
};
let gray = matches!(img.layout(), ChannelLayout::Gray | ChannelLayout::GrayA);
let cmyk = matches!(img.layout(), ChannelLayout::Cmyk | ChannelLayout::CmykA);
let mode = if gray {
ColorMode::Grayscale
} else if cmyk {
ColorMode::Cmyk
} else {
ColorMode::Rgb
};
let target = match mode {
ColorMode::Grayscale => ChannelLayout::GrayA,
ColorMode::Cmyk => ChannelLayout::CmykA,
_ => ChannelLayout::Rgba,
};
let sample = match depth {
SampleType::U8 => CS::U8,
SampleType::U16 => CS::U16,
SampleType::F32 => CS::F32,
};
let conv = img.convert(target, sample);
let stem = std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or(name.to_string());
let mut doc = Document::new(stem, Size::new(w, h), mode, depth);
doc.icc_profile = img.icc.clone().map(std::sync::Arc::new);
if let Some((x, _)) = img.meta.dpi {
doc.resolution_dpi = x;
}
let mut layer = Layer::raster("Background", doc.pixel_format());
let data = conv.to_normalized();
layer.surface_mut().ok_or("new raster layer has no pixels")?.write_region(Rect::from_xywh(0, 0, w, h), &data);
doc.layers.push(layer);
Ok(doc)
}
#[allow(dead_code)]
pub fn export_flat(doc: &Document, path: &str) -> Result<Vec<u8>, String> {
let format = photocraft_codecs::from_extension(path).ok_or_else(|| format!("unknown file type for {path}"))?;
let buf = photocraft_compose::flatten(doc);
let (w, h) = (buf.rect.width(), buf.rect.height());
let data: Vec<f32> = buf.px.iter().flat_map(|p| *p).collect();
let img = match doc.depth {
SampleType::U8 => Image::from_u8(w, h, ChannelLayout::Rgba, buf.to_rgba8().pixels),
SampleType::U16 => Image::from_u16(w, h, ChannelLayout::Rgba, &data.iter().map(|v| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).collect::<Vec<_>>()),
SampleType::F32 => Image::from_f32(w, h, ChannelLayout::Rgba, &data),
}
.map_err(|e| e.to_string())?;
let img = match &doc.icc_profile {
Some(icc) if doc.mode == ColorMode::Rgb => img.with_icc(Some((**icc).clone())),
_ => img,
};
photocraft_codecs::encode(&img, format, &EncodeOptions::default()).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -723,31 +714,6 @@ mod tests {
use photocraft_ui_egui::{PhotocraftApp, prefs_ui};
use serde_json::{Value, json};
/// #6601: a chosen path whose name isn't valid Unicode is reported, never handed on lossily
/// (Open would fail with "No such file", Save would write a second, renamed file).
#[test]
fn a_chosen_path_that_is_not_valid_unicode_is_an_error_not_another_name() {
#[cfg(unix)]
let bad = {
use std::os::unix::ffi::OsStrExt;
std::env::temp_dir().join(std::ffi::OsStr::from_bytes(b"img\xFF.png"))
};
#[cfg(windows)]
let bad = {
use std::os::windows::ffi::OsStringExt;
let mut wide: Vec<u16> = "img".encode_utf16().collect();
wide.push(0xD800);
wide.extend(".png".encode_utf16());
std::env::temp_dir().join(std::ffi::OsString::from_wide(&wide))
};
let good = std::env::temp_dir().join("ok.png");
let save = |path: &Path| answer_one(path, true, FileDialogAnswer::SaveTo);
assert!(matches!(save(&bad), FileDialogAnswer::Unavailable(e) if e.contains("not valid Unicode") && e.contains("another name")));
assert!(matches!(save(&good), FileDialogAnswer::SaveTo(p) if p == good.to_str().unwrap()));
let open = answer([good.as_path(), bad.as_path()], false, FileDialogAnswer::Paths);
assert!(matches!(open, FileDialogAnswer::Unavailable(e) if e.contains("open it again")));
}
/// #6121: the file type picked in the save dialog gives the name its extension where the
/// dialog left the one it opened with, and only there.
#[test]
@@ -865,9 +831,6 @@ mod tests {
assert!(!globs.iter().any(|glob| matches(glob, "txt")));
let svg_filter = open_filters().into_iter().find(|(name, _)| *name == "SVG").map(|(_, exts)| exts).unwrap_or_default();
assert_eq!(svg_filter, open_filter_extensions(SVG_EXTS));
let psd_filter = open_filters().into_iter().find(|(name, _)| *name == "Photoshop").map(|(_, exts)| exts).unwrap_or_default();
assert_eq!(psd_filter, open_filter_extensions(&["psd", "psb", "psdt"]));
assert!(psd_filter.iter().any(|glob| matches(glob, "psd")) && psd_filter.iter().any(|glob| matches(glob, "PSD")));
assert!(svg_filter.iter().any(|glob| matches(glob, "svg")) && svg_filter.iter().any(|glob| matches(glob, "svgz")));
}
@@ -897,10 +860,6 @@ mod tests {
assert_eq!(open_filter_extensions(OPEN_EXTS), OPEN_EXTS);
assert_eq!(open_filter_extensions(&["pcraft"]), ["pcraft"]);
assert_eq!(open_filters().into_iter().find(|(name, _)| *name == "SVG").map(|(_, exts)| exts), Some(vec!["svg".to_string(), "svgz".to_string()]));
assert_eq!(
open_filters().into_iter().find(|(name, _)| *name == "Photoshop").map(|(_, exts)| exts),
Some(vec!["psd".to_string(), "psb".to_string(), "psdt".to_string()])
);
}
#[test]
@@ -1054,50 +1013,6 @@ mod tests {
assert_eq!(dialog_file_name("", true), None);
}
/// A name in another letter case reaches the same file where the file system ignores case
/// (Windows, and macOS by default), and only there. Two files with the same contents are two.
#[test]
fn same_file_follows_the_file_system() {
let dir = std::env::temp_dir().join(format!("photocraft-same-file-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (cat, dog, upper) = (dir.join("CAT.psd"), dir.join("dog.psd"), dir.join("CAT.PSD"));
std::fs::write(&cat, b"same").unwrap();
std::fs::write(&dog, b"same").unwrap();
assert!(same_file(&cat, &cat));
assert!(!same_file(&cat, &dog));
assert_eq!(same_file(&upper, &cat), upper.exists(), "the same file only where letter case is ignored");
assert!(!same_file(&dir.join("missing.psd"), &cat));
std::fs::remove_dir_all(&dir).unwrap();
}
/// A hard link is another entry in the folder, though it reaches the same file: a save to it
/// replaces that entry and leaves the other name on the old file. Another spelling of a name,
/// where letter case is ignored, is the same entry.
#[test]
fn a_hard_link_is_another_entry() {
let dir = std::env::temp_dir().join(format!("photocraft-same-entry-{}", std::process::id()));
std::fs::create_dir_all(dir.join("other")).unwrap();
let (cat, bare, upper, other) = (dir.join("CAT.psd"), dir.join("CAT"), dir.join("CAT.PSD"), dir.join("other").join("CAT.psd"));
std::fs::write(&cat, b"cat").unwrap();
std::fs::hard_link(&cat, &bare).unwrap();
std::fs::hard_link(&cat, &other).unwrap();
// Where both names reach one file (one inode on Unix), the folder still lists both.
let one_file = |_: &Path, _: &Path| true;
assert!(!same_entry(&bare, &cat, one_file));
assert!(!same_entry(&bare, &cat, same_file));
assert!(!same_entry(&other, &cat, same_file), "another folder");
assert!(same_entry(&cat, &cat, one_file));
assert!(same_entry(&cat, &cat, same_file));
if upper.exists() {
assert!(same_entry(&upper, &cat, same_file), "one entry where letter case is ignored");
} else {
std::fs::hard_link(&cat, &upper).unwrap();
assert!(!same_entry(&upper, &cat, same_file), "two entries where letter case tells names apart");
}
assert!(!same_entry(&dir.join("missing.psd"), &cat, same_file));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn block_on_waits_for_a_wake_from_another_thread() {
let (tx, rx) = std::sync::mpsc::channel::<std::task::Waker>();
@@ -1375,29 +1290,4 @@ mod tests {
assert_eq!(image_from_files(&[tga]), Some((1, 1, vec![255, 0, 0, 255])));
assert!(image_from_files(&[bin]).is_none());
}
/// #7582: a panic while reading the OS clipboard (in arboard, or decoding a copied file) is
/// the paste's error in the status bar, not a crash: the document stays and the next paste
/// works. The same for a panic while writing it.
#[test]
fn a_panicking_clipboard_read_is_reported_not_a_crash() {
let panics = Rc::new(Cell::new(true));
let p = panics.clone();
let get = guarded_clipboard_get(move || if p.get() { panic!("clipboard decoder bug") } else { Ok(Some((2, 2, [0u8, 0, 255, 255].repeat(4)))) });
let mut app = PhotocraftApp::new(Session::new(), Services { clipboard_get_image: Some(get), ..Default::default() });
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
let layers = app.session.active().unwrap().doc.layers.len();
let error = app.run("edit.paste", json!({})).unwrap_err();
assert!(error.contains("Paste failed"), "{error}");
assert!(app.ui.status_error && app.ui.status.contains("Paste failed"), "{}", app.ui.status);
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers, "the document is unchanged");
panics.set(false);
app.run("edit.paste", json!({})).unwrap();
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1, "the next paste works");
let mut set = guarded_clipboard_set(|_: u32, _: u32, _: &[u8]| panic!("clipboard writer bug"));
assert!(set(1, 1, &[0; 4]).unwrap_err().contains("Copy failed"));
}
}
@@ -1,6 +1,5 @@
use super::*;
use photocraft_color::{Color, ColorMode, SampleType};
use photocraft_doc::Size;
use photocraft_color::Color;
use photocraft_format::{Autosaver, RecoveryEntry, SaveOptions, list_recovery};
use std::sync::atomic::{AtomicUsize, Ordering};
+35 -408
View File
@@ -3,9 +3,8 @@
//! copy of the program.
//!
//! The first launch takes an exclusive OS lock on `instance.lock` in the settings directory (the OS
//! drops it when the process ends, crash included), listens on a loopback port (inside a Flatpak
//! sandbox, on a Unix socket: see [`transport`]) and writes where it listens plus a fresh 256-bit
//! token to `instance.json`. A later launch that finds the lock taken sends its
//! drops it when the process ends, crash included), listens on a loopback port and writes the port
//! plus a fresh 256-bit token to `instance.json`. A later launch that finds the lock taken sends its
//! paths there; the primary queues them for the UI as [`OsEvent::Open`], which opens each one and
//! brings the window forward. Explorer starts one process per file when several are opened at once;
//! the lock decides the race, so they all end up as tabs in one window.
@@ -27,11 +26,7 @@
use std::fs::{File, OpenOptions};
use std::io::{BufReader, Read, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
#[cfg(unix)]
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::Path;
#[cfg(unix)]
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{SyncSender, sync_channel};
use std::sync::{Arc, Mutex, PoisonError};
@@ -43,9 +38,6 @@ use serde_json::{Value, json};
const LOCK_FILE: &str = "instance.lock";
const INFO_FILE: &str = "instance.json";
/// The Unix socket a sandboxed primary listens on, in the app's runtime directory.
#[cfg(unix)]
const SOCKET_FILE: &str = "instance.sock";
/// More paths than this in one request is not a file manager.
const MAX_PATHS: usize = 4096;
/// Launches served at once; a stalled client holds only its own slot, never the next launch.
@@ -67,45 +59,6 @@ pub struct Timing {
/// Generous enough for a primary that is still opening its window.
pub const TIMING: Timing = Timing { handoff: Duration::from_secs(10), ack: Duration::from_secs(8), request: Duration::from_secs(5) };
/// How the primary listens for later launches.
#[derive(Clone, Debug, PartialEq)]
pub enum Transport {
/// A loopback port (the default): private to the machine, reachable by every process on it.
Loopback,
/// A Unix socket at this path. Inside a Flatpak sandbox a loopback port is not enough: each
/// `flatpak run` has its own network namespace, so the second sandbox's `127.0.0.1` never
/// reaches the first (#1540). `$XDG_RUNTIME_DIR/app/<app id>/` is the same directory in every
/// sandbox of the app, and the user's alone (0700).
#[cfg(unix)]
Socket(PathBuf),
}
/// The transport for this process: a Unix socket when running inside a Flatpak (`FLATPAK_ID` is
/// set and `XDG_RUNTIME_DIR` names the runtime directory), else a loopback port.
pub fn transport() -> Transport {
#[cfg(unix)]
if let Some(path) = sandbox_socket(std::env::var_os("FLATPAK_ID").as_deref(), std::env::var_os("XDG_RUNTIME_DIR").as_deref()) {
return Transport::Socket(path);
}
Transport::Loopback
}
/// `$XDG_RUNTIME_DIR/app/<FLATPAK_ID>/instance.sock`, when both are set.
#[cfg(unix)]
fn sandbox_socket(flatpak_id: Option<&std::ffi::OsStr>, runtime_dir: Option<&std::ffi::OsStr>) -> Option<PathBuf> {
let id = flatpak_id.filter(|v| !v.is_empty())?;
let dir = runtime_dir.filter(|v| !v.is_empty())?;
Some(Path::new(dir).join("app").join(id).join(SOCKET_FILE))
}
/// Where the running primary listens, as `instance.json` records it.
#[derive(Clone, Debug, PartialEq)]
enum Endpoint {
Port(u16),
#[cfg(unix)]
Socket(PathBuf),
}
/// Outcome of [`claim`].
pub enum Launch {
/// This process is the running instance; keep [`Primary`] alive and attach it to the app.
@@ -129,71 +82,10 @@ pub struct LaunchContext<'a> {
pub control: bool,
/// Paths that aren't valid Unicode: this launch reports them.
pub unreadable_paths: bool,
/// Options that only apply to the process they're given to (`--safe-gpu`, …), including the
/// ones this launch's environment sets (see [`env_launch_options`]).
/// Options that only apply to the process they're given to (`--safe-gpu`, …).
pub launch_options: &'a [&'static str],
}
/// Variables documented as the equivalent of a per-launch flag, each with the flag it stands for
/// (`PHOTOCRAFT_PRESENT_MODE` is `--present-mode`) and the rule that says whether its value asks for
/// that option at all. Setting one asks for the same thing as giving the flag, so it belongs to this
/// process alone.
const ENV_LAUNCH_OPTIONS: [(&str, &str, ValueRule); 6] = [
("PHOTOCRAFT_PRESENT_MODE", "--present-mode", present_mode_is_on),
("PHOTOCRAFT_FRAME_LATENCY", "--frame-latency", frame_latency_is_on),
("PHOTOCRAFT_STROKE_TRACE", "--stroke-trace", stroke_trace_is_on),
("PHOTOCRAFT_IN_WINDOW_MENUS", "--in-window-menus", flag_is_on),
("PHOTOCRAFT_AUTOMATION_READ_ROOT", "--automation-read-root", path_is_set),
("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "--automation-write-root", path_is_set),
];
/// Whether a variable's value asks this launch for its own option. Each rule matches how that
/// variable is read where it takes effect, so a value ignored there keeps no process here either.
type ValueRule = fn(&str) -> bool;
/// A value `presentation::parse` accepts, handed over as written: it trims, treats empty as unset and
/// warns and ignores anything it does not recognise, so an unrecognised `PHOTOCRAFT_PRESENT_MODE`
/// changes nothing there and so needs no process of its own here.
fn present_mode_is_on(value: &str) -> bool {
crate::presentation::parse(Some(value), None).0.mode.is_some()
}
/// [`present_mode_is_on`] for `PHOTOCRAFT_FRAME_LATENCY`, which takes `1` to `3`.
fn frame_latency_is_on(value: &str) -> bool {
crate::presentation::parse(None, Some(value)).0.latency.is_some()
}
/// Anything except `""`, `0`, `off` and `false`, which is how `StrokeTrace::from_value` reads it: the
/// rest is a stderr summary or a CSV path. It trims, so `" 0 "` is off there and off here.
fn stroke_trace_is_on(value: &str) -> bool {
!matches!(value.trim(), "" | "0" | "off" | "false")
}
/// Any value except empty and exactly `0`, as `main.rs` reads `PHOTOCRAFT_IN_WINDOW_MENUS` - which is
/// why `off` means on there, why this rule is not shared with the stroke trace above, and why it does
/// not trim: a padded `" 0 "` enables the menus there, so it keeps a process here too.
fn flag_is_on(value: &str) -> bool {
!value.is_empty() && value != "0"
}
/// A path is an option; an empty value is no path. Not trimmed: a path may end in a space. An empty
/// root is read as no root unless `--control` is given, and that flag keeps its own process already.
fn path_is_set(value: &str) -> bool {
!value.is_empty()
}
/// The per-process options this launch's environment sets, named as the flags they stand for. A
/// launch that sets only variables would otherwise hand its files to the running instance, which
/// never sees the override, and exit.
pub fn env_launch_options() -> Vec<&'static str> {
env_launch_options_from(|name| std::env::var_os(name))
}
/// [`env_launch_options`] with the reader given, so the rule is testable without the environment.
fn env_launch_options_from(get: impl Fn(&str) -> Option<std::ffi::OsString>) -> Vec<&'static str> {
ENV_LAUNCH_OPTIONS.iter().filter(|(name, _, is_on)| get(name).is_some_and(|v| is_on(&v.to_string_lossy()))).map(|(_, flag, _)| *flag).collect()
}
/// Why this launch runs as its own instance, or `None` to take part in single-instance.
pub fn skip_reason(c: &LaunchContext) -> Option<String> {
if !c.per_user_settings {
@@ -218,10 +110,6 @@ pub fn skip_reason(c: &LaunchContext) -> Option<String> {
pub struct Primary {
lock: Mutex<Option<File>>,
shared: Arc<Shared>,
/// The socket file to remove on the first shutdown (a Unix socket transport only); taken
/// then, so the second call (`Drop`) can't remove the socket the next primary has bound.
#[cfg(unix)]
socket: Mutex<Option<PathBuf>>,
}
struct Shared {
@@ -263,10 +151,6 @@ impl Primary {
self.shared.closed.store(true, Ordering::Release);
// Dropping the senders refuses every connection still waiting for the UI.
self.shared.inbox.lock().unwrap_or_else(PoisonError::into_inner).pending.clear();
#[cfg(unix)]
if let Some(path) = self.socket.lock().unwrap_or_else(PoisonError::into_inner).take() {
let _ = std::fs::remove_file(path);
}
drop(self.lock.lock().unwrap_or_else(PoisonError::into_inner).take());
}
}
@@ -327,15 +211,9 @@ pub fn claim(dir: &Path, paths: &[String]) -> Launch {
claim_with(dir, paths, TIMING)
}
/// [`claim`] with explicit timeouts, over the transport this process would use ([`transport`]).
/// [`claim`] with explicit timeouts. Until `timing.handoff` runs out, each round tries the lock
/// (free once a quitting or crashed primary is gone) and then the running primary.
pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
claim_in(dir, paths, timing, &transport())
}
/// [`claim`] over `transport`. Until `timing.handoff` runs out, each round tries the lock (free
/// once a quitting or crashed primary is gone) and then the running primary, wherever
/// `instance.json` says it listens.
pub fn claim_in(dir: &Path, paths: &[String], timing: Timing, transport: &Transport) -> Launch {
if let Err(e) = std::fs::create_dir_all(dir) {
return Launch::Standalone(format!("{}: {e}", dir.display()));
}
@@ -349,7 +227,7 @@ pub fn claim_in(dir: &Path, paths: &[String], timing: Timing, transport: &Transp
loop {
match lock.try_lock() {
Ok(()) => {
return match serve(dir, lock, timing, transport) {
return match serve(dir, lock, timing) {
Ok(primary) => Launch::Primary(primary),
Err(e) => Launch::Standalone(e),
};
@@ -357,8 +235,8 @@ pub fn claim_in(dir: &Path, paths: &[String], timing: Timing, transport: &Transp
Err(std::fs::TryLockError::WouldBlock) => {}
Err(std::fs::TryLockError::Error(e)) => return Launch::Standalone(format!("{}: {e}", lock_path.display())),
}
if let Some((endpoint, token)) = read_info(dir) {
match send(&endpoint, &token, paths, deadline) {
if let Some((port, token)) = read_info(dir) {
match send(port, &token, paths, deadline) {
Ok(()) => return Launch::Forwarded,
Err(e) => last = e,
}
@@ -370,15 +248,11 @@ pub fn claim_in(dir: &Path, paths: &[String], timing: Timing, transport: &Transp
}
}
fn serve(dir: &Path, lock: File, timing: Timing, transport: &Transport) -> Result<Primary, String> {
let (listener, endpoint) = Listener::bind(transport)?;
fn serve(dir: &Path, lock: File, timing: Timing) -> Result<Primary, String> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| format!("loopback listener: {e}"))?;
let port = listener.local_addr().map_err(|e| format!("loopback listener: {e}"))?.port();
let token = generate_token().map_err(|e| e.to_string())?;
write_info(dir, &endpoint, &token)?;
#[cfg(unix)]
let socket = match &endpoint {
Endpoint::Socket(path) => Some(path.clone()),
Endpoint::Port(_) => None,
};
write_info(dir, port, &token)?;
let shared = Arc::new(Shared { inbox: Mutex::default(), closed: AtomicBool::new(false) });
let listening = Arc::clone(&shared);
let spawned = std::thread::Builder::new().name("single-instance".into()).spawn(move || {
@@ -386,7 +260,7 @@ fn serve(dir: &Path, lock: File, timing: Timing, transport: &Transport) -> Resul
let clients = Arc::new(AtomicUsize::new(0));
loop {
let stream = match listener.accept() {
Ok(stream) => stream,
Ok((stream, _)) => stream,
// Back off instead of spinning when accept keeps failing (e.g. out of descriptors).
Err(_) => {
std::thread::sleep(Duration::from_millis(100));
@@ -414,117 +288,11 @@ fn serve(dir: &Path, lock: File, timing: Timing, transport: &Transport) -> Resul
}
});
spawned.map_err(|e| format!("single-instance listener thread: {e}"))?;
Ok(Primary {
lock: Mutex::new(Some(lock)),
shared,
#[cfg(unix)]
socket: Mutex::new(socket),
})
}
/// The primary's listener on either transport.
enum Listener {
Tcp(TcpListener),
#[cfg(unix)]
Unix(UnixListener),
}
impl Listener {
fn bind(transport: &Transport) -> Result<(Listener, Endpoint), String> {
match transport {
Transport::Loopback => {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| format!("loopback listener: {e}"))?;
let port = listener.local_addr().map_err(|e| format!("loopback listener: {e}"))?.port();
Ok((Listener::Tcp(listener), Endpoint::Port(port)))
}
#[cfg(unix)]
Transport::Socket(path) => {
let at = |e: std::io::Error| format!("{}: {e}", path.display());
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(at)?;
}
// This process holds the lock, so a file already there is a crashed primary's.
let _ = std::fs::remove_file(path);
let listener = UnixListener::bind(path).map_err(at)?;
// The runtime directory is the user's alone already; keep the socket so too.
std::fs::set_permissions(path, std::os::unix::fs::PermissionsExt::from_mode(0o600)).map_err(at)?;
Ok((Listener::Unix(listener), Endpoint::Socket(path.clone())))
}
}
}
fn accept(&self) -> std::io::Result<Stream> {
match self {
Listener::Tcp(l) => l.accept().map(|(s, _)| Stream::Tcp(s)),
#[cfg(unix)]
Listener::Unix(l) => l.accept().map(|(s, _)| Stream::Unix(s)),
}
}
}
/// One connection, on either transport.
enum Stream {
Tcp(TcpStream),
#[cfg(unix)]
Unix(UnixStream),
}
impl Stream {
/// Connect to the primary; `timeout` bounds the loopback connect (a Unix connect is immediate).
fn connect(endpoint: &Endpoint, timeout: Duration) -> std::io::Result<Stream> {
match endpoint {
Endpoint::Port(port) => TcpStream::connect_timeout(&SocketAddr::from((Ipv4Addr::LOCALHOST, *port)), timeout).map(Stream::Tcp),
#[cfg(unix)]
Endpoint::Socket(path) => UnixStream::connect(path).map(Stream::Unix),
}
}
fn set_read_timeout(&self, dur: Option<Duration>) -> std::io::Result<()> {
match self {
Stream::Tcp(s) => s.set_read_timeout(dur),
#[cfg(unix)]
Stream::Unix(s) => s.set_read_timeout(dur),
}
}
fn set_write_timeout(&self, dur: Option<Duration>) -> std::io::Result<()> {
match self {
Stream::Tcp(s) => s.set_write_timeout(dur),
#[cfg(unix)]
Stream::Unix(s) => s.set_write_timeout(dur),
}
}
/// One read through a shared reference (both socket types read through `&self`).
fn read_once(&self, buf: &mut [u8]) -> std::io::Result<usize> {
match self {
Stream::Tcp(s) => (&*s).read(buf),
#[cfg(unix)]
Stream::Unix(s) => (&*s).read(buf),
}
}
}
impl Write for Stream {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
match self {
Stream::Tcp(s) => s.write(buf),
#[cfg(unix)]
Stream::Unix(s) => s.write(buf),
}
}
fn flush(&mut self) -> std::io::Result<()> {
match self {
Stream::Tcp(s) => s.flush(),
#[cfg(unix)]
Stream::Unix(s) => s.flush(),
}
}
Ok(Primary { lock: Mutex::new(Some(lock)), shared })
}
/// Read one request, hand its paths to the UI and answer `ok` only if the UI took them.
fn serve_client(stream: Stream, token: &str, shared: &Shared, timing: Timing) {
fn serve_client(stream: TcpStream, token: &str, shared: &Shared, timing: Timing) {
let taken = match read_request(&stream, timing.request) {
Some(line) => match parse_request(&line, token) {
Some((paths, wait)) => shared.hand_over(paths, wait.min(timing.ack)),
@@ -540,7 +308,7 @@ fn serve_client(stream: Stream, token: &str, shared: &Shared, timing: Timing) {
/// A reader that fails once `until` passes, however slowly the bytes trickle in.
struct Deadline<'a> {
stream: &'a Stream,
stream: &'a TcpStream,
until: Instant,
}
@@ -549,30 +317,25 @@ impl Read for Deadline<'_> {
let left = self.until.checked_duration_since(Instant::now()).filter(|d| !d.is_zero());
let left = left.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::TimedOut, "request deadline passed"))?;
self.stream.set_read_timeout(Some(left))?;
self.stream.read_once(buf)
let mut stream = self.stream;
stream.read(buf)
}
}
/// The request line, if it arrives complete within `total`.
fn read_request(stream: &Stream, total: Duration) -> Option<String> {
fn read_request(stream: &TcpStream, total: Duration) -> Option<String> {
let mut reader = BufReader::new(Deadline { stream, until: Instant::now() + total });
let mut line = String::new();
matches!(read_bounded_line(&mut reader, &mut line), Ok(LineRead::Line)).then_some(line)
}
/// Endpoint and token, written to a temporary file and renamed so a reader never sees half of it.
/// Port and token, written to a temporary file and renamed so a reader never sees half of it.
/// On Unix the file is the user's alone (0600): another local user who read the token could make
/// this app open any path. (Windows keeps the per-user ACL of the settings folder.)
fn write_info(dir: &Path, endpoint: &Endpoint, token: &str) -> Result<(), String> {
fn write_info(dir: &Path, port: u16, token: &str) -> Result<(), String> {
let path = dir.join(INFO_FILE);
let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id()));
let mut body = json!({"token": token, "pid": std::process::id()});
match endpoint {
Endpoint::Port(port) => body["port"] = json!(port),
#[cfg(unix)]
Endpoint::Socket(socket) => body["socket"] = json!(socket.to_string_lossy()),
}
let body = body.to_string();
let body = json!({"port": port, "token": token, "pid": std::process::id()}).to_string();
// A leftover from a crash could carry wider permissions; the mode applies only on create.
let _ = std::fs::remove_file(&tmp);
let mut options = OpenOptions::new();
@@ -586,15 +349,10 @@ fn write_info(dir: &Path, endpoint: &Endpoint, token: &str) -> Result<(), String
})
}
fn read_info(dir: &Path) -> Option<(Endpoint, String)> {
fn read_info(dir: &Path) -> Option<(u16, String)> {
let v: Value = serde_json::from_slice(&std::fs::read(dir.join(INFO_FILE)).ok()?).ok()?;
let token = v.get("token")?.as_str()?.to_string();
#[cfg(unix)]
if let Some(socket) = v.get("socket").and_then(Value::as_str) {
return Some((Endpoint::Socket(PathBuf::from(socket)), token));
}
let port = u16::try_from(v.get("port")?.as_u64()?).ok()?;
Some((Endpoint::Port(port), token))
Some((port, v.get("token")?.as_str()?.to_string()))
}
/// `{"token":…,"open":[…],"wait_ms":…}`: the paths, and how long the sender will wait for the UI.
@@ -612,10 +370,11 @@ fn parse_request(line: &str, token: &str) -> Option<(Vec<String>, Duration)> {
Some((paths, wait))
}
/// Offer `paths` to the primary at `endpoint`; `Ok` once its UI has taken them.
fn send(endpoint: &Endpoint, token: &str, paths: &[String], deadline: Instant) -> Result<(), String> {
/// Offer `paths` to the primary on `port`; `Ok` once its UI has taken them.
fn send(port: u16, token: &str, paths: &[String], deadline: Instant) -> Result<(), String> {
let left = deadline.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()).ok_or("out of time")?;
let mut stream = Stream::connect(endpoint, left).map_err(|e| e.to_string())?;
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
let mut stream = TcpStream::connect_timeout(&addr, left).map_err(|e| e.to_string())?;
// The primary must answer before our deadline, so it waits a little less than we do.
let wait_ms = u64::try_from(left.saturating_sub(REPLY_MARGIN).as_millis()).unwrap_or(u64::MAX);
let request = json!({"token": token, "open": paths, "wait_ms": wait_ms});
@@ -676,21 +435,6 @@ mod tests {
}
}
/// A socket path short enough for every Unix (`sun_path` is about 100 bytes; macOS: 104).
#[cfg(unix)]
fn short_socket_path(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("pc-si-{}-{tag}", std::process::id())).join(SOCKET_FILE)
}
/// The loopback port the primary under `dir` listens on.
fn port_of(dir: &Path) -> u16 {
match read_info(dir).unwrap().0 {
Endpoint::Port(port) => port,
#[cfg(unix)]
Endpoint::Socket(path) => panic!("expected a loopback port, found {}", path.display()),
}
}
#[test]
fn second_launch_hands_its_paths_to_the_first() {
let dir = temp_dir("handoff");
@@ -752,7 +496,7 @@ mod tests {
// instance.json from a process that is gone; the lock is free.
std::fs::write(dir.join(INFO_FILE), r#"{"port":1,"token":"x","pid":1}"#).unwrap();
let primary = primary(&dir);
assert!(read_info(&dir).is_some_and(|(endpoint, _)| endpoint != Endpoint::Port(1)));
assert!(read_info(&dir).is_some_and(|(port, _)| port != 1));
drop(primary);
// Released with the handle, as on process exit.
assert!(matches!(claim_with(&dir, &[], FAST), Launch::Primary(_)));
@@ -772,10 +516,10 @@ mod tests {
primary.shutdown();
assert!(matches!(waiting.join().unwrap(), Launch::Primary(_)), "the waiting launch becomes the primary");
// After shutdown new connections are refused too.
let (endpoint, token) = read_info(&dir).unwrap();
let (port, token) = read_info(&dir).unwrap();
drop(primary);
let short = Instant::now() + Duration::from_millis(300);
assert!(send(&endpoint, &token, &["b.psd".into()], short).is_err());
assert!(send(port, &token, &["b.psd".into()], short).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
@@ -813,7 +557,7 @@ mod tests {
let dir = temp_dir("stalled");
let primary = primary(&dir);
let mut ui = primary.connect(&egui::Context::default());
let port = port_of(&dir);
let (port, _) = read_info(&dir).unwrap();
// Connects and never sends a line (each would otherwise hold the listener).
let stalled: Vec<TcpStream> = (0..3).map(|_| TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap()).collect();
let started = Instant::now();
@@ -831,7 +575,7 @@ mod tests {
fn a_trickling_client_is_cut_off_at_the_request_deadline() {
let dir = temp_dir("trickle");
let primary = primary(&dir);
let port = port_of(&dir);
let (port, _) = read_info(&dir).unwrap();
let mut slow = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
let started = Instant::now();
let mut cut_off = false;
@@ -858,7 +602,7 @@ mod tests {
let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id()));
std::fs::write(&tmp, "{}").unwrap();
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).unwrap();
write_info(&dir, &Endpoint::Port(1), &generate_token().unwrap()).unwrap();
write_info(&dir, 1, &generate_token().unwrap()).unwrap();
let mode = std::fs::metadata(dir.join(INFO_FILE)).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
let _ = std::fs::remove_dir_all(&dir);
@@ -872,7 +616,7 @@ mod tests {
let held = File::create(dir.join(LOCK_FILE)).unwrap();
held.lock().unwrap();
let closed = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port();
write_info(&dir, &Endpoint::Port(closed), &generate_token().unwrap()).unwrap();
write_info(&dir, closed, &generate_token().unwrap()).unwrap();
assert!(matches!(claim_with(&dir, &["a.psd".into()], FAST), Launch::Standalone(_)));
drop(held);
let _ = std::fs::remove_dir_all(&dir);
@@ -901,121 +645,4 @@ mod tests {
let reason = skip_reason(&LaunchContext { launch_options: &["--safe-gpu"], ..base });
assert!(reason.is_some_and(|r| r.contains("--safe-gpu")));
}
/// #1540: inside a Flatpak sandbox the hand-off runs over a Unix socket in the app's runtime
/// directory, which every sandbox of the app shares, with the same lock, token and reply.
#[cfg(unix)]
#[test]
fn a_sandboxed_second_launch_hands_its_paths_over_a_unix_socket() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir("socket");
// Short: a Unix socket path is limited to about 100 bytes (macOS: 104).
let socket = short_socket_path("a");
let transport = Transport::Socket(socket.clone());
let primary = match claim_in(&dir, &[], FAST, &transport) {
Launch::Primary(p) => p,
_ => panic!("first launch should be primary"),
};
assert_eq!(std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777, 0o600);
let info: Value = serde_json::from_slice(&std::fs::read(dir.join(INFO_FILE)).unwrap()).unwrap();
assert_eq!(info.get("socket").and_then(Value::as_str), Some(socket.to_str().unwrap()));
assert!(info.get("port").is_none());
let mut ui = primary.connect(&egui::Context::default());
let paths = ["/home/a/one.psd".to_string()];
let (d, p, t) = (dir.clone(), paths.clone(), transport.clone());
let second = std::thread::spawn(move || claim_in(&d, &p, TIMING, &t));
assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(paths.to_vec())]);
assert!(matches!(second.join().unwrap(), Launch::Forwarded));
// The token still gates the socket.
let (endpoint, _) = read_info(&dir).unwrap();
assert!(send(&endpoint, "not the token", &["x.psd".into()], Instant::now() + FAST.handoff).is_err());
primary.shutdown();
assert!(!socket.exists(), "shutdown removes the socket file");
// The next primary binds the same path; the old one's drop (a second shutdown) must leave it.
let next = match claim_in(&dir, &[], FAST, &transport) {
Launch::Primary(p) => p,
_ => panic!("the lock is free again"),
};
drop(primary);
assert!(socket.exists(), "a second shutdown doesn't remove the next primary's socket");
drop(next);
let _ = std::fs::remove_dir_all(&dir);
}
/// A socket file left by a crashed primary (and a stale `instance.json` naming it) is replaced.
#[cfg(unix)]
#[test]
fn a_crashed_primary_s_socket_file_does_not_block_the_next_one() {
use std::os::unix::fs::FileTypeExt;
let dir = temp_dir("stale-socket");
let socket = short_socket_path("b");
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(socket.parent().unwrap()).unwrap();
std::fs::write(&socket, b"not a socket").unwrap();
write_info(&dir, &Endpoint::Socket(socket.clone()), &generate_token().unwrap()).unwrap();
let primary = match claim_in(&dir, &["a.psd".into()], FAST, &Transport::Socket(socket.clone())) {
Launch::Primary(p) => p,
Launch::Forwarded => panic!("nothing was listening"),
Launch::Standalone(e) => panic!("{e}"),
};
assert!(std::fs::metadata(&socket).unwrap().file_type().is_socket());
drop(primary);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(socket.parent().unwrap());
}
/// The socket transport is chosen only inside a Flatpak with a runtime directory.
#[cfg(unix)]
#[test]
fn the_socket_transport_needs_a_flatpak_id_and_a_runtime_directory() {
use std::ffi::OsStr;
let (id, run) = (OsStr::new("ai.storyteller.photocraft"), OsStr::new("/run/user/1000"));
assert_eq!(sandbox_socket(Some(id), Some(run)), Some(PathBuf::from("/run/user/1000/app/ai.storyteller.photocraft/instance.sock")));
assert_eq!(sandbox_socket(None, Some(run)), None);
assert_eq!(sandbox_socket(Some(id), None), None);
assert_eq!(sandbox_socket(Some(OsStr::new("")), Some(run)), None);
assert_eq!(sandbox_socket(Some(id), Some(OsStr::new(""))), None);
}
/// A variable documented as the equivalent of a per-launch flag counts as that flag: a launch
/// that sets only variables has per-process options, so it keeps its own process rather than
/// handing its files to the running instance, which never sees the override.
#[test]
fn an_environment_override_counts_as_a_process_option() {
let with = |values: &[(&str, &str)]| env_launch_options_from(|n| values.iter().find(|(k, _)| *k == n).map(|(_, v)| (*v).into()));
assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), vec!["--present-mode"]);
assert_eq!(
with(&[("PHOTOCRAFT_STROKE_TRACE", "strokes.csv"), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "/tmp/out")]),
vec!["--stroke-trace", "--automation-write-root"]
);
// Nothing set, or a value that means off, is an ordinary launch. Each variable is off the way
// it is read where it takes effect, not by one rule shared across all six.
assert!(with(&[]).is_empty());
assert!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "0"), ("PHOTOCRAFT_FRAME_LATENCY", "")]).is_empty());
// The trace is off at `off` and `false` as well, and a value the presentation parser rejects
// is ignored there, so neither keeps a process of its own here.
assert!(with(&[("PHOTOCRAFT_STROKE_TRACE", "off"), ("PHOTOCRAFT_PRESENT_MODE", "maybe"), ("PHOTOCRAFT_FRAME_LATENCY", "4")]).is_empty());
assert!(with(&[("PHOTOCRAFT_AUTOMATION_READ_ROOT", "")]).is_empty());
// Spaces are part of the value only where the reader keeps them: the trace and the two
// presentation values are trimmed where they take effect, the menu flag and a path are not.
assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", " novsync "), ("PHOTOCRAFT_STROKE_TRACE", " 1 ")]), vec!["--present-mode", "--stroke-trace"]);
assert_eq!(
with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", " 0 "), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", " /tmp/out ")]),
vec!["--in-window-menus", "--automation-write-root"]
);
// A whitespace-only menu value is not empty, so it enables the menus there and here too.
assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", " ")]), vec!["--in-window-menus"]);
// `PHOTOCRAFT_IN_WINDOW_MENUS=off` means menus in the window there, so it means it here too.
assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "off")]), vec!["--in-window-menus"]);
let base = LaunchContext {
per_user_settings: true,
new_instance_flag: false,
new_instance_env: None,
control: false,
unreadable_paths: false,
launch_options: &[],
};
let reason = skip_reason(&LaunchContext { launch_options: &with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), ..base });
assert!(reason.as_deref().is_some_and(|r| r.contains("--present-mode")), "{reason:?}");
}
}
-5
View File
@@ -1,5 +0,0 @@
//! The crate's integration tests, linked into one test binary.
mod command_line_usage;
mod control_exit_code;
mod startup_without_display;
-16
View File
@@ -1,16 +0,0 @@
<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"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M3 20h18" />
<rect x="5" y="4" width="5" height="12" rx="1" />
<rect x="14" y="9" width="5" height="7" rx="1" />
</svg>

Before

Width:  |  Height:  |  Size: 416 B

-16
View File
@@ -1,16 +0,0 @@
<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"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M12 3v18" />
<rect x="5" y="5" width="14" height="5" rx="1" />
<rect x="8" y="14" width="8" height="5" rx="1" />
</svg>

Before

Width:  |  Height:  |  Size: 416 B

-16
View File
@@ -1,16 +0,0 @@
<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"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M4 3v18" />
<rect x="8" y="5" width="12" height="5" rx="1" />
<rect x="8" y="14" width="7" height="5" rx="1" />
</svg>

Before

Width:  |  Height:  |  Size: 415 B

-16
View File
@@ -1,16 +0,0 @@
<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"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M20 3v18" />
<rect x="4" y="5" width="12" height="5" rx="1" />
<rect x="9" y="14" width="7" height="5" rx="1" />
</svg>

Before

Width:  |  Height:  |  Size: 416 B

-16
View File
@@ -1,16 +0,0 @@
<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"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M3 4h18" />
<rect x="5" y="8" width="5" height="12" rx="1" />
<rect x="14" y="8" width="5" height="7" rx="1" />
</svg>

Before

Width:  |  Height:  |  Size: 415 B

-16
View File
@@ -1,16 +0,0 @@
<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"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M3 12h18" />
<rect x="5" y="5" width="5" height="14" rx="1" />
<rect x="14" y="8" width="5" height="8" rx="1" />
</svg>

Before

Width:  |  Height:  |  Size: 416 B

-19
View File
@@ -1,19 +0,0 @@
<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"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M4 7h16" />
<rect x="7" y="2" width="10" height="3" rx="1" />
<path d="M4 14h16" />
<rect x="7" y="9" width="10" height="3" rx="1" />
<path d="M4 21h16" />
<rect x="7" y="16" width="10" height="3" rx="1" />
</svg>

Before

Width:  |  Height:  |  Size: 516 B

-22
View File
@@ -1,22 +0,0 @@
<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"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<rect x="2" y="6" width="4" height="12" rx="1" />
<path d="M4 2v2" />
<path d="M4 20v2" />
<rect x="10" y="6" width="4" height="12" rx="1" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<rect x="18" y="6" width="4" height="12" rx="1" />
<path d="M20 2v2" />
<path d="M20 20v2" />
</svg>

Before

Width:  |  Height:  |  Size: 585 B

-16
View File
@@ -1,16 +0,0 @@
<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"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M3 4v16" />
<path d="M21 4v16" />
<rect x="9" y="7" width="6" height="10" rx="1" />
</svg>

Before

Width:  |  Height:  |  Size: 387 B

-19
View File
@@ -1,19 +0,0 @@
<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"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M3 4v16" />
<rect x="5" y="7" width="3" height="10" rx="1" />
<path d="M10 4v16" />
<rect x="12" y="7" width="3" height="10" rx="1" />
<path d="M17 4v16" />
<rect x="19" y="7" width="3" height="10" rx="1" />
</svg>

Before

Width:  |  Height:  |  Size: 517 B

-19
View File
@@ -1,19 +0,0 @@
<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"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M7 4v16" />
<rect x="2" y="7" width="3" height="10" rx="1" />
<path d="M14 4v16" />
<rect x="9" y="7" width="3" height="10" rx="1" />
<path d="M21 4v16" />
<rect x="16" y="7" width="3" height="10" rx="1" />
</svg>

Before

Width:  |  Height:  |  Size: 516 B

-19
View File
@@ -1,19 +0,0 @@
<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"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M4 3h16" />
<rect x="7" y="5" width="10" height="3" rx="1" />
<path d="M4 10h16" />
<rect x="7" y="12" width="10" height="3" rx="1" />
<path d="M4 17h16" />
<rect x="7" y="19" width="10" height="3" rx="1" />
</svg>

Before

Width:  |  Height:  |  Size: 517 B

-22
View File
@@ -1,22 +0,0 @@
<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"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<rect x="6" y="2" width="12" height="4" rx="1" />
<path d="M2 4h2" />
<path d="M20 4h2" />
<rect x="6" y="10" width="12" height="4" rx="1" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<rect x="6" y="18" width="12" height="4" rx="1" />
<path d="M2 20h2" />
<path d="M20 20h2" />
</svg>

Before

Width:  |  Height:  |  Size: 585 B

-16
View File
@@ -1,16 +0,0 @@
<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"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M4 3h16" />
<path d="M4 21h16" />
<rect x="7" y="9" width="10" height="6" rx="1" />
</svg>

Before

Width:  |  Height:  |  Size: 387 B

-16
View File
@@ -1,16 +0,0 @@
<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"
>
<!-- PhotoCraft's own Slice Select tool glyph (not from an icon set): the Slice knife with a pointer. -->
<path d="M10 14l4-4" />
<path d="M14 10l5.6-5.6a2 2 0 0 1 2.8 2.8L16.5 12.5z" />
<path d="M4 12v9l2.5-2.5 2 3.5 1.7-1-2-3.5H12z" />
</svg>

Before

Width:  |  Height:  |  Size: 454 B

-17
View File
@@ -1,17 +0,0 @@
<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"
>
<!-- PhotoCraft's own Create Warped Text glyph (not from an icon set): a T above a bowed arc. -->
<path d="M6 5V3h12v2" />
<path d="M12 3v11" />
<path d="M9.5 14h5" />
<path d="M3 17c5 5 13 5 18 0" />
</svg>

Before

Width:  |  Height:  |  Size: 419 B

-3
View File
@@ -8,11 +8,8 @@ tiles/<blake3>.zst
blobs/<blake3>.zst
thumb.png
composite/preview.png
save.lock
```
`save.lock` exists only in directory bundles: a save holds an OS file lock on it while it writes objects, publishes the manifest and removes unreferenced objects, so concurrent saves of one bundle take turns. It is empty, and readers ignore it.
The manifest is versioned and describes the document tree. Tiles and blobs are content-addressed. Loaders verify ZIP entry bounds and CRCs; document content also uses stored hashes to detect mismatched or corrupted tile/blob content. Unsupported newer format versions fail explicitly.
## Implemented load limits
-1
View File
@@ -19,7 +19,6 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_
- Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink.
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened.
- TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit.
- JPEG, as Photoshop's JPEG Options: File › Save, Save As and Save a Copy ask for the Quality on Photoshop's 0–12 scale (with its Low, Medium, High and Maximum presets) and a Format Option: Baseline ("Standard"), Baseline Optimized (Huffman tables fitted to the image) or Progressive. The quality starts at the opened JPEG's own quality, else at the Export preference's JPEG quality; left there, an opened JPEG is written at exactly its own quality. Scripts pass `"quality": 0..12` and `"jpegFormat": "baseline" | "optimized" | "progressive"` (on the CLI, `--quality` on the encoder's 1–100 scale and `--jpeg-format`) and aren't asked; recorded actions replay the answer. The scale maps onto the encoder's 1–100 as `round(q / 12 × 99 + 1)`. RGB JPEGs are written 4:2:0, where Baseline Optimized keeps the standard tables for now (Gray and CMYK, written 4:4:4, get fitted ones). Matte, the progressive scan count (Scans 3–5) and the file-size preview aren't offered.
- DDS, for game and 3D textures: Save As and Save a Copy ask (DDS Options) for the compression (None, uncompressed and lossless; BC1/DXT1, whose alpha is on or off; BC2/DXT3; BC3/DXT5) and whether to generate mipmaps (the full chain down to 1×1, each level a box filter of the one above). Scripts pass `ddsCompression` (`none`, `bc1`, `bc2`, `bc3`) and `ddsMipmaps` (`--dds-compression`, `--dds-mipmaps` on the CLI). Opening one reads its top mip level.
Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through
-60
View File
@@ -17,13 +17,6 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.brianblakely]
sync_github_name = true
[people.abdul-karim-mia]
real_name = "Abdul karim mia"
sync_github_name = false
[people.jonathanwxh-cell]
display_name = "jonathnwxh"
sync_github_name = false
@@ -123,9 +116,6 @@ sync_github_name = false
[people.axlecoffee]
sync_github_name = true
[people.zi-ger]
sync_github_name = true
[people.Losminek]
sync_github_name = true
@@ -176,9 +166,6 @@ sync_github_name = true
real_name = "Sidney Markowitz"
sync_github_name = true
[people.theoabw]
real_name = "Theo Wilenius"
[people.TaylorBurke]
sync_github_name = true
@@ -204,50 +191,3 @@ sync_github_name = true
[people.ha1des]
sync_github_name = true
[people.webdevtodayjason]
display_name = "Jason Brashear"
[people.AndreeSalazar]
real_name = "Andreé Salazar"
sync_github_name = true
[people.LorenzoFasolino]
real_name = "Lorenzo Fasolino"
display_name = "Lorenzo Fasolino"
sync_github_name = false
[people.zolyy]
real_name = "Gall Zoltan"
sync_github_name = false
[people.theshubh77]
real_name = "Shubham Bhamare"
sync_github_name = true
[people.ksaxena149]
sync_github_name = true
[people.giang17]
real_name = "Giang Nguyen"
sync_github_name = true
[people.aqibmohd271]
sync_github_name = true
[people.marcoarnulfo]
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
[people.Gotix-eg]
real_name = "Ahmed Tammam"
display_name = "Gotix"
sync_github_name = true
[people.adi-1108]
real_name = "Aditya Vishwakarma"
sync_github_name = true
[people.benandez]
real_name = "Martin Ball"
+3 -3
View File
@@ -108,11 +108,11 @@ The same reader is shared, as an independent copy, with VectorCraft's `vectorcra
* Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries,
checksums, budgets, cycles, hostile streams, every truncation) and property tests of random
mutations, here and in `photocraft-io`'s `tests/it/affinity.rs`.
mutations, here and in `photocraft-io`'s `tests/affinity.rs`.
* `cargo xtask corpus --affinity` fetches 21 public CC0/MIT documents, including the four
Affinity 3 `.af` files of [samuel-etver/vector-art](https://github.com/samuel-etver/vector-art)
(CC0), at pinned commits, each checked against `xtask/affinity-corpus.sha256`. `tests/it/real_files.rs`
parses them; `photocraft-io`'s `tests/it/affinity_corpus.rs` imports and flattens each one and
(CC0), at pinned commits, each checked against `xtask/affinity-corpus.sha256`. `tests/real_files.rs`
parses them; `photocraft-io`'s `tests/affinity_corpus.rs` imports and flattens each one and
compares it with the thumbnail Affinity saved in it (mean difference 0–4.6 of 255, with a
ceiling per file).
* `fuzz/` has `cargo-fuzz` targets for the whole reader (`preview`), the object stream (`stream`)
-9
View File
@@ -20,15 +20,6 @@ impl Stream {
self.class(id) == Some(Tag::of(class))
}
/// The raw slot count of an object's array field (including null slots), for charging
/// traversal work before nulls are filtered out. `None` when the field is not an array.
pub fn array_len(&self, id: ObjId, tag: &[u8; 4]) -> Option<usize> {
match self.field(id, tag)? {
Value::Array(a) => Some(a.len()),
_ => None,
}
}
/// A child object (shared, linked or inline); `None` for null or missing.
pub fn obj(&self, id: ObjId, tag: &[u8; 4]) -> Option<ObjId> {
match self.field(id, tag)? {
+1 -10
View File
@@ -210,16 +210,7 @@ fn anchors(records: &[Value]) -> Option<Vec<Anchor>> {
fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option<SubPath> {
let first = anchors.first()?;
if let Some(radii) = radii.filter(|r| !r.is_empty()) {
// Index each record's maximum radius once. Scanning the whole radius list per anchor
// was O(anchors * radii), which a file with large anchor and radius arrays could drive
// into quadratic work. Duplicate record indices keep the maximum, matching the prior
// fold(0.0, f64::max) behavior.
let mut radius_by_record: std::collections::HashMap<usize, f64> = std::collections::HashMap::with_capacity(radii.len());
for &(rec, r) in radii {
let slot = radius_by_record.entry(rec).or_insert(0.0);
*slot = slot.max(r);
}
let corner = |a: &Anchor| radius_by_record.get(&a.record).copied().unwrap_or(0.0);
let corner = |a: &Anchor| radii.iter().filter(|(rec, _)| *rec == a.record).map(|(_, r)| *r).fold(0.0, f64::max);
let polyline = anchors.iter().all(|a| a.inh.is_none_or(|h| h == a.p) && a.out.is_none_or(|h| h == a.p));
if polyline {
let mut pts: Vec<(Point, f64)> = anchors.iter().map(|a| (a.p, corner(a))).collect();
-15
View File
@@ -283,12 +283,6 @@ const MAX_TREE_DEPTH: usize = 128;
/// Total layer nodes imported.
const MAX_NODES: usize = 500_000;
/// Total raw `Chld` slots inspected across the whole traversal, including null slots and slots
/// reached through repeated references to a shared group. The node limit only counts produced
/// nodes, so a shared group holding a large null-filled child array could still be rescanned
/// many times; this bounds that work. Generous relative to MAX_NODES for real documents.
const MAX_CHILD_SLOTS: usize = 8_000_000;
/// Read a document: archive, `doc.dat` and the model.
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
let mut archive = Archive::open(bytes, limits)?;
@@ -303,7 +297,6 @@ pub(crate) struct Reader<'s, 'a, 'b> {
pub(crate) dpi: f64,
warnings: BTreeMap<String, usize>,
nodes: usize,
child_slots: usize,
active: HashSet<ObjId>,
pub(crate) geometry_budget: crate::geometry::Budget,
/// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so
@@ -319,7 +312,6 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
dpi: 72.0,
warnings: BTreeMap::new(),
nodes: 0,
child_slots: 0,
active: HashSet::new(),
geometry_budget: crate::geometry::Budget::default(),
pixels_left: limits.max_pixel_bytes,
@@ -391,13 +383,6 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
let s = self.s;
let mut out = Vec::new();
let mut mask = None;
// Charge every raw child slot (null slots included) against a document-wide budget
// before filtering. A shared group with a large null-filled Chld array could otherwise
// be rescanned through each reference without the node limit noticing.
self.child_slots = self.child_slots.saturating_add(s.array_len(parent, b"Chld").unwrap_or(0));
if self.child_slots > MAX_CHILD_SLOTS {
return Err(Error::Limit("too many child slots in the layer tree"));
}
for c in s.objs(parent, b"Chld") {
if s.is(c, b"MRst") {
// A mask layer inside a group or layer masks that container.
+7 -36
View File
@@ -73,40 +73,11 @@ pub struct Object {
pub chain: Vec<Tag>,
/// Fields in stream order; positional fields have tag 0.
pub fields: Vec<(Tag, Value)>,
/// First-occurrence index of each non-positional tag, so `get` is O(1). A shared object
/// can be referenced by many sibling layers, and each reference used to rescan all of its
/// fields; indexing once keeps repeated lookups from multiplying into quadratic work.
index: std::collections::HashMap<Tag, usize>,
}
impl Object {
pub fn new(class: Tag, kind: Kind, chain: Vec<Tag>, fields: Vec<(Tag, Value)>) -> Self {
let mut index = std::collections::HashMap::with_capacity(fields.len());
for (i, (t, _)) in fields.iter().enumerate() {
// Positional fields share tag 0 and are not looked up by tag; keep first-match
// semantics for real tags.
if t.0 != 0 {
index.entry(*t).or_insert(i);
}
}
Object { class, kind, chain, fields, index }
}
pub fn get(&self, tag: Tag) -> Option<&Value> {
self.index.get(&tag).map(|&i| &self.fields[i].1)
}
/// Replaces the fields (the root object is filled in after it is pushed) and rebuilds the
/// tag index so `get` stays correct.
fn set_fields(&mut self, fields: Vec<(Tag, Value)>) {
self.index.clear();
self.index.reserve(fields.len());
for (i, (t, _)) in fields.iter().enumerate() {
if t.0 != 0 {
self.index.entry(*t).or_insert(i);
}
}
self.fields = fields;
self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v)
}
}
@@ -153,10 +124,10 @@ pub fn parse(bytes: &[u8]) -> Result<Stream, Error> {
c.u32()?;
}
let mut p = Parser { c, objects: Vec::new(), shared: std::collections::HashMap::new(), depth: 0, values: 0 };
let root = p.push(Object::new(class, Kind::Root, vec![class], Vec::new()))?;
let root = p.push(Object { class, kind: Kind::Root, chain: vec![class], fields: Vec::new() })?;
let fields = p.fields(true, class)?;
if let Some(o) = p.objects.get_mut(root) {
o.set_fields(fields);
o.fields = fields;
}
Ok(Stream { root, objects: p.objects })
}
@@ -281,7 +252,7 @@ impl Parser<'_> {
}
0x30 => {
let fields = self.fields(false, class)?;
let id = self.push(Object::new(class, Kind::Positional, Vec::new(), fields))?;
let id = self.push(Object { class, kind: Kind::Positional, chain: Vec::new(), fields })?;
Value::Obj(Some(id))
}
0x31 | 0x32 if !tagged => return Err(Error::Malformed("object without a field tag")),
@@ -379,7 +350,7 @@ impl Parser<'_> {
}
};
let fields = self.fields(true, class)?;
let id = self.push(Object::new(class, Kind::Inline, vec![class], fields))?;
let id = self.push(Object { class, kind: Kind::Inline, chain: vec![class], fields })?;
Ok(Value::Obj(Some(id)))
}
@@ -398,7 +369,7 @@ impl Parser<'_> {
return Err(Error::Malformed("object defined twice"));
}
// Register before the fields: an object's own fields may refer back to it.
let id = self.push(Object::new(Tag(0), Kind::Shared, Vec::new(), Vec::new()))?;
let id = self.push(Object { class: Tag(0), kind: Kind::Shared, chain: Vec::new(), fields: Vec::new() })?;
self.shared.insert(key, id);
let mut chain = Vec::new();
let mut fields = Vec::new();
@@ -424,7 +395,7 @@ impl Parser<'_> {
let class = chain.first().copied().unwrap_or(Tag(0));
fields.extend(self.fields(true, class)?);
if let Some(o) = self.objects.get_mut(id) {
*o = Object::new(class, Kind::Shared, chain, fields);
*o = Object { class, kind: Kind::Shared, chain, fields };
}
Ok(Value::Obj(Some(id)))
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! Unit tests on synthetic inputs; `tests/it/real_files.rs` covers public Affinity documents.
//! Unit tests on synthetic inputs; `tests/real_files.rs` covers public Affinity documents.
mod preview {
use crate::container::header;
@@ -48,10 +48,8 @@ fn isolated(name: &str, check: impl FnOnce()) {
check();
return;
}
// The harness names a test by its module path below the crate root.
let test = module_path!().split_once("::").map_or(name.to_string(), |(_, m)| format!("{m}::{name}"));
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", &test, "--nocapture"])
.args(["--exact", name, "--nocapture"])
.env("PHOTOCRAFT_COMPOUND_CASE", name)
.spawn()
.unwrap();
-4
View File
@@ -1,4 +0,0 @@
//! The crate's integration tests, linked into one test binary.
mod compound_limits;
mod real_files;
File renamed without changes.
+1 -8
View File
@@ -33,8 +33,6 @@ fn main() {
depth_map: None,
},
FilterParams::SmartBlur { radius: 3.0, threshold: 25.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal },
FilterParams::SmartBlur { radius: 12.0, threshold: 25.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal },
FilterParams::SmartBlur { radius: 100.0, threshold: 25.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal },
FilterParams::ShapeBlur { radius: 10.0, shape: BlurShape::Star },
FilterParams::TiltShift { blur: 15.0, center_x: 0.5, center_y: 0.5, angle: 0.0, focus: 0.1, transition: 0.15 },
FilterParams::IrisBlur { pins: vec![IrisPin::default()] },
@@ -62,12 +60,7 @@ fn main() {
let area = output_area(&p, s.content_bounds(), r, None).intersect(&r);
let t = std::time::Instant::now();
let out = apply(&s, &p, area, r, None);
// Smart Blur's cost peaks near radius 12, where High quality visits every pixel in the circle.
let name = match p {
FilterParams::SmartBlur { radius, .. } => format!("{} r{radius}", p.label()),
_ => p.label().to_string(),
};
println!("{name:<18} {w}x{h} RGBA8: {:.2?}", t.elapsed());
println!("{:<18} {w}x{h} RGBA8: {:.2?}", p.label(), t.elapsed());
drop(out);
}
}
+4 -17
View File
@@ -127,23 +127,10 @@ fn main() {
photocraft_algo::puppet::ITERATIONS,
t.elapsed().as_secs_f64() * 1e3
);
// Compare the old path-dependent preview with the command-equivalent drag solve, using
// changing destinations and the same cached factorization (no remeshing per event).
for (label, rounds, warm) in [("6 warm rounds", 6, true), ("canonical cold solve", photocraft_algo::puppet::ITERATIONS, false)] {
let mut previous = v.clone();
let mut times = Vec::new();
for step in 1..=60 {
let mut dst: Vec<[f64; 2]> = pw.pins.iter().map(|p| p.dst).collect();
dst[1][0] -= f64::from(step) * 10.0;
dst[1][1] += (f64::from(step) * 0.1).sin() * 400.0;
let t = Instant::now();
let (next, _) = solver.solve(&dst, &[None; 3], pw.mode, rounds, warm.then_some(previous.as_slice()));
times.push(t.elapsed().as_secs_f64() * 1e3);
previous = std::hint::black_box(next);
}
times.sort_by(f64::total_cmp);
println!("puppet drag ({label}, 60 moves): median {:.2} ms, p95 {:.2} ms, max {:.2} ms", times[30], times[56], times[59]);
}
let dst: Vec<[f64; 2]> = pw.pins.iter().map(|p| p.dst).collect();
let t = Instant::now();
let _ = solver.solve(&dst, &[None; 3], PuppetMode::Normal, 4, Some(&v));
println!("puppet interactive re-solve (4 warm rounds): {:.2} ms", t.elapsed().as_secs_f64() * 1e3);
let t = Instant::now();
let _ = render(&s, b, &solver.mesh, &v, &order, Interp::Bicubic);
println!("puppet render 24 MP: {:.0} ms", t.elapsed().as_secs_f64() * 1e3);
+25 -140
View File
@@ -437,7 +437,7 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
F::Chrome => gs(1.0 + g("smoothness") * 0.4) + 2,
F::ConteCrayon => 0,
F::GraphicPen => (g("strokeLength") / 2.0) as i32 + 2,
F::HalftonePattern => gs(halftone_cell(e) * 0.15),
F::HalftonePattern => gs(0.5 + g("size") * 0.3),
F::NotePaper => gs(1.0) + gs(1.2) + 2,
F::Photocopy => gs(1.0 + g("detail") * 0.5),
F::Plaster => gs(0.5 + g("smoothness") * 0.8) + gs(1.5) + 2,
@@ -454,55 +454,6 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
fn cutout_sigma(e: &GalleryEffect) -> f32 {
0.5 + e.get("edgeSimplicity") * (1.1 - 0.3 * e.get("edgeFidelity"))
}
/// Halftone Pattern's screen spacing in pixels (about 3.5 at Size 1, 25.5 at 12).
fn halftone_cell(e: &GalleryEffect) -> f32 {
1.5 + 2.0 * e.get("size").clamp(1.0, 12.0)
}
/// The round-dot spot function `(cos 2πu + cos 2πv) / 2` sampled over a cell and sorted: a
/// value's rank among them is the share of the cell below it, which makes the thresholds even.
fn halftone_dot_ranks() -> &'static [f32] {
static RANKS: std::sync::OnceLock<Vec<f32>> = std::sync::OnceLock::new();
RANKS.get_or_init(|| {
const N: usize = 128;
let mut v: Vec<f32> = (0..N * N)
.map(|i| {
let (u, w) = (((i % N) as f32 + 0.5) / N as f32, ((i / N) as f32 + 0.5) / N as f32);
((2.0 * PI * u).cos() + (2.0 * PI * w).cos()) / 2.0
})
.collect();
v.sort_by(f32::total_cmp);
v
})
}
/// For each tone (0 = paper … 1 = solid ink, 256 steps), the level `τ` at which the soft
/// threshold `clamp(0.5 + (τ − s) · gain)` over evenly spread thresholds `s` inks exactly that
/// share of a cell on average.
fn halftone_offsets(gain: f32) -> Vec<f32> {
let half = 0.5 / gain.max(1e-3);
// Average ink over s in [0, 1] for level τ: 1 below τ − half, a ramp to 0 at τ + half.
let cover = |tau: f32| {
let (a, z) = (tau - half, tau + half);
let solid = a.clamp(0.0, 1.0);
let (r0, r1) = (a.clamp(0.0, 1.0), z.clamp(0.0, 1.0));
let ramp = if r1 > r0 { (0.5 + tau * gain) * (r1 - r0) - 0.5 * gain * (r1 * r1 - r0 * r0) } else { 0.0 };
solid + ramp
};
(0..256)
.map(|i| {
let target = i as f32 / 255.0;
let (mut lo, mut hi) = (-half - 1.0, 1.0 + half + 1.0);
for _ in 0..40 {
let mid = (lo + hi) / 2.0;
if cover(mid) < target { lo = mid } else { hi = mid }
}
match i {
0 => -half - 1.0,
255 => 1.0 + half + 1.0,
_ => (lo + hi) / 2.0,
}
})
.collect()
}
fn daubs_radius(e: &GalleryEffect) -> usize {
let wide = matches!(e.choice("brushType"), "wideSharp" | "wideBlurry");
let r = e.get("brushSize") / 2.0 * if wide { 1.5 } else { 1.0 };
@@ -555,7 +506,6 @@ pub(crate) fn run(effects: &[GalleryEffect], src: &Image, out: Rect, ctx: &Ctx)
res
}
/// Applies one gallery effect to the working window in place.
fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
let g = |k: &str| e.get(k);
let (w, h) = (win.w, win.h);
@@ -983,46 +933,36 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
}
}
F::HalftonePattern => {
// A halftone screen that keeps a continuous range of tones: each pixel compares its
// ink (the darkness of the image) with the screen's threshold there, and Contrast is
// how steeply: at 0 the image keeps its tones with the screen faintly through it, at
// 50 it is crisp dots, lines or rings. The thresholds are spread evenly over each cell,
// and each tone is offset so a cell's average ink is exactly that tone: black stays
// solid and white stays paper at any Contrast.
let cell = halftone_cell(e);
let gain = 0.5 + g("contrast") * 0.6;
let offsets = halftone_offsets(gain);
let size = g("size");
let con = g("contrast") / 50.0;
let cell = 3.0 + size * 2.0;
let mut l = win.lum();
blur1(&mut l, w, h, cell * 0.15);
blur1(&mut l, w, h, 0.5 + size * 0.3);
let b = ctx.bounds;
let (cx, cy) = ((b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0);
let ty = e.choice("patternType");
let dots = halftone_dot_ranks();
for i in 0..win.c.len() {
let (x, y) = win.xy(i);
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
let band = |t: f32| ((t / cell).rem_euclid(1.0) - 0.5).abs() * 2.0;
let spot = match ty {
// Rings round the middle of the image, inked from the middle of each ring.
"circle" => band(((fx - cx).powi(2) + (fy - cy).powi(2)).sqrt()),
// Horizontal lines, inked from the middle of each.
"line" => band(fy),
// Round dots on a screen at 45° (as a black halftone screen is), that grow
// from each cell's centre, meet at 50% as a checkerboard of squares square to
// the image, and fill the cell at 100%.
let d = clamp01((1.0 - l[i] - 0.5) * (1.0 + con * 3.0) + 0.5);
let aa = 1.0 / cell;
let ink = match ty {
"circle" => {
let r = ((fx - cx).powi(2) + (fy - cy).powi(2)).sqrt() / cell;
let q = (r.fract() - 0.5).abs() * 2.0;
smoothstep(d + aa, d - aa, q)
}
"line" => {
let q = ((fy / cell).fract() - 0.5).abs() * 2.0;
smoothstep(d + aa, d - aa, q)
}
_ => {
let (rx, ry) = ((fx + fy) * std::f32::consts::FRAC_1_SQRT_2, (fy - fx) * std::f32::consts::FRAC_1_SQRT_2);
let (u, v) = ((rx / cell).rem_euclid(1.0), (ry / cell).rem_euclid(1.0));
let f = ((2.0 * PI * u).cos() + (2.0 * PI * v).cos()) / 2.0;
let k = dots.partition_point(|&r| r < f);
k as f32 / dots.len().max(1) as f32
let (u, v) = ((fx / cell).fract() - 0.5, (fy / cell).fract() - 0.5);
let q = (u * u + v * v).sqrt() * 2.0;
let rr = d.sqrt() * 1.15;
smoothstep(rr + aa, rr - aa, q)
}
};
let tone = clamp01(1.0 - l[i]) * (offsets.len() - 1) as f32;
let (k, f) = (tone.floor() as usize, tone.fract());
let at = |j: usize| offsets.get(j.min(offsets.len() - 1)).copied().unwrap_or(0.5);
let level = at(k) + (at(k + 1) - at(k)) * f;
let ink = clamp01(0.5 + (level - spot) * gain);
win.c[i] = mix(bg, fg, ink);
}
}
@@ -1314,43 +1254,6 @@ mod tests {
}
}
/// Flat greys keep their tone through every pattern and Contrast, black and white stay
/// solid, and Contrast makes the screen crisper.
#[test]
fn halftone_keeps_tones_and_contrast_sets_how_hard() {
// It used to be all ink or paper at any Contrast, with white crosses in the shadows
// (dots could never fill their cell) and mid-greys that came out the wrong darkness.
use crate::GalleryFilter as G;
let b = Rect::new(0, 0, 60, 60);
let run = |grey: f32, pattern: &str, contrast: f32| {
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true));
s.fill_rect(b, &[grey, grey, grey, 1.0]);
let mut e = GalleryEffect::new(G::HalftonePattern);
assert!(e.set_choice("patternType", pattern));
e.set("size", 4.0);
e.set("contrast", contrast);
let out = apply_tiled(&s, &FilterParams::FilterGallery { effects: vec![e] }, b, b, None, 256, Some(b)).read_region(b);
// Ink (1 = foreground black) of every pixel away from the canvas edge.
out.chunks(4).enumerate().filter(|(i, _)| (6..54).contains(&(i % 60)) && (6..54).contains(&(i / 60))).map(|(_, p)| 1.0 - p[0]).collect::<Vec<f32>>()
};
for pattern in ["dot", "circle", "line"] {
for contrast in [0.0, 5.0, 50.0] {
for grey in [0.2, 0.5, 0.8] {
let ink = run(grey, pattern, contrast);
let mean = ink.iter().sum::<f32>() / ink.len() as f32;
assert!((mean - (1.0 - grey)).abs() < 0.06, "{pattern} contrast {contrast}: grey {grey} inks {mean}");
}
let solid = run(0.0, pattern, contrast);
assert!(solid.iter().all(|&v| v > 0.99), "{pattern} contrast {contrast}: black isn't solid");
let paper = run(1.0, pattern, contrast);
assert!(paper.iter().all(|&v| v < 0.01), "{pattern} contrast {contrast}: white isn't paper");
}
let soft = run(0.5, pattern, 0.0).iter().filter(|&&v| v > 0.05 && v < 0.95).count();
let hard = run(0.5, pattern, 50.0).iter().filter(|&&v| v > 0.05 && v < 0.95).count();
assert!(hard * 4 < soft, "{pattern}: contrast 50 should be much crisper ({hard} vs {soft} grey pixels)");
}
}
#[test]
fn params_parse_and_clamp() {
for f in crate::GalleryFilter::ALL {
@@ -1402,7 +1305,7 @@ mod tests {
}
/// Visual check: `GALLERY_RAW=src.raw GALLERY_W=480 GALLERY_H=322 cargo test -p photocraft-algo --release gallery_sheet -- --ignored`
/// writes `<key>.raw` (RGB8) beside the input for every filter (or one, see `GALLERY_ONLY`).
/// writes `<key>.raw` (RGB8) beside the input for every filter.
#[test]
#[ignore]
fn gallery_sheet() {
@@ -1415,29 +1318,11 @@ mod tests {
let b = Rect::new(0, 0, w, h);
s.write_region(b, &data);
let dir = std::path::Path::new(&path).parent().unwrap().to_path_buf();
// Optional: GALLERY_ONLY=<key> renders one filter; GALLERY_SET="key=value,key=name" sets its
// sliders (numbers) or choices (names); GALLERY_TAG is appended to the file name.
let only = std::env::var("GALLERY_ONLY").ok();
let set = std::env::var("GALLERY_SET").unwrap_or_default();
let tag = std::env::var("GALLERY_TAG").unwrap_or_default();
if let Some(k) = &only {
assert!(crate::GalleryFilter::ALL.iter().any(|f| f.key() == k), "GALLERY_ONLY={k} is not a gallery filter");
}
for f in crate::GalleryFilter::ALL.iter().filter(|f| only.as_deref().is_none_or(|k| k == f.key())) {
let mut e = GalleryEffect::new(*f);
for kv in set.split(',').filter(|kv| !kv.is_empty()) {
let (k, v) = kv.split_once('=').unwrap();
// `set` ignores unknown keys; a misspelt one would render the default unnoticed.
assert!(f.params().iter().any(|p| p.key == k), "{k} is not a parameter of {}", f.key());
match v.parse::<f32>() {
Ok(n) => e.set(k, n),
Err(_) => assert!(e.set_choice(k, v), "{k} has no choice {v}"),
}
}
let fp = FilterParams::FilterGallery { effects: vec![e] };
for f in crate::GalleryFilter::ALL {
let fp = FilterParams::FilterGallery { effects: vec![GalleryEffect::new(*f)] };
let out = crate::apply_in(&s, &fp, b, b, None, b).read_region(b);
let raw: Vec<u8> = out.chunks(4).flat_map(|p| [p[0], p[1], p[2]].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect();
std::fs::write(dir.join(format!("{}{tag}.raw", f.key())), raw).unwrap();
std::fs::write(dir.join(format!("{}.raw", f.key())), raw).unwrap();
}
}
}
+21 -124
View File
@@ -189,11 +189,6 @@ mod shape_tests;
/// Smart Blur: averages only neighbours within `threshold` levels of the
/// centre pixel (so edges stay crisp); Edge Only / Overlay Edge draw the
/// edges (where neighbours differ by more than the threshold) in white.
///
/// Neighbours outside `src` read as zero, as [`Image::get`] does. Pixels whose whole
/// kernel lies inside `src` (all of them, with the usual halo) read neighbours at
/// precomputed offsets into the window instead of by coordinates, and for up to five
/// channels go through [`smart_rows`] (#7921).
pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold: f32, quality: BlurQuality, mode: SmartBlurMode) -> Vec<f32> {
let n = src.ch;
let cc = ncol(ctx, n);
@@ -206,48 +201,31 @@ pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, thresho
BlurQuality::High => 12,
};
let stride = ((ri + target - 1) / target).max(1);
let win = src.rect;
let ww = win.width() as isize;
// Each offset with its distance in samples within the window.
let offs: Vec<(i32, i32, isize)> = (-ri..=ri)
let offs: Vec<(i32, i32)> = (-ri..=ri)
.step_by(stride as usize)
.flat_map(|dy| (-ri..=ri).step_by(stride as usize).map(move |dx| (dx, dy)))
.filter(|(dx, dy)| ((dx * dx + dy * dy) as f32) <= r * r + 0.5)
.map(|(dx, dy)| (dx, dy, (dy as isize * ww + dx as isize) * n as isize))
.collect();
let at = |x: i32, y: i32| win.contains(x, y).then(|| ((y - win.y0) as usize * ww as usize + (x - win.x0) as usize) * n);
let zeros = [0.0f32; MAXC];
let data = &src.data[..];
// The pixel at sample `i`, or zeros outside the window.
let sample = |i: Option<usize>| i.and_then(|i| data.get(i..i + n)).unwrap_or(&zeros[..n]);
let white = native(ctx, [1.0, 1.0, 1.0, 1.0]);
let black = native(ctx, [0.0, 0.0, 0.0, 1.0]);
let mut res = src.crop(out);
let inner = Rect::new(win.x0.saturating_add(ri), win.y0.saturating_add(ri), win.x1.saturating_sub(ri), win.y1.saturating_sub(ri)).intersect(&out);
let lin: Vec<isize> = offs.iter().map(|o| o.2).collect();
let rows = match (mode, n, ctx.alpha) {
(SmartBlurMode::Normal, 1, false) => smart_rows::<1, false>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 1, true) => smart_rows::<1, true>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 2, false) => smart_rows::<2, false>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 2, true) => smart_rows::<2, true>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 3, false) => smart_rows::<3, false>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 3, true) => smart_rows::<3, true>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 4, false) => smart_rows::<4, false>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 4, true) => smart_rows::<4, true>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 5, false) => smart_rows::<5, false>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 5, true) => smart_rows::<5, true>(src, out, inner, &lin, t, &mut res),
_ => Rect::EMPTY,
};
let mut q = [0.0f32; MAXC];
let mut acc = [0.0f32; MAXC];
for (i, px) in res.chunks_exact_mut(n).enumerate() {
let (x, y) = xy(out, i);
if rows.contains(x, y) {
continue;
}
let me: [f32; MAXC] = std::array::from_fn(|c| if c < n { px[c] } else { 0.0 });
let differs = |q: &[f32]| q[..cc].iter().zip(&me).any(|(q, m)| (q - m).abs() > t);
let differs = |q: &[f32]| (0..cc).any(|c| (q[c] - me[c]).abs() > t);
if mode != SmartBlurMode::Normal {
let edge = [(1, 0), (0, 1), (-1, 0), (0, -1)].into_iter().any(|(dx, dy)| differs(sample(at(x + dx, y + dy))));
let mut edge = false;
for (dx, dy) in [(1, 0), (0, 1), (-1, 0), (0, -1)] {
for (c, v) in q.iter_mut().enumerate().take(n) {
*v = src.get(x + dx, y + dy, c);
}
if differs(&q[..n]) {
edge = true;
break;
}
}
let a = if ctx.alpha { px[n - 1] } else { 1.0 };
if edge {
px[..cc].copy_from_slice(&white[..cc]);
@@ -259,28 +237,18 @@ pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, thresho
}
continue;
}
let interior = x - ri >= win.x0 && x + ri < win.x1 && y - ri >= win.y0 && y + ri < win.y1;
let centre = if interior { at(x, y) } else { None };
acc[..n].fill(0.0);
let mut wsum = 0.0f32;
for &(dx, dy, d) in &offs {
let q = sample(match centre {
Some(c) => c.checked_add_signed(d),
None => at(x + dx, y + dy),
});
if differs(q) {
for &(dx, dy) in &offs {
for (c, v) in q.iter_mut().enumerate().take(n) {
*v = src.get(x + dx, y + dy, c);
}
if differs(&q[..n]) {
continue;
}
if ctx.alpha {
let a = q[n - 1];
for (s, v) in acc.iter_mut().zip(&q[..cc]) {
*s += v * a;
}
acc[n - 1] += a;
} else {
for (s, v) in acc.iter_mut().zip(q) {
*s += v;
}
let a = if ctx.alpha { q[n - 1] } else { 1.0 };
for c in 0..n {
acc[c] += if ctx.alpha && c < n - 1 { q[c] * a } else { q[c] };
}
wsum += 1.0;
}
@@ -294,77 +262,6 @@ pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, thresho
res
}
/// Smart Blur's normal mode over `inner` (pixels whose whole kernel lies in `src`) for `N`
/// channels, the last alpha if `A`; `lin` are the neighbours as sample offsets in the window.
/// Each offset sweeps a whole row, so neighbours load contiguously and a rejected one adds
/// `-0.0` (an exact identity) instead of branching: every pixel sums its neighbours in the same
/// order as [`smart_blur`]'s per-pixel loop, giving the same bits. Returns the area written: the
/// rows finished, empty if the layout doesn't match.
fn smart_rows<const N: usize, const A: bool>(src: &Image, out: Rect, inner: Rect, lin: &[isize], t: f32, res: &mut [f32]) -> Rect {
let win = src.rect;
if src.ch != N || inner.is_empty() || !win.contains_rect(&inner) || !out.contains_rect(&inner) {
return Rect::EMPTY;
}
let cc = if A { N - 1 } else { N };
let (ww, ow, len) = (win.width() as usize, out.width() as usize, inner.width() as usize);
let pixels = len * N;
let mut acc = vec![[0.0f32; N]; len];
let mut wsum = vec![0.0f32; len];
// Rows before `y` are done; a row is written only once all its neighbours were read.
let done = |y: i32| Rect::new(inner.x0, inner.y0, inner.x1, y);
for y in inner.y0..inner.y1 {
let centre = ((y - win.y0) as usize * ww + (inner.x0 - win.x0) as usize) * N;
let Some(me) = src.data.get(centre..centre + pixels) else {
return done(y);
};
let me = me.as_chunks::<N>().0;
acc.fill([0.0; N]);
wsum.fill(0.0);
for &d in lin {
let Some(q) = centre.checked_add_signed(d).and_then(|i| src.data.get(i..i + pixels)) else {
return done(y);
};
for (((s, w), q), m) in acc.iter_mut().zip(wsum.iter_mut()).zip(q.as_chunks::<N>().0).zip(me) {
// As `differs`: a NaN difference is not over the threshold.
let mut far = false;
for c in 0..cc {
far |= (q[c] - m[c]).abs() > t;
}
let keep = !far;
if A {
let a = q[N - 1];
for c in 0..N - 1 {
s[c] += if keep { q[c] * a } else { -0.0 };
}
s[N - 1] += if keep { a } else { -0.0 };
} else {
for c in 0..N {
s[c] += if keep { q[c] } else { -0.0 };
}
}
*w += if keep { 1.0 } else { 0.0 };
}
}
let o = ((y - out.y0) as usize * ow + (inner.x0 - out.x0) as usize) * N;
let Some(dst) = res.get_mut(o..o + pixels) else {
return done(y);
};
for ((px, s), &w) in dst.as_chunks_mut::<N>().0.iter_mut().zip(&acc).zip(&wsum) {
if w > 0.0 {
for c in 0..N {
px[c] = s[c] / w;
}
unpremul_px(px, A);
}
}
}
inner
}
#[cfg(test)]
#[path = "smart_blur_tests.rs"]
mod smart_tests;
// ---------- lens blur ----------
pub(crate) struct LensSpec<'a> {
+2 -19
View File
@@ -411,14 +411,6 @@ pub enum FilterParams {
},
// ---- Render ----
/// Clouds (or Difference Clouds, which subtracts them from the pixels) between two straight
/// sRGB RGBA colours; the cloud size follows the bounds.
Clouds {
seed: u32,
difference: bool,
foreground: [f32; 4],
background: [f32; 4],
},
/// Variance 1–64, strength 1–64; colours straight sRGB RGBA.
Fibers {
variance: f32,
@@ -614,13 +606,7 @@ impl FilterParams {
/// the reference bounds even where the layer is transparent, as Photoshop's does on an
/// empty layer, while still reading no neighbourhood.
pub fn renders_from_nothing(&self) -> bool {
matches!(self, FilterParams::Fibers { .. } | FilterParams::Clouds { difference: false, .. })
}
/// Whether the filter places and sizes its pattern by the canvas even with a selection
/// (Clouds, Fibers): its bounds are then the canvas, and the selection only masks the result.
pub fn sizes_by_canvas(&self) -> bool {
matches!(self, FilterParams::Clouds { .. } | FilterParams::Fibers { .. })
matches!(self, FilterParams::Fibers { .. })
}
/// Human-readable name.
@@ -667,8 +653,6 @@ impl FilterParams {
FilterParams::Displace { .. } => "Displace",
FilterParams::Shear { .. } => "Shear",
FilterParams::ZigZag { .. } => "ZigZag",
FilterParams::Clouds { difference: false, .. } => "Clouds",
FilterParams::Clouds { difference: true, .. } => "Difference Clouds",
FilterParams::Fibers { .. } => "Fibers",
FilterParams::LensFlare { .. } => "Lens Flare",
FilterParams::LightingEffects { .. } => "Lighting Effects",
@@ -713,7 +697,7 @@ fn halo_ext(p: &FilterParams) -> Halo {
FilterParams::Wind { method, .. } => r(stylize2::wind_reach(*method)),
FilterParams::Displace { .. } | FilterParams::Shear { .. } | FilterParams::ZigZag { .. } | FilterParams::SpinBlur { .. } => Halo::Bounds,
// Generators and per-pixel renders only need the bounds geometry (always in the context).
FilterParams::Fibers { .. } | FilterParams::LensFlare { .. } | FilterParams::Clouds { .. } => Halo::Radius(0),
FilterParams::Fibers { .. } | FilterParams::LensFlare { .. } => Halo::Radius(0),
FilterParams::LightingEffects { .. } => r(1.0),
FilterParams::Relight { intensity, .. } if *intensity == 0.0 => Halo::Radius(0),
FilterParams::Relight { .. } => Halo::Radius(relight::halo_radius_max()),
@@ -833,7 +817,6 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
}
FilterParams::Shear { points, undefined } => distort2::shear(src, out, ctx, points, *undefined),
FilterParams::ZigZag { amount, ridges, style } => distort2::zigzag(src, out, ctx, *amount, *ridges, *style),
FilterParams::Clouds { seed, difference, foreground, background } => render::clouds(src, out, ctx, *seed, *difference, (*foreground, *background)),
FilterParams::Fibers { variance, strength, seed, foreground, background } => {
render::fibers(src, out, ctx, *variance, *strength, *seed, (*foreground, *background))
}
+19 -81
View File
@@ -19,7 +19,6 @@
//! copied untouched. [`ProxyImage::render`] is the cheap preview path: bilinear field and
//! bilinear source on a downsampled RGBA copy, re-rendered only where a dab changed things.
use crate::par;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde::{Deserialize, Serialize};
@@ -187,10 +186,6 @@ struct StrokeCursor {
carry: f64,
}
/// Dabs over this many field nodes compute their rows in parallel (an 800 px brush has about
/// 125,000); smaller ones are not worth the hand-off.
const PARALLEL_DAB_NODES: usize = 16_384;
/// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a
/// drag is many small pushes and its edge stays smooth.
fn dab_spacing(r: f64) -> f64 {
@@ -492,11 +487,9 @@ impl LiquifyField {
bilinear(&self.d, self.w, self.h, gx, gy)
}
};
// Every node reads only the snapshot and the field as it was before this dab (the writes
// land after), so rows are independent: a large brush computes them in parallel.
let mut writes: Vec<(usize, [f32; 2], f32)> = Vec::with_capacity((i1 - i0 + 1) * (j1 - j0 + 1));
let inv_cell = 1.0 / self.cell;
let node_row = |j: usize| -> Vec<(usize, [f32; 2], f32)> {
let mut row = Vec::with_capacity(i1 - i0 + 1);
for j in j0..=j1 {
for i in i0..=i1 {
let p = self.node_pos(i, j);
let (dx, dy) = (p[0] - c[0], p[1] - c[1]);
@@ -510,7 +503,7 @@ impl LiquifyField {
if s.tool == LiquifyTool::Freeze || s.tool == LiquifyTool::Thaw {
let f = self.freeze[idx];
let v = if s.tool == LiquifyTool::Freeze { (f as f64 + k_raw).min(1.0) } else { (f as f64 - k_raw).max(0.0) };
row.push((idx, self.d[idx], v as f32));
writes.push((idx, self.d[idx], v as f32));
continue;
}
let k = k_raw * (1.0 - f64::from(self.freeze[idx]));
@@ -560,13 +553,10 @@ impl LiquifyField {
[(q[0] + dq[0] - p[0]) as f32, (q[1] + dq[1] - p[1]) as f32]
}
};
row.push((idx, new, self.freeze[idx]));
writes.push((idx, new, self.freeze[idx]));
}
row
};
let rows = j1 - j0 + 1;
let writes = par::map(rows, rows * (i1 - i0 + 1) > PARALLEL_DAB_NODES, |row| node_row(j0 + row));
for (idx, d, f) in writes.into_iter().flatten() {
}
for (idx, d, f) in writes {
self.d[idx] = d;
self.freeze[idx] = f;
}
@@ -663,10 +653,7 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
if field.is_identity() || b.is_empty() {
return out;
}
// A 1D Catmull-Rom stencil's absolute weights sum to at most 5/4, so the 2D
// interpolated displacement can reach 25/16 of the largest nodal displacement.
let maxd = ((f64::from(field.max_displacement()) * (25.0 / 16.0)).ceil() as i32).saturating_add(3);
let has_freeze = field.freeze.iter().any(|&f| f > 0.0);
let maxd = f64::from(field.max_displacement()).ceil() as i32 + 3;
let tiles: Vec<Rect> = b.tiles().map(|t| t.rect().intersect(&b)).filter(|r| !r.is_empty() && field.touches(*r)).collect();
let work = |t: &Rect| -> Option<(Rect, Vec<f32>)> {
let w = t.width() as usize;
@@ -683,15 +670,7 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
let px = f64::from(t.x0) + x as f64 + 0.5;
let gx = (px - f64::from(b.x0) - 0.5) / field.cell;
let wx = catmull_rom(gx - gx.floor());
let mut d = field.sample_cubic(gx, gy, &wx, &wy);
if has_freeze {
let mask = f64::from(field.freeze_at(px, py)).clamp(0.0, 1.0);
if mask > 0.0 {
let linear = field.sample(px, py);
d[0] = d[0] * (1.0 - mask) + linear[0] * mask;
d[1] = d[1] * (1.0 - mask) + linear[1] * mask;
}
}
let d = field.sample_cubic(gx, gy, &wx, &wy);
if d[0] != 0.0 || d[1] != 0.0 {
moved = true;
}
@@ -941,23 +920,18 @@ impl ProxyImage {
return false;
}
source_scratch.resize(source_len, [0.0; 4]);
// Read and premultiply the source in one pass per band of rows, the bands in parallel: at
// an 800 px brush it is about a million pixels (P30).
let opaque = !src.format().alpha;
const BAND_ROWS: usize = 32;
par::chunks_mut(source_scratch, sw * BAND_ROWS, true, |band_index, band| {
let y = src_rect.y0 + (band_index * BAND_ROWS) as i32;
src.read_rgba_into(Rect::new(src_rect.x0, y, src_rect.x1, y + (band.len() / sw) as i32), band);
for pixel in band.iter_mut() {
if opaque {
pixel[3] = 1.0;
}
let alpha = pixel[3];
pixel[0] *= alpha;
pixel[1] *= alpha;
pixel[2] *= alpha;
src.read_rgba_into(src_rect, source_scratch);
if !src.format().alpha {
for pixel in source_scratch.iter_mut() {
pixel[3] = 1.0;
}
});
}
for pixel in source_scratch.iter_mut() {
let alpha = pixel[3];
pixel[0] *= alpha;
pixel[1] *= alpha;
pixel[2] *= alpha;
}
let x0 = (region.x0 - bounds.x0) as usize;
let x1 = (region.x1 - bounds.x0) as usize;
@@ -1402,42 +1376,6 @@ mod tests {
assert!(g.freeze.iter().all(|f| *f == 0.0));
}
#[test]
fn fully_frozen_pixels_keep_their_mapping_when_an_adjacent_area_is_warped() {
let bounds = bounds();
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true));
for y in 0..bounds.height() {
for x in 0..bounds.width() {
src.write_pixel(x as i32, y as i32, &[x as f32 / bounds.width() as f32, y as f32 / bounds.height() as f32, 0.0, 1.0]);
}
}
let mut mask_only = LiquifyField::new(bounds, 2.0);
mask_only.apply_stroke(&lasso(&[[0.0, 0.0], [40.0, 0.0], [40.0, 64.0], [0.0, 64.0]], None));
assert_eq!(apply_liquify(&src, &mask_only), src);
let mut field = LiquifyField::new(bounds, 2.0);
let mut first_warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 40.0);
first_warp.points = vec![vec![22.0, 32.0], vec![34.0, 32.0]];
field.apply_stroke(&first_warp);
field.apply_stroke(&lasso(&[[0.0, 0.0], [40.0, 0.0], [40.0, 64.0], [0.0, 64.0]], None));
let frozen_pixel = (37, 32);
assert!(field.freeze_at(f64::from(frozen_pixel.0) + 0.5, f64::from(frozen_pixel.1) + 0.5) > 0.999);
let before = apply_liquify(&src, &field).pixel(frozen_pixel.0, frozen_pixel.1);
assert_ne!(before, src.pixel(frozen_pixel.0, frozen_pixel.1), "freezing must retain the earlier warp");
let neighbor_before = apply_liquify(&src, &field).pixel(43, 32);
let mut adjacent_warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 24.0);
adjacent_warp.points = vec![vec![44.0, 32.0], vec![52.0, 32.0]];
field.apply_stroke(&adjacent_warp);
let after = apply_liquify(&src, &field).pixel(frozen_pixel.0, frozen_pixel.1);
let neighbor_after = apply_liquify(&src, &field).pixel(43, 32);
assert_eq!(before, after, "a fully frozen pixel must keep its pre-neighbor-warp mapping");
assert_ne!(neighbor_before, neighbor_after, "an adjacent unfrozen pixel must still move");
}
#[test]
fn twirl_pucker_bloat_and_push_left_have_the_right_sense() {
let at = |tool: LiquifyTool, pts: Vec<Vec<f64>>| {
-173
View File
@@ -124,179 +124,6 @@ pub(crate) fn hsb_hsl(src: &Image, out: Rect, ctx: &Ctx, input: HsbModel, output
#[cfg(test)]
mod tests {
use super::*;
use photocraft_color::{PixelFormat, SampleType};
use photocraft_raster::Surface;
const DEPTHS: [SampleType; 3] = [SampleType::U8, SampleType::U16, SampleType::F32];
fn custom_fixture(depth: SampleType, mode: ColorMode, alpha: bool) -> (Surface, Rect) {
let b = Rect::new(-2, -1, 3, 4);
let format = PixelFormat::new(mode, depth, alpha);
let mut src = Surface::new(format);
let mut pixels = Vec::new();
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
for c in 0..format.channels() {
pixels.push(((x - b.x0) + 5 * (y - b.y0) + 3 * c as i32) as f32 / 40.0);
}
}
}
src.write_region(b, &pixels);
(src, b)
}
fn assert_sample(actual: f32, expected: f32, depth: SampleType) {
let (expected, tolerance) = match depth {
SampleType::U8 => (expected.clamp(0.0, 1.0), 1.0 / 255.0),
SampleType::U16 => (expected.clamp(0.0, 1.0), 1.0 / 65535.0),
SampleType::F32 => (expected, 1e-6),
};
assert!((actual - expected).abs() <= tolerance, "{depth:?}: {actual} != {expected}");
}
fn run_custom(src: &Surface, b: Rect, kernel: Vec<f32>, scale: f32, offset: f32) -> Surface {
crate::apply_in(src, &crate::FilterParams::Custom { kernel, scale, offset }, b, b, None, b)
}
#[test]
fn custom_all_25_taps_use_row_major_offsets_and_repeat_canvas_edges() {
for depth in DEPTHS {
let (src, b) = custom_fixture(depth, ColorMode::Rgb, true);
for tap in 0..25 {
let mut kernel = vec![0.0; 25];
kernel[tap] = 1.0;
let out = run_custom(&src, b, kernel, 1.0, 0.0);
let (dx, dy) = (tap as i32 % 5 - 2, tap as i32 / 5 - 2);
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
let expected = src.pixel((x + dx).clamp(b.x0, b.x1 - 1), (y + dy).clamp(b.y0, b.y1 - 1));
let actual = out.pixel(x, y);
for c in 0..3 {
assert_sample(actual[c], expected[c], depth);
}
assert_eq!(actual[3], src.pixel(x, y)[3], "tap={tap}, alpha must not shift");
}
}
}
}
}
#[test]
fn custom_short_kernel_zero_pads_and_ignores_entries_after_25() {
for depth in DEPTHS {
let (src, b) = custom_fixture(depth, ColorMode::Rgb, true);
let mut short = vec![0.0; 13];
short[0] = 0.25;
short[12] = 0.75;
let mut padded = short.clone();
padded.resize(25, 0.0);
let mut long = padded.clone();
long.extend([1e6, -1e6, f32::NAN]);
let out = run_custom(&src, b, short, 1.0, 0.0);
assert_eq!(out.read_region(b), run_custom(&src, b, padded, 1.0, 0.0).read_region(b));
assert_eq!(out.read_region(b), run_custom(&src, b, long, 1.0, 0.0).read_region(b));
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
let corner = src.pixel((x - 2).max(b.x0), (y - 2).max(b.y0));
let centre = src.pixel(x, y);
let actual = out.pixel(x, y);
for c in 0..3 {
assert_sample(actual[c], 0.25 * corner[c] + 0.75 * centre[c], depth);
}
}
}
}
}
#[test]
fn custom_scale_zero_is_one_and_offset_is_in_255ths() {
for depth in DEPTHS {
let (src, b) = custom_fixture(depth, ColorMode::Rgb, true);
let before = src.read_region(b);
let mut kernel = vec![0.0; 25];
kernel[12] = 2.0;
let out = run_custom(&src, b, kernel.clone(), 0.0, -51.0);
assert_eq!(out.read_region(b), run_custom(&src, b, kernel, 1.0, -51.0).read_region(b));
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
let original = src.pixel(x, y);
let actual = out.pixel(x, y);
for c in 0..3 {
assert_sample(actual[c], 2.0 * original[c] - 0.2, depth);
}
assert_eq!(actual[3], original[3]);
}
}
assert_eq!(src.read_region(b), before);
}
}
#[test]
fn custom_neighbourhood_convolution_preserves_each_pixels_alpha() {
for depth in DEPTHS {
for mode in [ColorMode::Rgb, ColorMode::Cmyk] {
let (src, b) = custom_fixture(depth, mode, true);
let out = run_custom(&src, b, vec![1.0; 25], 25.0, 0.0);
let alpha = src.channels() - 1;
assert_ne!(out.pixel(b.x0, b.y0)[0], src.pixel(b.x0, b.y0)[0], "fixture must change colour");
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
assert_eq!(out.pixel(x, y)[alpha], src.pixel(x, y)[alpha], "{depth:?}, {mode:?}, {x},{y}");
}
}
}
}
}
#[test]
fn custom_cmyk_convolves_brightness_including_black_with_or_without_alpha() {
for depth in DEPTHS {
for alpha in [false, true] {
let (src, b) = custom_fixture(depth, ColorMode::Cmyk, alpha);
for offset in [-51.0, 51.0] {
let mut kernel = vec![0.0; 25];
kernel[12] = 2.0;
let out = run_custom(&src, b, kernel, 1.0, offset);
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
let original = src.pixel(x, y);
let actual = out.pixel(x, y);
for c in 0..4 {
// Twice the reflected brightness, plus offset, then back to ink.
assert_sample(actual[c], 2.0 * original[c] - 1.0 - offset / 255.0, depth);
}
if alpha {
assert_eq!(actual[4], original[4]);
}
}
}
}
}
}
}
#[test]
fn custom_float_retains_hdr_while_integer_storage_clamps() {
let b = Rect::new(0, 0, 1, 1);
for depth in DEPTHS {
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, depth, true));
src.fill_rect(b, &[0.0, 0.5, 1.0, 0.4]);
let mut kernel = vec![0.0; 25];
kernel[12] = 2.0;
let out = run_custom(&src, b, kernel, 1.0, -51.0);
let actual = out.pixel(0, 0);
let original = src.pixel(0, 0);
for c in 0..3 {
assert_sample(actual[c], 2.0 * original[c] - 0.2, depth);
}
assert_eq!(actual[3], original[3]);
if depth == SampleType::F32 {
assert!(actual[0] < 0.0 && actual[2] > 1.0);
} else {
assert_eq!((actual[0], actual[2]), (0.0, 1.0));
}
}
}
#[test]
fn hsb_hsl_round_trip() {
+5 -92
View File
@@ -897,39 +897,14 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec<f32>], cover: &[Vec<bool>], o
label[i] = img as i32;
}
}
if no == 0 {
if no == 0 || nn == no {
continue;
}
let (ox, oy) = (sx / no as f64, sy / no as f64);
let (cx, cy) = (nx / nn as f64, ny / nn as f64);
let contained = nn == no;
let (ox, oy) = if contained {
// Wholly inside the composite (#5745: two photos in one document, the second moved
// by Auto-Align so the canvas crops it to the part over the first). Its side of the
// seam is the one it lies towards from the composite's centre; an image centred on
// the composite has no side, and the earlier images keep it.
let (mut px, mut py, mut pn) = (0.0f64, 0.0f64, 0usize);
for (i, l) in label.iter().enumerate() {
if *l >= 0 {
px += (i % w) as f64;
py += (i / w) as f64;
pn += 1;
}
}
let (px, py) = (px / pn.max(1) as f64, py / pn.max(1) as f64);
if (cx - px).abs() < 0.5 && (cy - py).abs() < 0.5 {
continue;
}
(px, py)
} else {
(sx / no as f64, sy / no as f64)
};
let vertical = (cx - ox).abs() >= (cy - oy).abs();
// Image on the increasing side along the seam's cross axis?
let positive = if vertical { cx > ox } else { cy > oy };
// A contained image has no pixels of its own, so a seam on its outer edge would leave it
// nothing: break cost ties away from its side (towards larger k when it lies on the
// decreasing side) so it keeps its side of the overlap.
let prefer_high = contained && !positive;
let cost = |i: usize| -> f32 { 0.002 + (luma[img][i] - luma[label[i] as usize][i]).abs() };
let (lines, len) = if vertical { (h, w) } else { (w, h) };
let idx = |line: usize, k: usize| if vertical { line * w + k } else { k * w + line };
@@ -950,8 +925,7 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec<f32>], cover: &[Vec<bool>], o
let mut best = (0.0f32, usize::MAX);
if let Some(pl) = prev_line.filter(|pl| *pl + 1 == line) {
best = (f32::INFINITY, usize::MAX);
let dks = if prefer_high { [1i64, 0, -1] } else { [-1i64, 0, 1] };
for dk in dks {
for dk in [-1i64, 0, 1] {
let kk = k as i64 + dk;
if kk < 0 || kk >= len as i64 {
continue;
@@ -978,12 +952,7 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec<f32>], cover: &[Vec<bool>], o
if seam[line] != usize::MAX {
continue;
}
let Some(k) = (0..len)
.filter(|&k| overlap[idx(line, k)])
.min_by(|&a, &b| acc[idx(line, a)].total_cmp(&acc[idx(line, b)]).then(if prefer_high { b.cmp(&a) } else { a.cmp(&b) }))
else {
continue;
};
let Some(k) = (0..len).filter(|&k| overlap[idx(line, k)]).min_by(|&a, &b| acc[idx(line, a)].total_cmp(&acc[idx(line, b)])) else { continue };
let (mut l, mut k) = (line, k);
loop {
seam[l] = k;
@@ -1003,13 +972,9 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec<f32>], cover: &[Vec<bool>], o
if s == usize::MAX {
continue;
}
let on_side = |k: usize| if positive { k > s } else { k < s };
// A contained image has no other cells, so where the overlap has nothing on its side
// of the seam (a line one cell wide) it takes the seam cell rather than nothing.
let take_seam = contained && !(0..len).any(|k| overlap[idx(line, k)] && on_side(k));
for k in 0..len {
let i = idx(line, k);
if overlap[i] && (on_side(k) || (take_seam && k == s)) {
if overlap[i] && ((positive && k > s) || (!positive && k < s)) {
label[i] = img as i32;
}
}
@@ -1620,58 +1585,6 @@ mod tests {
assert!(max_jump < 0.1, "max step {max_jump}");
assert!(cov.iter().all(|c| *c == 1.0));
}
#[test]
fn an_image_wholly_inside_the_composite_still_gets_its_side() {
// #5745: the first image covers the whole 120×40 grid, the second only its right part
// (x ≥ 70), all of it overlap. It used to be skipped, so the first image kept everything.
let (w, h) = (120usize, 40usize);
let luma = vec![vec![0.5f32; w * h], vec![0.5f32; w * h]];
let cover: Vec<Vec<bool>> = vec![vec![true; w * h], (0..w * h).map(|i| i % w >= 70).collect()];
let labels = seam_labels(w, h, &luma, &cover, &[0, 1]);
for y in 0..h {
assert_eq!(labels[y * w + 5], 0, "row {y}");
assert_eq!(labels[y * w + w - 1], 1, "row {y}");
// The second image's region is one run at the right of its seam.
let row = &labels[y * w..(y + 1) * w];
let first = row.iter().position(|l| *l == 1).unwrap();
assert!(first >= 70 && row[first..].iter().all(|l| *l == 1), "row {y}: {row:?}");
}
// The same on the left (x < 50) and on top (y < 15): the seam stays inside the overlap
// instead of on the image's outer edge, which used to leave it nothing.
let left: Vec<Vec<bool>> = vec![vec![true; w * h], (0..w * h).map(|i| i % w < 50).collect()];
let labels = seam_labels(w, h, &luma, &left, &[0, 1]);
for y in 0..h {
let row = &labels[y * w..(y + 1) * w];
assert_eq!(row[0], 1, "row {y}: {row:?}");
assert_eq!(row[w - 1], 0, "row {y}");
let last = row.iter().rposition(|l| *l == 1).unwrap();
assert!(last < 50 && row[..=last].iter().all(|l| *l == 1), "row {y}: {row:?}");
}
let (tw, th) = (40usize, 120usize);
let tluma = vec![vec![0.5f32; tw * th], vec![0.5f32; tw * th]];
let top: Vec<Vec<bool>> = vec![vec![true; tw * th], (0..tw * th).map(|i| i / tw < 50).collect()];
let labels = seam_labels(tw, th, &tluma, &top, &[0, 1]);
for x in 0..tw {
assert_eq!(labels[x], 1, "column {x}");
assert_eq!(labels[(th - 1) * tw + x], 0, "column {x}");
}
// One column or row wide: the seam can only run through it, and the image keeps it.
for col in [0, w - 1] {
let strip: Vec<Vec<bool>> = vec![vec![true; w * h], (0..w * h).map(|i| i % w == col).collect()];
let labels = seam_labels(w, h, &luma, &strip, &[0, 1]);
assert!(labels.iter().enumerate().all(|(i, l)| *l == i32::from(i % w == col)), "column {col}: {labels:?}");
}
let strip: Vec<Vec<bool>> = vec![vec![true; tw * th], (0..tw * th).map(|i| i / tw == 0).collect()];
let labels = seam_labels(tw, th, &tluma, &strip, &[0, 1]);
assert!(labels.iter().enumerate().all(|(i, l)| *l == i32::from(i / tw == 0)), "{labels:?}");
// An image centred on the composite has no side: the first image keeps the overlap.
let centred: Vec<Vec<bool>> = vec![vec![true; w * h], (0..w * h).map(|i| (20..100).contains(&(i % w))).collect()];
assert!(seam_labels(w, h, &luma, &centred, &[0, 1]).iter().all(|l| *l == 0));
// Two images covering the same cells, likewise.
let same = vec![vec![true; w * h]; 2];
assert!(seam_labels(w, h, &luma, &same, &[0, 1]).iter().all(|l| *l == 0));
}
}
/// The row-parallel `reduce` / `expand` / `push_pull` / `multiband` against the serial code they
+26 -401
View File
@@ -61,15 +61,8 @@ pub enum Dither {
Noise,
}
/// `(v.clamp(0, 1) * 255).round() as u8`, without the libm `roundf` call per channel (that call
/// was about a tenth of the palette scan on a large image). `x` is in 0..=255, so truncating
/// with an integer cast and adding one when the exact remainder is at least a half is
/// round-half-away-from-zero, the same as `f32::round`; NaN gives 0 in both. The
/// `to8_matches_round_for_every_float` test checks every input.
fn to8(v: f32) -> u8 {
let x = v.clamp(0.0, 1.0) * 255.0;
let t = x as u32;
(t + u32::from(x - t as f32 >= 0.5)) as u8
(v.clamp(0.0, 1.0) * 255.0).round() as u8
}
fn rgb8(p: &[f32; 4]) -> Rgb8 {
@@ -173,116 +166,15 @@ fn forced_colors(f: Forced) -> Vec<Rgb8> {
}
}
/// Unique 8-bit colours with counts (opaque pixels only), sorted by colour.
/// Unique 8-bit colours with counts (opaque pixels only).
fn histogram(px: &[[f32; 4]]) -> Vec<(Rgb8, u32)> {
// Pack each opaque pixel into one u32 (r, g, b from the top, so the numeric order is the
// `[u8; 3]` order), sort the plain integers and count the runs. This replaces a SipHash
// `HashMap` insert per pixel: counts are integers, so any order of work gives the same
// result, and equal colours sit next to each other after the sort. One 4-byte value per
// pixel is the only large allocation (no per-thread tables).
let pack = |p: &[f32; 4]| {
let [r, g, b] = rgb8(p);
u32::from(r) << 16 | u32::from(g) << 8 | u32::from(b)
};
#[cfg(not(target_arch = "wasm32"))]
let keys = {
use rayon::prelude::*;
let mut keys: Vec<u32> = px.par_iter().filter(|p| p[3] >= 0.5).map(pack).collect();
keys.par_sort_unstable();
keys
};
#[cfg(target_arch = "wasm32")]
let keys = {
let mut keys: Vec<u32> = px.iter().filter(|p| p[3] >= 0.5).map(pack).collect();
keys.sort_unstable();
keys
};
let mut out: Vec<(Rgb8, u32)> = Vec::new();
for k in keys {
let c = [(k >> 16) as u8, (k >> 8) as u8, k as u8];
match out.last_mut() {
Some((last, n)) if *last == c => *n = n.saturating_add(1),
_ => out.push((c, 1)),
}
}
out
}
/// Widest axis of `b` and its split score (weighted extent), or `None` for a box that cannot
/// be split. Every score is bit for bit what the old per-iteration scan gave.
fn split_score(b: &[([f32; 3], u32)]) -> Option<(usize, f32)> {
if b.len() <= 1 {
return None;
}
// One pass per axis, then the weight sum: the same folds the old per-iteration scan ran
// (measured faster than a single fused pass, which the compiler does not vectorise).
let mut best = (0usize, -1.0f32);
for a in 0..3 {
let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a])));
if hi - lo > best.1 {
best = (a, hi - lo);
}
}
let w: f32 = b.iter().map(|p| p.1 as f32).sum();
Some((best.0, best.1 * w.sqrt()))
}
/// Maps an f32 to a u32 whose unsigned order is exactly `f32::total_cmp` order.
fn total_order_key(v: f32) -> u32 {
let bits = v.to_bits();
// Negative: flip every bit (reverses their order, puts them below). Positive: set the sign bit.
bits ^ ((((bits as i32) >> 31) as u32) | 0x8000_0000)
}
/// Stable LSD radix sort of `b` along `axis`, in the order of `f32::total_cmp`. Stable like
/// `sort_by`, so ties keep their original order and the result is element for element the same;
/// it is several times faster than a comparison sort on millions of points.
fn radix_sort_along(b: &mut Vec<([f32; 3], u32)>, axis: usize) {
let n = b.len();
let Some(&fill) = b.first() else { return };
if n < 2 {
return;
}
// Three digits of 11, 11 and 10 bits; count all of them in one read.
let mut counts = [[0u32; 2048]; 3];
for e in b.iter() {
let k = total_order_key(e.0[axis]);
counts[0][(k & 0x7ff) as usize] += 1;
counts[1][((k >> 11) & 0x7ff) as usize] += 1;
counts[2][(k >> 22) as usize] += 1;
}
let mut src = std::mem::take(b);
let mut dst = vec![fill; n];
for (pass, count) in counts.iter_mut().enumerate() {
// A digit that is the same for every point would not move anything.
if count.iter().any(|&c| c as usize == n) {
continue;
}
let mut sum = 0u32;
for c in count.iter_mut() {
let here = *c;
*c = sum;
sum += here;
}
let shift = 11 * pass as u32;
for e in &src {
let d = ((total_order_key(e.0[axis]) >> shift) & 0x7ff) as usize;
dst[count[d] as usize] = *e;
count[d] += 1;
}
std::mem::swap(&mut src, &mut dst);
}
*b = src;
}
/// Stable sort of `b` along `axis`. Boxes this small are quicker with `sort_by` than with the
/// radix sort's fixed counting cost.
fn sort_along(b: &mut Vec<([f32; 3], u32)>, axis: usize) {
if b.len() >= 256 {
radix_sort_along(b, axis);
} else {
b.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis]));
let mut m: std::collections::HashMap<Rgb8, u32> = std::collections::HashMap::new();
for p in px.iter().filter(|p| p[3] >= 0.5) {
*m.entry(rgb8(p)).or_default() += 1;
}
let mut v: Vec<(Rgb8, u32)> = m.into_iter().collect();
v.sort_unstable();
v
}
/// Median cut over `pts` (feature vectors + weights) into at most `n` boxes; returns box means.
@@ -291,23 +183,29 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
return Vec::new();
}
let mut boxes: Vec<Vec<([f32; 3], u32)>> = vec![pts.to_vec()];
// Each box's (axis, score), kept in lockstep with `boxes` (same swap_remove and pushes).
// Only the two boxes made by a split change, so only those are scanned again; rescanning
// every box on every iteration was most of the time here.
let mut scores: Vec<Option<(usize, f32)>> = vec![split_score(pts)];
while boxes.len() < n {
// Split the box with the largest weighted extent (first one wins a tie, as before).
let (bi, axis, score) = scores
// Split the box with the largest weighted extent.
let (bi, axis, score) = boxes
.iter()
.enumerate()
.filter_map(|(i, s)| s.map(|(axis, score)| (i, axis, score)))
.filter(|(_, b)| b.len() > 1)
.map(|(i, b)| {
let mut best = (0usize, -1.0f32);
for a in 0..3 {
let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a])));
if hi - lo > best.1 {
best = (a, hi - lo);
}
}
let w: f32 = b.iter().map(|p| p.1 as f32).sum();
(i, best.0, best.1 * w.sqrt())
})
.fold((usize::MAX, 0, -1.0f32), |acc, x| if x.2 > acc.2 { x } else { acc });
if bi == usize::MAX || score <= 0.0 {
break;
}
let mut b = boxes.swap_remove(bi);
scores.swap_remove(bi);
sort_along(&mut b, axis);
b.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis]));
let total: u64 = b.iter().map(|p| u64::from(p.1)).sum();
let mut acc = 0u64;
let mut cut = 1;
@@ -319,8 +217,6 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
}
}
let rest = b.split_off(cut);
scores.push(split_score(&b));
scores.push(split_score(&rest));
boxes.push(b);
boxes.push(rest);
}
@@ -343,18 +239,8 @@ fn kmeans(pts: &[([f32; 3], u32)], centers: &mut [[f32; 3]], iters: usize) {
let lookup = Nearest::new(centers.iter().copied());
let mut sum = vec![[0.0f64; 3]; centers.len()];
let mut cnt = vec![0.0f64; centers.len()];
// The nearest-centre search is the expensive part and has no cross-point state, so it
// runs in parallel. The f64 sums below stay sequential in the original point order:
// float addition is not associative, and this keeps every bit of the result unchanged.
#[cfg(not(target_arch = "wasm32"))]
let nearest: Vec<u32> = {
use rayon::prelude::*;
pts.par_iter().map(|(p, _)| lookup.find(*p) as u32).collect()
};
#[cfg(target_arch = "wasm32")]
let nearest: Vec<u32> = pts.iter().map(|(p, _)| lookup.find(*p) as u32).collect();
for ((p, w), &i) in pts.iter().zip(&nearest) {
let i = i as usize;
for (p, w) in pts {
let i = lookup.find(*p);
for a in 0..3 {
sum[i][a] += f64::from(p[a]) * f64::from(*w);
}
@@ -372,20 +258,6 @@ fn lab_to_rgb8(lab: [f32; 3]) -> Rgb8 {
photocraft_color::convert::lab_to_srgb(lab).map(to8)
}
/// Histogram colours as CIE Lab points with their counts. Each point depends only on its own
/// colour and `collect` keeps the order, so the parallel map gives the same list; the log/pow
/// in `srgb_to_lab` made this a visible serial cost with millions of unique colours.
fn lab_points(hist: &[(Rgb8, u32)]) -> Vec<([f32; 3], u32)> {
let lab = |(c, n): &(Rgb8, u32)| (srgb_to_lab(c.map(|v| f32::from(v) / 255.0)), *n);
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
hist.par_iter().map(lab).collect()
}
#[cfg(target_arch = "wasm32")]
hist.iter().map(lab).collect()
}
/// Builds the palette for `px`. `colors` is the requested count (2..=256) for the computed
/// kinds; forced colours are included first and count toward it.
pub fn build_palette(px: &[[f32; 4]], kind: PaletteKind, colors: usize, forced: Forced) -> Result<Vec<Rgb8>, String> {
@@ -424,7 +296,7 @@ pub fn build_palette(px: &[[f32; 4]], kind: PaletteKind, colors: usize, forced:
if hist.len() <= room {
hist.iter().map(|h| h.0).collect()
} else {
let pts = lab_points(&hist);
let pts: Vec<([f32; 3], u32)> = hist.iter().map(|(c, n)| (srgb_to_lab(c.map(|v| f32::from(v) / 255.0)), *n)).collect();
let mut centers = median_cut(&pts, room);
kmeans(&pts, &mut centers, 4);
let mut v: Vec<Rgb8> = centers.iter().map(|c| lab_to_rgb8(*c)).collect();
@@ -738,253 +610,6 @@ mod tests {
(0..w * h).map(|i| [(i % w) as f32 / (w - 1) as f32, (i / w) as f32 / (h - 1) as f32, 0.3, 1.0]).collect()
}
fn to8_reference(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
}
#[test]
fn to8_matches_round_on_samples_and_edges() {
// Every half-way point and its neighbours, plus a dense sweep of the unit range.
for k in 0..=256u32 {
let mid = (k as f32 + 0.5) / 255.0;
for d in -3i32..=3 {
let v = f32::from_bits((mid.to_bits() as i32 + d) as u32);
assert_eq!(to8(v), to8_reference(v), "{v:e}");
}
}
for i in 0..200_000u32 {
let v = f32::from_bits(i * 20_000);
assert_eq!(to8(v), to8_reference(v), "{v:e}");
}
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.0, 0.0, 1.0, -1.0, 2.0, 1e30, -1e30, f32::MIN_POSITIVE, 1e-45] {
assert_eq!(to8(v), to8_reference(v), "{v:e}");
}
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
#[ignore = "checks all 2^32 floats, a few seconds in release; run with --release --ignored"]
fn to8_matches_round_for_every_float() {
use rayon::prelude::*;
let bad = (0..=u32::MAX).into_par_iter().filter(|&b| to8(f32::from_bits(b)) != to8_reference(f32::from_bits(b))).count();
assert_eq!(bad, 0);
}
/// A repeatable 96x64 image: a gradient with up to 31 levels of integer-hash noise and a
/// few see-through pixels.
fn golden_image() -> Vec<[f32; 4]> {
let (w, h) = (96usize, 64usize);
(0..w * h)
.map(|i| {
let (x, y) = ((i % w) as f32, (i / w) as f32);
let noise = |salt: u32| ((((i as u32).wrapping_mul(2_654_435_761).wrapping_add(salt.wrapping_mul(40_503))) >> 13) & 31) as f32 / 255.0;
let alpha = if i % 17 == 0 { 0.25 } else { 1.0 };
[x / (w - 1) as f32 * 0.8 + noise(1), y / (h - 1) as f32 * 0.7 + noise(2), ((x + y) / (w + h) as f32) * 0.6 + noise(3), alpha]
})
.collect()
}
/// Palettes written down from the version before the speed-up (hash-map histogram, every box
/// rescanned each split, comparison sort, serial k-means). Any change to how the palettes
/// are built has to give these exact colours.
#[test]
fn palettes_match_the_recorded_originals() {
let px = golden_image();
#[rustfmt::skip]
let cases: Vec<(PaletteKind, usize, Forced, &[Rgb8])> = vec![
(
PaletteKind::Adaptive,
16,
Forced::None,
&[
[40, 173, 80], [196, 175, 150], [92, 173, 103], [92, 81, 71], [195, 83, 118], [39, 82, 49],
[142, 82, 95], [195, 37, 101], [143, 36, 79], [40, 128, 64], [196, 129, 133], [92, 127, 87],
[91, 36, 56], [38, 35, 33], [144, 128, 111], [144, 174, 127],
],
),
(
PaletteKind::Perceptual,
16,
Forced::None,
&[
[104, 111, 87], [51, 129, 68], [48, 178, 79], [110, 74, 77], [165, 46, 93], [205, 50, 107],
[200, 154, 143], [44, 54, 42], [190, 101, 121], [61, 32, 44], [155, 115, 111], [48, 93, 56],
[119, 42, 71], [84, 172, 98], [126, 165, 115], [172, 168, 135],
],
),
(
PaletteKind::Selective,
24,
Forced::BlackWhite,
&[
[0, 0, 0], [255, 255, 255], [102, 107, 85], [65, 69, 55], [87, 133, 89], [109, 73, 76],
[164, 42, 92], [204, 51, 102], [199, 153, 142], [54, 170, 84], [199, 107, 126], [65, 33, 45],
[153, 102, 102], [187, 180, 145], [40, 106, 54], [100, 176, 107], [138, 174, 123], [50, 188, 80],
[32, 37, 33], [47, 139, 69], [144, 143, 116], [41, 80, 49], [108, 33, 64], [151, 70, 94],
],
),
(
PaletteKind::Adaptive,
8,
Forced::Primaries,
&[
[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [0, 255, 255],
[255, 0, 255], [255, 255, 0],
],
),
(
PaletteKind::Perceptual,
12,
Forced::BlackWhite,
&[
[0, 0, 0], [255, 255, 255], [104, 106, 85], [137, 43, 79], [148, 170, 126], [181, 144, 132],
[66, 172, 87], [88, 49, 60], [57, 118, 67], [42, 54, 41], [183, 101, 118], [196, 50, 105],
],
),
];
for (kind, colors, forced, expected) in cases {
assert_eq!(build_palette(&px, kind, colors, forced).unwrap(), expected, "{kind:?} {colors} {forced:?}");
}
}
/// The pre-speed-up median cut and k-means, kept as the oracle for the faster ones.
fn median_cut_reference(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
if pts.is_empty() || n == 0 {
return Vec::new();
}
let mut boxes: Vec<Vec<([f32; 3], u32)>> = vec![pts.to_vec()];
while boxes.len() < n {
let (bi, axis, score) = boxes
.iter()
.enumerate()
.filter(|(_, b)| b.len() > 1)
.map(|(i, b)| {
let mut best = (0usize, -1.0f32);
for a in 0..3 {
let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a])));
if hi - lo > best.1 {
best = (a, hi - lo);
}
}
let w: f32 = b.iter().map(|p| p.1 as f32).sum();
(i, best.0, best.1 * w.sqrt())
})
.fold((usize::MAX, 0, -1.0f32), |acc, x| if x.2 > acc.2 { x } else { acc });
if bi == usize::MAX || score <= 0.0 {
break;
}
let mut b = boxes.swap_remove(bi);
b.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis]));
let total: u64 = b.iter().map(|p| u64::from(p.1)).sum();
let mut acc = 0u64;
let mut cut = 1;
for (i, p) in b.iter().enumerate() {
acc += u64::from(p.1);
if acc * 2 >= total {
cut = (i + 1).clamp(1, b.len() - 1);
break;
}
}
let rest = b.split_off(cut);
boxes.push(b);
boxes.push(rest);
}
boxes
.iter()
.map(|b| {
let w: f64 = b.iter().map(|p| f64::from(p.1)).sum();
std::array::from_fn(|a| (b.iter().map(|p| f64::from(p.0[a]) * f64::from(p.1)).sum::<f64>() / w) as f32)
})
.collect()
}
fn kmeans_reference(pts: &[([f32; 3], u32)], centers: &mut [[f32; 3]], iters: usize) {
for _ in 0..iters {
let lookup = Nearest::new(centers.iter().copied());
let mut sum = vec![[0.0f64; 3]; centers.len()];
let mut cnt = vec![0.0f64; centers.len()];
for (p, w) in pts {
let i = lookup.find(*p);
for a in 0..3 {
sum[i][a] += f64::from(p[a]) * f64::from(*w);
}
cnt[i] += f64::from(*w);
}
for (i, c) in centers.iter_mut().enumerate() {
if cnt[i] > 0.0 {
*c = std::array::from_fn(|a| (sum[i][a] / cnt[i]) as f32);
}
}
}
}
/// Points with many repeated coordinates (ties), negative values, -0.0 and weights up to
/// a few thousand, so the stable-sort and float-order rules are all exercised.
fn tie_heavy_points(n: usize, seed: u64) -> Vec<([f32; 3], u32)> {
let mut s = seed;
let mut next = move || {
s = s.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
(s >> 33) as u32
};
(0..n)
.map(|_| {
let mut c = |range: u32| match next() % 40 {
0 => -0.0,
1 => 0.0,
_ => (next() % range) as f32 * 0.5 - (range / 4) as f32,
};
([c(24), c(300), c(150)], 1 + next() % 3000)
})
.collect()
}
#[test]
fn faster_median_cut_and_kmeans_equal_the_originals_bit_for_bit() {
// 40 000 points cross the 256-point radix threshold on the first splits.
for (n, boxes, seed) in [(40_000, 256, 1), (40_000, 37, 2), (3_000, 256, 3), (200, 64, 4), (2, 8, 5)] {
let pts = tie_heavy_points(n, seed);
let new = median_cut(&pts, boxes);
let old = median_cut_reference(&pts, boxes);
assert_eq!(new.len(), old.len());
for (a, b) in new.iter().zip(&old) {
assert_eq!(a.map(f32::to_bits), b.map(f32::to_bits), "median cut, n={n} boxes={boxes}");
}
let (mut a, mut b) = (new, old);
kmeans(&pts, &mut a, 3);
kmeans_reference(&pts, &mut b, 3);
for (x, y) in a.iter().zip(&b) {
assert_eq!(x.map(f32::to_bits), y.map(f32::to_bits), "kmeans, n={n} boxes={boxes}");
}
}
}
#[test]
fn radix_sort_is_the_stable_total_cmp_sort() {
for axis in 0..3 {
let mut pts = tie_heavy_points(5_000, 9 + axis as u64);
// The weight field doubles as an id: equal keys must keep their input order.
for (i, p) in pts.iter_mut().enumerate() {
p.1 = i as u32;
}
pts[10].0[axis] = f32::NAN;
pts[11].0[axis] = -f32::NAN;
pts[12].0[axis] = f32::INFINITY;
pts[13].0[axis] = f32::NEG_INFINITY;
let mut expected = pts.clone();
expected.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis]));
radix_sort_along(&mut pts, axis);
let bits = |v: &[([f32; 3], u32)]| v.iter().map(|p| (p.0.map(f32::to_bits), p.1)).collect::<Vec<_>>();
assert_eq!(bits(&pts), bits(&expected), "axis {axis}");
}
}
#[test]
fn histogram_counts_opaque_pixels_sorted() {
let px = [[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], [1.0, 0.0, 0.0, 0.5], [1.0, 0.0, 0.0, 0.49], [0.0, 0.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0]];
assert_eq!(histogram(&px), vec![([0, 0, 0], 1), ([0, 0, 255], 2), ([255, 0, 0], 2)]);
assert!(histogram(&[]).is_empty());
}
#[test]
fn fixed_palettes_have_expected_sizes() {
assert_eq!(web_palette().len(), 216);
+2 -54
View File
@@ -1,4 +1,4 @@
//! Render: Clouds, Difference Clouds, Fibers, Lens Flare, Lighting Effects.
//! Render: Fibers, Lens Flare, Lighting Effects.
use photocraft_geom::Rect;
@@ -18,57 +18,6 @@ fn value_noise(x: f32, y: f32, seed: u32) -> f32 {
top + (bot - top) * sy
}
fn cloud_hash(x: i32, y: i32, seed: u32) -> f32 {
let mut h = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ seed.wrapping_mul(0xcb1a_b31f);
h ^= h >> 13;
h = h.wrapping_mul(0x5bd1_e995);
h ^= h >> 15;
(h & 0x00ff_ffff) as f32 / 0x00ff_ffff as f32
}
/// Smooth value noise, summed over octaves (fBm), in 0..1.
fn clouds_value(x: f32, y: f32, base: f32, seed: u32) -> f32 {
let (mut sum, mut amp, mut freq, mut norm) = (0.0, 1.0, 1.0 / base, 0.0);
for o in 0..8u32 {
let (fx, fy) = (x * freq, y * freq);
let (ix, iy) = (fx.floor() as i32, fy.floor() as i32);
let (tx, ty) = (fx - ix as f32, fy - iy as f32);
let (sx, sy) = (tx * tx * (3.0 - 2.0 * tx), ty * ty * (3.0 - 2.0 * ty));
let s = seed.wrapping_add(o * 7919);
let top = cloud_hash(ix, iy, s) + (cloud_hash(ix + 1, iy, s) - cloud_hash(ix, iy, s)) * sx;
let bot = cloud_hash(ix, iy + 1, s) + (cloud_hash(ix + 1, iy + 1, s) - cloud_hash(ix, iy + 1, s)) * sx;
sum += (top + (bot - top) * sy) * amp;
norm += amp;
amp *= 0.5;
freq *= 2.0;
}
// Stretch the bell-shaped sum so the clouds use the full foreground→background range.
((sum / norm - 0.5) * 2.2 + 0.5).clamp(0.0, 1.0)
}
/// Clouds between the foreground and background colours; Difference Clouds subtracts them from
/// the pixels (alpha kept). The cloud size is a quarter of the bounds' longer side, and the
/// pattern is anchored at the bounds' corner (so it moves with a smart object).
pub(crate) fn clouds(src: &Image, out: Rect, ctx: &Ctx, seed: u32, difference: bool, colours: ([f32; 4], [f32; 4])) -> Vec<f32> {
let n = src.ch;
let (fg, bg) = colours;
let base = (ctx.bounds.width().max(ctx.bounds.height()) as f32 / 4.0).clamp(16.0, 512.0);
let mut res = src.crop(out);
for (i, px) in res.chunks_exact_mut(n).enumerate() {
let (x, y) = xy(out, i);
let (x, y) = (x - ctx.bounds.x0, y - ctx.bounds.y0);
let t = clouds_value(x as f32, y as f32, base, seed);
let c: [f32; 3] = std::array::from_fn(|k| fg[k] + (bg[k] - fg[k]) * t);
if difference {
let p = rgba(ctx, px);
set_rgba(ctx, px, [(p[0] - c[0]).abs(), (p[1] - c[1]).abs(), (p[2] - c[2]).abs(), p[3]]);
} else {
set_rgba(ctx, px, [c[0], c[1], c[2], 1.0]);
}
}
res
}
/// Fibers: vertical fibrous streaks between the foreground and background
/// colours. Variance sets how quickly the colour varies across fibres (and
/// how short they are); strength stretches each fibre vertically.
@@ -83,9 +32,8 @@ pub(crate) fn fibers(src: &Image, out: Rect, ctx: &Ctx, variance: f32, strength:
let fy = fx / (2.0 + st * 1.5) * (0.5 + var / 32.0);
let mut res = src.crop(out);
for (i, px) in res.chunks_exact_mut(n).enumerate() {
// Anchored at the bounds' corner, so the fibres move with a smart object.
let (x, y) = xy(out, i);
let (xf, yf) = ((x - ctx.bounds.x0) as f32, (y - ctx.bounds.y0) as f32);
let (xf, yf) = (x as f32, y as f32);
let mut sum = 0.0;
let mut amp = 1.0;
let mut norm = 0.0;
+5 -33
View File
@@ -141,15 +141,11 @@ pub const OBJECT_AT_WORK_PX: usize = 60_000;
/// A heuristic, not a semantic segmenter (that is #2788): on a working-resolution copy of the
/// canvas the area around the point is grown over pixels of similar colour (CIELAB, bounded both
/// by the distance to the seed's running mean and by the local edge step), so it stops at the
/// object's outline. A region covering most of the canvas boundary is treated as background.
/// Otherwise GrabCut refines it inside its padded bounding box (the grown area probable
/// object's outline. A region that grows over most of the canvas is the background: nothing is
/// found. Otherwise GrabCut refines it inside its padded bounding box (the grown area probable
/// foreground, the cursor definite foreground, outside the box definite background), the part
/// connected to the cursor is kept and the boundary is re-cut at full resolution, as the
/// rectangle mode does.
///
/// When the whole-canvas pass finds nothing in a large document (an object only a few working
/// pixels wide blurs into its surroundings), the search is repeated at finer steps in windows of
/// the same working size around the point, down to full resolution.
pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: usize) -> Option<Region> {
if canvas.width() < 4 || canvas.height() < 4 || !x.is_finite() || !y.is_finite() {
return None;
@@ -158,30 +154,7 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u
if !canvas.contains(px, py) {
return None;
}
let work_px = work_px.max(1024);
let mut step = super::scale_for(canvas, work_px);
if let Some(r) = object_in(sampler, canvas, px, py, step) {
return Some(r);
}
// Side (working pixels) of the square windows of the finer passes.
let side = (work_px as f64).sqrt() as usize;
while step > 1 {
step = (step / 4).max(1);
let half = (side.saturating_mul(step) / 2).min(i32::MAX as usize / 4) as i32;
let window = Rect::new(px.saturating_sub(half), py.saturating_sub(half), px.saturating_add(half), py.saturating_add(half)).intersect(&canvas);
if window.width() < 4 || window.height() < 4 {
continue;
}
if let Some(r) = object_in(sampler, window, px, py, step) {
return Some(r);
}
}
None
}
/// One [`object_at`] pass over `canvas` (the document or a window of it) at working step `step`;
/// `(px, py)` lies inside `canvas`.
fn object_in(sampler: &dyn Sampler, canvas: Rect, px: i32, py: i32, step: usize) -> Option<Region> {
let step = super::scale_for(canvas, work_px.max(1024));
let img = sampler.rgb_scaled(canvas, step);
let (w, h) = (img.w, img.h);
if w < 2 || h < 2 {
@@ -193,9 +166,8 @@ fn object_in(sampler: &dyn Sampler, canvas: Rect, px: i32, py: i32, step: usize)
let sy = (((py - canvas.y0) as usize) / step).min(h - 1);
let grown = grow_similar(&lab, w, h, sx, sy);
let count = grown.iter().filter(|g| **g).count();
let boundary = grown.iter().enumerate().filter(|(i, on)| **on && (i % w == 0 || i % w + 1 == w || i / w == 0 || i / w + 1 == h)).count();
// shortcut: cropped objects covering most of the frame need a semantic segmenter (#2788).
if boundary * 2 >= 2 * w + 2 * h - 4 || count < 4 {
// Background (or a flat image): no single object under the cursor.
if count * 10 > w * h * 6 || count < 4 {
return None;
}
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize);
-54
View File
@@ -751,38 +751,6 @@ fn object_at_finds_the_disc_under_the_cursor() {
assert!(v > 0.9, "IoU {v}");
}
#[test]
fn object_at_keeps_large_foregrounds_but_not_the_surrounding_background() {
let (w, h) = (400, 300);
for margin in [30, 20, 8] {
let inside = |x: usize, y: usize| x >= margin && x < w - margin && y >= margin && y < h - margin;
let img = RgbImage::from_fn(w, h, |x, y| {
let noise = ((x * 13 + y * 7) % 17) as f32 / 850.0;
if inside(x, y) { [0.8 + noise, 0.2, 0.1] } else { [0.1, 0.2, 0.8 + noise] }
});
let truth: Vec<_> = (0..w * h).map(|i| inside(i % w, i / w)).collect();
let sampler = ImageSampler { img: &img, origin: (-40, 25) };
let canvas = Rect::new(-40, 25, w as i32 - 40, h as i32 + 25);
let region = grabcut::object_at(&sampler, canvas, 160.0, 175.0, grabcut::OBJECT_AT_WORK_PX)
.unwrap_or_else(|| panic!("missing large foreground with margin {margin}"));
let local = Region { bbox: region.bbox.translate(40, -25), mask: region.mask };
assert!(iou(&local, &truth, w, h) > 0.95, "margin {margin}");
assert!(grabcut::object_at(&sampler, canvas, -39.0, 26.0, grabcut::OBJECT_AT_WORK_PX).is_none(), "background margin {margin}");
}
}
#[test]
fn object_at_allows_a_foreground_touching_one_edge_but_rejects_a_flat_image() {
let img = RgbImage::from_fn(400, 300, |x, y| if x < 360 && (20..280).contains(&y) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] });
let sampler = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, 400, 300);
let region = grabcut::object_at(&sampler, canvas, 200.0, 150.0, grabcut::OBJECT_AT_WORK_PX).unwrap();
assert!(region.at(0, 150) > 0.9 && region.at(350, 150) > 0.9);
assert!(region.at(380, 150) < 0.1 && region.at(200, 5) < 0.1);
let flat = RgbImage::from_fn(400, 300, |_, _| [0.5; 3]);
assert!(grabcut::object_at(&ImageSampler { img: &flat, origin: (0, 0) }, canvas, 200.0, 150.0, grabcut::OBJECT_AT_WORK_PX).is_none());
}
#[test]
fn object_at_on_the_background_or_off_canvas_finds_nothing() {
let (img, _) = disc_scene(320, 240, 160.0, 120.0, 70.0, 7);
@@ -794,28 +762,6 @@ fn object_at_on_the_background_or_off_canvas_finds_nothing() {
assert!(grabcut::object_at(&s, canvas, 400.0, 10.0, 0).is_none());
}
/// #6576: a 24 px object in a 4000×3000 document is found (a 1000×800 one still is).
#[test]
fn object_at_finds_small_object_in_large_canvas() {
let (w, h) = (4000usize, 3000usize);
let canvas = Rect::new(0, 0, w as i32, h as i32);
for (ox, oy, ow, oh) in [(188usize, 138usize, 24usize, 24usize), (1500, 1100, 1000, 800)] {
let inside = |x: usize, y: usize| x >= ox && x < ox + ow && y >= oy && y < oy + oh;
let img = RgbImage::from_fn(w, h, |x, y| if inside(x, y) { [0.8, 0.1, 0.1] } else { [0.9, 0.9, 0.9] });
let truth: Vec<bool> = (0..w * h).map(|i| inside(i % w, i / w)).collect();
let s = ImageSampler { img: &img, origin: (0, 0) };
let (cx, cy) = ((ox + ow / 2) as f32, (oy + oh / 2) as f32);
let reg = grabcut::object_at(&s, canvas, cx, cy, grabcut::OBJECT_AT_WORK_PX);
let reg = reg.unwrap_or_else(|| panic!("the {ow}×{oh} object is not found"));
let v = iou(&reg, &truth, w, h);
assert!(v > 0.8, "{ow}×{oh}: IoU {v}");
}
// The flat background around the small object is still nothing.
let img = RgbImage::from_fn(w, h, |x, y| if (188..212).contains(&x) && (138..162).contains(&y) { [0.8, 0.1, 0.1] } else { [0.9, 0.9, 0.9] });
let s = ImageSampler { img: &img, origin: (0, 0) };
assert!(grabcut::object_at(&s, canvas, 400.0, 300.0, grabcut::OBJECT_AT_WORK_PX).is_none());
}
// ---------- Select Subject on the cases reported in #2788 ----------
/// A figure whose upper part is close to the background colour (a cream shirt on a near-white
-24
View File
@@ -1,28 +1,4 @@
use super::*;
#[test]
#[ignore = "release Object Finder timing on a 24 MP document"]
#[allow(clippy::assertions_on_constants)]
fn object_finder_large_foreground_release_timing() {
assert!(!cfg!(debug_assertions), "run with --release");
let (w, h) = (6000, 4000);
let img = RgbImage::from_fn(w, h, |x, y| if (300..5700).contains(&x) && (200..3800).contains(&y) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] });
let sampler = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, w as i32, h as i32);
for (name, x, y) in [("foreground", 3000.0, 2000.0), ("background", 10.0, 10.0)] {
let start = std::time::Instant::now();
let region = grabcut::object_at(&sampler, canvas, x, y, grabcut::OBJECT_AT_WORK_PX);
println!("OBJECT_FINDER {w}x{h} {name}: {:.1} ms", start.elapsed().as_secs_f64() * 1000.0);
if name == "foreground" {
let region = region.unwrap();
assert!(region.at(3000, 2000) > 0.9 && region.at(310, 210) > 0.9);
assert!(region.at(10, 10) < 0.1);
} else {
assert!(region.is_none());
}
}
}
#[test]
#[ignore = "release comparison of actual Quick Selection strokes on 24-36 MP documents"]
#[allow(clippy::assertions_on_constants)]
-173
View File
@@ -1,173 +0,0 @@
//! The pre-#7921 Smart Blur (every neighbour read per channel through `Image::get`) stays as an
//! exact oracle: the offset fast path must give the same bits for every mode, depth and layout.
use super::*;
use photocraft_color::ColorMode;
#[allow(clippy::too_many_arguments)]
fn smart_blur_oracle(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold: f32, quality: BlurQuality, mode: SmartBlurMode) -> Vec<f32> {
let n = src.ch;
let cc = ncol(ctx, n);
let r = radius.clamp(0.1, 100.0);
let t = threshold.clamp(0.1, 100.0) / 255.0;
let ri = r.ceil() as i32;
let target = match quality {
BlurQuality::Low => 4,
BlurQuality::Medium => 7,
BlurQuality::High => 12,
};
let stride = ((ri + target - 1) / target).max(1);
let offs: Vec<(i32, i32)> = (-ri..=ri)
.step_by(stride as usize)
.flat_map(|dy| (-ri..=ri).step_by(stride as usize).map(move |dx| (dx, dy)))
.filter(|(dx, dy)| ((dx * dx + dy * dy) as f32) <= r * r + 0.5)
.collect();
let white = native(ctx, [1.0, 1.0, 1.0, 1.0]);
let black = native(ctx, [0.0, 0.0, 0.0, 1.0]);
let mut res = src.crop(out);
let mut q = [0.0f32; MAXC];
let mut acc = [0.0f32; MAXC];
for (i, px) in res.chunks_exact_mut(n).enumerate() {
let (x, y) = xy(out, i);
let me: [f32; MAXC] = std::array::from_fn(|c| if c < n { px[c] } else { 0.0 });
let differs = |q: &[f32]| (0..cc).any(|c| (q[c] - me[c]).abs() > t);
if mode != SmartBlurMode::Normal {
let mut edge = false;
for (dx, dy) in [(1, 0), (0, 1), (-1, 0), (0, -1)] {
for (c, v) in q.iter_mut().enumerate().take(n) {
*v = src.get(x + dx, y + dy, c);
}
if differs(&q[..n]) {
edge = true;
break;
}
}
let a = if ctx.alpha { px[n - 1] } else { 1.0 };
if edge {
px[..cc].copy_from_slice(&white[..cc]);
} else if mode == SmartBlurMode::EdgeOnly {
px[..cc].copy_from_slice(&black[..cc]);
}
if ctx.alpha {
px[n - 1] = a;
}
continue;
}
acc[..n].fill(0.0);
let mut wsum = 0.0f32;
for &(dx, dy) in &offs {
for (c, v) in q.iter_mut().enumerate().take(n) {
*v = src.get(x + dx, y + dy, c);
}
if differs(&q[..n]) {
continue;
}
let a = if ctx.alpha { q[n - 1] } else { 1.0 };
for c in 0..n {
acc[c] += if ctx.alpha && c < n - 1 { q[c] * a } else { q[c] };
}
wsum += 1.0;
}
if wsum > 0.0 {
for c in 0..n {
px[c] = acc[c] / wsum;
}
unpremul_px(px, ctx.alpha);
}
}
res
}
/// Flat blocks with small noise, so some neighbours fall within the threshold and some don't.
fn image(rect: Rect, n: usize, alpha: bool) -> Image {
let w = rect.width() as usize;
let data = (0..w * rect.height() as usize)
.flat_map(|i| {
let (x, y) = (i % w, i / w);
let block = ((x / 5 + y / 7) % 3) as f32 * 0.3;
(0..n).map(move |c| {
let noise = ((i * 31 + c * 13) % 17) as f32 / 160.0;
if alpha && c + 1 == n { [1.0, 0.0, 0.4, 0.75][(x + y) % 4] } else { block + noise }
})
})
.collect();
Image { rect, ch: n, data }
}
fn exact(a: &[f32], b: &[f32], what: &str) {
assert_eq!(a.len(), b.len(), "{what}");
for (i, (a, b)) in a.iter().zip(b).enumerate() {
assert!(a.to_bits() == b.to_bits() || (a.is_nan() && b.is_nan()), "{what}: sample {i}: {a} != {b}");
}
}
#[test]
fn offset_fast_path_matches_the_per_channel_oracle() {
let mut layouts: Vec<(ColorMode, usize, bool)> = vec![
(ColorMode::Rgb, 3, false),
(ColorMode::Rgb, 4, true),
(ColorMode::Grayscale, 1, false),
(ColorMode::Grayscale, 2, true),
(ColorMode::Cmyk, 4, false),
(ColorMode::Cmyk, 5, true),
(ColorMode::Lab, 4, true),
];
layouts.extend((1..=MAXC).flat_map(|n| [(ColorMode::Multichannel, n, false), (ColorMode::Multichannel, n, true)]));
let win = Rect::new(-7, -4, 33, 30);
for (mode, n, alpha) in layouts {
let src = image(win, n, alpha);
let ctx = Ctx { bounds: win, mode, alpha };
for radius in [0.1f32, 1.0, 3.0, 12.0, 17.5] {
let ri = f32::ceil(radius) as i32;
// The usual tile (the window less the halo), the whole window, and one sticking out of it.
let inner = Rect::new(win.x0 + ri, win.y0 + ri, win.x1 - ri, win.y1 - ri);
for out in [inner, win, Rect::new(-12, -9, 10, 8), Rect::EMPTY] {
for quality in [BlurQuality::Low, BlurQuality::Medium, BlurQuality::High] {
for sm in [SmartBlurMode::Normal, SmartBlurMode::EdgeOnly, SmartBlurMode::OverlayEdge] {
for threshold in [8.0, 60.0] {
let what = format!("{mode:?} n={n} alpha={alpha} r={radius} {out:?} {quality:?} {sm:?} t={threshold}");
exact(
&smart_blur(&src, out, &ctx, radius, threshold, quality, sm),
&smart_blur_oracle(&src, out, &ctx, radius, threshold, quality, sm),
&what,
);
}
}
}
}
}
}
}
#[test]
fn out_of_window_neighbours_read_as_zero() {
// A one-pixel window: every neighbour is outside and reads as transparent black, so a black
// centre averages with all of them and a white one keeps itself.
let ctx = Ctx { bounds: Rect::new(0, 0, 1, 1), mode: ColorMode::Rgb, alpha: true };
for v in [0.0, 1.0] {
let src = Image { rect: Rect::new(0, 0, 1, 1), ch: 4, data: vec![v, v, v, 1.0] };
let got = smart_blur(&src, src.rect, &ctx, 3.0, 25.0, BlurQuality::High, SmartBlurMode::Normal);
exact(&got, &smart_blur_oracle(&src, src.rect, &ctx, 3.0, 25.0, BlurQuality::High, SmartBlurMode::Normal), "1 px");
}
}
#[test]
fn nan_and_infinite_samples_match_the_oracle() {
// A NaN difference is never over the threshold, so it is averaged in; an infinite one is.
let win = Rect::new(0, 0, 24, 20);
for (n, alpha) in [(3, false), (4, true)] {
let mut src = image(win, n, alpha);
for (i, v) in [(5, f32::NAN), (97, f32::INFINITY), (230, f32::NEG_INFINITY), (311, -0.0)] {
let len = src.data.len();
src.data[(i * n) % len] = v;
}
let ctx = Ctx { bounds: win, mode: ColorMode::Rgb, alpha };
let out = Rect::new(4, 4, 20, 16);
for threshold in [8.0, 100.0] {
exact(
&smart_blur(&src, out, &ctx, 4.0, threshold, BlurQuality::High, SmartBlurMode::Normal),
&smart_blur_oracle(&src, out, &ctx, 4.0, threshold, BlurQuality::High, SmartBlurMode::Normal),
&format!("n={n} t={threshold}"),
);
}
}
}
+23 -81
View File
@@ -254,21 +254,6 @@ pub fn warp_output_size(src_rect: Rect, h: &Homography) -> Option<(u64, u64)> {
Some((((x1 - x0) as u64).saturating_add(2), ((y1 - y0) as u64).saturating_add(2)))
}
/// The farthest from the origin (px) [`warp_surface`] places a pixel: each side of
/// [`warp_output_bounds`] is clamped to ±2²⁰.
pub const WARP_REACH: i32 = 1 << 20;
/// Would [`warp_surface`] clip the result of warping `src_rect` by `h`: does a warped corner land
/// past [`WARP_REACH`], whether the result is that large or only placed that far out? A caller
/// refuses such a warp rather than lose the pixels beyond it. Empty and singular inputs have no
/// output to clip.
pub fn warp_output_clipped(src_rect: Rect, h: &Homography) -> bool {
let Some(corners) = warp_corners(src_rect, h) else { return false };
let lim = f64::from(WARP_REACH);
let within = |v: f64| v.is_finite() && v.floor() >= -lim && v.ceil() <= lim;
!corners.iter().all(|&(x, y)| within(x) && within(y))
}
fn warp_corners(src_rect: Rect, h: &Homography) -> Option<[(f64, f64); 4]> {
if src_rect.is_empty() || h.inverse().is_none() {
return None;
@@ -279,13 +264,11 @@ fn warp_corners(src_rect: Rect, h: &Homography) -> Option<[(f64, f64); 4]> {
)
}
/// The pixel rectangle over the warped `corners` plus a pixel for filter support, each side
/// clamped to [`WARP_REACH`]; `None` when a corner is not finite.
fn corner_bounds(corners: &[(f64, f64); 4]) -> Option<Rect> {
if !corners.iter().all(|c| c.0.is_finite() && c.1.is_finite()) {
return None;
}
let lim = f64::from(WARP_REACH);
let lim = (1 << 20) as f64;
let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1;
let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1;
let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim) as i32 + 1;
@@ -311,7 +294,7 @@ fn warp(
converted = src.try_convert(fmt)?;
&converted
};
let out = Surface::new(fmt);
let mut out = Surface::new(fmt);
if src_rect.is_empty() || clip.is_some_and(|c| c.is_empty()) {
return Ok(out);
}
@@ -383,7 +366,7 @@ fn warp(
return Ok(None);
}
let k = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
// Short of 100 %: the result is still pruned and handed back after the last tile.
// The last few percent are the writes.
ctl.progress(0.98 * k as f32 / total as f32);
// Source footprint of this tile (inverse-mapped corners of its sampled part), padded for the filter.
let tc = [(t.x0, t.y0), (t.x1, t.y0), (t.x1, t.y1), (t.x0, t.y1)].map(|(x, y)| (x as f64, y as f64));
@@ -512,40 +495,41 @@ fn warp(
any = true;
let o = ((y - t.y0) as usize * w + (x - t.x0) as usize) * n;
for c in 0..a {
// Un-premultiply by the unclamped alpha: bicubic overshoots it past 1 next to
// partly transparent pixels, and dividing by the clamped 1 would lighten the
// colour by the overshoot (#6203). Integer storage clips on write; float
// colours retain their signed/HDR range.
outp[o + c] = (acc[c] / acc[a]) as f32;
// Integer storage clips on write; float colours retain their signed/HDR range.
outp[o + c] = (acc[c] / al) as f32;
}
outp[o + a] = al as f32;
}
}
Ok(any.then_some((*t, outp)))
};
// Each tile is written as soon as it is warped, so only the tiles in flight hold their `f32`
// pixels: kept until the last one was done, they cost 16 bytes a pixel of an RGBA result on
// top of the result's own (#7807).
let out = std::sync::Mutex::new(out);
let warp_and_write = |tile: &Rect| -> Result<(), photocraft_raster::AllocationError> {
if let Some((r, v)) = work(tile)? {
out.lock().unwrap_or_else(std::sync::PoisonError::into_inner).try_write_region(r, &v)?;
}
Ok(())
};
let result_bytes = tiles.len().checked_mul(std::mem::size_of::<(Rect, Vec<f32>)>()).ok_or(photocraft_raster::AllocationError::SizeOverflow)?;
let mut done = Vec::new();
done.try_reserve_exact(tiles.len()).map_err(|_| photocraft_raster::AllocationError::NotEnoughMemory { bytes: result_bytes })?;
let done = std::sync::Mutex::new(done);
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
tiles.into_par_iter().try_for_each(|tile| warp_and_write(&tile))?;
tiles.into_par_iter().try_for_each(|tile| -> Result<(), photocraft_raster::AllocationError> {
if let Some(result) = work(&tile)? {
done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(result);
}
Ok(())
})?;
}
#[cfg(target_arch = "wasm32")]
for tile in tiles {
warp_and_write(&tile)?;
if let Some(result) = work(&tile)? {
done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(result);
}
}
let done = done.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
if ctl.cancelled() {
return Ok(Surface::new(fmt));
return Ok(out);
}
for (r, v) in done {
out.try_write_region(r, &v)?;
}
let mut out = out.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
out.prune();
Ok(out)
}
@@ -567,8 +551,6 @@ mod tests {
}
}
/// The unclipped size matches the clipped bounds where nothing is clipped, saturates when it
/// can't be represented, and is `None` for empty or singular inputs.
#[test]
fn output_size_saturates_overflows_and_preserves_filter_support() {
let src = Rect::new(0, 0, 24, 16);
@@ -581,20 +563,6 @@ mod tests {
assert_eq!(warp_output_size(src, &Homography([0.0; 9])), None);
}
/// [`warp_output_clipped`] says yes exactly when [`warp_output_bounds`] clamps a side (#7807).
#[test]
fn output_clipped_matches_the_clamped_bounds() {
let src = Rect::new(0, 0, 24, 16);
let at = |dx: f64| Homography([1.0, 0.0, dx, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
let edge = f64::from(WARP_REACH) - 24.0;
assert!(!warp_output_clipped(src, &at(edge)), "the right edge lands on the reach");
assert_eq!(warp_output_bounds(src, &at(edge)).map(|b| b.x1), Some(WARP_REACH + 1));
assert!(warp_output_clipped(src, &at(edge + 1.0)), "one pixel past it");
assert_eq!(warp_output_bounds(src, &at(edge + 1.0)).map(|b| b.x1), Some(WARP_REACH + 1), "clamped");
assert!(warp_output_clipped(src, &at(-f64::from(WARP_REACH) - 1.0)));
assert!(!warp_output_clipped(Rect::EMPTY, &at(1e12)), "nothing to clip");
}
#[test]
fn the_interruptible_warp_reports_progress_and_stops_when_cancelled() {
let mut s = Surface::new(PixelFormat::RGBA8);
@@ -840,32 +808,6 @@ mod tests {
assert!((p[0] - 0.2).abs() < 0.01 && p[3] > 0.99, "{p:?}");
}
/// #6203: a bicubic rotation of a sprite whose edge pixels are partly transparent overshoots
/// its alpha past 1. The colour must stay the sprite's, not grow with the overshoot into a
/// light border.
#[test]
fn rotation_keeps_the_colour_of_a_soft_edged_sprite() {
let mut s = rgba();
s.fill_rect(Rect::new(10, 10, 30, 30), &[0.8, 0.6, 0.4, 0.5]);
s.fill_rect(Rect::new(11, 11, 29, 29), &[0.8, 0.6, 0.4, 1.0]);
let (c, k) = (20.0, std::f64::consts::FRAC_1_SQRT_2 * 10.0);
let q = [[c, c - 2.0 * k], [c + 2.0 * k, c], [c, c + 2.0 * k], [c - 2.0 * k, c]];
let h = Homography::rect_to_quad([10.0, 10.0, 30.0, 30.0], q).unwrap();
for interp in [Interp::Bicubic, Interp::Bilinear] {
let out = warp_surface(&s, s.content_bounds(), &h, interp).unwrap();
let b = out.content_bounds();
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
let p = out.pixel(x, y);
if p[3] > 0.0 {
let off = (0..3).map(|i| (p[i] - [0.8, 0.6, 0.4][i]).abs()).fold(0.0f32, f32::max);
assert!(off <= 1.0 / 255.0, "{interp:?} ({x}, {y}): {p:?}");
}
}
}
}
}
#[test]
fn large_downscale_is_prefiltered() {
// A 1px checkerboard shrunk 8× must come out ~50% grey, not aliased black/white.
+12 -106
View File
@@ -345,41 +345,21 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
let src_copy = surf.clone();
let fmt = surf.format();
let n = fmt.channels();
// Scratch for `sample`: the dab's source pixels, the four bilinear corners, the result.
let mut win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() };
let (mut corners, mut s) = (vec![0.0f32; 4 * n], vec![0.0f32; n]);
let mut dabs = 0;
for p in points {
let mc = metric(&hd_inv, lud, lvd, *p);
// Image-space footprint: map a metric circle's bounding points.
let footprint = |h: &Homography, lu: f64, lv: f64, c: [f64; 2]| {
let mut bb = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
for k in 0..16 {
let a = k as f64 / 16.0 * std::f64::consts::TAU;
let (x, y) = h.apply((c[0] + r_m * a.cos()) / lu, (c[1] + r_m * a.sin()) / lv);
bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x), bb[3].max(y)];
}
Rect::new(bb[0].floor() as i32, bb[1].floor() as i32, (bb[2].ceil() as i32).saturating_add(1), (bb[3].ceil() as i32).saturating_add(1))
};
let area = footprint(&hd, lud, lvd, mc);
let mut bb = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
for k in 0..16 {
let a = k as f64 / 16.0 * std::f64::consts::TAU;
let (u, v) = ((mc[0] + r_m * a.cos()) / lud, (mc[1] + r_m * a.sin()) / lvd);
let (x, y) = hd.apply(u, v);
bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x), bb[3].max(y)];
}
let area = Rect::new(bb[0].floor() as i32, bb[1].floor() as i32, (bb[2].ceil() as i32).saturating_add(1), (bb[3].ceil() as i32).saturating_add(1));
if area.is_empty() || area.width() > 4096 || area.height() > 4096 {
continue;
}
// The source footprint, read once; `sample` reads any pixel outside it from the surface.
// Skipped when it dwarfs the destination (cloning from near to far in strong perspective):
// decoding it would cost more than the few per-pixel reads it saves.
win.area = footprint(&hs, lus, lvs, [mc[0] + offset[0], mc[1] + offset[1]]).inflate(2);
let dest_px = u64::from(area.width()) * u64::from(area.height());
if win.area.is_empty()
|| win.area.width() > 4096
|| win.area.height() > 4096
|| u64::from(win.area.width()) * u64::from(win.area.height()) > 4 * dest_px
{
win.area = Rect::new(0, 0, 0, 0);
win.data.clear();
} else {
src_copy.read_region_into(win.area, &mut win.data);
}
let mut data = surf.read_region(area);
let w = area.width() as usize;
let mut changed = false;
@@ -396,7 +376,7 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
// Source pixel at the same metric offset.
let sm = [m[0] + offset[0], m[1] + offset[1]];
let (sx, sy) = hs.apply(sm[0] / lus, sm[1] / lvs);
sample(&src_copy, &win, sx - 0.5, sy - 0.5, &mut corners, &mut s);
let s = sample(&src_copy, sx - 0.5, sy - 0.5, n);
for c in 0..n {
px[c] += (s[c] - px[c]) * k;
}
@@ -410,42 +390,11 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
dabs
}
/// Pixels of `area` read from the source surface (`read_region` layout).
struct Window {
area: Rect,
data: Vec<f32>,
}
/// Bilinear sample of `s` at (`x`, `y`) into `out` (one pixel), with `corners` (resized to four
/// pixels) as reused scratch. A footprint inside `win` is read from it, any other with one region
/// read from `s`.
fn sample(s: &Surface, win: &Window, x: f64, y: f64, corners: &mut Vec<f32>, out: &mut [f32]) {
let n = out.len();
fn sample(s: &Surface, x: f64, y: f64, n: usize) -> Vec<f32> {
let (x0, y0) = (x.floor() as i32, y.floor() as i32);
let (tx, ty) = ((x - x0 as f64) as f32, (y - y0 as f64) as f32);
let (x1, y1) = (x0.saturating_add(1), y0.saturating_add(1));
let foot = Rect::new(x0, y0, x1.saturating_add(1), y1.saturating_add(1));
if !win.area.contains_rect(&foot) && foot.width() == 2 && foot.height() == 2 {
// Outside the window (or none): one region read looks the tile up once, where four
// `read_pixel` calls would look it up four times. Same corner order as below.
s.read_region_into(foot, corners);
} else {
corners.resize(4 * n, 0.0);
let w = win.area.width() as usize;
for (k, (px, py)) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)].into_iter().enumerate() {
let dst = &mut corners[k * n..(k + 1) * n];
if win.area.contains(px, py) {
let o = ((py - win.area.y0) as usize * w + (px - win.area.x0) as usize) * n;
dst.copy_from_slice(&win.data[o..o + n]);
} else {
s.read_pixel(px, py, dst);
}
}
}
let r = corners;
for (c, o) in out.iter_mut().enumerate() {
*o = (r[c] * (1.0 - tx) + r[n + c] * tx) * (1.0 - ty) + (r[2 * n + c] * (1.0 - tx) + r[3 * n + c] * tx) * ty;
}
let r = s.read_region(Rect::new(x0, y0, x0 + 2, y0 + 2));
(0..n).map(|c| (r[c] * (1.0 - tx) + r[n + c] * tx) * (1.0 - ty) + (r[2 * n + c] * (1.0 - tx) + r[3 * n + c] * tx) * ty).collect()
}
#[cfg(test)]
@@ -538,47 +487,4 @@ mod tests {
assert_eq!(n, 1);
assert!(s.rgba(dx as i32, dy as i32)[0] > 0.8, "{:?}", s.rgba(dx as i32, dy as i32));
}
#[test]
fn sample_at_the_edge_of_i32_does_not_overflow() {
let s = Surface::new(PixelFormat::RGBA8);
let win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() };
let (mut corners, mut out) = (Vec::new(), [1.0; 4]);
sample(&s, &win, 3e9, 3e9, &mut corners, &mut out);
assert_eq!(out, [0.0; 4]);
}
#[test]
fn sample_interpolates_across_tile_edges() {
let mut s = Surface::new(PixelFormat::RGBA8);
let (a, b) = ([1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]);
// Left of x = 256 is red, right is blue; the 2×2 footprint straddles the tile edge.
s.fill_rect(Rect::new(0, 0, 256, 512), &a);
s.fill_rect(Rect::new(256, 0, 512, 512), &b);
let (mut corners, mut out) = (Vec::new(), [0.0; 4]);
let want = [0.75, 0.0, 0.25, 1.0];
// From the surface, from a window holding the footprint, and from one holding half of it.
for area in [Rect::new(0, 0, 0, 0), Rect::new(250, 250, 260, 260), Rect::new(250, 250, 256, 260)] {
let mut win = Window { area, data: Vec::new() };
s.read_region_into(area, &mut win.data);
sample(&s, &win, 255.25, 255.5, &mut corners, &mut out);
assert!(out.iter().zip(want).all(|(o, w)| (o - w).abs() < 1e-6), "{area:?}: {out:?}");
}
// Four different corners, one per tile, so both axes of the corner order count.
let mut q = Surface::new(PixelFormat::RGBA8);
for (x, y, px) in [(255, 255, [1.0, 0.0, 0.0, 1.0]), (256, 255, [0.0, 1.0, 0.0, 1.0]), (255, 256, [0.0, 0.0, 1.0, 1.0]), (256, 256, [1.0; 4])] {
q.fill_rect(Rect::new(x, y, x + 1, y + 1), &px);
}
let want = [0.375, 0.25, 0.75, 1.0];
for area in [Rect::new(0, 0, 0, 0), Rect::new(250, 250, 260, 260)] {
let mut win = Window { area, data: Vec::new() };
q.read_region_into(area, &mut win.data);
sample(&q, &win, 255.25, 255.75, &mut corners, &mut out);
assert!(out.iter().zip(want).all(|(o, w)| (o - w).abs() < 1e-6), "{area:?}: {out:?}");
}
// Outside every tile, the default (transparent) pixel.
let win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() };
sample(&s, &win, -10.5, -10.5, &mut corners, &mut out);
assert_eq!(out, [0.0; 4]);
}
}
-159
View File
@@ -88,162 +88,3 @@ pub(crate) fn ntsc(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
}
res
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{FilterParams, apply};
use photocraft_color::{PixelFormat, SampleType};
use photocraft_raster::Surface;
const DEPTHS: [SampleType; 3] = [SampleType::U8, SampleType::U16, SampleType::F32];
fn tolerance(depth: SampleType) -> f32 {
match depth {
SampleType::U8 => 1.0 / 255.0,
SampleType::U16 => 1.0 / 65535.0,
SampleType::F32 => 1e-6,
}
}
fn rows(depth: SampleType, bounds: Rect, pixels: &[[f32; 4]]) -> Surface {
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, depth, true));
for (row, px) in pixels.iter().enumerate() {
let y = bounds.y0 + row as i32;
src.fill_rect(Rect::new(bounds.x0, y, bounds.x1, y + 1), px);
}
src
}
#[test]
fn deinterlace_all_field_and_interpolation_choices_use_bounds_relative_rows() {
// Odd document origin: parity is relative to the bounds, not absolute y.
let b = Rect::new(-2, 11, 0, 16);
let pixels = [0.0, 0.2, 0.6, 0.8, 1.0].map(|v| [v, 1.0 - v, v / 2.0, 1.0]);
for depth in DEPTHS {
let src = rows(depth, b, &pixels);
let before = src.read_region(b);
for (eliminate_even, interpolate, neighbours) in [
(false, false, [(1, 1), (1, 1), (1, 1), (3, 3), (3, 3)]),
(false, true, [(1, 1), (1, 1), (1, 3), (3, 3), (3, 3)]),
(true, false, [(0, 0), (0, 0), (2, 2), (2, 2), (4, 4)]),
(true, true, [(0, 0), (0, 2), (2, 2), (2, 4), (4, 4)]),
] {
let out = apply(&src, &FilterParams::DeInterlace { eliminate_even, interpolate }, b, b, None);
for (row, (a, c)) in neighbours.into_iter().enumerate() {
for x in b.x0..b.x1 {
let (pa, pc) = (src.pixel(x, b.y0 + a), src.pixel(x, b.y0 + c));
let actual = out.pixel(x, b.y0 + row as i32);
for channel in 0..4 {
let expected = (pa[channel] + pc[channel]) / 2.0;
assert!(
(actual[channel] - expected).abs() <= tolerance(depth),
"{depth:?}, even={eliminate_even}, interp={interpolate}, row={row}: {actual:?}"
);
}
}
}
}
assert_eq!(src.read_region(b), before);
}
}
#[test]
fn deinterlace_interpolation_weights_colour_by_alpha() {
let b = Rect::new(0, 0, 1, 3);
for depth in DEPTHS {
for [a, c] in [[0.25, 0.75], [1.0, 0.0], [0.0, 0.0]] {
let src = rows(depth, b, &[[1.0, 0.0, 0.0, a], [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, c]]);
let out = apply(&src, &FilterParams::DeInterlace { eliminate_even: true, interpolate: true }, b, b, None);
let (a, c) = (src.pixel(0, 0)[3], src.pixel(0, 2)[3]);
let total = a + c;
let expected = if total > 0.0 { [a / total, 0.0, c / total, total / 2.0] } else { [0.0; 4] };
let actual = out.pixel(0, 1);
for (v, want) in actual.iter().zip(expected) {
assert!((v - want).abs() <= tolerance(depth), "{depth:?}: {actual:?} != {expected:?}");
}
assert_eq!(out.pixel(0, 0), src.pixel(0, 0));
assert_eq!(out.pixel(0, 2), src.pixel(0, 2));
}
}
}
#[test]
fn deinterlace_one_and_two_row_images_without_alpha_handle_missing_neighbours() {
for depth in DEPTHS {
for height in [1, 2] {
let b = Rect::new(0, -3, 1, -3 + height);
let mut src = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false));
src.fill_rect(b, &[0.2]);
if height == 2 {
src.fill_rect(Rect::new(0, -2, 1, -1), &[0.8]);
}
for eliminate_even in [false, true] {
for interpolate in [false, true] {
let out = apply(&src, &FilterParams::DeInterlace { eliminate_even, interpolate }, b, b, None);
let kept_y = if height == 2 && !eliminate_even { -2 } else { -3 };
for y in b.y0..b.y1 {
assert_eq!(out.pixel(0, y), src.pixel(0, kept_y), "{depth:?}, height={height}, even={eliminate_even}, interp={interpolate}");
}
}
}
}
}
}
#[test]
fn deinterlace_leaves_pixels_outside_reference_bounds_unchanged() {
let area = Rect::new(0, 0, 1, 5);
let b = Rect::new(0, 1, 1, 4);
let src = rows(SampleType::F32, area, &[[0.1; 4], [0.2; 4], [0.4; 4], [0.8; 4], [1.0; 4]]);
for eliminate_even in [false, true] {
for interpolate in [false, true] {
let out = apply(&src, &FilterParams::DeInterlace { eliminate_even, interpolate }, area, b, None);
assert_eq!(out.pixel(0, 0), src.pixel(0, 0));
assert_eq!(out.pixel(0, 4), src.pixel(0, 4));
}
}
}
#[test]
fn ntsc_safe_colours_and_alpha_are_bit_exact_at_all_depths() {
let b = Rect::new(0, 0, 1, 5);
for depth in DEPTHS {
let src = rows(depth, b, &[[0.0, 0.0, 0.0, 0.0], [0.5, 0.5, 0.5, 0.25], [1.0, 1.0, 1.0, 0.75], [0.2, 0.3, 0.4, 1.0], [1.0, 0.0, 1.0, 1.0]]);
let out = apply(&src, &FilterParams::NtscColors, b, b, None);
assert_eq!(out.read_region(b), src.read_region(b), "{depth:?}");
}
}
fn luma_chroma(p: &[f32]) -> (f32, f32) {
let y = 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2];
let i = 0.595716 * p[0] - 0.274453 * p[1] - 0.321263 * p[2];
let q = 0.211456 * p[0] - 0.522591 * p[1] + 0.311135 * p[2];
(y, i.hypot(q))
}
#[test]
fn ntsc_saturated_colours_keep_luma_alpha_and_meet_composite_limits() {
// Independent signal checks: -20..120 IRE relative to the 7.5..100 IRE luma range.
let (lo, hi) = ((-20.0 - 7.5) / 92.5, (120.0 - 7.5) / 92.5);
let colours = [[1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.25], [1.0, 1.0, 0.0, 0.5], [0.0, 1.0, 1.0, 0.75]];
let b = Rect::new(0, 0, 1, colours.len() as i32);
for depth in DEPTHS {
let src = rows(depth, b, &colours);
let out = apply(&src, &FilterParams::NtscColors, b, b, None);
// The rounded YIQ inverse contributes < 0.0003; integer writes add one sample step.
let eps = 0.0003 + 2.0 * tolerance(depth);
for y in b.y0..b.y1 {
let (before, after) = (src.pixel(0, y), out.pixel(0, y));
let (y0, c0) = luma_chroma(&before);
let (y1, c1) = luma_chroma(&after);
assert!(y0 - c0 < lo || y0 + c0 > hi, "fixture must require correction");
assert!(c1 < c0, "chroma not reduced: {depth:?} {after:?}");
assert!((y1 - y0).abs() <= eps, "luma changed: {depth:?} {before:?} -> {after:?}");
assert!(y1 - c1 >= lo - eps && y1 + c1 <= hi + eps, "signal out of gamut: {depth:?} {after:?}");
assert!(after[..3].iter().all(|v| v.is_finite() && *v >= 0.0 && *v <= 1.0));
assert_eq!(after[3], before[3], "alpha changed: {depth:?}");
}
}
}
}
+3 -76
View File
@@ -15,7 +15,7 @@
use photocraft_color::PixelFormat;
use photocraft_geom::{Rect, TileCoord};
use photocraft_raster::{Interrupt, Surface, Tile};
use photocraft_raster::{Surface, Tile};
use std::sync::Arc;
use crate::transform::{Interp, bicubic};
@@ -75,16 +75,6 @@ pub fn place_source_projective(
/// coordinates → destination document coordinates). Output has `src`'s format with alpha
/// added; pixels outside the warped area are transparent.
pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync), interp: Interp) -> Surface {
mesh(src, src_rect, f, interp, &Interrupt::NONE)
}
/// [`warp_mesh_surface`] reporting progress per destination tile; `None` once `ctl` is cancelled.
pub fn warp_mesh_surface_with(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync), interp: Interp, ctl: &Interrupt) -> Option<Surface> {
let out = mesh(src, src_rect, f, interp, ctl);
(!ctl.cancelled()).then_some(out)
}
fn mesh(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync), interp: Interp, ctl: &Interrupt) -> Surface {
if src_rect.is_empty() {
let fmt = src.format();
return Surface::new(PixelFormat::new(fmt.mode, fmt.sample, true));
@@ -114,7 +104,7 @@ fn mesh(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Syn
tris.push([a, a + w1 + 1, a + w1]);
}
}
triangles(src, src_rect, &verts, &tris, interp, ctl)
warp_triangles(src, src_rect, &verts, &tris, interp)
}
/// Probe lines per axis [`mapped_lengths`] follows through the map.
@@ -159,12 +149,6 @@ fn mapped_lengths(src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync)) -
/// interpolation of its source positions. Later triangles in `tris` draw over earlier ones (the
/// order is the depth order). Output has `src`'s format with alpha added.
pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris: &[[usize; 3]], interp: Interp) -> Surface {
triangles(src, src_rect, verts, tris, interp, &Interrupt::NONE)
}
/// [`warp_triangles`], reporting progress per destination tile to `ctl`. A cancelled `ctl` skips
/// the remaining tiles; the caller discards the result.
fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris: &[[usize; 3]], interp: Interp, ctl: &Interrupt) -> Surface {
let mut fmt = src.format();
let converted;
let src = if fmt.alpha {
@@ -224,16 +208,7 @@ fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris
}
let n = fmt.channels();
let a = n - 1;
// Tiles finished so far, for progress reported per tile (from any worker thread).
let finished = std::sync::atomic::AtomicUsize::new(0);
let total = tiles.len().max(1);
let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(TileCoord, Arc<Tile>)> {
if ctl.cancelled() {
return None;
}
let k = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
// The last few percent are putting the tiles in place.
ctl.progress(0.98 * k as f32 / total as f32);
if tris.is_empty() {
return None;
}
@@ -431,8 +406,7 @@ fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris
any = true;
let b = o * n;
for c in 0..a {
// The unclamped alpha, so a bicubic overshoot past 1 doesn't lighten the colour (#6203).
outp[b + c] = (acc[c] / acc[a]).clamp(0.0, 1.0) as f32;
outp[b + c] = (acc[c] / al).clamp(0.0, 1.0) as f32;
}
outp[b + a] = al as f32;
}
@@ -456,9 +430,6 @@ fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris
};
#[cfg(target_arch = "wasm32")]
let done: Vec<(TileCoord, Arc<Tile>)> = tiles.iter().zip(bins.iter()).filter_map(work).collect();
if ctl.cancelled() {
return out;
}
out.put_tiles(done);
out
}
@@ -532,28 +503,6 @@ mod tests {
assert!(alpha(100, 55) > 0.99 && alpha(100, 20) < 0.01, "third {} {}", alpha(100, 55), alpha(100, 20));
}
#[test]
fn the_interruptible_mesh_warp_reports_progress_and_stops_when_cancelled() {
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(Rect::new(0, 0, 600, 400), &[0.2, 0.4, 0.6, 1.0]);
let r = s.content_bounds();
let w = Warp::preset(WarpStyle::Arc, 30.0, [0.0, 0.0, 600.0, 400.0]);
let map = |x: f64, y: f64| w.map(x, y);
let reported = std::sync::Mutex::new(0.0f32);
let progress = |p: f32| {
let mut m = reported.lock().unwrap();
*m = m.max(p);
};
let never = || false;
let out = warp_mesh_surface_with(&s, r, &map, Interp::Bicubic, &Interrupt::new(&never, &progress)).expect("not cancelled");
let want = warp_mesh_surface(&s, r, &map, Interp::Bicubic);
assert_eq!(out.content_bounds(), want.content_bounds());
assert_eq!(out.read_region(want.content_bounds()), want.read_region(want.content_bounds()));
assert!(*reported.lock().unwrap() > 0.9);
let always = || true;
assert!(warp_mesh_surface_with(&s, r, &map, Interp::Bicubic, &Interrupt::cancel_only(&always)).is_none());
}
#[test]
fn a_mesh_warp_far_off_canvas_writes_only_the_two_regions() {
// #995: the old and warped regions were cleared as one dense `src ∪ warped` buffer, about
@@ -589,28 +538,6 @@ mod tests {
}
}
#[test]
fn a_rotated_soft_edged_sprite_keeps_its_colour() {
// #6203, as in Free Transform: the bicubic alpha overshoots past 1 next to the half
// transparent edge, and must not lighten the colour.
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true));
src.fill_rect(Rect::new(10, 10, 30, 30), &[0.8, 0.6, 0.4, 0.5]);
src.fill_rect(Rect::new(11, 11, 29, 29), &[0.8, 0.6, 0.4, 1.0]);
let k = std::f64::consts::FRAC_1_SQRT_2;
let rotate = |x: f64, y: f64| (20.0 + (x - 20.0) * k - (y - 20.0) * k, 20.0 + (x - 20.0) * k + (y - 20.0) * k);
let out = warp_mesh_surface(&src, src.content_bounds(), &rotate, Interp::Bicubic);
let b = out.content_bounds();
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
let p = out.pixel(x, y);
if p[3] > 0.0 {
let off = (0..3).map(|i| (p[i] - [0.8, 0.6, 0.4][i]).abs()).fold(0.0f32, f32::max);
assert!(off <= 1.0 / 255.0, "({x}, {y}): {p:?}");
}
}
}
}
#[test]
fn translation_and_known_arc_points() {
let src = sample(SampleType::U8);
@@ -1,108 +0,0 @@
//! Liquify source reads must depend on the mapping, not the output tile phase.
use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, LiquifyTool, apply_liquify};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
/// Keeps the document size fixed while moving its edges relative to global tiles.
fn bounds(shift: i32) -> Rect {
Rect::new(shift, 0, shift + 1024, 512)
}
/// Builds translated copies of RGB ramps and alternating columns so missing
/// interpolation taps change the sampled colors.
fn source(sample: SampleType, shift: i32) -> Surface {
let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, false));
let mut row = vec![0.0; 1024 * 3];
for y in 0..512 {
for (x, pixel) in row.as_chunks_mut::<3>().0.iter_mut().enumerate() {
*pixel = [x as f32 / 1024.0, (x % 2) as f32, y as f32 / 512.0];
}
surface.write_region(Rect::new(shift, y, shift + 1024, y + 1), &row);
}
surface
}
/// Creates matching brush paths in document-relative coordinates, optionally
/// beside a frozen region that gives the displacement field a sharp boundary.
fn strokes(shift: i32, masked: bool) -> Vec<LiquifyStroke> {
let offset = f64::from(shift);
let mut strokes = Vec::new();
if masked {
let mut freeze = LiquifyStroke::new(LiquifyTool::LassoMask, 1.0);
freeze.amount = Some(1.0);
freeze.points = vec![vec![offset, 0.0], vec![offset + 508.0, 0.0], vec![offset + 508.0, 512.0], vec![offset, 512.0]];
strokes.push(freeze);
}
let mut warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 512.0);
warp.density = 100.0;
warp.points = vec![vec![offset + 512.0, 256.5], vec![offset + 499.2, 256.5]];
strokes.extend(std::iter::repeat_n(warp, 8));
for stroke in &strokes {
stroke.validate().expect("the fixture uses supported brush parameters");
}
strokes
}
/// Checks that a half-tile translation preserves editable output across sample
/// depths and mesh sizes when image content and brush paths move together.
fn check_tile_translation(masked: bool) {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let original = source(sample, 0);
let translated = source(sample, 128);
for cell in [2.0, 4.0] {
let field = LiquifyField::from_strokes(bounds(0), cell, &strokes(0, masked));
let shifted_field = LiquifyField::from_strokes(bounds(128), cell, &strokes(128, masked));
if masked {
assert!(field.d == shifted_field.d, "translation must preserve the nodal displacement values");
}
// The reported pixels lie entirely on the editable side of the mask.
assert_eq!(field.freeze_at(509.5, 220.5), 0.0);
assert_eq!(shifted_field.freeze_at(637.5, 220.5), 0.0);
let output = apply_liquify(&original, &field);
let shifted_output = apply_liquify(&translated, &shifted_field);
assert_ne!(output.pixel(509, 220), original.pixel(509, 220), "the fixture must actually warp the inspected strip");
let mut worst = 0.0f32;
// Translation changes which output tile contains this strip, while
// preserving the relative source image, brush path and document edges.
for y in 220..290 {
for x in 509..512 {
let a = output.pixel(x, y);
let b = shifted_output.pixel(x + 128, y);
for (a, b) in a.iter().zip(&b) {
worst = worst.max((a - b).abs());
}
}
}
// Coordinate translation can perturb the last few F32 arithmetic bits.
// This is far smaller than the clipped source-stencil error.
let tolerance = if sample == SampleType::F32 { 2e-6 } else { 0.0 };
assert!(worst <= tolerance, "{sample:?}, meshSize {cell}, masked={masked}: moving the document by half a tile changed output by {worst}");
}
}
}
#[test]
fn cubic_source_footprint_is_independent_of_output_tile_phase() {
check_tile_translation(true);
}
#[test]
fn ordinary_unmasked_warp_is_independent_of_output_tile_phase() {
check_tile_translation(false);
}
#[test]
fn source_reads_still_clamp_at_the_document_edge() {
let mut warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 256.0);
warp.density = 100.0;
warp.points = vec![vec![1008.0, 256.5], vec![944.0, 256.5]];
warp.validate().expect("the edge fixture uses supported brush parameters");
let field = LiquifyField::from_strokes(bounds(0), 4.0, &[warp]);
assert!(field.sample(1023.5, 256.5)[0] > 4.0, "the fixture must sample beyond the right document edge");
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let original = source(sample, 0);
let output = apply_liquify(&original, &field);
assert_eq!(output.pixel(1023, 256), original.pixel(1023, 256), "{sample:?}: an out-of-document source read must repeat the true document edge");
}
}
-5
View File
@@ -1,5 +0,0 @@
//! The crate's integration tests, linked into one test binary.
mod liquify_source_footprint;
mod retouch_blur_exact;
mod warp_clipped;
File renamed without changes.
+1 -2
View File
@@ -10,6 +10,7 @@ rust-version.workspace = true
workspace = true
[dependencies]
photocraft-geom = { workspace = true }
photocraft-doc = { workspace = true }
photocraft-compose = { workspace = true }
photocraft-codecs = { workspace = true }
@@ -27,8 +28,6 @@ getrandom = { workspace = true }
cap-std = "4"
[dev-dependencies]
# Synthetic camera raws (#7789).
photocraft-raw = { workspace = true, features = ["testgen"] }
rmcp = { version = "3.5", default-features = false, features = ["client", "server", "macros", "transport-io"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] }
+1 -6
View File
@@ -100,8 +100,6 @@ impl Headless {
files::open_bytes(name, &bytes)?
}
};
// Preferences › Raw Defaults › Resolution, as the app's File › Open applies it.
file_cmds::raw_defaults(&self.session, &o.warnings, &mut o.document);
let path = match file_cmds::template_name(&self.session, requested) {
Some(untitled) => {
o.document.name = untitled;
@@ -261,10 +259,7 @@ impl Headless {
files::save(&doc, &target, format, opts, Some(writer))?
}
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)?;
let (bytes, warnings) = files::save_bytes(&doc, target_text, format, opts)?;
workspace.write(target_text, &bytes)?;
warnings
}
-149
View File
@@ -351,19 +351,6 @@ mod tests {
Mutex::new(Headless::trusted_local())
}
#[test]
fn document_activation_reply_survives_engine_rpc() {
let h = session();
let mut g = h.lock().unwrap();
for _ in 0..2 {
g.handle("engine.execute", json!({"command": "file.new", "params": {"width": 4, "height": 4}})).unwrap();
}
let reply = g.handle("engine.execute", json!({"command": "document.activate", "params": {"offset": -1}})).unwrap();
assert_eq!(reply, json!({"document": 0}));
let reply = g.handle("engine.execute", json!({"command": "document.activate", "params": {"document": 1}})).unwrap();
assert_eq!(reply, json!({"document": 1}));
}
#[test]
fn execute_reports_invalid_filter_parameters_without_changing_history() {
let h = session();
@@ -836,142 +823,6 @@ mod tests {
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn doc_export_data_sets_judges_pixel_replacement_paths_like_apply() {
let base = std::env::temp_dir().join(format!("pc-doc-data-set-pixels-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let red = photocraft_codecs::Image::from_u8(4, 4, photocraft_codecs::ChannelLayout::Rgb, [255, 0, 0].repeat(16)).unwrap();
let photo = base.join("photo.png");
std::fs::write(&photo, photocraft_codecs::encode(&red, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white", "name": "card"})).unwrap();
let layer = h.handle("engine.execute", json!({"command": "layer.new.layer", "params": {}})).unwrap()["layer"].clone();
h.handle(
"engine.execute",
json!({"command": "image.variables.define", "params": {"defs": [{"name": "photo", "layer": layer, "type": "pixelReplacement"}]}}),
)
.unwrap();
// Storing the path is allowed; reading it is what the gate judges.
h.handle(
"engine.execute",
json!({"command": "image.variables.dataSets", "params": {"dataSets": [
{"name": "swap", "values": [{"variable": "photo", "kind": "pixels", "value": photo.to_string_lossy()}]},
]}}),
)
.unwrap();
let applied = h.handle("engine.execute", json!({"command": "image.applyDataSet", "params": {"name": "swap"}})).unwrap_err().to_string();
assert!(applied.contains("ambient filesystem paths"), "{applied}");
let sets = root.join("sets");
let exported = h.handle("doc.exportDataSets", json!({"dir": sets.to_string_lossy(), "format": "png"})).unwrap_err().to_string();
assert!(exported.contains("ambient filesystem paths"), "{exported}");
assert_eq!(std::fs::read_dir(&sets).map_or(0, Iterator::count), 0, "nothing of the refused batch is written");
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn workspace_psd_saves_embed_no_file_a_linked_smart_object_names() {
use photocraft_engine::doc::{LayerContent, SmartSource};
const MARKER: &[u8] = b"PHOTOCRAFT-LINKED-0001";
let has_marker = |bytes: &[u8]| bytes.windows(MARKER.len()).any(|w| w == MARKER);
let base = std::env::temp_dir().join(format!("pc-psd-linked-embed-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let outside = base.join("outside.txt");
std::fs::write(&outside, MARKER).unwrap();
// A document whose smart object names a file outside the roots as its source.
let mut session = photocraft_engine::Session::new();
session.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let state = session.active_mut().unwrap();
let LayerContent::Smart(smart) = &mut std::sync::Arc::make_mut(&mut state.doc).layers[0].content else { panic!("smart object") };
smart.source = SmartSource::Linked { path: outside.to_string_lossy().into_owned() };
std::fs::write(root.join("in.pcraft"), photocraft_engine::smart_cmds::encode_source(&state.doc).unwrap()).unwrap();
// A trusted export embeds the linked file, as before.
assert!(has_marker(&photocraft_io::export(&state.doc, "psd", &Default::default()).unwrap().bytes));
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.open", json!({"path": "in.pcraft"})).unwrap();
let saved = h.handle("doc.save", json!({"path": "out.psd"})).unwrap();
let warnings = saved["warnings"].to_string();
assert!(warnings.contains("written as pixels"), "{warnings}");
assert!(!has_marker(&std::fs::read(root.join("out.psd")).unwrap()), "the linked file outside the roots is not embedded");
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn allowed_filters_do_not_rerender_a_smart_object_from_its_linked_file() {
use photocraft_engine::doc::{LayerContent, SmartSource};
let base = std::env::temp_dir().join(format!("pc-linked-rerender-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let red = photocraft_codecs::Image::from_u8(8, 8, photocraft_codecs::ChannelLayout::Rgb, [255, 0, 0].repeat(64)).unwrap();
let outside = base.join("red.png");
std::fs::write(&outside, photocraft_codecs::encode(&red, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
// A document whose white smart object names a file outside the roots as its source.
let mut session = photocraft_engine::Session::new();
session.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let state = session.active_mut().unwrap();
let LayerContent::Smart(smart) = &mut std::sync::Arc::make_mut(&mut state.doc).layers[0].content else { panic!("smart object") };
smart.source = SmartSource::Linked { path: outside.to_string_lossy().into_owned() };
std::fs::write(root.join("p.pcraft"), photocraft_engine::smart_cmds::encode_source(&state.doc).unwrap()).unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.open", json!({"path": "p.pcraft"})).unwrap();
h.handle("engine.execute", json!({"command": "filter.blur.gaussianBlur", "params": {"radius": 0.5}})).unwrap();
let doc = &h.session.active().unwrap().doc;
let LayerContent::Smart(smart) = &doc.layers[0].content else { panic!("smart object") };
assert_eq!(smart.smart_filters.len(), 1, "the filter is still recorded");
assert_eq!(smart.cache.as_ref().unwrap().rgba(4, 4), [1.0, 1.0, 1.0, 1.0], "the linked file outside the roots is not read");
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn workspace_layer_exports_keep_linked_smart_objects_as_pixels() {
use photocraft_engine::doc::{LayerContent, SmartSource};
const MARKER: &[u8] = b"PHOTOCRAFT-LINKED-0002";
let has_marker = |bytes: &[u8]| bytes.windows(MARKER.len()).any(|w| w == MARKER);
let base = std::env::temp_dir().join(format!("pc-export-layers-linked-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let outside = base.join("outside.txt");
std::fs::write(&outside, MARKER).unwrap();
// A document whose smart object names a file outside the roots as its source.
let mut session = photocraft_engine::Session::new();
session.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let state = session.active_mut().unwrap();
let LayerContent::Smart(smart) = &mut std::sync::Arc::make_mut(&mut state.doc).layers[0].content else { panic!("smart object") };
smart.source = SmartSource::Linked { path: outside.to_string_lossy().into_owned() };
std::fs::write(root.join("in.pcraft"), photocraft_engine::smart_cmds::encode_source(&state.doc).unwrap()).unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.open", json!({"path": "in.pcraft"})).unwrap();
let out = root.join("layers");
for format in ["psd", "psb"] {
let exported = h.handle("doc.exportLayers", json!({"dir": out.to_string_lossy(), "format": format})).unwrap();
assert_eq!(exported["count"], 1, "{exported}");
}
let files: Vec<_> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().path()).collect();
assert_eq!(files.len(), 2, "{files:?}");
for file in files {
assert!(!has_marker(&std::fs::read(&file).unwrap()), "{} embeds no file outside the roots", file.display());
}
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn trusted_local_render_keeps_full_size_behavior() {
let mut h = Headless::trusted_local();
+1 -1
View File
@@ -1197,7 +1197,7 @@ mod tests {
break;
}
assert!(t.elapsed().as_secs() < 60, "doc_inspect never saw the finished job: {inspected}");
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
}
+8 -51
View File
@@ -7,7 +7,7 @@ use crate::security::MAX_REQUEST_BYTES;
/// Read a complete request before allowing any of its bytes to reach rmcp. `lines()`
/// buffers without a limit, while forwarding partial input can make a valid JSON prefix
/// executable at EOF after an oversized suffix is rejected.
async fn bounded_line<R: tokio::io::AsyncRead + Unpin>(source: &mut BufReader<R>, maximum: usize) -> std::io::Result<Option<Vec<u8>>> {
async fn bounded_line<R: tokio::io::AsyncRead + Unpin>(source: &mut BufReader<R>, maximum: usize) -> std::io::Result<Option<String>> {
let mut bytes = Vec::new();
loop {
let available = source.fill_buf().await?;
@@ -15,7 +15,7 @@ async fn bounded_line<R: tokio::io::AsyncRead + Unpin>(source: &mut BufReader<R>
if bytes.is_empty() {
return Ok(None);
}
return Ok(Some(bytes));
return String::from_utf8(bytes).map(Some).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e));
}
let newline = available.iter().position(|&byte| byte == b'\n');
let length = newline.map_or(available.len(), |at| at + 1);
@@ -29,7 +29,7 @@ async fn bounded_line<R: tokio::io::AsyncRead + Unpin>(source: &mut BufReader<R>
if bytes.last() == Some(&b'\r') {
bytes.pop();
}
return Ok(Some(bytes));
return String::from_utf8(bytes).map(Some).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e));
}
}
}
@@ -48,16 +48,11 @@ impl PhotocraftMcp {
let reader = tokio::spawn(async move {
let mut lines = BufReader::new(input);
while let Some(line) = bounded_line(&mut lines, MAX_REQUEST_BYTES).await? {
if std::str::from_utf8(&line).is_ok_and(|text| text.trim().is_empty()) {
if line.trim().is_empty() {
continue;
}
// Bytes that are not UTF-8 are malformed JSON too: they get the -32700 reply
// like any other bad line instead of ending the session.
match serde_json::from_slice::<Value>(&line) {
Ok(_) => {
to_service.write_all(&line).await?;
to_service.write_all(b"\n").await?;
}
match serde_json::from_str::<Value>(&line) {
Ok(_) => to_service.write_all(format!("{line}\n").as_bytes()).await?,
Err(e) => {
let reply = json!({"jsonrpc":"2.0", "id":null, "error":{"code":-32700, "message":format!("parse error: {e}")}});
if errors.send(reply.to_string()).await.is_err() {
@@ -104,8 +99,8 @@ mod tests {
#[tokio::test]
async fn bounded_line_accepts_exact_limit_and_final_line() {
let mut input = BufReader::new(std::io::Cursor::new(b"1234\nlast!".to_vec()));
assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some(&b"1234"[..]));
assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some(&b"last!"[..]));
assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some("1234"));
assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some("last!"));
assert!(bounded_line(&mut input, 5).await.unwrap().is_none());
let mut oversized_eof = BufReader::new(std::io::Cursor::new(b"longer".to_vec()));
@@ -148,42 +143,4 @@ mod tests {
let documents = observer.headless_op(|h| Ok(h.session.documents().len())).await.unwrap().unwrap();
assert_eq!(documents, 0, "the oversized tool call must not run");
}
async fn next_reply(replies: &mut tokio::io::Lines<BufReader<tokio::io::DuplexStream>>) -> Value {
let line = tokio::time::timeout(std::time::Duration::from_secs(5), replies.next_line()).await.expect("a reply in time").unwrap().expect("a reply line");
serde_json::from_str(&line).unwrap()
}
#[tokio::test]
async fn a_line_that_is_not_utf8_gets_a_parse_error_and_the_session_keeps_serving() {
let (mut client, input) = tokio::io::duplex(1 << 16);
let (output, replies) = tokio::io::duplex(1 << 16);
let server = PhotocraftMcp::headless();
let observer = server.clone();
let session = tokio::spawn(server.serve_io(input, output));
let mut replies = BufReader::new(replies).lines();
let initialize = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "utf8-test", "version": "1"}
}});
client.write_all(format!("{initialize}\n").as_bytes()).await.unwrap();
assert_eq!(next_reply(&mut replies).await["id"], 1);
client.write_all(b"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n").await.unwrap();
client.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":\xff\xfe}\n").await.unwrap();
let error = next_reply(&mut replies).await;
assert_eq!(error["error"]["code"], -32700, "{error}");
assert_eq!(error["id"], Value::Null, "{error}");
let call = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "doc_new", "arguments": {"width": 8, "height": 8}}});
client.write_all(format!("{call}\n").as_bytes()).await.unwrap();
let reply = next_reply(&mut replies).await;
assert_eq!(reply["id"], 2, "{reply}");
assert_ne!(reply["result"]["isError"], true, "{reply}");
let documents = observer.headless_op(|h| Ok(h.session.documents().len())).await.unwrap().unwrap();
assert_eq!(documents, 1, "the call after the bad line must run");
drop(client);
session.await.unwrap().unwrap();
}
}
+4 -37
View File
@@ -340,8 +340,6 @@ fn params_contain_ambient_path(id: &str, params: &Value) -> bool {
}
"image.mode.rgb" | "image.mode.grayscale" | "image.mode.cmyk" | "image.mode.lab" => &["profile"],
"edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo" | "view.proofSetup" | "view.gamutWarning" => &["profile"],
// New Document's Color Profile (#321) can name an .icc file.
"file.new" => &["profile"],
"edit.colorSettings" => &["workingRgb", "workingCmyk", "workingGray"],
_ => &[],
};
@@ -358,13 +356,6 @@ fn profile_command_may_read_ambient(id: &str, params: &Value) -> bool {
if matches!(id, "color.profileMismatch" | "edit.colorSettings" | "edit.installedProfiles" | "view.proofSetup") {
return true;
}
// New Document skips exact `working` and `none`; the other working aliases resolve
// the configured working space, which may be an .icc path.
if id == "file.new" {
return params.get("profile").and_then(Value::as_str).is_some_and(|profile| {
matches!(profile, "default" | "working-cmyk" | "workingCmyk" | "working-rgb" | "workingRgb" | "working-gray" | "workingGray")
});
}
matches!(id, "edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo")
&& params.get("profile").and_then(Value::as_str).is_some_and(|profile| {
matches!(profile, "working" | "default" | "working-cmyk" | "workingCmyk" | "working-rgb" | "workingRgb" | "working-gray" | "workingGray")
@@ -383,27 +374,18 @@ fn preferences_may_grant_ambient_paths(id: &str, params: &Value) -> bool {
fn preference_uses_ambient_filesystem(path: &str) -> bool {
// The engine skips empty segments (`.scriptEvents.enabled` sets `scriptEvents.enabled`), so
// judge the first non-empty one; no segment at all is the whole-preferences update.
let mut segments = path.split('.').filter(|segment| !segment.is_empty());
let Some(section) = segments.next() else { return true };
// File Handling's LUT folder (#6558) is listed from disk; the rest of that section is not.
let Some(section) = path.split('.').find(|segment| !segment.is_empty()) else { return true };
matches!(section, "colorSettings" | "scriptEvents" | "historyLog" | "plugIns" | "scratchDisks")
|| (section == "fileHandling" && segments.next().is_none_or(|key| key == "lutFolder"))
}
fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
match id {
"actions.load"
| "brush.presets.importAbr"
| "brush.presets.importImage"
| "gradient.presets.importGrd"
| "plugin.install"
| "swatches.import"
| "layer.layerStyle.contours.load" => {
"actions.load" | "brush.presets.importAbr" | "brush.presets.importImage" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => {
// `data` wins over `path` in these commands; any `path` without it reads the filesystem.
params.get("data").is_none() && params.get("path").is_some()
}
// With a `path` the file is written there; without one the bytes come back as `data`.
"actions.save" | "swatches.export" | "layer.layerStyle.contours.save" => params.get("path").is_some(),
"actions.save" | "swatches.export" => params.get("path").is_some(),
// Shortcut sets: a .kys read or written by path, or a Summarize page written by path.
"edit.keyboardShortcuts" => {
params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some())
@@ -777,12 +759,6 @@ mod tests {
assert!(authorize_desktop_engine_command("image.mode.cmyk", &serde_json::json!({})).is_err());
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err());
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "srgb"})).is_ok());
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err());
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "adobe-rgb-compat"})).is_ok());
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "working"})).is_ok());
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "none"})).is_ok());
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "default"})).is_err());
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "working-cmyk"})).is_err());
// Shortcut sets: text in and out is fine; a path reads or writes the filesystem.
let ks = |p: serde_json::Value| authorize_engine_command("edit.keyboardShortcuts", &p);
assert!(ks(serde_json::json!({"exportKys": true, "summarize": true, "importKys": "<photoshop-keyboard-shortcuts/>"})).is_ok());
@@ -845,8 +821,6 @@ mod tests {
("swatches.export", serde_json::json!({"path": "/outside/set.ase"})),
("actions.load", serde_json::json!({"path": "/outside/set.atn"})),
("actions.save", serde_json::json!({"path": "/outside/set.atn"})),
("layer.layerStyle.contours.load", serde_json::json!({"path": "/outside/set.shc"})),
("layer.layerStyle.contours.save", serde_json::json!({"path": "/outside/set.shc"})),
] {
assert!(authorize_engine_command(id, &params).is_err(), "{id}: {params}");
}
@@ -859,8 +833,6 @@ mod tests {
("swatches.export", serde_json::json!({"format": "ase"})),
("actions.load", serde_json::json!({"data": "QUJD"})),
("actions.save", serde_json::json!({"set": "S"})),
("layer.layerStyle.contours.load", serde_json::json!({"data": "QUJD"})),
("layer.layerStyle.contours.save", serde_json::json!({"names": ["a"]})),
] {
assert!(authorize_engine_command(id, &params).is_ok(), "{id}: {params}");
}
@@ -875,8 +847,6 @@ mod tests {
"historyLog.filePath",
".historyLog.filePath",
"plugIns.additionalPluginsFolder",
"fileHandling.lutFolder",
"fileHandling",
"scratchDisks.disks",
".",
"..historyLog.filePath",
@@ -887,7 +857,6 @@ mod tests {
authorize_engine_command("prefs.set", &serde_json::json!({"values": {"interface.language": "fr", "historyLog.filePath": "/outside/log"}})).is_err()
);
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "interface.language", "value": "fr"})).is_ok());
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "fileHandling.autosave", "value": false})).is_ok());
}
#[test]
@@ -918,9 +887,7 @@ mod tests {
}
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.
if matches!(id, "prefs.get" | "prefs.reset" | "edit.installedProfiles") {
if matches!(id, "prefs.get" | "prefs.reset") {
return false;
}
// These registry descriptions refer to document vector paths, not host files.
File renamed without changes.
@@ -1,184 +0,0 @@
//! M11 acceptance, part two: ten more scripted edit tasks (21–30) over MCP (roadmap M11 DoD).
//!
//! Same contract as `agent_tasks.rs`: each task uses only MCP tools (`doc_*`, `command_run`) the
//! way an agent would, and checks the outcome through `doc_inspect`, `document.pixel` or the
//! exported files, never engine internals.
use photocraft_automation::{AuthorizedWorkspace, PhotocraftMcp};
use rmcp::model::{CallToolRequestParams, CallToolResult, ClientConfig};
use rmcp::service::RunningService;
use rmcp::{ClientHandler, RoleClient, ServiceExt};
use serde_json::{Value, json};
#[derive(Clone, Default)]
struct Client;
impl ClientHandler for Client {
fn get_info(&self) -> ClientConfig {
ClientConfig::default()
}
}
type Conn = RunningService<RoleClient, Client>;
type Server = tokio::task::JoinHandle<()>;
async fn connect(root: &std::path::Path) -> (Conn, Server) {
let (s, c) = tokio::io::duplex(1 << 20);
let workspace = AuthorizedWorkspace::new(Some(root), Some(root)).expect("test workspace");
let server = tokio::spawn(async move {
if let Ok(running) = PhotocraftMcp::headless_with_workspace(workspace).serve(s).await {
let _ = running.waiting().await;
}
});
(Client.serve(c).await.expect("client init"), server)
}
async fn shutdown(c: Conn, server: Server) {
c.cancel().await.unwrap();
server.await.unwrap();
}
fn text(r: &CallToolResult) -> String {
r.content.iter().filter_map(|c| c.as_text()).map(|t| t.text.clone()).collect::<Vec<_>>().join("\n")
}
/// Call a tool and parse its JSON result, failing the test on a tool error.
async fn tool(c: &Conn, name: &str, args: Value) -> Value {
let mut p = CallToolRequestParams::new(name.to_owned());
if let Value::Object(m) = args {
p = p.with_arguments(m);
}
let r = c.call_tool(p).await.expect("call_tool transport");
assert_ne!(r.is_error, Some(true), "{name} failed: {}", text(&r));
serde_json::from_str(&text(&r)).unwrap_or(Value::String(text(&r)))
}
async fn run(c: &Conn, id: &str, params: Value) -> Value {
tool(c, "command_run", json!({"id": id, "params": params})).await
}
async fn inspect(c: &Conn) -> Value {
tool(c, "doc_inspect", json!({})).await
}
async fn pixel(c: &Conn, x: i32, y: i32) -> Vec<f64> {
let v = run(c, "document.pixel", json!({"x": x, "y": y})).await;
let arr = if v.is_array() { v } else { v.get("result").cloned().unwrap_or(v) };
arr.as_array().unwrap_or_else(|| panic!("pixel: {arr}")).iter().map(|x| x.as_f64().unwrap()).collect()
}
fn top_layer(doc: &Value) -> &Value {
&doc["layers"][0]
}
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("pc-agent-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn close(a: &[f64], b: &[f64], tol: f64) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol)
}
/// The next ten acceptance tasks (21–30): fill layers, adjustment layers with masks, grouping,
/// merging, gradients of edits, channel round trips, JPEG export and a native save round trip
/// that preserves a group.
#[tokio::test(flavor = "multi_thread")]
async fn agent_completes_ten_extended_tasks() {
let dir = tmp("tasks3");
let (c, server) = connect(&dir).await;
// 21. Paint a red background and cover it with a solid-colour fill layer.
tool(&c, "doc_new", json!({"width": 64, "height": 64, "background": "white"})).await;
run(&c, "select.all", json!({})).await;
run(&c, "edit.fill", json!({"color": "#ff0000"})).await;
run(&c, "select.deselect", json!({})).await;
run(&c, "layer.newFillLayer.solidColor", json!({"color": "#00ffff"})).await;
assert_eq!(top_layer(&inspect(&c).await)["kind"], "Fill", "task 21");
let cyan = pixel(&c, 32, 32).await;
assert!(cyan[1] > 0.99 && cyan[2] > 0.99 && cyan[0] < 0.01, "task 21: the fill covers red {cyan:?}");
// 22. An Invert adjustment flips it to red; deleting the adjustment restores cyan.
run(&c, "layer.newAdjustmentLayer.invert", json!({})).await;
assert_eq!(top_layer(&inspect(&c).await)["kind"], "Adjustment", "task 22");
let inverted = pixel(&c, 32, 32).await;
assert!(inverted[0] > 0.99 && inverted[1] < 0.01 && inverted[2] < 0.01, "task 22: cyan inverts to red {inverted:?}");
run(&c, "layer.delete", json!({})).await;
assert!(close(&pixel(&c, 32, 32).await, &cyan, 1e-4), "task 22: deleting the adjustment restores cyan");
// 23. Hide a rectangular selection behind the layer's mask.
run(&c, "layer.delete", json!({})).await; // drop the fill so the red background shows
run(&c, "select.rect", json!({"x": 16, "y": 16, "width": 32, "height": 32})).await;
run(&c, "layer.layerMask.hideSelection", json!({})).await;
let doc = inspect(&c).await;
assert_eq!(top_layer(&doc)["hasMask"], true, "task 23");
let hidden = pixel(&c, 32, 32).await;
let kept = pixel(&c, 4, 4).await;
assert!(hidden[3] < 0.01, "task 23: the selection is masked away {hidden:?}");
assert!(kept[3] > 0.99 && kept[0] > 0.99, "task 23: outside keeps the red {kept:?}");
run(&c, "layer.layerMask.delete", json!({})).await;
// 24. Group every layer; the pixels are unchanged.
let before = pixel(&c, 32, 32).await;
run(&c, "select.allLayers", json!({})).await;
run(&c, "layer.groupLayers", json!({})).await;
let doc = inspect(&c).await;
assert_eq!(top_layer(&doc)["kind"], "Group", "task 24: {doc}");
assert!(close(&pixel(&c, 32, 32).await, &before, 1e-4), "task 24: grouping is visual only");
// 25. Flatten the grouped document to one layer.
run(&c, "layer.flattenImage", json!({})).await;
assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 1, "task 25");
// 26. Duplicate the layer and delete the copy.
run(&c, "layer.duplicate", json!({})).await;
assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 2, "task 26");
run(&c, "layer.delete", json!({})).await;
assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 1, "task 26");
// 27. A Curves adjustment darkens the red; deleting it restores the original.
run(&c, "layer.newAdjustmentLayer.curves", json!({"points": [[0, 0], [128, 32], [255, 128]]})).await;
let dark = pixel(&c, 32, 32).await;
assert!(dark[0] < 0.9, "task 27: the red channel deepens {dark:?}");
run(&c, "layer.delete", json!({})).await;
assert!(close(&pixel(&c, 32, 32).await, &before, 1e-4), "task 27: deleting restores the red");
// 28. Hue/Saturation at -100 desaturates the red to gray.
run(&c, "layer.newAdjustmentLayer.hueSaturation", json!({"saturation": -100})).await;
let gray = pixel(&c, 32, 32).await;
assert!((gray[0] - gray[1]).abs() < 0.05 && (gray[1] - gray[2]).abs() < 0.05, "task 28: saturated red goes gray {gray:?}");
run(&c, "layer.delete", json!({})).await;
// 29. Save a selection to a channel, load it back and paint through it.
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 24, "height": 24})).await;
run(&c, "select.saveSelection", json!({"name": "Box"})).await;
run(&c, "select.deselect", json!({})).await;
run(&c, "select.loadSelection", json!({"channel": "Box"})).await;
run(&c, "edit.fill", json!({"color": "#0000ff"})).await;
let painted = pixel(&c, 32, 32).await;
let outside = pixel(&c, 4, 4).await;
assert!(painted[2] > 0.99 && painted[0] < 0.01, "task 29: the loaded channel fills blue {painted:?}");
assert!(outside[0] > 0.99 && outside[2] < 0.01, "task 29: outside keeps red {outside:?}");
run(&c, "select.deselect", json!({})).await;
// 30. Save natively with a group, reopen and find the group and its content intact.
run(&c, "layer.new.layer", json!({"name": "Badge"})).await;
run(&c, "select.rect", json!({"x": 40, "y": 40, "width": 16, "height": 16})).await;
run(&c, "edit.fill", json!({"color": "#00ff00"})).await;
run(&c, "select.deselect", json!({})).await;
run(&c, "select.allLayers", json!({})).await;
run(&c, "layer.groupLayers", json!({"name": "Set"})).await;
tool(&c, "doc_save", json!({"path": "grouped.pcraft"})).await;
tool(&c, "doc_close", json!({})).await;
tool(&c, "doc_open", json!({"path": "grouped.pcraft"})).await;
let doc = inspect(&c).await;
assert_eq!(top_layer(&doc)["kind"], "Group", "task 30: {doc}");
assert_eq!(top_layer(&doc)["name"], "Set", "task 30: {doc}");
let green = pixel(&c, 48, 48).await;
assert!(green[1] > 0.99 && green[0] < 0.01, "task 30: the badge inside the group survives {green:?}");
tool(&c, "doc_close", json!({})).await;
shutdown(c, server).await;
std::fs::remove_dir_all(dir).unwrap();
}
-16
View File
@@ -1,16 +0,0 @@
//! The crate's integration tests, linked into one test binary.
mod agent_tasks;
mod agent_tasks_extended;
mod agent_tasks_more;
mod bridge;
mod inactive_save;
mod krita;
mod layered_save_dirty;
mod mcp;
mod mcp_wire;
mod missing_font_warnings;
mod native_save_name;
mod openraster;
mod raw_defaults;
mod smart_filter_authorization;
@@ -1,20 +0,0 @@
//! #7789: the headless (MCP) `doc.open` opens a developed raw at Preferences › Raw Defaults ›
//! Resolution, as the app's File › Open does.
use photocraft_automation::Headless;
use photocraft_raw::testgen::{DngSpec, mosaic, scene};
use serde_json::json;
#[test]
fn doc_open_gives_a_raw_the_raw_defaults_resolution() {
let dir = std::env::temp_dir().join(format!("pc-raw-defaults-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (w, h) = (24, 16);
let path = dir.join("IMG_0001.dng");
std::fs::write(&path, DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build()).unwrap();
let mut headless = Headless::trusted_local();
headless.handle("engine.execute", json!({"command": "prefs.set", "params": {"path": "rawDefaults.resolution", "value": 240}})).unwrap();
headless.handle("doc.open", json!({"path": path.to_str().unwrap()})).unwrap();
assert_eq!(headless.session.active().unwrap().doc.resolution_dpi, 240.0);
std::fs::remove_dir_all(dir).unwrap();
}
File renamed without changes.
@@ -774,12 +774,11 @@ async fn embedded_smart_objects_can_be_edited_saved_and_unpacked_without_file_au
let client = connect(PhotocraftMcp::headless()).await;
let run = async |id: &str, params: Value| json_of(&call(&client, "command_run", json!({"id": id, "params": params})).await);
json_of(&call(&client, "doc_new", json!({"width": 16, "height": 12, "background": "white"})).await);
run("layer.smartObjects.convertToSmartObject", json!({})).await;
let outer = run("layer.smartObjects.convertToSmartObject", json!({})).await["layer"].clone();
// An explicit target works even with a regular pixel layer selected.
run("layer.new.layer", json!({"name": "Other layer"})).await;
assert_eq!(run("layer.smartObjects.editContents", json!({"layer": outer})).await["document"], 1);
// A smart object cannot be converted again, so nest one inside the contents instead.
run("layer.smartObjects.convertToSmartObject", json!({})).await;
assert_eq!(run("layer.smartObjects.editContents", json!({})).await["document"], 2);
run("layer.setProps", json!({"name": "Edited photo", "opacity": 0.5})).await;
assert_eq!(run("layer.smartObjects.saveContents", json!({})).await["updated"], true);
File renamed without changes.
Loaded 100 of 872 files, more files were not shown because too many files have changed in this diff. Show more