mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
Merge main into Gradient Overlay method branch
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -0,0 +1,30 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
+16
-14
@@ -113,7 +113,7 @@ jobs:
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
|
||||
libgl1-mesa-dev libgtk-3-dev
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
|
||||
@@ -122,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@v2
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration') }}
|
||||
cache-on-failure: true
|
||||
@@ -251,9 +251,9 @@ jobs:
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
|
||||
libgl1-mesa-dev libgtk-3-dev
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
shared-key: test
|
||||
save-if: false
|
||||
@@ -306,8 +306,8 @@ jobs:
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: corpus
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
@@ -328,10 +328,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: wasm32
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
@@ -351,17 +351,17 @@ jobs:
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: ci-web
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
cache-on-failure: true
|
||||
- name: Install trunk
|
||||
run: |
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
curl --proto '=https' -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
trunk --version
|
||||
# package.sh runs Trunk's release builds, validates relative URLs, cuts the wasm into
|
||||
@@ -419,15 +419,17 @@ jobs:
|
||||
- { crate: format, name: load }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@nightly
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: dtolnay/rust-toolchain@2c76c034f9e32a5d6e2c104dc4df23a22476db04 # nightly
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
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 }}
|
||||
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time=${{ inputs.fuzz_seconds }}
|
||||
env:
|
||||
FUZZ_SECONDS: ${{ inputs.fuzz_seconds }}
|
||||
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time="$FUZZ_SECONDS"
|
||||
- name: Upload crash artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
- name: Install mdBook
|
||||
run: cargo install mdbook --version 0.4.52 --locked
|
||||
- name: Build documentation
|
||||
|
||||
@@ -18,16 +18,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- name: nix flake check --no-build
|
||||
run: |
|
||||
if [ -f flake.nix ]; then
|
||||
nix flake check --no-build --all-systems
|
||||
else
|
||||
echo "No flake.nix present in this repository; skipping check."
|
||||
fi
|
||||
nix:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
|
||||
@@ -44,9 +44,9 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: perf-release
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
@@ -19,7 +19,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
|
||||
| `docs/parity-checklist.md` | Generated list of every Photoshop menu item, live or missing |
|
||||
| `docs/scorecard.md` | Generated scorecard: performance budgets and numbers, corpus floors, per-area checklists (tools, files, UI, type, automation, reliability, distribution), settings that do nothing |
|
||||
| `crates/<name>/README.md` (where present) | Public API of that crate |
|
||||
| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` |
|
||||
| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs and GIMP-authored XCFs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` |
|
||||
|
||||
## 2. Workspace map
|
||||
|
||||
@@ -27,7 +27,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
|
||||
crates/
|
||||
geom cms color raster L0 foundation (geometry, ICC colour management, pixel formats + blend math, COW tiles)
|
||||
psd codecs L0 standalone format crates (no workspace deps; publishable)
|
||||
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2); the one isolated unsafe crate
|
||||
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2, Windows barrel button); the one isolated unsafe crate
|
||||
doc L1 document model (layers, masks, adjustments, effects, smart objects: pure data)
|
||||
ops paint algo text vector L2 history, brush engine, imaging algorithms, type engine, paths/shapes
|
||||
compose gpu format L3 CPU compositor (the oracle), wgpu compositor, .pcraft native format
|
||||
@@ -52,7 +52,7 @@ xtask/ cargo xtask layers | wasm | ci | stats | corpus | t
|
||||
People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it. The shared standard is `../craftrules/standards/never-crash.md`.
|
||||
|
||||
- **Non-test code never panics.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!`. Return the crate's error type and propagate with `?`; use `ok_or(..)?`, `let .. else { return Err(..) }`, `if let`, or `unwrap_or*` where a fallback is truly correct (never one that silently corrupts a document). Unfinished features return an "unsupported" error. The only exception is a provably infallible literal: `#[allow(clippy::expect_used)]` plus `.expect("why it can't fail")`.
|
||||
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop). Only its `src/macos.rs` allows `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
|
||||
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop) and on Windows a pointer message hook (`GetPointerPenInfo`). Only its `src/macos.rs` and `src/windows.rs` allow `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s and pointer messages); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
|
||||
- **Input-derived numbers are hostile.** Use `get()` rather than `[i]`/`[a..b]` for indices from files, params, selections or arithmetic on them; slice strings only at char boundaries; use `checked_*`/`saturating_*` for lengths, offsets and counts; guard division by zero and NaN/inf casts; cap allocations sized by input.
|
||||
- **Bound recursion** with depth limits or seen-sets (documents can be deep or cyclic).
|
||||
- **Don't cascade.** Handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)`) and treat thread joins as `Result`s.
|
||||
@@ -107,7 +107,7 @@ cargo xtask test-corpus # if you touched psd, io, codecs, compose, gpu, text
|
||||
|
||||
While iterating, run less and keep the full commands above as the final gate: `cargo test -p <crate> --lib --tests` skips the examples and doctests plain `cargo test` builds every time (ui-egui has 11 examples), and `cargo test -p <crate> --test <file>` runs one integration-test file. `cargo nextest run` runs tests in parallel across binaries; see `docs/development.md` › Faster test loops.
|
||||
|
||||
**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora.
|
||||
**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop- and GIMP-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora.
|
||||
|
||||
Commands must **never panic** on bad input (Rule 9): every `run` closure and the code it calls returns `Err`, not a panic, for any params or document state. New commands come with a graceful-failure test (empty/out-of-range/wrong-type params → `Err`, not a crash).
|
||||
|
||||
|
||||
+4
-1
@@ -27,9 +27,10 @@ next to them.
|
||||
| `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) |
|
||||
| `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`, `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/*.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`, `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 |
|
||||
@@ -111,11 +112,13 @@ next to the files:
|
||||
| Path (fetched) | Title | Author | Source | License |
|
||||
|---|---|---|---|---|
|
||||
| `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
|
||||
| `corpus/gimp/` (28 files, 251 KB) | GIMP XCF oracles: 40×30 procedural layers written by GIMP 3.2.6 through the corpus generator `tools/gimp-xcf/xcf_fixtures.py`, each with GIMP's merged rendering as a PNG | PhotoCraft contributors (authored with GIMP 3.2.6 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`gimp/`, with its generator and README) | MIT OR Apache-2.0 |
|
||||
| `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi |
|
||||
| `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) |
|
||||
| `corpus/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
+97
-49
@@ -849,7 +849,7 @@ dependencies = [
|
||||
"maybe-owned",
|
||||
"rustix 1.1.5",
|
||||
"rustix-linux-procfs",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
"winx",
|
||||
]
|
||||
|
||||
@@ -1234,7 +1234,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=3d37da389a3618ab9b7610
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"miniz_oxide 0.8.9",
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
"tempfile",
|
||||
"winprint",
|
||||
"winsafe",
|
||||
@@ -1896,7 +1896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1934,6 +1934,18 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exacl"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de9e551ced9d700a31dacb8b168129640240fcd7a279a3fa4d59608b84ebcae"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"log",
|
||||
"scopeguard",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exr"
|
||||
version = "1.74.2"
|
||||
@@ -2104,7 +2116,7 @@ version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
|
||||
dependencies = [
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2129,9 +2141,17 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"linebender_resource_handle",
|
||||
"memmap2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
"objc2-foundation 0.3.2",
|
||||
"parlance",
|
||||
"read-fonts",
|
||||
"roxmltree 0.21.1",
|
||||
"smallvec",
|
||||
"windows",
|
||||
"windows-core",
|
||||
"yeslogic-fontconfig-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3033,7 +3053,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3695,7 +3715,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"png 0.18.1",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4441,7 +4461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4618,7 +4638,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"async-io",
|
||||
@@ -4663,7 +4683,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-affinity"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4673,7 +4693,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-algo"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4686,7 +4706,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-automation"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"cap-std",
|
||||
@@ -4699,6 +4719,7 @@ dependencies = [
|
||||
"photocraft-geom",
|
||||
"photocraft-io",
|
||||
"rmcp",
|
||||
"rustix 1.1.5",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4708,7 +4729,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cli"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"photocraft-automation",
|
||||
"photocraft-codecs",
|
||||
@@ -4722,7 +4743,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cms"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"moxcms",
|
||||
"rayon",
|
||||
@@ -4730,7 +4751,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-codecs"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"exr",
|
||||
"flate2",
|
||||
@@ -4751,7 +4772,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-color"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"serde",
|
||||
@@ -4759,7 +4780,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-compose"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"photocraft-color",
|
||||
@@ -4774,7 +4795,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-doc"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4785,7 +4806,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-engine"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"craft-print",
|
||||
@@ -4811,20 +4832,23 @@ dependencies = [
|
||||
"photocraft-text",
|
||||
"photocraft-vector",
|
||||
"rayon",
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"wat",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-format"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"crc32fast",
|
||||
"exacl",
|
||||
"flate2",
|
||||
"libc",
|
||||
"photocraft-codecs",
|
||||
@@ -4838,21 +4862,23 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-geom"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-gpu"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"half",
|
||||
"log",
|
||||
"photocraft-cms",
|
||||
"photocraft-color",
|
||||
"photocraft-compose",
|
||||
"photocraft-doc",
|
||||
@@ -4866,14 +4892,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-heif"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-io"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"craft-docpath",
|
||||
@@ -4896,25 +4922,25 @@ dependencies = [
|
||||
"photocraft-vector",
|
||||
"proptest",
|
||||
"resvg",
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ipp"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ops"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"photocraft-doc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-paint"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4925,7 +4951,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-plugins"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4940,7 +4966,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-psd"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"photocraft-psd",
|
||||
@@ -4951,17 +4977,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raster"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
"proptest",
|
||||
"rayon",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raw"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4976,7 +5003,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-tablet"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
@@ -4988,7 +5015,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-testkit"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"memory-stats",
|
||||
"photocraft-color",
|
||||
@@ -4999,7 +5026,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-text"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"parley",
|
||||
@@ -5008,12 +5035,13 @@ dependencies = [
|
||||
"photocraft-geom",
|
||||
"photocraft-psd",
|
||||
"photocraft-raster",
|
||||
"rayon",
|
||||
"skrifa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ui-egui"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-print",
|
||||
@@ -5055,7 +5083,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-vector"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-doc",
|
||||
@@ -5066,7 +5094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5088,7 +5116,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web-boot"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"js-sys",
|
||||
@@ -5481,7 +5509,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5942,6 +5970,15 @@ version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -6000,7 +6037,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6067,7 +6104,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6705,7 +6742,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6932,10 +6969,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7335,7 +7372,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7442,7 +7479,7 @@ dependencies = [
|
||||
"kurbo 0.11.3",
|
||||
"log",
|
||||
"pico-args",
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
"rustybuzz",
|
||||
"simplecss",
|
||||
"siphasher",
|
||||
@@ -8095,7 +8132,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8675,7 +8712,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -8688,6 +8725,17 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
|
||||
|
||||
[[package]]
|
||||
name = "yeslogic-fontconfig-sys"
|
||||
version = "6.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76"
|
||||
dependencies = [
|
||||
"dlib",
|
||||
"once_cell",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
+2
-2
@@ -25,8 +25,8 @@ RUN set -eu; \
|
||||
asset="trunk-${arch}-unknown-linux-musl.tar.gz"; \
|
||||
url="https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}"; \
|
||||
cd /tmp; \
|
||||
curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \
|
||||
curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset.sha256" -o "$asset.sha256"; \
|
||||
curl --proto '=https' -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \
|
||||
curl --proto '=https' -fsSL --retry 3 -A Photocraft-dev "$url/$asset.sha256" -o "$asset.sha256"; \
|
||||
printf '%s %s\n' "$(cat "$asset.sha256")" "$asset" | sha256sum -c -; \
|
||||
tar -xzf "$asset" -C /usr/local/bin trunk; \
|
||||
rm "$asset" "$asset.sha256"; \
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
<img alt="macOS · Windows · Linux · FreeBSD · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-native-2f7bf5?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-3a3a3a?style=flat-square">
|
||||
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_photocraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/photocraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -199,7 +202,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🗂️ Formats</h4>
|
||||
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
|
||||
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/xcf.md">GIMP XCF (read, with layers, groups, masks, blend modes, channels and the selection)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🪄 The everyday essentials</h4>
|
||||
|
||||
@@ -12,6 +12,14 @@ fn main() -> std::io::Result<()> {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return Ok(());
|
||||
}
|
||||
// Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which
|
||||
// stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe
|
||||
// from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use;
|
||||
// the print command checks for it first and reports an error instead.
|
||||
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
|
||||
println!("cargo:rustc-link-arg-bins=/DELAYLOAD:windows.data.pdf.dll");
|
||||
println!("cargo:rustc-link-arg-bins=delayimp.lib");
|
||||
}
|
||||
let mut res = winresource::WindowsResource::new();
|
||||
// FileVersion / ProductVersion strings and the numeric FILEVERSION / PRODUCTVERSION are left
|
||||
// to `WindowsResource::new`, which reads the Cargo package version. A pre-release such as
|
||||
|
||||
@@ -15,19 +15,21 @@ pub const USAGE: &str = "\
|
||||
photocraft-cli: headless PhotoCraft
|
||||
|
||||
USAGE:
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
|
||||
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, …).
|
||||
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.
|
||||
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] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
|
||||
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] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
|
||||
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
|
||||
@@ -69,18 +71,23 @@ struct Subcommand {
|
||||
}
|
||||
|
||||
const SUBCOMMANDS: &[Subcommand] = &[
|
||||
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert },
|
||||
Subcommand {
|
||||
name: "convert",
|
||||
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"],
|
||||
bare: &["--tiff-layers"],
|
||||
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"],
|
||||
bare: &["--in-place", "--tiff-layers"],
|
||||
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
|
||||
bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
|
||||
run: batch,
|
||||
},
|
||||
Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet },
|
||||
@@ -219,6 +226,11 @@ 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("--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"))?;
|
||||
}
|
||||
o.encode.dds.mipmaps = a.has("--dds-mipmaps");
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
@@ -444,11 +456,32 @@ fn is_input(p: &Path) -> bool {
|
||||
p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase()))
|
||||
}
|
||||
|
||||
/// Drops the Save and view steps the desktop records from `action` (Batch saves each result
|
||||
/// itself, #3143), keeping its unchecked and dialog flags on the steps that remain (#6860).
|
||||
fn strip_shell_steps(action: &mut Action) {
|
||||
use photocraft_engine::actions_cmds::{shell_save_command, shell_view_command};
|
||||
let (mut steps, mut disabled, mut dialogs) = (Vec::new(), std::collections::BTreeSet::new(), std::collections::BTreeSet::new());
|
||||
for (i, (id, p)) in std::mem::take(&mut action.steps).into_iter().enumerate() {
|
||||
if shell_save_command(&id) || shell_view_command(&id) {
|
||||
continue;
|
||||
}
|
||||
if action.disabled.contains(&i) {
|
||||
disabled.insert(steps.len());
|
||||
}
|
||||
if action.dialogs.contains(&i) {
|
||||
dialogs.insert(steps.len());
|
||||
}
|
||||
steps.push((id, p));
|
||||
}
|
||||
(action.steps, action.disabled, action.dialogs) = (steps, disabled, dialogs);
|
||||
}
|
||||
|
||||
fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
let actions_path = a.get("--actions").ok_or("batch needs --actions <file>")?;
|
||||
let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in <dir>")?);
|
||||
let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out <dir>")?);
|
||||
let (actions, set, warnings) = load_actions_file(actions_path, a.get("--action"))?;
|
||||
let (actions, mut set, warnings) = load_actions_file(actions_path, a.get("--action"))?;
|
||||
set.iter_mut().for_each(strip_shell_steps);
|
||||
for w in &warnings {
|
||||
let _ = writeln!(err, "warning: {w}");
|
||||
}
|
||||
@@ -751,4 +784,37 @@ mod batch_action_set_tests {
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Save and view steps recorded inside a batched action set are skipped, also in a called
|
||||
/// action, and unchecked steps stay unchecked once they are gone (#6860).
|
||||
#[test]
|
||||
fn batch_skips_save_and_view_steps_inside_an_action_set() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-save-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
|
||||
std::fs::create_dir_all(dir.join("in")).unwrap();
|
||||
let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
let set = json!({"version": 1, "actions": [
|
||||
{"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}], ["file.saveAs", {}]]},
|
||||
{"name": "Rotate", "steps": [
|
||||
["file.save", {}],
|
||||
["image.imageRotation.90cw", {}],
|
||||
["view.fitOnScreen", {}],
|
||||
["image.imageRotation.90cw", {}],
|
||||
["file.saveAs", {}],
|
||||
], "disabled": [3], "dialogs": [1]},
|
||||
]});
|
||||
std::fs::write(dir.join("actions.json"), set.to_string()).unwrap();
|
||||
for action in ["Outer", "Rotate"] {
|
||||
let (code, stdout, stderr) =
|
||||
invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]);
|
||||
assert_eq!(code, 0, "{stdout}{stderr}");
|
||||
let (code, info, stderr) = invoke(&["info", &path(&format!("{action}/a.png")), "--compact"]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
let info: Value = serde_json::from_str(&info).unwrap();
|
||||
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "{action}: rotated once, the unchecked step skipped");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,19 +163,29 @@ fn info_and_convert_report_decode_warnings() {
|
||||
gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0");
|
||||
}
|
||||
gif.push(0x3B);
|
||||
// A GIF opens in Indexed Color (#7886), so `convert` to PNG also notes the palette PNG it
|
||||
// writes, after the decode warning.
|
||||
let cases = [
|
||||
("whole.jpg", &jpeg[..], None),
|
||||
("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing")),
|
||||
("anim.gif", &gif[..], Some("only the first of 3 frames was imported")),
|
||||
("whole.jpg", &jpeg[..], None, None),
|
||||
("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing"), None),
|
||||
("anim.gif", &gif[..], Some("only the first of 3 frames was imported"), Some("written as an 8-bit palette PNG (2 colours)")),
|
||||
];
|
||||
for (name, bytes, warning) in cases {
|
||||
for (name, bytes, warning, export) in cases {
|
||||
let file = d.join(name);
|
||||
std::fs::write(&file, bytes).unwrap();
|
||||
let (out, _) = ok(bin().arg("info").arg(&file).arg("--compact"));
|
||||
let v: Value = serde_json::from_str(&out).unwrap();
|
||||
assert_eq!(v["warnings"], json!(warning.into_iter().collect::<Vec<_>>()), "{name}");
|
||||
let (_, err) = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png"))));
|
||||
assert_eq!(err.trim(), warning.map(|w| format!("warning: {w}")).unwrap_or_default(), "{name}");
|
||||
let expected: Vec<String> = warning.into_iter().chain(export).map(|w| format!("warning: {w}")).collect();
|
||||
assert_eq!(
|
||||
err.trim(),
|
||||
expected.join(
|
||||
"
|
||||
"
|
||||
),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
@@ -494,6 +504,39 @@ fn dpx_converts_both_ways_with_the_bits_asked_for() {
|
||||
}
|
||||
}
|
||||
|
||||
/// DDS converts both ways; `--dds-compression` picks the FourCC (bytes 84–87; none writes
|
||||
/// uncompressed pixels) and `--dds-mipmaps` the mip count (bytes 28–31). A compression that
|
||||
/// isn't none, bc1, bc2 or bc3 is refused and nothing is written (#1327).
|
||||
#[test]
|
||||
fn dds_converts_both_ways_with_the_compression_asked_for() {
|
||||
let d = tmp("dds");
|
||||
let a = d.join("a.png");
|
||||
write_png(&a, 8, 4, 3);
|
||||
for (args, fourcc, mips) in [
|
||||
(&[][..], [0; 4], 1),
|
||||
(&["--dds-compression", "bc1"][..], *b"DXT1", 1),
|
||||
(&["--dds-compression", "DXT3", "--dds-mipmaps"][..], *b"DXT3", 4),
|
||||
(&["--dds-compression", "bc3"][..], *b"DXT5", 1),
|
||||
(&["--dds-mipmaps"][..], [0; 4], 4),
|
||||
] {
|
||||
let out = d.join("a.dds");
|
||||
ok(bin().arg("convert").arg(&a).arg(&out).args(args));
|
||||
let bytes = std::fs::read(&out).unwrap();
|
||||
let mip_count = u32::from_le_bytes(bytes[28..32].try_into().unwrap());
|
||||
assert_eq!((&bytes[..4], &bytes[84..88], mip_count), (&b"DDS "[..], &fourcc[..], mips), "{args:?}");
|
||||
let back = d.join("back.png");
|
||||
ok(bin().arg("convert").arg(&out).arg(&back));
|
||||
assert!(back.exists(), "{args:?}");
|
||||
}
|
||||
for bad in ["bc7", "dxt2", ""] {
|
||||
let out = d.join("bad.dds");
|
||||
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dds-compression", bad]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1), "--dds-compression {bad}");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dds-compression `{bad}`")), "--dds-compression {bad}");
|
||||
assert!(!out.exists(), "--dds-compression {bad} wrote a file");
|
||||
}
|
||||
}
|
||||
|
||||
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is
|
||||
/// refused and nothing is written.
|
||||
#[test]
|
||||
|
||||
@@ -21,4 +21,4 @@ flate2 = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "WritableStream", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "ReadableStreamDefaultReader", "WritableStream", "WritableStreamDefaultWriter", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
//! one part, however large the app grows.
|
||||
//!
|
||||
//! The browser inflates the parts (`DecompressionStream`: Safari 16.4, Firefox 113, Chrome 80).
|
||||
//! In one that can't, this module inflates them itself, a little slower.
|
||||
//! In one that can't, this module inflates them itself, a little slower. Some servers recognise
|
||||
//! the gzip in a part and send it with `Content-Encoding: gzip` (Apache's `mod_mime_magic`
|
||||
//! labels it `x-gzip`), so the browser has inflated it already: the joined download is then
|
||||
//! taken as it is, by its first bytes.
|
||||
//!
|
||||
//! A download that fails is tried again, the later tries past the browser's cache (a bad copy
|
||||
//! there would otherwise fail forever). When every try fails the page says so and offers a reload.
|
||||
@@ -36,6 +39,34 @@ fn parts(list: &str) -> Option<Vec<String>> {
|
||||
(!parts.is_empty() && parts.iter().all(plain)).then_some(parts)
|
||||
}
|
||||
|
||||
/// What the joined parts hold, told by their first bytes.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Payload {
|
||||
/// The gzip packaging made, still to be inflated.
|
||||
Gzip,
|
||||
/// The app's WebAssembly: the server sent the gzip with a `Content-Encoding` the browser
|
||||
/// already undid.
|
||||
Wasm,
|
||||
}
|
||||
|
||||
/// Enough leading bytes to tell the payloads apart.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
const HEAD: usize = 4;
|
||||
|
||||
/// What a download starting with `head` (at least `HEAD` bytes, or all there were) holds, or
|
||||
/// `None` when it is neither, such as a web page or nothing at all.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn payload(head: &[u8]) -> Option<Payload> {
|
||||
if head.starts_with(&[0x1f, 0x8b]) {
|
||||
Some(Payload::Gzip)
|
||||
} else if head.starts_with(b"\0asm") {
|
||||
Some(Payload::Wasm)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Gzip inflated a chunk at a time, for browsers that can't do it themselves.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
struct Inflater(flate2::write::GzDecoder<Vec<u8>>);
|
||||
@@ -69,11 +100,11 @@ mod boot {
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::{JsFuture, spawn_local};
|
||||
use web_sys::{
|
||||
Headers, ReadableStream, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, TransformStream,
|
||||
TransformStreamDefaultController, Transformer, WritableStream,
|
||||
Headers, ReadableStream, ReadableStreamDefaultReader, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions,
|
||||
TransformStream, TransformStreamDefaultController, Transformer, WritableStream, WritableStreamDefaultWriter,
|
||||
};
|
||||
|
||||
use super::Inflater;
|
||||
use super::{HEAD, Inflater, Payload};
|
||||
|
||||
/// Where packaging puts the app, next to this module's own files.
|
||||
const PARTS: &str = "./app/parts.txt";
|
||||
@@ -172,11 +203,71 @@ mod boot {
|
||||
Ok(compressed.pipe_through(&pair))
|
||||
}
|
||||
|
||||
/// The next chunk `reader` gives, or `None` at the end of the stream.
|
||||
async fn read(reader: &ReadableStreamDefaultReader) -> Result<Option<js_sys::Uint8Array>, JsValue> {
|
||||
let result = JsFuture::from(reader.read()).await?;
|
||||
if js_sys::Reflect::get(&result, &JsValue::from_str("done"))?.is_truthy() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(js_sys::Reflect::get(&result, &JsValue::from_str("value"))?.dyn_into()?))
|
||||
}
|
||||
|
||||
/// Writes `chunk` into `writer`. When nothing will read it any more (the app's loader gave up
|
||||
/// on its stream), the download from `reader` is cancelled too, so it doesn't stay pending.
|
||||
async fn write(chunk: &js_sys::Uint8Array, reader: &ReadableStreamDefaultReader, writer: &WritableStreamDefaultWriter) -> Result<(), JsValue> {
|
||||
if let Err(e) = JsFuture::from(writer.write_with_chunk(chunk)).await {
|
||||
let _ = reader.cancel_with_reason(&e);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes the `held` chunks, then the rest of `reader`'s stream, into `writer`, then closes it.
|
||||
async fn pass_on(held: Vec<js_sys::Uint8Array>, reader: &ReadableStreamDefaultReader, writer: &WritableStreamDefaultWriter) -> Result<(), JsValue> {
|
||||
for chunk in &held {
|
||||
write(chunk, reader, writer).await?;
|
||||
}
|
||||
while let Some(chunk) = read(reader).await? {
|
||||
write(&chunk, reader, writer).await?;
|
||||
}
|
||||
JsFuture::from(writer.close()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The app's WebAssembly from the `joined` parts: inflated, unless its first bytes show the
|
||||
/// browser has already done that.
|
||||
async fn unpacked(joined: &ReadableStream) -> Result<ReadableStream, JsValue> {
|
||||
let reader: ReadableStreamDefaultReader = joined.get_reader().unchecked_into();
|
||||
let (mut head, mut held) = (Vec::with_capacity(HEAD), Vec::new());
|
||||
while head.len() < HEAD {
|
||||
let Some(chunk) = read(&reader).await? else { break };
|
||||
let wanted = u32::try_from(HEAD - head.len()).unwrap_or(u32::MAX).min(chunk.length());
|
||||
head.extend(chunk.subarray(0, wanted).to_vec());
|
||||
held.push(chunk);
|
||||
}
|
||||
let Some(payload) = super::payload(&head) else {
|
||||
let e = JsValue::from_str("the download is not PhotoCraft's WebAssembly");
|
||||
// Stops the rest of the download, which nothing will read.
|
||||
let _ = reader.cancel_with_reason(&e);
|
||||
return Err(e);
|
||||
};
|
||||
let rest = TransformStream::new()?;
|
||||
let writer = rest.writable().get_writer()?;
|
||||
spawn_local(async move {
|
||||
if let Err(e) = pass_on(held, &reader, &writer).await {
|
||||
let _ = writer.abort_with_reason(&e);
|
||||
}
|
||||
});
|
||||
match payload {
|
||||
Payload::Gzip => inflated(&rest.readable()),
|
||||
Payload::Wasm => Ok(rest.readable()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(fresh: bool) -> Result<(), JsValue> {
|
||||
let list = JsFuture::from(fetch(PARTS, fresh).await?.text()?).await?.as_string().unwrap_or_default();
|
||||
let parts = super::parts(&list).ok_or_else(|| JsValue::from_str("app/parts.txt is not a parts list"))?;
|
||||
let joined = TransformStream::new()?;
|
||||
let wasm = inflated(&joined.readable())?;
|
||||
// Why the download stopped: the app's loader only reports that its stream broke.
|
||||
let cause = Rc::new(RefCell::new(None));
|
||||
let failed = cause.clone();
|
||||
@@ -187,6 +278,10 @@ mod boot {
|
||||
failed.replace(Some(e));
|
||||
}
|
||||
});
|
||||
let wasm = match unpacked(&joined.readable()).await {
|
||||
Ok(wasm) => wasm,
|
||||
Err(e) => return Err(cause.take().unwrap_or(e)),
|
||||
};
|
||||
let headers = Headers::new()?;
|
||||
headers.set("Content-Type", "application/wasm")?;
|
||||
let init = ResponseInit::new();
|
||||
@@ -258,7 +353,7 @@ fn main() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Inflater, MARKER, parts};
|
||||
use super::{HEAD, Inflater, MARKER, Payload, parts, payload};
|
||||
|
||||
fn gzip(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
@@ -317,6 +412,22 @@ mod tests {
|
||||
assert!(run(b"").is_err(), "nothing at all");
|
||||
}
|
||||
|
||||
/// A server that recognises the gzip (Apache's `mod_mime_magic`) sends it with
|
||||
/// `Content-Encoding: x-gzip`, and the browser inflates it on the way (#7639). The joined parts
|
||||
/// are then the app's WebAssembly, which must start as it is, not be inflated a second time.
|
||||
#[test]
|
||||
fn the_first_bytes_tell_gzip_from_wasm_the_browser_already_inflated() {
|
||||
let mut wasm = b"\0asm\x01\0\0\0".to_vec();
|
||||
wasm.extend(sample());
|
||||
let packed = gzip(&wasm);
|
||||
assert_eq!(payload(&packed[..HEAD]), Some(Payload::Gzip), "as packaged");
|
||||
assert_eq!(payload(&wasm[..HEAD]), Some(Payload::Wasm), "inflated by the browser");
|
||||
assert_eq!(payload(&packed[..2]), Some(Payload::Gzip), "a download shorter than HEAD");
|
||||
assert_eq!(payload(b"<!DOCTYPE html>"), None, "a web page");
|
||||
assert_eq!(payload(b"\0as"), None, "a cut-off wasm header");
|
||||
assert_eq!(payload(b""), None, "nothing at all");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parts_list_gives_its_names_in_order() {
|
||||
let list = format!("{MARKER}\nphotocraft-web-0123abcd.part-aa\r\n\n photocraft-web-0123abcd.part-ab \n");
|
||||
|
||||
@@ -17,11 +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, .ase), which go to the preset libraries.
|
||||
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .act, .ase), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "pdn", "ora", "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", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef",
|
||||
"orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
|
||||
"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", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef",
|
||||
"nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "af", "afdesign", "afphoto", "afpub",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
const CANVAS_ID: &str = "photocraft_canvas";
|
||||
@@ -322,6 +322,12 @@ 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() {
|
||||
@@ -427,6 +433,8 @@ fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::Cu
|
||||
}
|
||||
})),
|
||||
write: Some(Box::new(|path: &str, bytes: &[u8]| download(path, bytes))),
|
||||
// A download may be cancelled and never reports back: it doesn't save the document.
|
||||
write_unconfirmed: true,
|
||||
encode_png: Some(Box::new(|w, h, rgba| {
|
||||
let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec()).map_err(|e| e.to_string())?;
|
||||
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string())
|
||||
|
||||
@@ -11,6 +11,14 @@ fn main() {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return;
|
||||
}
|
||||
// Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which
|
||||
// stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe
|
||||
// from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use;
|
||||
// the print command checks for it first and reports an error instead.
|
||||
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
|
||||
println!("cargo:rustc-link-arg-bins=/DELAYLOAD:windows.data.pdf.dll");
|
||||
println!("cargo:rustc-link-arg-bins=delayimp.lib");
|
||||
}
|
||||
let mut res = winresource::WindowsResource::new();
|
||||
res.set_icon("../../assets/app-icon/photocraft.ico")
|
||||
.set("ProductName", "PhotoCraft")
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
//! 3. The platform convention: macOS `~/Library/Application Support/Photocraft`, Windows
|
||||
//! `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`.
|
||||
//!
|
||||
//! One exception: on Windows the platform convention keeps crash-recovery autosaves out of the
|
||||
//! roaming profile ([`recovery_dir`]).
|
||||
//! One exception: the platform convention keeps crash-recovery autosaves out of what is synced
|
||||
//! with the settings: the Windows roaming profile and the Linux config folder ([`recovery_dir`]).
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -77,18 +77,35 @@ pub fn recovery_dir() -> Option<PathBuf> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The recovery folder for `data` (#5648). Autosaves hold whole documents and are rewritten every
|
||||
/// few minutes, so on Windows the platform convention puts them in the machine-local
|
||||
/// `%LOCALAPPDATA%\Photocraft\Recovery`: the roaming `%APPDATA%` may be synced at sign-in and
|
||||
/// sign-out or capped by policy. They stay out of `%TEMP%`, which cleanup tools may empty before
|
||||
/// a crashed document is recovered. The override and portable folders keep everything in one
|
||||
/// place, and other platforms already keep these folders local.
|
||||
/// The recovery folder for `data`. Autosaves hold whole documents and are rewritten every few
|
||||
/// minutes, so with the platform convention they stay out of what is synced with the settings:
|
||||
/// - Windows (#5648): the machine-local `%LOCALAPPDATA%\Photocraft\Recovery`; the roaming
|
||||
/// `%APPDATA%` may be synced at sign-in and sign-out or capped by policy.
|
||||
/// - Linux and other XDG systems (#6612): `$XDG_STATE_HOME/photocraft/Recovery`, by default
|
||||
/// `~/.local/state/photocraft/Recovery`, where the XDG Base Directory spec puts state that
|
||||
/// should outlive a restart but isn't configuration; dotfile managers and config sync often copy
|
||||
/// `~/.config` whole. As the spec asks, a relative `XDG_STATE_HOME` is ignored.
|
||||
/// - macOS: `~/Library/Application Support/Photocraft/Recovery` as before. Apple's guidelines put
|
||||
/// app-created data that can't be regenerated there; `~/Library/Caches` may be purged by the
|
||||
/// system.
|
||||
///
|
||||
/// They are never in a temp or cache folder, which cleanup tools may empty before a crashed
|
||||
/// document is recovered. The override and portable folders keep everything in one place.
|
||||
pub fn resolve_recovery(data: &DataDir, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
if cfg!(windows)
|
||||
&& data.mode == Mode::Platform
|
||||
&& let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty())
|
||||
{
|
||||
return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY));
|
||||
if data.mode == Mode::Platform {
|
||||
if cfg!(windows) {
|
||||
if let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty()) {
|
||||
return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY));
|
||||
}
|
||||
} else if !cfg!(target_os = "macos") {
|
||||
let state = env("XDG_STATE_HOME")
|
||||
.map(PathBuf::from)
|
||||
.filter(|s| s.is_absolute())
|
||||
.or_else(|| env("HOME").filter(|h| !h.is_empty()).map(|h| PathBuf::from(h).join(".local").join("state")));
|
||||
if let Some(state) = state {
|
||||
return Some(state.join("photocraft").join(RECOVERY));
|
||||
}
|
||||
}
|
||||
}
|
||||
data.dir.as_ref().map(|d| d.join(RECOVERY))
|
||||
}
|
||||
@@ -623,6 +640,34 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// #6612: on Linux (and other XDG systems) autosaves go to `$XDG_STATE_HOME`, by default
|
||||
/// `~/.local/state`, not the config folder; a relative `XDG_STATE_HOME` is ignored. Windows
|
||||
/// and macOS don't read it, and an override or portable folder keeps them inside itself.
|
||||
#[test]
|
||||
fn autosaves_go_to_the_xdg_state_folder() {
|
||||
let root = temp("recovery-xdg");
|
||||
let settings = root.join("home").join(".config").join("photocraft");
|
||||
let home = root.join("home");
|
||||
let state = root.join("state");
|
||||
let platform = DataDir { dir: Some(settings.clone()), mode: Mode::Platform, warning: None };
|
||||
let xdg = !cfg!(windows) && !cfg!(target_os = "macos");
|
||||
let expect = |xdg_path: PathBuf| Some(if xdg { xdg_path } else { settings.join("Recovery") });
|
||||
assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), expect(state.join("photocraft").join("Recovery")));
|
||||
let default = home.join(".local").join("state").join("photocraft").join("Recovery");
|
||||
assert_eq!(resolve_recovery(&platform, env_of(&[("HOME", &home)])), expect(default.clone()));
|
||||
let relative = PathBuf::from("relative/state");
|
||||
assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &relative), ("HOME", &home)])), expect(default.clone()));
|
||||
let empty = PathBuf::new();
|
||||
assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &empty), ("HOME", &home)])), expect(default));
|
||||
// No home at all: beside the settings, as before.
|
||||
assert_eq!(resolve_recovery(&platform, |_| None), Some(settings.join("Recovery")));
|
||||
for mode in [Mode::Override, Mode::Portable] {
|
||||
let d = DataDir { dir: Some(settings.clone()), mode, warning: None };
|
||||
assert_eq!(resolve_recovery(&d, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), Some(settings.join("Recovery")), "{mode:?}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// An autosave bundle and its sidecar, as `format::Autosaver` writes them.
|
||||
fn autosave(dir: &Path, key: &str) {
|
||||
std::fs::create_dir_all(dir.join(format!("{key}.pcraft"))).unwrap();
|
||||
@@ -784,6 +829,7 @@ mod tests {
|
||||
/// now-existing folder and the original is put back, so both autosaves stay (#6412 review).
|
||||
#[test]
|
||||
fn a_launch_that_times_out_never_writes_where_a_move_removes() {
|
||||
let _spawns = crate::no_child_spawns();
|
||||
let root = temp("recovery-timeout");
|
||||
let legacy = root.join("roaming").join("Recovery");
|
||||
autosave(&legacy, "old");
|
||||
@@ -866,6 +912,7 @@ mod tests {
|
||||
/// once the lock is released, the move happens.
|
||||
#[test]
|
||||
fn a_move_waits_for_another_process_holding_the_lock() {
|
||||
let _spawns = crate::no_child_spawns();
|
||||
let root = temp("recovery-lock");
|
||||
let legacy = root.join("roaming").join("Recovery");
|
||||
autosave(&legacy, "a");
|
||||
|
||||
@@ -34,6 +34,13 @@ 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());
|
||||
@@ -80,6 +87,19 @@ 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();
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
//! - macOS: Metal → CPU
|
||||
//!
|
||||
//! The choice that worked is remembered in `performance.gpuBackend` (Preferences › Performance,
|
||||
//! where it can be reset). With `auto`, Intel adapters on Windows use DX12. `WGPU_BACKEND` still
|
||||
//! overrides everything, and `--safe-gpu` forces the CPU path for one launch.
|
||||
//! 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.
|
||||
//!
|
||||
//! "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
|
||||
@@ -33,6 +35,10 @@ 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;
|
||||
|
||||
@@ -204,6 +210,28 @@ pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Instance backends for `auto` on Windows (no `WGPU_BACKEND`) when `dx12_vendors`, the PCI vendor
|
||||
/// ids of the DX12 adapters, includes Intel: DX12, with GL as the last resort (see [`rank`]). An
|
||||
/// instance with Vulkan loads the Intel Vulkan driver before the adapter selector runs, and #4's
|
||||
/// crash is in that driver. `dx12_vendors` is called only for that plan; `None` keeps [`backends`]'
|
||||
/// choice.
|
||||
pub fn windows_auto_backends(plan: &Plan, os: Os, dx12_vendors: impl FnOnce() -> Vec<u32>) -> Option<wgpu::Backends> {
|
||||
let auto = plan.env.is_none() && plan.backend == GpuBackend::Auto && os == Os::Windows;
|
||||
(auto && dx12_vendors().contains(&INTEL)).then_some(wgpu::Backends::DX12 | wgpu::Backends::GL)
|
||||
}
|
||||
|
||||
/// The PCI vendor ids of the DX12 adapters, from a DX12-only instance (DXGI, no Vulkan driver)
|
||||
/// with `desc`'s flags and backend options (the shader compiler from [`dx12_compiler`]).
|
||||
fn dx12_vendors(desc: &wgpu::InstanceDescriptor) -> Vec<u32> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
backends: wgpu::Backends::DX12,
|
||||
flags: desc.flags,
|
||||
backend_options: desc.backend_options.clone(),
|
||||
..wgpu::InstanceDescriptor::new_without_display_handle()
|
||||
});
|
||||
crate::services::block_on(instance.enumerate_adapters(wgpu::Backends::DX12)).iter().map(|a| a.get_info().vendor).collect()
|
||||
}
|
||||
|
||||
/// The DX12 shader compiler (#712). wgpu's default loads `dxcompiler.dll` by name, and the
|
||||
/// Windows DLL search falls through to the current directory and `PATH`, so another program's DXC
|
||||
/// build (a browser's, an SDK's) got loaded and failed device creation or crashed the first shader
|
||||
@@ -220,6 +248,18 @@ 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 {
|
||||
@@ -228,13 +268,16 @@ pub struct Candidate {
|
||||
pub backend: wgpu::Backend,
|
||||
/// Can present to the window.
|
||||
pub surface_ok: bool,
|
||||
/// Its name matches the adapter the user asked for ([`adapter_matches`]).
|
||||
pub wanted: bool,
|
||||
}
|
||||
|
||||
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
|
||||
/// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for
|
||||
/// Intel on Windows. 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 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).
|
||||
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 {
|
||||
@@ -250,7 +293,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 && a.vendor == INTEL => 0,
|
||||
wgpu::Backend::Dx12 if os == Os::Windows && dx12_first_on_windows(a.vendor).is_some() => 0,
|
||||
wgpu::Backend::Vulkan | wgpu::Backend::Metal => 1,
|
||||
wgpu::Backend::Dx12 => 2,
|
||||
wgpu::Backend::Gl => 3,
|
||||
@@ -259,19 +302,62 @@ fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
|
||||
(fallback, ty, be)
|
||||
}
|
||||
|
||||
/// The adapter to use among `adapters` (`None` when there is none).
|
||||
/// The adapter to use among `adapters` (`None` when there is none). Adapters the user asked for
|
||||
/// win over the rest when any of them can present; otherwise the ranking decides (see
|
||||
/// [`wanted_missed`]).
|
||||
pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option<usize> {
|
||||
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
|
||||
let usable = |a: &&Candidate| a.surface_ok;
|
||||
let any_wanted = adapters.iter().filter(usable).any(|a| a.wanted);
|
||||
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok && (a.wanted || !any_wanted)).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the
|
||||
/// same vendor was available).
|
||||
pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool {
|
||||
let Some(c) = adapters.get(chosen) else { return false };
|
||||
os == Os::Windows
|
||||
&& c.vendor == INTEL
|
||||
&& c.backend == wgpu::Backend::Dx12
|
||||
&& adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)
|
||||
/// Whether an adapter called `name` is the one the user asked for: `wanted` is a case-insensitive
|
||||
/// piece of its name ("nvidia"), or several words that each appear in it ("arc a370m" finds
|
||||
/// "Intel(R) Arc(tm) A370M Graphics"). An empty `wanted` matches nothing.
|
||||
pub fn adapter_matches(name: &str, wanted: &str) -> bool {
|
||||
photocraft_engine::prefs::gpu_adapter_matches(name, wanted)
|
||||
}
|
||||
|
||||
/// The user asked for an adapter and none of `adapters` that can present is it.
|
||||
pub fn wanted_missed(adapters: &[Candidate], wanted: Option<&str>) -> bool {
|
||||
wanted.is_some() && !adapters.iter().any(|a| a.surface_ok && a.wanted)
|
||||
}
|
||||
|
||||
/// Choosing an adapter is Linux-only for now: Windows has its own Intel/DX12 selection and macOS a
|
||||
/// single Metal path, and neither is tested with a requested adapter.
|
||||
pub const ADAPTER_CHOICE: bool = cfg!(target_os = "linux");
|
||||
|
||||
/// The adapter request that applies to this launch: none off Linux (`linux`), and none in CPU mode,
|
||||
/// which wants a software adapter whatever the preference says.
|
||||
pub fn applicable_adapter(wanted: Option<String>, backend: GpuBackend, linux: bool) -> Option<String> {
|
||||
wanted.filter(|_| linux && backend != GpuBackend::Cpu)
|
||||
}
|
||||
|
||||
/// Whether `PHOTOCRAFT_GPU_ADAPTER` makes this launch its own process: only when startup will act
|
||||
/// on it (a non-empty value, on Linux, outside CPU mode). Otherwise it changes nothing, and the
|
||||
/// launch may still be forwarded to the running window.
|
||||
pub fn adapter_override_is_process_specific(env: Option<&str>, mode: RenderingMode, linux: bool) -> bool {
|
||||
env.is_some_and(|v| !v.trim().is_empty()) && linux && mode != RenderingMode::Cpu
|
||||
}
|
||||
|
||||
/// The adapter to ask for: `PHOTOCRAFT_GPU_ADAPTER` when set (for one launch), else
|
||||
/// `performance.gpuAdapter`; `None` is automatic.
|
||||
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)
|
||||
}
|
||||
|
||||
/// wgpu compiles indirect-call validation shaders inside `request_device`; Metal's compiler
|
||||
@@ -283,9 +369,9 @@ 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 on Windows, software adapters for `cpu`) and the marker update once the
|
||||
/// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default).
|
||||
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
|
||||
/// 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).
|
||||
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) {
|
||||
create.instance_descriptor.backends = b;
|
||||
@@ -298,7 +384,13 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel
|
||||
let env = std::env::var("WGPU_DX12_COMPILER").ok();
|
||||
create.instance_descriptor.backend_options.dx12.shader_compiler = dx12_compiler(env.as_deref(), exe.as_deref().and_then(Path::parent));
|
||||
}
|
||||
let select = plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows));
|
||||
let without_vulkan = windows_auto_backends(plan, os, || dx12_vendors(&create.instance_descriptor));
|
||||
if let Some(b) = without_vulkan {
|
||||
create.instance_descriptor.backends = b;
|
||||
}
|
||||
let vulkan_left_out = without_vulkan.is_some();
|
||||
let wanted = applicable_adapter(wanted, plan.backend, ADAPTER_CHOICE);
|
||||
let select = wanted.is_some() || (plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows)));
|
||||
if select {
|
||||
let backend = plan.backend;
|
||||
create.native_adapter_selector = Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| {
|
||||
@@ -311,17 +403,24 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel
|
||||
device_type: i.device_type,
|
||||
backend: i.backend,
|
||||
surface_ok: surface.is_none_or(|s| a.is_surface_supported(s)),
|
||||
wanted: wanted.as_deref().is_some_and(|w| adapter_matches(&i.name, w)),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let i = pick(&cands, backend, os).ok_or_else(|| "no graphics adapter found".to_string())?;
|
||||
if let Some(w) = wanted.as_deref().filter(|_| wanted_missed(&cands, wanted.as_deref())) {
|
||||
*note.lock().unwrap_or_else(PoisonError::into_inner) =
|
||||
Some(format!("No graphics adapter named \"{w}\" can draw the window; using the automatic choice"));
|
||||
}
|
||||
if backend == GpuBackend::Cpu && cands.get(i).is_some_and(|c| c.device_type != wgpu::DeviceType::Cpu) {
|
||||
*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 && intel_dx12_applied(&cands, i, os) {
|
||||
if backend == GpuBackend::Auto
|
||||
&& let Some(maker) = dx12_default_applied(&cands, i, os, vulkan_left_out)
|
||||
{
|
||||
*note.lock().unwrap_or_else(PoisonError::into_inner) =
|
||||
Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into());
|
||||
Some(format!("{maker} graphics on Windows: using DirectX 12 (the {maker} Vulkan driver is known to crash)"));
|
||||
}
|
||||
adapters.get(i).cloned().ok_or_else(|| "no graphics adapter found".to_string())
|
||||
}));
|
||||
@@ -443,6 +542,13 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Preferences › Performance › Graphics Adapter (`performance.gpuAdapter`), read before the window
|
||||
/// opens; empty (automatic) when the file or value is missing.
|
||||
pub fn read_gpu_adapter(path: Option<&Path>) -> String {
|
||||
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
v.get("performance").and_then(|p| p.get("gpuAdapter")).and_then(Value::as_str).unwrap_or("").chars().take(256).collect()
|
||||
}
|
||||
|
||||
/// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless
|
||||
/// turned on), read before the window opens: it picks the surface's present mode.
|
||||
pub fn read_low_latency(path: Option<&Path>) -> bool {
|
||||
@@ -604,7 +710,82 @@ mod tests {
|
||||
}
|
||||
|
||||
fn cand(vendor: u32, device_type: wgpu::DeviceType, backend: wgpu::Backend) -> Candidate {
|
||||
Candidate { vendor, device_type, backend, surface_ok: true }
|
||||
Candidate { vendor, device_type, backend, surface_ok: true, wanted: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_requested_adapter_beats_the_automatic_ranking() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
// The order Vulkan lists this machine's GPUs in: iGPU, dGPU, eGPU.
|
||||
let mut a = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::DiscreteGpu, B::Vulkan), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
|
||||
assert_eq!(pick(&a, Auto, Os::Other), Some(1), "automatic takes the first discrete adapter, the Arc");
|
||||
a[2].wanted = true;
|
||||
assert_eq!(pick(&a, Auto, Os::Other), Some(2));
|
||||
a[2].wanted = false;
|
||||
a[0].wanted = true;
|
||||
assert_eq!(pick(&a, Auto, Os::Other), Some(0), "even the iGPU, when asked for");
|
||||
// A wanted adapter that can't present is skipped, not an error.
|
||||
a[0].surface_ok = false;
|
||||
assert_eq!(pick(&a, Auto, Os::Other), Some(1));
|
||||
assert!(wanted_missed(&a, Some("iris")));
|
||||
assert!(!wanted_missed(&a, None));
|
||||
a[0].surface_ok = true;
|
||||
assert!(!wanted_missed(&a, Some("iris")));
|
||||
// Among several wanted ones (same GPU on Vulkan and GL) the usual ranking decides.
|
||||
let mut gl = [cand(0x10de, T::DiscreteGpu, B::Gl), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
|
||||
gl.iter_mut().for_each(|c| c.wanted = true);
|
||||
assert_eq!(pick(&gl, Auto, Os::Other), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adapter_request_applies_on_linux_outside_cpu_mode() {
|
||||
let w = || Some("nvidia".to_string());
|
||||
assert_eq!(applicable_adapter(w(), Auto, true), w());
|
||||
assert_eq!(applicable_adapter(w(), Vulkan, true), w());
|
||||
assert_eq!(applicable_adapter(w(), Cpu, true), None);
|
||||
assert_eq!(applicable_adapter(w(), Auto, false), None, "Windows and macOS keep their own selection");
|
||||
assert_eq!(applicable_adapter(None, Auto, true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adapter_override_keeps_its_own_process_only_when_it_applies() {
|
||||
let on = |env, mode, linux| adapter_override_is_process_specific(env, mode, linux);
|
||||
assert!(on(Some("nvidia"), RenderingMode::Auto, true));
|
||||
assert!(on(Some("nvidia"), RenderingMode::Gpu, true));
|
||||
assert!(!on(Some("nvidia"), RenderingMode::Cpu, true), "ignored in CPU mode");
|
||||
assert!(!on(Some("nvidia"), RenderingMode::Auto, false), "ignored off Linux");
|
||||
assert!(!on(Some(" "), RenderingMode::Auto, true));
|
||||
assert!(!on(None, RenderingMode::Auto, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_names_match_loosely_and_the_environment_wins() {
|
||||
assert!(adapter_matches("NVIDIA GeForce RTX 5060 Ti", "nvidia"));
|
||||
assert!(adapter_matches("Intel(R) Arc(tm) A370M Graphics (DG2)", " ARC A370M "));
|
||||
assert!(!adapter_matches("Intel(R) Iris(R) Xe Graphics (ADL GT2)", "arc"));
|
||||
assert!(!adapter_matches("anything", ""));
|
||||
assert!(!adapter_matches("anything", " "));
|
||||
assert_eq!(wanted_adapter(None, ""), None);
|
||||
assert_eq!(wanted_adapter(Some(" "), "iris"), Some("iris".into()));
|
||||
assert_eq!(wanted_adapter(Some("nvidia"), "iris"), Some("nvidia".into()));
|
||||
assert_eq!(wanted_adapter(Some(&"x".repeat(1000)), "").map(|w| w.len()), Some(256));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_adapter_is_read_before_the_window_opens() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-gpu-adapter-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("preferences.json");
|
||||
let read = |json: &str| {
|
||||
std::fs::write(&file, json).unwrap();
|
||||
read_gpu_adapter(Some(&file))
|
||||
};
|
||||
assert_eq!(read(r#"{"performance":{"gpuAdapter":"nvidia"}}"#), "nvidia");
|
||||
assert_eq!(read(r#"{"performance":{"gpuAdapter":7}}"#), "");
|
||||
assert_eq!(read(r#"{"performance":{}}"#), "");
|
||||
assert_eq!(read("not json"), "");
|
||||
assert_eq!(read_gpu_adapter(None), "");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -613,13 +794,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!(intel_dx12_applied(&intel, i, Os::Windows));
|
||||
assert_eq!(dx12_default_applied(&intel, i, Os::Windows, false), Some("Intel"));
|
||||
// Elsewhere (and for other vendors) Vulkan stays first.
|
||||
assert_eq!(intel[pick(&intel, Auto, Os::Other).unwrap()].backend, B::Vulkan);
|
||||
let nv = [cand(0x10de, T::DiscreteGpu, B::Dx12), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
|
||||
let i = pick(&nv, Auto, Os::Windows).unwrap();
|
||||
assert_eq!(nv[i].backend, B::Vulkan);
|
||||
assert!(!intel_dx12_applied(&nv, i, Os::Windows));
|
||||
assert_eq!(dx12_default_applied(&nv, i, Os::Windows, false), None);
|
||||
// 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));
|
||||
@@ -630,6 +811,66 @@ 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.
|
||||
#[test]
|
||||
fn auto_on_windows_with_an_intel_adapter_leaves_vulkan_out_of_the_instance() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
const NVIDIA: u32 = 0x10de;
|
||||
const AMD: u32 = 0x1002;
|
||||
const WARP: u32 = 0x1414;
|
||||
let auto = plan(Auto, None, None, false, Os::Windows);
|
||||
let dx12_gl = Some(wgpu::Backends::DX12 | wgpu::Backends::GL);
|
||||
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![INTEL, WARP]), dx12_gl);
|
||||
// Intel iGPU + discrete GPU: the discrete GPU is then used on DX12.
|
||||
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![NVIDIA, INTEL, WARP]), dx12_gl);
|
||||
// No Intel adapter: egui's default backends, Vulkan included.
|
||||
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![NVIDIA, WARP]), None);
|
||||
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![AMD]), None);
|
||||
assert_eq!(windows_auto_backends(&auto, Os::Windows, Vec::new), None);
|
||||
// `WGPU_BACKEND`, `--safe-gpu`, a chosen backend, a crash fallback and other platforms
|
||||
// don't list the DX12 adapters at all.
|
||||
let not_listed = || -> Vec<u32> { panic!("DX12 adapters listed") };
|
||||
for (p, os) in [
|
||||
(plan(Auto, None, Some("vulkan"), false, Os::Windows), Os::Windows),
|
||||
(plan(Auto, None, None, true, Os::Windows), Os::Windows),
|
||||
(plan(Vulkan, None, None, false, Os::Windows), Os::Windows),
|
||||
(plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Windows), Os::Windows),
|
||||
(plan(Auto, None, None, false, Os::Other), Os::Other),
|
||||
(plan(Auto, None, None, false, Os::Mac), Os::Mac),
|
||||
] {
|
||||
assert_eq!(windows_auto_backends(&p, os, not_listed), None, "{p:?}");
|
||||
}
|
||||
// Without Vulkan adapters to compare with, System Info still names the Intel default.
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_prefers_software_adapters() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
@@ -894,7 +1135,7 @@ mod tests {
|
||||
// The app's setup replaces wgpu's default (`Auto`: DXC by name from the search path).
|
||||
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
|
||||
let plan = plan(Auto, None, None, false, Os::Windows);
|
||||
configure(&mut setup, &plan, Os::Windows, Arc::new(Mutex::new(None)), Arc::default());
|
||||
configure(&mut setup, &plan, Os::Windows, None, Arc::new(Mutex::new(None)), Arc::default());
|
||||
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
|
||||
if std::env::var_os("WGPU_DX12_COMPILER").is_none() {
|
||||
let safe = match &create.instance_descriptor.backend_options.dx12.shader_compiler {
|
||||
@@ -918,13 +1159,13 @@ mod tests {
|
||||
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
|
||||
let egui_wgpu::WgpuSetup::CreateNew(before) = &setup else { panic!("a new instance") };
|
||||
let before = before.instance_descriptor.flags;
|
||||
configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, Arc::new(Mutex::new(None)), Arc::default());
|
||||
configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, None, Arc::new(Mutex::new(None)), Arc::default());
|
||||
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
|
||||
assert_eq!(create.instance_descriptor.flags, before, "{os:?}: flags untouched");
|
||||
}
|
||||
if cfg!(target_arch = "x86_64") && std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
|
||||
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
|
||||
configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, Arc::new(Mutex::new(None)), Arc::default());
|
||||
configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, None, Arc::new(Mutex::new(None)), Arc::default());
|
||||
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
|
||||
assert!(!create.instance_descriptor.flags.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
|
||||
}
|
||||
|
||||
@@ -420,6 +420,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let _spawns = crate::no_child_spawns();
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let LogFile { path, file: _file, owner: _owner } = rotate(&dir).expect("rotate");
|
||||
@@ -438,6 +439,7 @@ mod tests {
|
||||
/// `.2.log`, and a fourth deleted it.
|
||||
#[test]
|
||||
fn a_second_start_leaves_the_running_instance_log_alone() {
|
||||
let _spawns = crate::no_child_spawns();
|
||||
let dir = temp_dir("second");
|
||||
let mut first = rotate(&dir).expect("first start");
|
||||
assert_eq!(first.path, dir.join(LOG_FILE));
|
||||
@@ -461,6 +463,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn instance_logs_are_pruned_only_when_no_one_holds_them() {
|
||||
let _spawns = crate::no_child_spawns();
|
||||
let dir = temp_dir("prune");
|
||||
let first = rotate(&dir).expect("first start");
|
||||
// Earlier side-by-side runs that have exited, oldest first; the oldest has no lock file
|
||||
|
||||
+298
-110
@@ -71,8 +71,7 @@ mod services;
|
||||
// macOS gets one instance from LaunchServices (open-documents Apple events).
|
||||
#[cfg(any(not(target_os = "macos"), test))]
|
||||
mod single_instance;
|
||||
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", test))]
|
||||
// Tablet support (macOS AppKit monitor, Linux X11/Wayland, Windows barrel button).
|
||||
mod tablet;
|
||||
mod ui_state;
|
||||
mod window_frame;
|
||||
@@ -103,9 +102,7 @@ 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();
|
||||
// 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 };
|
||||
let viewport = egui::ViewportBuilder::default().with_icon(app_icon::viewport_icon(std::env::current_exe().ok().as_deref()));
|
||||
eframe::NativeOptions {
|
||||
viewport: viewport
|
||||
.with_app_id(APP_ID)
|
||||
@@ -205,6 +202,131 @@ fn print_stdout(text: &str) {
|
||||
let _ = out.write_all(text.as_bytes()).and_then(|()| out.flush());
|
||||
}
|
||||
|
||||
/// Everything the desktop launcher takes from the command line, on top of the environment
|
||||
/// defaults handed in to `parse_launch_args`.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct LaunchArgs {
|
||||
control_port: Option<u16>,
|
||||
control_token: Option<String>,
|
||||
control_token_file: Option<std::path::PathBuf>,
|
||||
automation_read_root: Option<std::path::PathBuf>,
|
||||
automation_write_root: Option<std::path::PathBuf>,
|
||||
files: Vec<String>,
|
||||
/// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1
|
||||
/// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are
|
||||
/// reported in a notice once the app is up; a control token is a usage error like a bad port.
|
||||
unreadable_paths: Vec<String>,
|
||||
safe_gpu: bool,
|
||||
new_instance: bool,
|
||||
/// Options that apply only to the process they're given to; a launch with any keeps its own.
|
||||
launch_options: Vec<&'static str>,
|
||||
present_mode_arg: Option<String>,
|
||||
frame_latency_arg: Option<String>,
|
||||
stroke_trace_arg: Option<String>,
|
||||
in_window_menus: bool,
|
||||
/// `--help` / `-h`: print [`USAGE`] and exit 0; parsing stops there.
|
||||
help: bool,
|
||||
/// `--version`: print the version and exit 0; parsing stops there.
|
||||
version: bool,
|
||||
/// Usage errors (a bad or missing control/automation value, an unknown option); a non-empty
|
||||
/// list fails the launch with exit status 2 (issues #701, #6429, #6431).
|
||||
errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parse the desktop launch arguments over the environment defaults in `out`. A control or
|
||||
/// automation flag with no value is a usage error naming the flag (issue #6429), never a silent
|
||||
/// fall back to a generated token or to no root: like `--control` with a bad port, it exits 2.
|
||||
/// So is an unknown option (#6431); a file whose name starts with `-` goes after `--`.
|
||||
fn parse_launch_args(args: impl Iterator<Item = std::ffi::OsString>, mut out: LaunchArgs) -> LaunchArgs {
|
||||
let mut args = args;
|
||||
// After `--`, every argument is a file, even one that starts with `-`.
|
||||
let mut only_files = false;
|
||||
while let Some(a) = args.next() {
|
||||
if only_files {
|
||||
match a.into_string() {
|
||||
Ok(s) => out.files.push(s),
|
||||
Err(raw) => out.unreadable_paths.push(raw.to_string_lossy().into_owned()),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match a.to_str() {
|
||||
Some("--") => only_files = true,
|
||||
Some("--help" | "-h") => {
|
||||
out.help = true;
|
||||
break;
|
||||
}
|
||||
Some("--control") => match args.next() {
|
||||
Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") {
|
||||
Ok(port) => out.control_port = Some(port),
|
||||
Err(error) => out.errors.push(error),
|
||||
},
|
||||
None => out.errors.push("--control: missing port value (expected `--control <port>`)".to_string()),
|
||||
},
|
||||
Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) {
|
||||
Some(Ok(token)) => out.control_token = Some(token),
|
||||
Some(Err(raw)) => out.errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())),
|
||||
None => out.errors.push("--control-token: missing token value (expected `--control-token <token>`)".to_string()),
|
||||
},
|
||||
Some("--control-token-file") => match args.next() {
|
||||
Some(value) => out.control_token_file = Some(std::path::PathBuf::from(value)),
|
||||
None => out.errors.push("--control-token-file: missing file path value (expected `--control-token-file <path>`)".to_string()),
|
||||
},
|
||||
Some("--automation-read-root") => {
|
||||
out.launch_options.push("--automation-read-root");
|
||||
match args.next() {
|
||||
Some(value) => out.automation_read_root = Some(std::path::PathBuf::from(value)),
|
||||
None => out.errors.push("--automation-read-root: missing directory value (expected `--automation-read-root <dir>`)".to_string()),
|
||||
}
|
||||
}
|
||||
Some("--automation-write-root") => {
|
||||
out.launch_options.push("--automation-write-root");
|
||||
match args.next() {
|
||||
Some(value) => out.automation_write_root = Some(std::path::PathBuf::from(value)),
|
||||
None => out.errors.push("--automation-write-root: missing directory value (expected `--automation-write-root <dir>`)".to_string()),
|
||||
}
|
||||
}
|
||||
Some("--safe-gpu") => {
|
||||
out.launch_options.push("--safe-gpu");
|
||||
out.safe_gpu = true;
|
||||
}
|
||||
Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => {
|
||||
out.launch_options.push(match flag {
|
||||
"--present-mode" => "--present-mode",
|
||||
"--frame-latency" => "--frame-latency",
|
||||
_ => "--stroke-trace",
|
||||
});
|
||||
let flag = flag.to_string();
|
||||
match args.next().map(std::ffi::OsString::into_string) {
|
||||
Some(Ok(v)) => match flag.as_str() {
|
||||
"--present-mode" => out.present_mode_arg = Some(v),
|
||||
"--frame-latency" => out.frame_latency_arg = Some(v),
|
||||
_ => out.stroke_trace_arg = Some(v),
|
||||
},
|
||||
// A bad value is reported and ignored; it never fails the launch.
|
||||
_ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"),
|
||||
}
|
||||
}
|
||||
Some("--in-window-menus") => {
|
||||
out.launch_options.push("--in-window-menus");
|
||||
out.in_window_menus = true;
|
||||
}
|
||||
Some("--new-instance") => out.new_instance = true,
|
||||
Some("--version") => {
|
||||
out.version = true;
|
||||
break;
|
||||
}
|
||||
// Old macOS passes a process serial number when launched from Finder.
|
||||
Some(s) if s.starts_with("-psn_") => {}
|
||||
// A typo such as `--safegpu`, or `--help` spelt another way, must not open the window
|
||||
// and then fail to open the flag as a file (#6431).
|
||||
Some(s) if s.starts_with('-') => out.errors.push(format!("unknown option `{s}` (put `--` before a file name that starts with `-`)")),
|
||||
Some(s) => out.files.push(s.to_owned()),
|
||||
None => out.unreadable_paths.push(a.to_string_lossy().into_owned()),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod control_port_tests {
|
||||
use super::{parse_control_port, usage_exit_code};
|
||||
@@ -234,124 +356,152 @@ mod control_port_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod launch_args_tests {
|
||||
use super::{LaunchArgs, parse_launch_args, usage_exit_code};
|
||||
use std::ffi::OsString;
|
||||
|
||||
fn parse(values: &[&str]) -> LaunchArgs {
|
||||
parse_launch_args(values.iter().map(OsString::from), LaunchArgs::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_without_a_value_are_usage_errors() {
|
||||
// Issue #6429: a missing value must fail the launch, not silently fall back to a
|
||||
// generated token or to no root.
|
||||
for flag in ["--control", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"] {
|
||||
let parsed = parse(&[flag]);
|
||||
assert_eq!(parsed.errors.len(), 1, "{flag}: {:?}", parsed.errors);
|
||||
assert!(parsed.errors[0].starts_with(flag), "{}", parsed.errors[0]);
|
||||
assert!(parsed.errors[0].contains("missing"), "{}", parsed.errors[0]);
|
||||
assert_eq!(usage_exit_code(&parsed.errors), Some(2));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_value_does_not_clear_an_environment_default() {
|
||||
// `--automation-read-root` with no value must not drop a root set through the env var.
|
||||
let defaults = LaunchArgs { automation_read_root: Some("/env/read".into()), automation_write_root: Some("/env/write".into()), ..Default::default() };
|
||||
let parsed = parse_launch_args(["--automation-read-root"].iter().map(OsString::from), defaults);
|
||||
assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/env/read")));
|
||||
assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/env/write")));
|
||||
assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_are_parsed_and_no_errors_remain() {
|
||||
let parsed = parse(&[
|
||||
"--control",
|
||||
"50494",
|
||||
"--control-token",
|
||||
"abcd",
|
||||
"--control-token-file",
|
||||
"/tmp/token",
|
||||
"--automation-read-root",
|
||||
"/r",
|
||||
"--automation-write-root",
|
||||
"/w",
|
||||
"--safe-gpu",
|
||||
"--in-window-menus",
|
||||
"--new-instance",
|
||||
"--present-mode",
|
||||
"mailbox",
|
||||
"photo.psd",
|
||||
]);
|
||||
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
|
||||
assert_eq!(parsed.control_port, Some(50494));
|
||||
assert_eq!(parsed.control_token.as_deref(), Some("abcd"));
|
||||
assert_eq!(parsed.control_token_file.as_deref(), Some(std::path::Path::new("/tmp/token")));
|
||||
assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/r")));
|
||||
assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/w")));
|
||||
assert!(parsed.safe_gpu);
|
||||
assert!(parsed.in_window_menus);
|
||||
assert!(parsed.new_instance);
|
||||
assert_eq!(parsed.present_mode_arg.as_deref(), Some("mailbox"));
|
||||
assert_eq!(parsed.launch_options, vec!["--automation-read-root", "--automation-write-root", "--safe-gpu", "--in-window-menus", "--present-mode"]);
|
||||
assert_eq!(parsed.files, vec!["photo.psd".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_stops_parsing() {
|
||||
let parsed = parse(&["--version", "--control"]);
|
||||
assert!(parsed.version);
|
||||
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_stops_parsing_and_double_dash_ends_options() {
|
||||
let parsed = parse(&["-h", "--safegpu", "--control"]);
|
||||
assert!(parsed.help);
|
||||
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
|
||||
|
||||
let parsed = parse(&["--safegpu", "--", "--help", "-x"]);
|
||||
assert!(!parsed.help);
|
||||
assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors);
|
||||
assert!(parsed.errors[0].contains("unknown option `--safegpu`"), "{}", parsed.errors[0]);
|
||||
assert_eq!(parsed.files, vec!["--help".to_string(), "-x".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
crash_guard::install_hook();
|
||||
// The system font scan can take seconds (minutes on some Macs): run it off the UI thread.
|
||||
photocraft_ui_egui::type_tool::warm_fonts();
|
||||
let mut control_port: Option<u16> = None;
|
||||
let mut usage_errors: Vec<String> = Vec::new();
|
||||
let mut launch = LaunchArgs {
|
||||
automation_read_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from),
|
||||
automation_write_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from),
|
||||
in_window_menus: std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) {
|
||||
match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") {
|
||||
Ok(port) => control_port = Some(port),
|
||||
Err(error) => usage_errors.push(error),
|
||||
Ok(port) => launch.control_port = Some(port),
|
||||
Err(error) => launch.errors.push(error),
|
||||
}
|
||||
}
|
||||
let mut control_token = None;
|
||||
let mut control_token_file = None;
|
||||
let mut automation_read_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from);
|
||||
let mut automation_write_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from);
|
||||
let mut files = Vec::new();
|
||||
// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1
|
||||
// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are
|
||||
// reported in a notice once the app is up; a control token is a usage error like a bad port.
|
||||
let mut unreadable_paths: Vec<String> = Vec::new();
|
||||
let mut safe_gpu = false;
|
||||
let mut new_instance = false;
|
||||
// Options that apply only to the process they're given to; a launch with any keeps its own.
|
||||
let mut launch_options: Vec<&'static str> = Vec::new();
|
||||
let (mut present_mode_arg, mut frame_latency_arg, mut stroke_trace_arg): (Option<String>, Option<String>, Option<String>) = (None, None, None);
|
||||
let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
// After `--`, every argument is a file, even one that starts with `-`.
|
||||
let mut only_files = false;
|
||||
while let Some(a) = args.next() {
|
||||
if only_files {
|
||||
match a.into_string() {
|
||||
Ok(s) => files.push(s),
|
||||
Err(raw) => unreadable_paths.push(raw.to_string_lossy().into_owned()),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match a.to_str() {
|
||||
Some("--") => only_files = true,
|
||||
Some("--help" | "-h") => {
|
||||
print_stdout(USAGE);
|
||||
return Ok(());
|
||||
}
|
||||
Some("--control") => match args.next() {
|
||||
Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") {
|
||||
Ok(port) => control_port = Some(port),
|
||||
Err(error) => usage_errors.push(error),
|
||||
},
|
||||
None => usage_errors.push("--control: missing port value (expected `--control <port>`)".to_string()),
|
||||
},
|
||||
Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) {
|
||||
Some(Ok(token)) => control_token = Some(token),
|
||||
Some(Err(raw)) => usage_errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())),
|
||||
None => control_token = None,
|
||||
},
|
||||
Some("--control-token-file") => control_token_file = args.next().map(std::path::PathBuf::from),
|
||||
Some("--automation-read-root") => {
|
||||
launch_options.push("--automation-read-root");
|
||||
automation_read_root = args.next().map(std::path::PathBuf::from);
|
||||
}
|
||||
Some("--automation-write-root") => {
|
||||
launch_options.push("--automation-write-root");
|
||||
automation_write_root = args.next().map(std::path::PathBuf::from);
|
||||
}
|
||||
Some("--safe-gpu") => {
|
||||
launch_options.push("--safe-gpu");
|
||||
safe_gpu = true;
|
||||
}
|
||||
Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => {
|
||||
launch_options.push(match flag {
|
||||
"--present-mode" => "--present-mode",
|
||||
"--frame-latency" => "--frame-latency",
|
||||
_ => "--stroke-trace",
|
||||
});
|
||||
let flag = flag.to_string();
|
||||
match args.next().map(std::ffi::OsString::into_string) {
|
||||
Some(Ok(v)) => match flag.as_str() {
|
||||
"--present-mode" => present_mode_arg = Some(v),
|
||||
"--frame-latency" => frame_latency_arg = Some(v),
|
||||
_ => stroke_trace_arg = Some(v),
|
||||
},
|
||||
// A bad value is reported and ignored; it never fails the launch.
|
||||
_ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"),
|
||||
}
|
||||
}
|
||||
Some("--in-window-menus") => {
|
||||
launch_options.push("--in-window-menus");
|
||||
in_window_menus = true;
|
||||
}
|
||||
Some("--new-instance") => new_instance = true,
|
||||
Some("--version") => {
|
||||
print_stdout(&format!("photocraft {}\n", photocraft_engine::build_info::long_version()));
|
||||
return Ok(());
|
||||
}
|
||||
// Old macOS passes a process serial number when launched from Finder.
|
||||
Some(s) if s.starts_with("-psn_") => {}
|
||||
// A typo such as `--safegpu`, or `--help` spelt another way, must not open the window
|
||||
// and then fail to open the flag as a file (#6431).
|
||||
Some(s) if s.starts_with('-') => usage_errors.push(format!("unknown option `{s}` (put `--` before a file name that starts with `-`)")),
|
||||
Some(s) => files.push(s.to_owned()),
|
||||
None => unreadable_paths.push(a.to_string_lossy().into_owned()),
|
||||
}
|
||||
launch = parse_launch_args(std::env::args_os().skip(1), launch);
|
||||
if launch.help {
|
||||
print_stdout(USAGE);
|
||||
return Ok(());
|
||||
}
|
||||
if launch.version {
|
||||
print_stdout(&format!("photocraft {}\n", photocraft_engine::build_info::long_version()));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// A malformed control port must not silently drop the control server (issue #701), and an
|
||||
// unknown option must not open the window (#6431): name the bad argument and fail the launch,
|
||||
// like `photocraft-cli` does for the same typos. Exit status 2 is the usual command-line usage
|
||||
// error, so a launcher sees the failure.
|
||||
if let Some(code) = usage_exit_code(&usage_errors) {
|
||||
for error in &usage_errors {
|
||||
// A malformed control port must not silently drop the control server (issue #701), a control
|
||||
// or automation flag without its value must not silently fall back to a generated token or
|
||||
// to no root (issue #6429), and an unknown option must not open the window (#6431): name the
|
||||
// bad argument and fail the launch, like `photocraft-cli` does for the same typos. Exit
|
||||
// status 2 is the usual command-line usage error, so a launcher sees the failure.
|
||||
if let Some(code) = usage_exit_code(&launch.errors) {
|
||||
for error in &launch.errors {
|
||||
eprintln!("photocraft: {error}");
|
||||
}
|
||||
eprintln!("Run `photocraft --help` for usage.");
|
||||
std::process::exit(code);
|
||||
}
|
||||
let LaunchArgs {
|
||||
control_port,
|
||||
control_token,
|
||||
control_token_file,
|
||||
automation_read_root,
|
||||
automation_write_root,
|
||||
files,
|
||||
unreadable_paths,
|
||||
safe_gpu,
|
||||
new_instance,
|
||||
mut launch_options,
|
||||
present_mode_arg,
|
||||
frame_latency_arg,
|
||||
stroke_trace_arg,
|
||||
in_window_menus,
|
||||
help: _,
|
||||
version: _,
|
||||
errors: _,
|
||||
} = launch;
|
||||
|
||||
// The log file lives under the settings directory; opened after the arguments, so `--version`
|
||||
// and usage errors leave no file behind. Records logged until now are written to it first.
|
||||
@@ -363,6 +513,11 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
}
|
||||
|
||||
// An adapter named in the environment is chosen at startup, which a forwarded launch never reaches.
|
||||
let rendering_mode = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref()).1;
|
||||
if gpu_startup::adapter_override_is_process_specific(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), rendering_mode, gpu_startup::ADAPTER_CHOICE) {
|
||||
launch_options.push("PHOTOCRAFT_GPU_ADAPTER");
|
||||
}
|
||||
// One PhotoCraft per user (#3708): files opened from Explorer or a file manager while it runs
|
||||
// become tabs in its window. Only with the per-user settings folder (a portable copy's may be
|
||||
// shared between users), and not for automation, `--new-instance` or per-process options.
|
||||
@@ -474,6 +629,11 @@ fn main() -> eframe::Result {
|
||||
let tablet_monitor = std::cell::OnceCell::new();
|
||||
#[cfg(target_os = "macos")]
|
||||
let tablet_monitor = &tablet_monitor;
|
||||
// The Windows pointer message hook; dropping it unhooks when the event loop returns.
|
||||
#[cfg(target_os = "windows")]
|
||||
let tablet_monitor_win = std::cell::OnceCell::new();
|
||||
#[cfg(target_os = "windows")]
|
||||
let tablet_monitor_win = &tablet_monitor_win;
|
||||
|
||||
// Read the displays' ICC profiles while the window opens (colour-managed canvas; `None`
|
||||
// where the platform has no reader).
|
||||
@@ -525,7 +685,9 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
let gpu_note: std::sync::Arc<std::sync::Mutex<Option<String>>> = Default::default();
|
||||
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
|
||||
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
|
||||
let wanted_adapter =
|
||||
gpu_startup::wanted_adapter(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), &gpu_startup::read_gpu_adapter(services::prefs_file().as_deref()));
|
||||
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, wanted_adapter, sentinel.clone(), gpu_note.clone());
|
||||
// Preferences › Performance › Low-Latency Drawing (#1343): without vsync a stroke shows about
|
||||
// 50 ms sooner; wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers.
|
||||
if gpu_startup::read_low_latency(services::prefs_file().as_deref()) {
|
||||
@@ -621,9 +783,16 @@ fn main() -> eframe::Result {
|
||||
info.canvas = "cpu".into();
|
||||
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);
|
||||
let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu;
|
||||
if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu {
|
||||
app.perf.gpu_info.fallback = Some("No compatible hardware graphics adapter; using software graphics.".into());
|
||||
// Keep an earlier note, such as a requested adapter that couldn't be used.
|
||||
let software = "No compatible hardware graphics adapter; using software graphics.";
|
||||
let fallback = &mut app.perf.gpu_info.fallback;
|
||||
*fallback = Some(match fallback.take() {
|
||||
Some(earlier) => format!("{earlier}. {software}"),
|
||||
None => software.into(),
|
||||
});
|
||||
}
|
||||
if !software_window
|
||||
&& plan.backend != photocraft_engine::prefs::GpuBackend::Cpu
|
||||
@@ -632,8 +801,9 @@ fn main() -> eframe::Result {
|
||||
{
|
||||
app.set_wgpu(rs);
|
||||
} else {
|
||||
// The window still draws with wgpu: record its errors instead of panicking.
|
||||
let _ = photocraft_ui_egui::gpu_canvas::DeviceHealth::watch(&rs.device);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
let fallback_reason = std::env::var("PHOTOCRAFT_GPU_STARTUP_FAILURE").ok().or_else(|| {
|
||||
@@ -700,8 +870,13 @@ fn main() -> eframe::Result {
|
||||
if let Some(monitor) = tablet::install_macos(&app.stylus.feed) {
|
||||
let _ = tablet_monitor.set(monitor);
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(monitor) = tablet::install_windows() {
|
||||
let _ = tablet_monitor_win.set(monitor);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
tablet::spawn_x11(&app.stylus.feed, display);
|
||||
app.services.right_button_down = tablet::right_button_service();
|
||||
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
|
||||
app.open_paths(&files);
|
||||
if !unreadable_paths.is_empty() {
|
||||
@@ -767,6 +942,19 @@ fn main() -> eframe::Result {
|
||||
result
|
||||
}
|
||||
|
||||
/// Tests that spawn child processes hold this for reading, tests that check who holds a file lock
|
||||
/// for writing ([`no_child_spawns`]): until its `exec`, a child holds a copy of every open file,
|
||||
/// `flock`s included, so a spawn in another test could keep a lock this process just dropped held
|
||||
/// for a moment.
|
||||
#[cfg(test)]
|
||||
pub(crate) static CHILD_SPAWNS: std::sync::RwLock<()> = std::sync::RwLock::new(());
|
||||
|
||||
/// Held by a test that checks who holds a file lock: no other test spawns a child meanwhile.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn no_child_spawns() -> std::sync::RwLockWriteGuard<'static, ()> {
|
||||
CHILD_SPAWNS.write().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
|
||||
@@ -495,6 +495,7 @@ mod tests {
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_hung_helper_is_stopped_and_big_replies_dont_block() {
|
||||
let _spawns = crate::CHILD_SPAWNS.read().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let t0 = std::time::Instant::now();
|
||||
let e = run_helper(std::process::Command::new("sleep").arg("30"), std::time::Duration::from_millis(300)).unwrap_err();
|
||||
assert!(e.contains("took longer than"), "{e}");
|
||||
|
||||
@@ -36,17 +36,22 @@ fn mock_portal(script: Script) -> Option<(zbus::blocking::Connection, Seen, std:
|
||||
/// once the user is done with it (here after 300 ms).
|
||||
fn mock_portal_with(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
|
||||
use std::io::BufRead;
|
||||
let spawns = crate::CHILD_SPAWNS.read().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut daemon =
|
||||
std::process::Command::new("dbus-daemon").args(["--session", "--nofork", "--print-address=1"]).stdout(std::process::Stdio::piped()).spawn().ok()?;
|
||||
drop(spawns);
|
||||
let mut address = String::new();
|
||||
std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?;
|
||||
let address = address.trim().to_string();
|
||||
let server = zbus::blocking::connection::Builder::address(address.as_str()).unwrap().name("org.freedesktop.portal.Desktop").unwrap().build().unwrap();
|
||||
let seen: Seen = Arc::default();
|
||||
let log = seen.clone();
|
||||
// A stream only gets the messages that arrive after it exists: made in the thread, it could
|
||||
// miss a `SaveFile` sent before the thread got going, and the call would wait forever.
|
||||
let messages = zbus::blocking::MessageIterator::from(&server);
|
||||
std::thread::spawn(move || {
|
||||
let mut script = script.into_iter();
|
||||
for msg in zbus::blocking::MessageIterator::from(&server) {
|
||||
for msg in messages {
|
||||
let Ok(msg) = msg else { break };
|
||||
let header = msg.header();
|
||||
if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") {
|
||||
|
||||
+168
-35
@@ -6,7 +6,7 @@ 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::RefCell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
@@ -17,12 +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, .ase) and keyboard shortcut sets
|
||||
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .act, .ase) and keyboard shortcut sets
|
||||
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "pdn", "ora", "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", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw",
|
||||
"arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
|
||||
"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", "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",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
|
||||
@@ -36,16 +36,48 @@ fn open_filter_extensions(extensions: &[&str]) -> Vec<String> {
|
||||
extensions.iter().map(|ext| if case_sensitive_globs { ext.chars().map(class).collect() } else { ext.to_string() }).collect()
|
||||
}
|
||||
|
||||
/// An explicit SVG filter keeps vector artwork discoverable even when a native picker defaults to
|
||||
/// the PhotoCraft-only filter. SVG is also included in All Formats for normal multi-format opens.
|
||||
/// File › Open's file types after All Formats, as Photoshop lists them: one per format, so a folder
|
||||
/// of mixed files can be narrowed to one kind (#6337). Together they cover every extension of
|
||||
/// [`OPEN_EXTS`]. An explicit SVG filter also keeps vector artwork discoverable when a native
|
||||
/// picker defaults to another filter.
|
||||
const OPEN_FILTERS: &[(&str, &[&str])] = &[
|
||||
("PhotoCraft", &["pcraft"]),
|
||||
("Photoshop", &["psd", "psb", "psdt"]),
|
||||
("Affinity", &["af", "afdesign", "afphoto", "afpub"]),
|
||||
("Krita", &["kra", "krz"]),
|
||||
("GIMP", &["xcf", "xcfgz", "gz"]),
|
||||
("OpenRaster", &["ora"]),
|
||||
("Paint.NET", &["pdn"]),
|
||||
("SVG", SVG_EXTS),
|
||||
("PNG", &["png"]),
|
||||
("JPEG", &["jpg", "jpeg"]),
|
||||
("HEIF", &["heic", "heif", "hif"]),
|
||||
("TIFF", &["tif", "tiff"]),
|
||||
("WebP", &["webp"]),
|
||||
("GIF", &["gif"]),
|
||||
("BMP", &["bmp"]),
|
||||
("Targa", &["tga"]),
|
||||
("ICO", &["ico"]),
|
||||
("QOI", &["qoi"]),
|
||||
("PCX", &["pcx"]),
|
||||
("DDS", &["dds"]),
|
||||
("XPM", &["xpm"]),
|
||||
("Portable Bitmap", &["pbm", "pgm", "ppm", "pam", "pfm"]),
|
||||
("OpenEXR", &["exr"]),
|
||||
("Radiance HDR", &["hdr"]),
|
||||
("FITS", &["fits", "fit", "fts"]),
|
||||
("DPX and Cineon", &["dpx", "cin"]),
|
||||
("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]),
|
||||
("Brushes", &["abr"]),
|
||||
("Gradients", &["grd"]),
|
||||
("Swatches", &["aco", "act", "ase"]),
|
||||
("Keyboard Shortcuts", &["kys"]),
|
||||
];
|
||||
|
||||
fn open_filters() -> Vec<(&'static str, Vec<String>)> {
|
||||
vec![
|
||||
("All Formats", open_filter_extensions(OPEN_EXTS)),
|
||||
("PhotoCraft", open_filter_extensions(&["pcraft"])),
|
||||
("OpenRaster", open_filter_extensions(&["ora"])),
|
||||
("Krita", open_filter_extensions(&["kra", "krz"])),
|
||||
("SVG", open_filter_extensions(SVG_EXTS)),
|
||||
]
|
||||
std::iter::once(("All Formats", open_filter_extensions(OPEN_EXTS)))
|
||||
.chain(OPEN_FILTERS.iter().map(|(name, exts)| (*name, open_filter_extensions(exts))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// File › Save As formats: (filter name, extensions). The filter matching the suggested name's
|
||||
@@ -68,11 +100,13 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
|
||||
("OpenEXR", &["exr"]),
|
||||
("FITS", &["fits", "fit"]),
|
||||
("DPX", &["dpx"]),
|
||||
// Game and 3D textures; DDS Options asks for the compression and mipmaps (#1327).
|
||||
("DDS", &["dds"]),
|
||||
];
|
||||
|
||||
/// 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"]), ("Swatch Exchange", &["ase"])];
|
||||
const OTHER_SAVE_FILTERS: &[(&str, &[&str])] = &[("Color Swatches", &["aco"]), ("Color Table", &["act"]), ("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>)> {
|
||||
@@ -160,10 +194,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
let dialog = if let Some(exts) = extensions {
|
||||
dialog.add_filter("Supported Files", &open_filter_extensions(&exts.iter().map(String::as_str).collect::<Vec<_>>()))
|
||||
} else {
|
||||
open_filters()
|
||||
.into_iter()
|
||||
.fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions))
|
||||
.add_filter("Paint.NET", &open_filter_extensions(&["pdn"]))
|
||||
open_filters().into_iter().fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions))
|
||||
};
|
||||
if multiple {
|
||||
let picked = dialog.pick_files();
|
||||
@@ -244,7 +275,7 @@ fn path_of(file: &rfd::FileHandle) -> String {
|
||||
}
|
||||
|
||||
/// Runs `future` to completion on this thread, sleeping until it is woken.
|
||||
fn block_on<T>(future: impl Future<Output = T>) -> T {
|
||||
pub(crate) fn block_on<T>(future: impl Future<Output = T>) -> T {
|
||||
struct Unpark(std::thread::Thread);
|
||||
impl std::task::Wake for Unpark {
|
||||
fn wake(self: Arc<Self>) {
|
||||
@@ -265,7 +296,7 @@ fn block_on<T>(future: impl Future<Output = T>) -> T {
|
||||
|
||||
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else `<exe dir>/PhotoCraftData` in
|
||||
/// portable mode, else the platform convention. Everything the app persists lives under it
|
||||
/// except, on Windows, crash-recovery autosaves; see [`crate::app_dirs`].
|
||||
/// except, on Windows and Linux, crash-recovery autosaves; see [`crate::app_dirs`].
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
crate::app_dirs::config_dir()
|
||||
}
|
||||
@@ -280,8 +311,9 @@ pub fn presets_dir() -> Option<PathBuf> {
|
||||
config_dir().map(|d| d.join("Presets"))
|
||||
}
|
||||
|
||||
/// Crash-recovery autosaves: under the settings directory, except on Windows, where they stay
|
||||
/// out of the roaming profile (see [`crate::app_dirs::recovery_dir`]).
|
||||
/// Crash-recovery autosaves: under the settings directory, except where that is synced with the
|
||||
/// settings: the Windows roaming profile and the Linux config folder (see
|
||||
/// [`crate::app_dirs::resolve_recovery`]).
|
||||
fn recovery_dir() -> Option<PathBuf> {
|
||||
crate::app_dirs::recovery_dir()
|
||||
}
|
||||
@@ -355,12 +387,13 @@ 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) = (store.clone(), store.clone(), store.clone());
|
||||
let (s1, s2, s4, s5) = (store.clone(), 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));
|
||||
})),
|
||||
@@ -464,6 +497,7 @@ fn shell_word(s: &str) -> String {
|
||||
|
||||
pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) -> Services {
|
||||
let clip: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
|
||||
let clip_unreadable: Rc<Cell<bool>> = Rc::default();
|
||||
let automation_read = automation.clone().map(|workspace| {
|
||||
Box::new(move |path: &str| {
|
||||
let bytes = workspace.read(path).map_err(|error| error.to_string())?;
|
||||
@@ -537,26 +571,47 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
}),
|
||||
clipboard_get_image: Some({
|
||||
let clip = clip.clone();
|
||||
let unreadable = clip_unreadable.clone();
|
||||
Box::new(move || {
|
||||
let mut slot = clip.try_borrow_mut().ok()?;
|
||||
let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?;
|
||||
let cb = match slot.as_mut() {
|
||||
Some(c) => c,
|
||||
None => slot.insert(arboard::Clipboard::new().ok()?),
|
||||
None => {
|
||||
// arboard opens only when some backend works. In a Flatpak on Wayland none
|
||||
// does (#5031): the sandbox hides the data-control protocol from it, and
|
||||
// `--socket=fallback-x11` gives no X server while a compositor runs.
|
||||
let opened = arboard::Clipboard::new();
|
||||
unreadable.set(opened.is_err());
|
||||
slot.insert(opened.map_err(|e| e.to_string())?)
|
||||
}
|
||||
};
|
||||
// Copied files first: Finder also puts the file's icon on the clipboard as an
|
||||
// image, which would otherwise paste instead of the file.
|
||||
if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) {
|
||||
return Some(img);
|
||||
match cb.get().file_list() {
|
||||
Ok(paths) => {
|
||||
if let Some(img) = image_from_files(&paths) {
|
||||
return Ok(Some(img));
|
||||
}
|
||||
}
|
||||
Err(error @ arboard::Error::ClipboardOccupied) => return Err(error.to_string()),
|
||||
Err(_) => {}
|
||||
}
|
||||
if let Ok(img) = cb.get_image() {
|
||||
return Some((img.width as u32, img.height as u32, img.bytes.into_owned()));
|
||||
match cb.get_image() {
|
||||
Ok(img) => Ok(Some((img.width as u32, img.height as u32, img.bytes.into_owned()))),
|
||||
Err(error) => {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(image) = wayland_clipboard_image() {
|
||||
return Ok(Some(image));
|
||||
}
|
||||
match error {
|
||||
arboard::Error::ContentNotAvailable => Ok(None),
|
||||
_ => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
return wayland_clipboard_image();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
None
|
||||
})
|
||||
}),
|
||||
clipboard_unreadable: Some(Box::new(move || clip_unreadable.get())),
|
||||
load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())),
|
||||
photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))),
|
||||
save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))),
|
||||
@@ -695,6 +750,57 @@ mod tests {
|
||||
assert_eq!(with_chosen_type(Path::new("/pics/art.psd"), opened, &[]), PathBuf::from("/pics/art.psd"));
|
||||
}
|
||||
|
||||
/// Regression using the real clipboard on an isolated Windows CI runner.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
#[ignore = "requires exclusive use of the Windows clipboard"]
|
||||
fn busy_native_clipboard_preserves_copied_shape() {
|
||||
for id in ["edit.paste", "edit.pasteSpecial.pasteInPlace"] {
|
||||
let mut native = native(None);
|
||||
let services = Services {
|
||||
clipboard_set_image: native.clipboard_set_image.take(),
|
||||
clipboard_get_image: native.clipboard_get_image.take(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
app.run("file.new", json!({"width": 80, "height": 60})).unwrap();
|
||||
app.run("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap();
|
||||
app.run("edit.copy", json!({})).unwrap();
|
||||
assert!(app.session.clipboard.as_ref().unwrap().layers.is_some());
|
||||
assert!(app.services.clipboard_get_image.as_mut().unwrap()().unwrap().is_some(), "native export succeeded");
|
||||
app.run("file.new", json!({"width": 80, "height": 60})).unwrap();
|
||||
|
||||
// Another thread holds the OS clipboard open beyond arboard's bounded retry window.
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel();
|
||||
let holder = std::thread::spawn(move || {
|
||||
// A non-null window distinguishes this owner from arboard's null-window opens.
|
||||
let guard = winsafe::HWND::GetDesktopWindow().OpenClipboard().unwrap();
|
||||
ready_tx.send(()).unwrap();
|
||||
release_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap();
|
||||
drop(guard);
|
||||
});
|
||||
ready_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap();
|
||||
let contention = arboard::Clipboard::new().unwrap().get_image().err();
|
||||
let first = app.run(id, json!({}));
|
||||
release_tx.send(()).unwrap();
|
||||
holder.join().unwrap();
|
||||
assert!(matches!(contention, Some(arboard::Error::ClipboardOccupied)), "diagnostic must actually occupy the clipboard: {contention:?}");
|
||||
eprintln!("paste while clipboard occupied: {first:?}");
|
||||
assert!(first.is_err(), "report contention rather than a successful no-op");
|
||||
assert!(
|
||||
app.session.clipboard.as_ref().is_some_and(|clip| clip.layers.is_some()),
|
||||
"a temporary OS read failure must not discard the copied editable shape"
|
||||
);
|
||||
app.run(id, json!({})).unwrap();
|
||||
let state = app.session.active().unwrap();
|
||||
assert!(
|
||||
matches!(state.doc.layer(state.active_layer.unwrap()).unwrap().content, photocraft_doc::LayerContent::Shape(_)),
|
||||
"retry keeps the shape editable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn open_filters_cover_uppercase_and_mixed_case_extensions() {
|
||||
@@ -726,15 +832,42 @@ 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")));
|
||||
}
|
||||
|
||||
/// #6337: after All Formats, every format File › Open reads has its own filter, and no filter
|
||||
/// offers an extension All Formats doesn't.
|
||||
#[test]
|
||||
fn every_open_format_has_its_own_filter() {
|
||||
let filters = open_filters();
|
||||
assert_eq!(filters[0], ("All Formats", open_filter_extensions(OPEN_EXTS)), "All Formats comes first");
|
||||
let mut named: Vec<&str> = OPEN_FILTERS.iter().flat_map(|(_, exts)| exts.iter().copied()).collect();
|
||||
let total = named.len();
|
||||
named.sort_unstable();
|
||||
named.dedup();
|
||||
assert_eq!(named.len(), total, "an extension is listed under two filters");
|
||||
let mut all = OPEN_EXTS.to_vec();
|
||||
all.sort_unstable();
|
||||
assert_eq!(named, all, "the named filters cover All Formats exactly");
|
||||
let names: Vec<&str> = filters.iter().map(|(name, _)| *name).collect();
|
||||
for name in ["Photoshop", "Affinity", "HEIF", "PNG", "JPEG", "TIFF", "Portable Bitmap", "OpenEXR", "Radiance HDR", "Paint.NET"] {
|
||||
assert!(names.contains(&name), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||
#[test]
|
||||
fn open_filters_keep_literal_extensions_on_windows_and_macos() {
|
||||
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]
|
||||
@@ -844,7 +977,7 @@ mod tests {
|
||||
assert!(format.caps().write, "{name}: .{ext} can't be written");
|
||||
}
|
||||
}
|
||||
for ext in ["ico", "gif", "bmp"] {
|
||||
for ext in ["ico", "gif", "bmp", "dds"] {
|
||||
assert!(save_filters("Untitled-1.psd").iter().any(|(_, exts)| exts.iter().any(|e| e == ext)), ".{ext} is offered");
|
||||
}
|
||||
assert_eq!(save_filters("icon.ico")[0].0, "ICO", "an .ico name leads with its own type");
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! 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 and writes the port
|
||||
//! 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 (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
|
||||
//! 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.
|
||||
@@ -26,7 +27,11 @@
|
||||
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};
|
||||
@@ -38,6 +43,9 @@ 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.
|
||||
@@ -59,6 +67,45 @@ 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.
|
||||
@@ -110,6 +157,10 @@ 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 {
|
||||
@@ -151,6 +202,10 @@ 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());
|
||||
}
|
||||
}
|
||||
@@ -211,9 +266,15 @@ pub fn claim(dir: &Path, paths: &[String]) -> Launch {
|
||||
claim_with(dir, paths, TIMING)
|
||||
}
|
||||
|
||||
/// [`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.
|
||||
/// [`claim`] with explicit timeouts, over the transport this process would use ([`transport`]).
|
||||
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()));
|
||||
}
|
||||
@@ -227,7 +288,7 @@ pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
|
||||
loop {
|
||||
match lock.try_lock() {
|
||||
Ok(()) => {
|
||||
return match serve(dir, lock, timing) {
|
||||
return match serve(dir, lock, timing, transport) {
|
||||
Ok(primary) => Launch::Primary(primary),
|
||||
Err(e) => Launch::Standalone(e),
|
||||
};
|
||||
@@ -235,8 +296,8 @@ pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
|
||||
Err(std::fs::TryLockError::WouldBlock) => {}
|
||||
Err(std::fs::TryLockError::Error(e)) => return Launch::Standalone(format!("{}: {e}", lock_path.display())),
|
||||
}
|
||||
if let Some((port, token)) = read_info(dir) {
|
||||
match send(port, &token, paths, deadline) {
|
||||
if let Some((endpoint, token)) = read_info(dir) {
|
||||
match send(&endpoint, &token, paths, deadline) {
|
||||
Ok(()) => return Launch::Forwarded,
|
||||
Err(e) => last = e,
|
||||
}
|
||||
@@ -248,11 +309,15 @@ pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
fn serve(dir: &Path, lock: File, timing: Timing, transport: &Transport) -> Result<Primary, String> {
|
||||
let (listener, endpoint) = Listener::bind(transport)?;
|
||||
let token = generate_token().map_err(|e| e.to_string())?;
|
||||
write_info(dir, port, &token)?;
|
||||
write_info(dir, &endpoint, &token)?;
|
||||
#[cfg(unix)]
|
||||
let socket = match &endpoint {
|
||||
Endpoint::Socket(path) => Some(path.clone()),
|
||||
Endpoint::Port(_) => None,
|
||||
};
|
||||
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 || {
|
||||
@@ -260,7 +325,7 @@ fn serve(dir: &Path, lock: File, timing: Timing) -> Result<Primary, String> {
|
||||
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));
|
||||
@@ -288,11 +353,117 @@ fn serve(dir: &Path, lock: File, timing: Timing) -> Result<Primary, String> {
|
||||
}
|
||||
});
|
||||
spawned.map_err(|e| format!("single-instance listener thread: {e}"))?;
|
||||
Ok(Primary { lock: Mutex::new(Some(lock)), shared })
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one request, hand its paths to the UI and answer `ok` only if the UI took them.
|
||||
fn serve_client(stream: TcpStream, token: &str, shared: &Shared, timing: Timing) {
|
||||
fn serve_client(stream: Stream, 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)),
|
||||
@@ -308,7 +479,7 @@ fn serve_client(stream: TcpStream, token: &str, shared: &Shared, timing: Timing)
|
||||
|
||||
/// A reader that fails once `until` passes, however slowly the bytes trickle in.
|
||||
struct Deadline<'a> {
|
||||
stream: &'a TcpStream,
|
||||
stream: &'a Stream,
|
||||
until: Instant,
|
||||
}
|
||||
|
||||
@@ -317,25 +488,30 @@ 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))?;
|
||||
let mut stream = self.stream;
|
||||
stream.read(buf)
|
||||
self.stream.read_once(buf)
|
||||
}
|
||||
}
|
||||
|
||||
/// The request line, if it arrives complete within `total`.
|
||||
fn read_request(stream: &TcpStream, total: Duration) -> Option<String> {
|
||||
fn read_request(stream: &Stream, 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)
|
||||
}
|
||||
|
||||
/// Port and token, written to a temporary file and renamed so a reader never sees half of it.
|
||||
/// Endpoint 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, port: u16, token: &str) -> Result<(), String> {
|
||||
fn write_info(dir: &Path, endpoint: &Endpoint, token: &str) -> Result<(), String> {
|
||||
let path = dir.join(INFO_FILE);
|
||||
let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id()));
|
||||
let body = json!({"port": port, "token": token, "pid": std::process::id()}).to_string();
|
||||
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();
|
||||
// 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();
|
||||
@@ -349,10 +525,15 @@ fn write_info(dir: &Path, port: u16, token: &str) -> Result<(), String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn read_info(dir: &Path) -> Option<(u16, String)> {
|
||||
fn read_info(dir: &Path) -> Option<(Endpoint, 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((port, v.get("token")?.as_str()?.to_string()))
|
||||
Some((Endpoint::Port(port), token))
|
||||
}
|
||||
|
||||
/// `{"token":…,"open":[…],"wait_ms":…}`: the paths, and how long the sender will wait for the UI.
|
||||
@@ -370,11 +551,10 @@ fn parse_request(line: &str, token: &str) -> Option<(Vec<String>, Duration)> {
|
||||
Some((paths, wait))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// 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> {
|
||||
let left = deadline.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()).ok_or("out of time")?;
|
||||
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
|
||||
let mut stream = TcpStream::connect_timeout(&addr, left).map_err(|e| e.to_string())?;
|
||||
let mut stream = Stream::connect(endpoint, 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});
|
||||
@@ -435,6 +615,21 @@ 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");
|
||||
@@ -496,7 +691,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(|(port, _)| port != 1));
|
||||
assert!(read_info(&dir).is_some_and(|(endpoint, _)| endpoint != Endpoint::Port(1)));
|
||||
drop(primary);
|
||||
// Released with the handle, as on process exit.
|
||||
assert!(matches!(claim_with(&dir, &[], FAST), Launch::Primary(_)));
|
||||
@@ -516,10 +711,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 (port, token) = read_info(&dir).unwrap();
|
||||
let (endpoint, token) = read_info(&dir).unwrap();
|
||||
drop(primary);
|
||||
let short = Instant::now() + Duration::from_millis(300);
|
||||
assert!(send(port, &token, &["b.psd".into()], short).is_err());
|
||||
assert!(send(&endpoint, &token, &["b.psd".into()], short).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -557,7 +752,7 @@ mod tests {
|
||||
let dir = temp_dir("stalled");
|
||||
let primary = primary(&dir);
|
||||
let mut ui = primary.connect(&egui::Context::default());
|
||||
let (port, _) = read_info(&dir).unwrap();
|
||||
let port = port_of(&dir);
|
||||
// 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();
|
||||
@@ -575,7 +770,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, _) = read_info(&dir).unwrap();
|
||||
let port = port_of(&dir);
|
||||
let mut slow = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
|
||||
let started = Instant::now();
|
||||
let mut cut_off = false;
|
||||
@@ -602,7 +797,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, 1, &generate_token().unwrap()).unwrap();
|
||||
write_info(&dir, &Endpoint::Port(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);
|
||||
@@ -616,7 +811,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, closed, &generate_token().unwrap()).unwrap();
|
||||
write_info(&dir, &Endpoint::Port(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);
|
||||
@@ -645,4 +840,79 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79).
|
||||
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79), and
|
||||
//! barrel button on Windows (#5905).
|
||||
//!
|
||||
//! winit 0.30 drops tablet data on both, so `photocraft-tablet` reads it beside winit (an AppKit
|
||||
//! local event monitor; XInput2 raw events on a second X connection) and this module writes each
|
||||
//! sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
|
||||
//! Pointer Events and automation's simulated pen. Windows needs nothing here: winit forwards
|
||||
//! `WM_POINTER` pressure as touch force.
|
||||
//! winit 0.30 drops tablet data on both macOS and X11, so `photocraft-tablet` reads it beside winit
|
||||
//! (an AppKit local event monitor; XInput2 raw events on a second X connection) and this module writes
|
||||
//! each sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
|
||||
//! Pointer Events and automation's simulated pen. On Windows, winit forwards `WM_POINTER` pressure
|
||||
//! as touch force, but drops `PEN_FLAG_BARREL` (`0x0001`); `photocraft-tablet` recovers the barrel
|
||||
//! button through a thread-local message hook beside winit.
|
||||
//!
|
||||
//! Wayland compositors give a pen only to clients that bind the tablet protocol
|
||||
//! (`zwp_tablet_v2`), which winit 0.30 doesn't (and binding it on winit's connection needs
|
||||
@@ -19,15 +21,36 @@ use photocraft_tablet::Sample;
|
||||
use photocraft_ui_egui::stylus::{PenSample, StylusFeed};
|
||||
|
||||
/// A tablet sample as the UI's pen sample.
|
||||
#[allow(dead_code)]
|
||||
pub fn pen_sample(s: Sample) -> PenSample {
|
||||
PenSample { pressure: s.pressure, tilt_x: s.tilt_x, tilt_y: s.tilt_y, rotation: s.rotation, eraser: s.eraser }.sanitized()
|
||||
}
|
||||
|
||||
/// Writes tablet samples into `feed` (`None` = a mouse).
|
||||
#[allow(dead_code)]
|
||||
pub fn sink(feed: StylusFeed) -> impl Fn(Option<Sample>) + Send + 'static {
|
||||
move |s| feed.set(s.map(pen_sample))
|
||||
}
|
||||
|
||||
/// Install the Windows pointer message hook (main thread; keep the result until the event loop
|
||||
/// returns). On failure this logs why: the pen barrel button is then missed.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn install_windows() -> Option<photocraft_tablet::windows::Monitor> {
|
||||
photocraft_tablet::windows::Monitor::install().map_err(|e| log::warn!("{e}")).ok()
|
||||
}
|
||||
|
||||
/// Stylus barrel button down check (Windows). The right mouse button is left to egui, which
|
||||
/// reports the logical secondary button (swapped buttons included) for the app's own window only.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn right_button_service() -> Option<photocraft_ui_egui::RightButtonDownFn> {
|
||||
Some(Box::new(photocraft_tablet::windows::is_barrel_button_down))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn right_button_service() -> Option<photocraft_ui_egui::RightButtonDownFn> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Install the AppKit tablet monitor (main thread, once winit created the shared application:
|
||||
/// from eframe's app creation, not before the event loop). Keep the result until the event loop
|
||||
/// returns. On failure this logs why: pens then paint like a mouse.
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
//! the work area plus its own frame, clear of the taskbar. winit pins the client area to the work
|
||||
//! area either way (`WM_NCCALCSIZE`), so the app's pixels do not move.
|
||||
//!
|
||||
//! An auto-hidden taskbar reserves no work area, so that trim leaves the maximize covering the
|
||||
//! whole monitor, and the shell, taking it for a full-screen app, no longer reveals the taskbar
|
||||
//! when the pointer reaches its edge (#4845). On each edge with an auto-hidden taskbar the guard
|
||||
//! instead stops the window one pixel short of the monitor, as other custom-frame apps do.
|
||||
//!
|
||||
//! The Windows calls go through safe `winsafe` wrappers; the workspace forbids `unsafe`, and
|
||||
//! winsafe can't wrap a raw handle without it, so the window is found by its handle instead.
|
||||
|
||||
@@ -29,7 +34,20 @@ pub use platform::service;
|
||||
mod platform {
|
||||
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
|
||||
use photocraft_ui_egui::WindowFrameFn;
|
||||
use winsafe::{HWND, HwndPlace, POINT, SIZE, co};
|
||||
use winsafe::prelude::Handle;
|
||||
use winsafe::{AtomStr, HWND, HwndPlace, POINT, SIZE, co};
|
||||
|
||||
/// A rect in desktop coordinates: `(left, top, right, bottom)`, right and bottom exclusive.
|
||||
type Rect = (i32, i32, i32, i32);
|
||||
|
||||
/// A monitor edge.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Edge {
|
||||
Left,
|
||||
Top,
|
||||
Right,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
/// A guard that keeps the window borderless each frame, or `None` when the app uses the
|
||||
/// system title bar (the window is decorated) or its handle can't be found.
|
||||
@@ -99,8 +117,11 @@ mod platform {
|
||||
return;
|
||||
};
|
||||
let (m, w) = (mi.rcMonitor, mi.rcWork);
|
||||
let Some((left, top, right, bottom)) =
|
||||
clamped_rect((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom))
|
||||
let (rect, monitor, work) = ((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom));
|
||||
// The taskbars (a couple of `FindWindowEx` calls) are only looked up while the window
|
||||
// covers the whole monitor, which a trimmed maximize no longer does.
|
||||
let Some((left, top, right, bottom)) = clamped_rect(rect, monitor, work)
|
||||
.or_else(|| covers(rect, monitor).then(|| short_of_autohide_taskbars(rect, monitor, work, &taskbar_edges(monitor))).flatten())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -153,6 +174,69 @@ mod platform {
|
||||
(corrected != rect).then_some(corrected)
|
||||
}
|
||||
|
||||
/// Whether `rect` reaches every edge of `monitor`: what the shell takes for a full-screen app.
|
||||
fn covers(rect: Rect, monitor: Rect) -> bool {
|
||||
rect.0 <= monitor.0 && rect.1 <= monitor.1 && rect.2 >= monitor.2 && rect.3 >= monitor.3
|
||||
}
|
||||
|
||||
/// The edges of `monitor` that have a taskbar (the primary `Shell_TrayWnd` or a secondary
|
||||
/// monitor's `Shell_SecondaryTrayWnd`), shown or hidden.
|
||||
fn taskbar_edges(monitor: Rect) -> Vec<Edge> {
|
||||
let mut edges = Vec::new();
|
||||
for class in ["Shell_TrayWnd", "Shell_SecondaryTrayWnd"] {
|
||||
let mut after: Option<HWND> = None;
|
||||
// One taskbar per monitor; the bound only guards against a misbehaving shell.
|
||||
for _ in 0..64 {
|
||||
let Ok(Some(tray)) = HWND::NULL.FindWindowEx(after.as_ref(), AtomStr::from_str(class), None) else {
|
||||
break;
|
||||
};
|
||||
if let Ok(r) = tray.GetWindowRect() {
|
||||
edges.extend(taskbar_edge((r.left, r.top, r.right, r.bottom), monitor));
|
||||
}
|
||||
after = Some(tray);
|
||||
}
|
||||
}
|
||||
edges
|
||||
}
|
||||
|
||||
/// The edge of `monitor` a taskbar with rect `taskbar` sits on: the one nearest its centre,
|
||||
/// or `None` when it is on another monitor. An auto-hidden taskbar is parked mostly past its
|
||||
/// edge with a sliver left on screen, so its centre may lie outside the monitor.
|
||||
fn taskbar_edge(taskbar: Rect, monitor: Rect) -> Option<Edge> {
|
||||
let (tl, tt, tr, tb) = (i64::from(taskbar.0), i64::from(taskbar.1), i64::from(taskbar.2), i64::from(taskbar.3));
|
||||
let (ml, mt, mr, mb) = (i64::from(monitor.0), i64::from(monitor.1), i64::from(monitor.2), i64::from(monitor.3));
|
||||
if tl >= tr || tt >= tb || ml >= mr || mt >= mb || tr <= ml || tl >= mr || tb <= mt || tt >= mb {
|
||||
return None;
|
||||
}
|
||||
// Doubled centre and distances, so nothing rounds.
|
||||
let (cx, cy) = (tl + tr, tt + tb);
|
||||
[(Edge::Left, cx - 2 * ml), (Edge::Top, cy - 2 * mt), (Edge::Right, 2 * mr - cx), (Edge::Bottom, 2 * mb - cy)]
|
||||
.into_iter()
|
||||
.min_by_key(|&(_, distance)| distance)
|
||||
.map(|(edge, _)| edge)
|
||||
}
|
||||
|
||||
/// A maximize covering its monitor, trimmed to stop one pixel short of each edge where a
|
||||
/// taskbar reserves no work area (it auto-hides), so the shell still reveals it there.
|
||||
/// `None` when there is nothing to trim.
|
||||
fn short_of_autohide_taskbars(rect: Rect, monitor: Rect, work: Rect, taskbars: &[Edge]) -> Option<Rect> {
|
||||
if !covers(rect, monitor) {
|
||||
return None;
|
||||
}
|
||||
let (mut l, mut t, mut r, mut b) = rect;
|
||||
for edge in taskbars {
|
||||
match edge {
|
||||
Edge::Left if work.0 == monitor.0 => l = monitor.0.checked_add(1)?,
|
||||
Edge::Top if work.1 == monitor.1 => t = monitor.1.checked_add(1)?,
|
||||
Edge::Right if work.2 == monitor.2 => r = monitor.2.checked_sub(1)?,
|
||||
Edge::Bottom if work.3 == monitor.3 => b = monitor.3.checked_sub(1)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let trimmed = (l, t, r, b);
|
||||
(l < r && t < b && trimmed != rect).then_some(trimmed)
|
||||
}
|
||||
|
||||
/// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes
|
||||
/// `WS_BORDER`). The sizing border (`WS_THICKFRAME`) stays, so edge resizing and Aero snap
|
||||
/// keep working, as they did with winit 0.27's undecorated windows.
|
||||
@@ -162,7 +246,7 @@ mod platform {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{borderless, clamped_rect};
|
||||
use super::{Edge, borderless, clamped_rect, short_of_autohide_taskbars, taskbar_edge};
|
||||
use winsafe::co;
|
||||
|
||||
#[test]
|
||||
@@ -221,6 +305,35 @@ mod platform {
|
||||
assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "dragged");
|
||||
assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "half-screen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_leaves_a_pixel_strip_on_an_auto_hidden_taskbar_edge() {
|
||||
// #4845: an auto-hidden taskbar reserves no work area.
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
let rect = (-8, -8, 1928, 1088);
|
||||
for (edge, expected) in
|
||||
[(Edge::Bottom, (-8, -8, 1928, 1079)), (Edge::Top, (-8, 1, 1928, 1088)), (Edge::Left, (1, -8, 1928, 1088)), (Edge::Right, (-8, -8, 1919, 1088))]
|
||||
{
|
||||
assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[edge]), Some(expected));
|
||||
assert_eq!(short_of_autohide_taskbars(expected, monitor, monitor, &[edge]), None, "already trimmed");
|
||||
}
|
||||
assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[]), None, "no taskbar");
|
||||
// A taskbar that reserves its work area is the clamp's job.
|
||||
assert_eq!(short_of_autohide_taskbars(rect, monitor, (0, 0, 1920, 1032), &[Edge::Bottom]), None);
|
||||
assert_eq!(short_of_autohide_taskbars((0, 0, 960, 1080), monitor, monitor, &[Edge::Bottom]), None, "half-screen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_taskbar_sits_on_the_monitor_edge_nearest_its_centre() {
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
assert_eq!(taskbar_edge((0, 1032, 1920, 1080), monitor), Some(Edge::Bottom), "shown");
|
||||
assert_eq!(taskbar_edge((0, 1078, 1920, 1126), monitor), Some(Edge::Bottom), "auto-hidden");
|
||||
assert_eq!(taskbar_edge((0, -46, 1920, 2), monitor), Some(Edge::Top));
|
||||
assert_eq!(taskbar_edge((-60, 0, 2, 1080), monitor), Some(Edge::Left));
|
||||
assert_eq!(taskbar_edge((1918, 0, 1980, 1080), monitor), Some(Edge::Right));
|
||||
assert_eq!(taskbar_edge((1920, 1032, 3840, 1080), monitor), None, "other monitor");
|
||||
assert!(taskbar_edge((i32::MIN, i32::MIN, i32::MAX, i32::MAX), monitor).is_some(), "no overflow");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- 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>
|
||||
|
After Width: | Height: | Size: 454 B |
@@ -5,6 +5,9 @@
|
||||
- **Headless:** an in-process `Headless` session owns the engine and performs file I/O.
|
||||
- **Bridge:** `BridgeClient` forwards tools to a desktop application started with `photocraft --control <port>`.
|
||||
|
||||
For a source checkout, first [install the CLI on PATH and register the server](../../../docs/mcp.md#install-and-register).
|
||||
The installed binary survives `cargo clean`; a symlink into `target/release` does not.
|
||||
|
||||
Start the headless server with:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -19,6 +19,7 @@ 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.
|
||||
- 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
|
||||
the document's CMYK ICC profile (relative colorimetric with black point compensation) and embed
|
||||
|
||||
@@ -159,13 +159,35 @@ sync_github_name = false
|
||||
real_name = "Ufuk Efe"
|
||||
sync_github_name = true
|
||||
|
||||
[people.nicolasfalesy]
|
||||
sync_github_name = true
|
||||
|
||||
[people.sidney]
|
||||
real_name = "Sidney Markowitz"
|
||||
sync_github_name = true
|
||||
|
||||
[people.TaylorBurke]
|
||||
sync_github_name = true
|
||||
|
||||
[people.1Git2Clone]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Byxis]
|
||||
sync_github_name = true
|
||||
|
||||
[people.00xJS]
|
||||
sync_github_name = true
|
||||
|
||||
[people.guillehr2]
|
||||
real_name = "Guillem Hermida"
|
||||
sync_github_name = true
|
||||
|
||||
[people.fjamesprice]
|
||||
sync_github_name = true
|
||||
|
||||
[people.ha1des]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -139,11 +139,18 @@ zstd window, 4096 saved revisions, array lengths no longer than the
|
||||
bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before
|
||||
anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of
|
||||
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
|
||||
nodes per curve, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient
|
||||
nodes per curve, 4 levels of spot colours defined through spot colours, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient
|
||||
stops; `photocraft-io` adds 300 000 pixels a side per document and 64 megapixels per image on the
|
||||
canvas. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
|
||||
crate has no panics outside tests.
|
||||
|
||||
Compound outlines have a separate active-reference cycle check and a depth cap of 64. Across
|
||||
the document, geometry expansion admits at most 500 000 combined outline calls and operand-slot
|
||||
visits (including nulls and non-object values), and four million points created or copied into
|
||||
compounds. Operand slots are charged before filtering, without first collecting an operand list.
|
||||
A compound that crosses a limit or contains a cycle is omitted
|
||||
with a warning as a whole; ordinary shared operands remain valid and keep each occurrence's transform.
|
||||
|
||||
## Privacy
|
||||
|
||||
Affinity documents can hold the folder they were saved in, user names, original image paths and
|
||||
|
||||
@@ -6,10 +6,103 @@ use crate::stream::{ObjId, Tag, Value};
|
||||
|
||||
/// Nodes per curve object.
|
||||
const MAX_NODES: usize = 4_000_000;
|
||||
const MAX_COMPOUND_DEPTH: usize = 64;
|
||||
const MAX_OUTLINE_VISITS: usize = 500_000;
|
||||
const MAX_OUTLINE_POINTS: usize = 4_000_000;
|
||||
|
||||
/// Geometry is expanded per occurrence, since parametric outlines can depend on their world
|
||||
/// transform. Bound work across the entire document instead of memoizing by object id alone.
|
||||
pub(crate) struct Budget {
|
||||
visits_left: usize,
|
||||
points_left: usize,
|
||||
}
|
||||
|
||||
impl Default for Budget {
|
||||
fn default() -> Self {
|
||||
Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS }
|
||||
}
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
/// Charge an outline call or a raw compound operand slot before inspecting it.
|
||||
fn visit(&mut self) -> Result<(), &'static str> {
|
||||
self.visits_left = self.visits_left.checked_sub(1).ok_or("compound geometry budget exhausted")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Charge both leaf geometry and each copy into a compound before allocating that copy.
|
||||
fn path(&mut self, path: &Path) -> Result<(), &'static str> {
|
||||
let points = path.subpaths.iter().fold(0usize, |n, s| n.saturating_add(1).saturating_add(s.segments.len().saturating_mul(3)));
|
||||
let Some(left) = self.points_left.checked_sub(points) else {
|
||||
// Do not rebuild an expensive leaf for every subsequent root after exhaustion.
|
||||
self.visits_left = 0;
|
||||
return Err("compound geometry budget exhausted");
|
||||
};
|
||||
self.points_left = left;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The node-space outline of a curve, shape or compound node, and whether it fills even-odd.
|
||||
/// Instances (master-page or symbol copies) take the geometry of their master.
|
||||
pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> {
|
||||
match outline_inner(r, id, class, world, &mut Vec::new()) {
|
||||
Ok(path) => path,
|
||||
Err(reason) => {
|
||||
r.warn(reason);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn outline_inner(r: &mut Reader, id: ObjId, class: Tag, world: Affine, active: &mut Vec<ObjId>) -> Result<Option<(Path, bool)>, &'static str> {
|
||||
r.geometry_budget.visit()?;
|
||||
if active.contains(&id) {
|
||||
return Err("cyclic compound shape");
|
||||
}
|
||||
// Independent of the layer-tree limit: Comp operands do not pass through Reader::node.
|
||||
if active.len() >= MAX_COMPOUND_DEPTH {
|
||||
return Err("compound depth exceeds 64 levels");
|
||||
}
|
||||
if class != Tag::of(b"Comp") {
|
||||
let path = leaf_outline(r, id, class, world);
|
||||
if let Some((path, _)) = &path {
|
||||
r.geometry_budget.path(path)?;
|
||||
}
|
||||
return Ok(path);
|
||||
}
|
||||
active.push(id);
|
||||
let result = compound(r, id, world, active);
|
||||
active.pop();
|
||||
result
|
||||
}
|
||||
|
||||
fn compound(r: &mut Reader, id: ObjId, world: Affine, active: &mut Vec<ObjId>) -> Result<Option<(Path, bool)>, &'static str> {
|
||||
let s = r.s;
|
||||
// One path of all operands' contours, filled even-odd, as before. A safety failure rejects
|
||||
// the whole outline: a partial add/subtract shape could silently change its meaning.
|
||||
let mut path = Path::default();
|
||||
// Charge every slot before filtering nulls or malformed values. Stream::objs would scan
|
||||
// and allocate the whole list first, repeating uncharged work for each shared occurrence.
|
||||
let children = if let Some(Value::Array(children)) = s.field(id, b"Chld") { children.as_slice() } else { &[] };
|
||||
for child in children {
|
||||
r.geometry_budget.visit()?;
|
||||
let Value::Obj(Some(c)) = child else { continue };
|
||||
let c = *c;
|
||||
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
|
||||
let Some(class) = s.class(c) else { return Ok(None) };
|
||||
if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) {
|
||||
r.warn("compound shapes that intersect or exclude (imported as add)");
|
||||
}
|
||||
if let Some((p, _)) = outline_inner(r, c, class, local.then(world), active)? {
|
||||
r.geometry_budget.path(&p)?;
|
||||
path.subpaths.extend(p.transformed(local).subpaths);
|
||||
}
|
||||
}
|
||||
Ok(Some((path, true)))
|
||||
}
|
||||
|
||||
fn leaf_outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> {
|
||||
let s = r.s;
|
||||
match &class.0.to_be_bytes() {
|
||||
b"PCrv" | b"TxtC" => {
|
||||
@@ -18,23 +111,6 @@ pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> O
|
||||
let multi = path.subpaths.len() > 1;
|
||||
Some((path, multi))
|
||||
}
|
||||
b"Comp" => {
|
||||
// One path of all the operands' contours in the compound's space, filled even-odd: this
|
||||
// matches add and subtract operands in every public sample; a true union of overlapping
|
||||
// added operands would differ.
|
||||
let mut path = Path::default();
|
||||
for c in s.objs(id, b"Chld") {
|
||||
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
|
||||
let class = s.class(c)?;
|
||||
if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) {
|
||||
r.warn("compound shapes that intersect or exclude (imported as add)");
|
||||
}
|
||||
if let Some((p, _)) = outline(r, c, class, local.then(world)) {
|
||||
path.subpaths.extend(p.transformed(local).subpaths);
|
||||
}
|
||||
}
|
||||
Some((path, true))
|
||||
}
|
||||
_ => {
|
||||
let (shape, box_owner) = match s.obj(id, b"Shpe") {
|
||||
Some(sh) => (sh, id),
|
||||
|
||||
@@ -298,6 +298,7 @@ pub(crate) struct Reader<'s, 'a, 'b> {
|
||||
warnings: BTreeMap<String, usize>,
|
||||
nodes: 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
|
||||
/// far hold).
|
||||
pixels_left: usize,
|
||||
@@ -305,7 +306,16 @@ pub(crate) struct Reader<'s, 'a, 'b> {
|
||||
|
||||
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
fn new(s: &'s Stream, archive: &'b mut Archive<'a>, limits: Limits) -> Self {
|
||||
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: limits.max_pixel_bytes }
|
||||
Self {
|
||||
s,
|
||||
archive,
|
||||
dpi: 72.0,
|
||||
warnings: BTreeMap::new(),
|
||||
nodes: 0,
|
||||
active: HashSet::new(),
|
||||
geometry_budget: crate::geometry::Budget::default(),
|
||||
pixels_left: limits.max_pixel_bytes,
|
||||
}
|
||||
}
|
||||
|
||||
/// Admit a layer's decoded pixels before they are allocated (#4960). `rgba` is the layer's
|
||||
|
||||
@@ -134,8 +134,19 @@ fn floats(v: &Value) -> Option<Vec<f64>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spot colours defined through spot colours. A spot colour (`Pant`) is read through its RGB
|
||||
/// equivalent, else its base colour, which in real files hold plain colours. Colours are shared
|
||||
/// objects, so one can link to itself, and with both links followed at every level a colour read
|
||||
/// costs up to 2^(depth+1) lookups.
|
||||
const MAX_SPOT_DEPTH: usize = 4;
|
||||
|
||||
/// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`).
|
||||
pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
|
||||
color_at(r, id, 0)
|
||||
}
|
||||
|
||||
/// [`color`] of an object `depth` spot colours down.
|
||||
fn color_at(r: &mut Reader, id: ObjId, depth: usize) -> Option<Color> {
|
||||
let s = r.s;
|
||||
let class = s.class(id)?;
|
||||
let col = || -> Option<Vec<f64>> {
|
||||
@@ -166,8 +177,12 @@ pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
|
||||
Color::Lab { l: 100.0 * u(0)?, a: 255.0 * u(1)? - 128.0, b: 255.0 * u(2)? - 128.0, alpha: u(3)? }
|
||||
}
|
||||
b"Pant" => {
|
||||
if depth >= MAX_SPOT_DEPTH {
|
||||
r.warn("spot colours nested deeper than 4 levels (left empty)");
|
||||
return None;
|
||||
}
|
||||
r.warn("spot colours (imported as their RGB equivalent)");
|
||||
return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c)));
|
||||
return s.obj(id, b"srgb").and_then(|c| color_at(r, c, depth + 1)).or_else(|| s.obj(id, b"base").and_then(|c| color_at(r, c, depth + 1)));
|
||||
}
|
||||
b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 },
|
||||
_ => {
|
||||
|
||||
@@ -538,3 +538,174 @@ mod pixels {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spot colours that link to themselves or to each other. Each document is read on a 1 MiB
|
||||
/// stack, the smallest main-thread stack PhotoCraft runs on (Windows), first in a child process
|
||||
/// that must finish within 20 s. A stack overflow aborts the process it happens in, so unbounded
|
||||
/// recursion fails the one test that caused it instead of taking the other tests in the binary
|
||||
/// down with it.
|
||||
mod spot_colours {
|
||||
use std::io::{Read, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::container::Limits;
|
||||
use crate::model::{Document, Kind};
|
||||
use crate::paint::{Color, Paint};
|
||||
use crate::stream::Tag;
|
||||
use crate::synth::{self, F, Method, tag};
|
||||
|
||||
/// A one-page document whose page holds `top`, read in a child process first and then here.
|
||||
fn read(top: Vec<F>) -> Document {
|
||||
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(top))]);
|
||||
let data = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
|
||||
let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None);
|
||||
let test = std::thread::current().name().unwrap_or("reader").to_owned();
|
||||
read_in_child(&bytes, &test);
|
||||
read_on_small_stack(bytes, &test).unwrap()
|
||||
}
|
||||
|
||||
/// Reads on a 1 MiB thread named after the test, so a stack overflow message names the test.
|
||||
fn read_on_small_stack(bytes: Vec<u8>, test: &str) -> Result<Document, crate::Error> {
|
||||
let reader = std::thread::Builder::new().name(test.to_owned()).stack_size(1 << 20).spawn(move || crate::read(&bytes, Limits::default())).unwrap();
|
||||
reader.join().unwrap_or_else(|panic| std::panic::resume_unwind(panic))
|
||||
}
|
||||
|
||||
/// Set in the child process to the name of the test it reads for.
|
||||
const CHILD: &str = "PHOTOCRAFT_AFFINITY_READ_CHILD";
|
||||
|
||||
/// Runs `read_child` in a new process of this test binary on `bytes` and fails the test if
|
||||
/// that process crashes, panics or takes over 20 s, or never read the document.
|
||||
fn read_in_child(bytes: &[u8], test: &str) {
|
||||
let mut child = Command::new(std::env::current_exe().unwrap())
|
||||
.args(["tests::spot_colours::read_child", "--exact", "--ignored", "--nocapture", "--test-threads=1"])
|
||||
.env(CHILD, test)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let drain = |mut pipe: Box<dyn Read + Send>| {
|
||||
std::thread::spawn(move || {
|
||||
let mut text = String::new();
|
||||
let _ = pipe.read_to_string(&mut text);
|
||||
text
|
||||
})
|
||||
};
|
||||
let stdout = drain(Box::new(child.stdout.take().unwrap()));
|
||||
let stderr = drain(Box::new(child.stderr.take().unwrap()));
|
||||
// Fed from a thread, so a child that never reads cannot block the deadline below; a child
|
||||
// that dies first breaks the pipe and is reported by its exit status.
|
||||
let (mut stdin, input) = (child.stdin.take().unwrap(), bytes.to_vec());
|
||||
let feed = std::thread::spawn(move || {
|
||||
let _ = stdin.write_all(&input);
|
||||
});
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
break Some(status);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break None;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
feed.join().unwrap();
|
||||
let output = format!("{}{}", stdout.join().unwrap(), stderr.join().unwrap());
|
||||
match status {
|
||||
None => panic!("reading the document did not finish within 20 s:\n{output}"),
|
||||
Some(status) if !status.success() => panic!("reading the document failed in a child process ({status}):\n{output}"),
|
||||
// A filter that matched no test would also exit successfully, having read nothing.
|
||||
Some(_) => assert!(output.contains(&format!("read {} bytes", bytes.len())), "the child process did not read the document:\n{output}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Not a test of its own: `read_in_child` runs it in a child process to read the document on
|
||||
/// its stdin. Without the variable, as when run with `--ignored`, it does nothing.
|
||||
#[test]
|
||||
#[ignore = "run by read_in_child in a child process"]
|
||||
fn read_child() {
|
||||
let Some(test) = std::env::var_os(CHILD) else { return };
|
||||
let mut bytes = Vec::new();
|
||||
std::io::stdin().read_to_end(&mut bytes).unwrap();
|
||||
let len = bytes.len();
|
||||
let _ = read_on_small_stack(bytes, &test.to_string_lossy());
|
||||
println!("read {len} bytes");
|
||||
}
|
||||
|
||||
/// An ellipse filled with `colour`.
|
||||
fn filled(id: u32, colour: F) -> F {
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"ShpN")],
|
||||
vec![
|
||||
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 1.0, 1.0])),
|
||||
(tag(b"Shpe"), F::Def(id + 1_000_000, vec![tag(b"ShpE")], vec![])),
|
||||
(tag(b"BFFl"), F::Shared(vec![F::Def(id + 2_000_000, vec![tag(b"FilS")], vec![(tag(b"Colr"), colour)])])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn spot(id: u32, fields: Vec<(Tag, F)>) -> F {
|
||||
F::Def(id, vec![tag(b"Pant")], fields)
|
||||
}
|
||||
|
||||
fn fills(doc: &Document) -> &[Paint] {
|
||||
match &doc.spreads[0].nodes[0].kind {
|
||||
Kind::Shape { fills, .. } => fills,
|
||||
other => panic!("expected a shape, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn warned(doc: &Document, what: &str) -> bool {
|
||||
doc.warnings.iter().any(|w| w.contains(what))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spot_colour_that_is_its_own_rgb_equivalent_is_refused() {
|
||||
let doc = read(vec![filled(10, spot(20, vec![(tag(b"srgb"), F::Ref(20))]))]);
|
||||
assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
|
||||
assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_colours_that_are_each_others_base_and_rgb_equivalent_are_refused() {
|
||||
let doc = read(vec![filled(10, spot(20, vec![(tag(b"base"), spot(21, vec![(tag(b"srgb"), F::Ref(20))]))]))]);
|
||||
assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
|
||||
assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_colours_of_spot_colours_do_not_take_exponential_time() {
|
||||
// Each level's RGB equivalent and base are the next level, and the last is in an unknown
|
||||
// model, so both are followed everywhere: 2^41 colour reads before the fix.
|
||||
fn level(id: u32, left: u32) -> F {
|
||||
if left == 0 {
|
||||
return F::Def(id, vec![tag(b"XXXX")], vec![]);
|
||||
}
|
||||
spot(id, vec![(tag(b"srgb"), level(id + 1, left - 1)), (tag(b"base"), F::Ref(id + 1))])
|
||||
}
|
||||
let doc = read(vec![filled(10, level(100, 40))]);
|
||||
assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
|
||||
}
|
||||
|
||||
/// `spots` spot colours, each defined through the next, around an opaque RGB colour.
|
||||
fn spots(id: u32, spots: u32) -> F {
|
||||
if spots == 0 {
|
||||
let rgba: Vec<u8> = [1.0f32, 0.5, 0.25, 1.0].iter().flat_map(|c| c.to_le_bytes()).collect();
|
||||
return F::Def(id, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct(rgba))]);
|
||||
}
|
||||
spot(id, vec![(tag(b"srgb"), self::spots(id + 1, spots - 1))])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_colours_nest_four_levels_and_no_deeper() {
|
||||
let doc = read(vec![filled(10, spots(100, 4))]);
|
||||
assert_eq!(fills(&doc), [Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.25, a: 1.0 })]);
|
||||
let doc = read(vec![filled(10, spots(100, 5))]);
|
||||
assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
|
||||
assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! #6423: compound operands form a graph, not necessarily a tree.
|
||||
#![cfg(feature = "synth")]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use photocraft_affinity::model::{Document, Kind};
|
||||
use photocraft_affinity::synth::{self, F, Method, tag};
|
||||
use photocraft_affinity::{Limits, read};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn compound(id: u32, children: Vec<F>) -> F {
|
||||
F::Def(id, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::Shared(children))])
|
||||
}
|
||||
|
||||
fn rectangle() -> F {
|
||||
F::Def(100, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(101, vec![tag(b"ShNR")], vec![]))])
|
||||
}
|
||||
|
||||
fn document_bytes(definitions: Vec<F>, roots: Vec<F>) -> Vec<u8> {
|
||||
let data = synth::stream(&[
|
||||
(tag(b"Defs"), F::Shared(definitions)),
|
||||
(
|
||||
tag(b"DocR"),
|
||||
F::Def(
|
||||
1,
|
||||
vec![tag(b"DocN")],
|
||||
vec![(
|
||||
tag(b"Chld"),
|
||||
F::Shared(vec![F::Def(
|
||||
2,
|
||||
vec![tag(b"Sprd")],
|
||||
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(roots))],
|
||||
)]),
|
||||
)],
|
||||
),
|
||||
),
|
||||
]);
|
||||
synth::container(&[("doc.dat", &data, Method::Zlib)], None)
|
||||
}
|
||||
|
||||
fn document(definitions: Vec<F>, roots: Vec<F>) -> Document {
|
||||
read(&document_bytes(definitions, roots), Limits::default()).unwrap()
|
||||
}
|
||||
|
||||
// A stack overflow aborts rather than unwinds. Keep that failure (and an exponential hang)
|
||||
// inside a child process, so the regression fails without killing the entire test runner.
|
||||
fn isolated(name: &str, check: impl FnOnce()) {
|
||||
if std::env::var("PHOTOCRAFT_COMPOUND_CASE").as_deref() == Ok(name) {
|
||||
check();
|
||||
return;
|
||||
}
|
||||
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", name, "--nocapture"])
|
||||
.env("PHOTOCRAFT_COMPOUND_CASE", name)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
assert!(status.success(), "compound import failed: {status}");
|
||||
break;
|
||||
}
|
||||
if start.elapsed() > Duration::from_secs(10) {
|
||||
child.kill().unwrap();
|
||||
child.wait().unwrap();
|
||||
panic!("compound import exceeded ten seconds");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported(doc: &Document, warning: &str) {
|
||||
assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Unsupported));
|
||||
assert!(doc.warnings.iter().any(|w| w.contains(warning)), "{:?}", doc.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_referencing_compound_is_reported_without_aborting() {
|
||||
isolated("self_referencing_compound_is_reported_without_aborting", || {
|
||||
let doc = document(vec![], vec![compound(10, vec![F::Ref(10)]), rectangle()]);
|
||||
unsupported(&doc, "cyclic compound");
|
||||
assert!(matches!(doc.spreads[0].nodes[1].kind, Kind::Shape { .. }), "a cycle must not poison unrelated shapes");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indirectly_cyclic_compound_is_not_imported_as_a_partial_shape() {
|
||||
isolated("indirectly_cyclic_compound_is_not_imported_as_a_partial_shape", || {
|
||||
let doc = document(vec![rectangle()], vec![compound(10, vec![F::Ref(100), compound(11, vec![F::Ref(10)])])]);
|
||||
unsupported(&doc, "cyclic compound");
|
||||
});
|
||||
}
|
||||
|
||||
fn nested(levels: u32, repeated: bool) -> Document {
|
||||
// Flat definitions bypass the stream parser's nesting cap: only references form the depth.
|
||||
let mut definitions = vec![rectangle()];
|
||||
let mut previous = 100;
|
||||
for id in 200..200 + levels {
|
||||
let children = if repeated { vec![F::Ref(previous), F::Ref(previous)] } else { vec![F::Ref(previous)] };
|
||||
definitions.push(compound(id, children));
|
||||
previous = id;
|
||||
}
|
||||
document(definitions, vec![F::Ref(previous)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_compound_graph_is_bounded_on_a_small_stack() {
|
||||
isolated("deep_compound_graph_is_bounded_on_a_small_stack", || {
|
||||
std::thread::Builder::new().stack_size(512 * 1024).spawn(|| unsupported(&nested(1000, false), "compound depth")).unwrap().join().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_operands_cannot_expand_exponentially() {
|
||||
isolated("shared_operands_cannot_expand_exponentially", || unsupported(&nested(40, true), "compound geometry budget"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_empty_operands_also_have_a_work_limit() {
|
||||
isolated("shared_empty_operands_also_have_a_work_limit", || {
|
||||
let mut definitions = vec![compound(100, vec![])];
|
||||
let mut previous = 100;
|
||||
for id in 200..240 {
|
||||
definitions.push(compound(id, vec![F::Ref(previous), F::Ref(previous)]));
|
||||
previous = id;
|
||||
}
|
||||
unsupported(&document(definitions, vec![F::Ref(previous)]), "compound geometry budget");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_budget_is_shared_across_layer_roots() {
|
||||
isolated("geometry_budget_is_shared_across_layer_roots", || {
|
||||
let mut definitions = vec![rectangle()];
|
||||
let mut previous = 100;
|
||||
for id in 200..214 {
|
||||
definitions.push(compound(id, vec![F::Ref(previous), F::Ref(previous)]));
|
||||
previous = id;
|
||||
}
|
||||
let mut roots = vec![F::Ref(previous); 12];
|
||||
roots.push(F::Ref(100));
|
||||
let doc = document(definitions, roots);
|
||||
let nodes = &doc.spreads[0].nodes;
|
||||
assert!(matches!(nodes[0].kind, Kind::Shape { .. }), "a modest shared graph still imports");
|
||||
assert!(matches!(nodes.last().unwrap().kind, Kind::Unsupported), "later roots cannot reset the exhausted budget");
|
||||
assert!(doc.warnings.iter().any(|w| w.contains("compound geometry budget")));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_shared_operands_keep_both_transformed_copies() {
|
||||
let mut translated = compound(201, vec![F::Ref(100)]);
|
||||
let F::Def(_, _, fields) = &mut translated else { unreachable!() };
|
||||
fields.push((tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 30.0, 0.0, 1.0, 40.0])));
|
||||
let doc = document(vec![rectangle(), compound(200, vec![F::Ref(100)]), translated], vec![compound(202, vec![F::Ref(200), F::Ref(201)])]);
|
||||
assert!(doc.warnings.is_empty(), "{:?}", doc.warnings);
|
||||
let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected compound outline") };
|
||||
assert_eq!(path.subpaths.len(), 2);
|
||||
for (a, b) in path.subpaths[0].segments.iter().flatten().zip(path.subpaths[1].segments.iter().flatten()) {
|
||||
assert_eq!(b.x, a.x + 30.0);
|
||||
assert_eq!(b.y, a.y + 40.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_operand_slots_cannot_bypass_geometry_budget() {
|
||||
isolated("null_operand_slots_cannot_bypass_geometry_budget", || {
|
||||
let bytes = document_bytes(vec![compound(10, vec![F::Null; 1_000_000])], vec![F::Ref(10); 20_000]);
|
||||
let start = Instant::now();
|
||||
let doc = read(&bytes, Limits::default()).unwrap();
|
||||
eprintln!("one million nulls / 20,000 placements: {} bytes, read {:.3}s", bytes.len(), start.elapsed().as_secs_f64());
|
||||
unsupported(&doc, "compound geometry budget");
|
||||
assert_eq!(doc.spreads[0].nodes.len(), 20_000);
|
||||
assert!(doc.spreads[0].nodes.iter().all(|n| matches!(n.kind, Kind::Unsupported)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_operand_slots_share_budget_across_layer_roots() {
|
||||
isolated("null_operand_slots_share_budget_across_layer_roots", || {
|
||||
let doc = document(vec![compound(10, vec![F::Null; 1_000])], vec![F::Ref(10); 1_000]);
|
||||
assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Shape { .. }), "a small empty compound remains valid");
|
||||
assert!(matches!(doc.spreads[0].nodes.last().unwrap().kind, Kind::Unsupported));
|
||||
assert!(doc.warnings.iter().any(|w| w.contains("compound geometry budget")));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_operand_slots_cannot_leave_a_partial_shape() {
|
||||
isolated("null_operand_slots_cannot_leave_a_partial_shape", || {
|
||||
let mut slots = vec![F::Ref(100)];
|
||||
slots.extend(vec![F::Null; 1_000_000]);
|
||||
let doc = document(vec![rectangle(), compound(10, slots)], vec![F::Ref(10)]);
|
||||
unsupported(&doc, "compound geometry budget");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_object_operand_slots_also_consume_budget() {
|
||||
isolated("non_object_operand_slots_also_consume_budget", || {
|
||||
let comp = F::Def(10, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::U8s(vec![0; 1_000_000]))]);
|
||||
unsupported(&document(vec![comp], vec![F::Ref(10)]), "compound geometry budget");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_null_operand_slots_preserve_shared_contours() {
|
||||
let doc = document(vec![rectangle(), compound(10, vec![F::Null, F::Ref(100), F::Null, F::Ref(100), F::Null])], vec![F::Ref(10)]);
|
||||
assert!(doc.warnings.is_empty(), "{:?}", doc.warnings);
|
||||
let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected compound outline") };
|
||||
assert_eq!(path.subpaths.len(), 2);
|
||||
assert_eq!(path.subpaths[0], path.subpaths[1]);
|
||||
}
|
||||
@@ -33,6 +33,8 @@ 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()] },
|
||||
@@ -60,7 +62,12 @@ 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);
|
||||
println!("{:<18} {w}x{h} RGBA8: {:.2?}", p.label(), t.elapsed());
|
||||
// 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());
|
||||
drop(out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Time and peak memory of the Photomerge multi-band blend: `n` RGB sources of `mp` megapixels
|
||||
//! (3:2), each overlapping the next by half its width.
|
||||
//! `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]` (default 4 × 24 MP).
|
||||
//! Peak memory is the growth of the process's high-water mark during the blend (Linux only).
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_algo::panorama::{self, RoiImage};
|
||||
|
||||
/// The process's peak resident set in MiB (`VmHWM`), where `/proc` has it.
|
||||
fn peak_mib() -> Option<f64> {
|
||||
let s = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
let kb: f64 = s.lines().find(|l| l.starts_with("VmHWM:"))?.split_whitespace().nth(1)?.parse().ok()?;
|
||||
Some(kb / 1024.0)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let n: usize = args.next().and_then(|a| a.parse().ok()).unwrap_or(4).max(1);
|
||||
let mp: f64 = args.next().and_then(|a| a.parse().ok()).unwrap_or(24.0);
|
||||
let sh = ((mp * 1e6 / 1.5).sqrt().round() as usize).max(2);
|
||||
let sw = sh * 3 / 2;
|
||||
let step = sw / 2;
|
||||
let (cw, ch) = (step * (n - 1) + sw, sh);
|
||||
let rois: Vec<RoiImage> = (0..n)
|
||||
.map(|k| {
|
||||
let px = (0..sw * sh * 3).map(|i| ((i / 3 % sw * 7 + i / 3 / sw * 3 + k * 31 + i % 3 * 50) % 251) as f32 / 251.0).collect();
|
||||
RoiImage { x0: (step * k) as i32, y0: 0, w: sw, h: sh, ch: 3, px, alpha: vec![1.0; sw * sh] }
|
||||
})
|
||||
.collect();
|
||||
// Each canvas column belongs to the image whose centre is nearest, as a seam would split it.
|
||||
let wts: Vec<Vec<f32>> = (0..n)
|
||||
.map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(n - 1) == k { 1.0 } else { 0.0 }).collect())
|
||||
.collect();
|
||||
let levels = panorama::blend_levels(cw, ch).min(7);
|
||||
let before = peak_mib();
|
||||
let t0 = Instant::now();
|
||||
let (px, cov) = panorama::multiband(cw, ch, &rois, &wts, levels);
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
let peak = peak_mib().zip(before).map_or("n/a".to_string(), |(a, b)| format!("{:.0} MiB", a - b));
|
||||
println!("{n} × {sw}×{sh} ({mp} MP) RGB, canvas {cw}×{ch}, {levels} levels: {ms:.0} ms, peak +{peak} during the blend");
|
||||
std::hint::black_box((px, cov));
|
||||
}
|
||||
+140
-25
@@ -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(0.5 + g("size") * 0.3),
|
||||
F::HalftonePattern => gs(halftone_cell(e) * 0.15),
|
||||
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,6 +454,55 @@ 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 };
|
||||
@@ -506,6 +555,7 @@ 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);
|
||||
@@ -933,36 +983,46 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
||||
}
|
||||
}
|
||||
F::HalftonePattern => {
|
||||
let size = g("size");
|
||||
let con = g("contrast") / 50.0;
|
||||
let cell = 3.0 + size * 2.0;
|
||||
// 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 mut l = win.lum();
|
||||
blur1(&mut l, w, h, 0.5 + size * 0.3);
|
||||
blur1(&mut l, w, h, cell * 0.15);
|
||||
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 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 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 (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 (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 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);
|
||||
}
|
||||
}
|
||||
@@ -1254,6 +1314,43 @@ 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 {
|
||||
@@ -1305,7 +1402,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.
|
||||
/// writes `<key>.raw` (RGB8) beside the input for every filter (or one, see `GALLERY_ONLY`).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn gallery_sheet() {
|
||||
@@ -1318,11 +1415,29 @@ 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();
|
||||
for f in crate::GalleryFilter::ALL {
|
||||
let fp = FilterParams::FilterGallery { effects: vec![GalleryEffect::new(*f)] };
|
||||
// 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] };
|
||||
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!("{}.raw", f.key())), raw).unwrap();
|
||||
std::fs::write(dir.join(format!("{}{tag}.raw", f.key())), raw).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+69
-4
@@ -212,12 +212,17 @@ fn box_pass_px<const N: usize>(src: &[f32], dst: &mut [f32], r: usize) {
|
||||
}
|
||||
|
||||
/// Box passes of the given radii in turn along the line in `s.line` (`w`-float elements, as many
|
||||
/// as `dst` holds plus twice the radii's sum; consumed), ending in `dst`.
|
||||
fn line_passes(dst: &mut [f32], w: usize, radii: &[usize], s: &mut Scratch) {
|
||||
/// as `dst` holds plus twice the radii's sum; consumed), ending in `dst`. Stops early (leaving
|
||||
/// `dst` unfinished) when `ctl` is cancelled before a pass: a column strip's passes take tens of
|
||||
/// milliseconds on a tall image, too long to finish once cancelled (#6205).
|
||||
fn line_passes(dst: &mut [f32], w: usize, radii: &[usize], s: &mut Scratch, ctl: &Interrupt) {
|
||||
let Scratch { line, next, acc } = s;
|
||||
let Some(len) = dst.len().checked_div(w) else { return };
|
||||
let mut tail: usize = radii.iter().sum();
|
||||
for (k, &r) in radii.iter().enumerate() {
|
||||
if ctl.cancelled() {
|
||||
return;
|
||||
}
|
||||
tail -= r;
|
||||
if k + 1 == radii.len() {
|
||||
box_pass(line, dst, w, r, acc);
|
||||
@@ -281,7 +286,7 @@ pub(crate) fn box_blur(
|
||||
(0..left).for_each(|_| s.line.extend_from_slice(first));
|
||||
s.line.extend_from_slice(&row);
|
||||
(0..right).for_each(|_| s.line.extend_from_slice(last));
|
||||
line_passes(dst, n, &radii, &mut s);
|
||||
line_passes(dst, n, &radii, &mut s, ctl);
|
||||
}
|
||||
});
|
||||
// Vertical passes, strip by strip, the same way down the columns.
|
||||
@@ -300,7 +305,10 @@ pub(crate) fn box_blur(
|
||||
(0..rows).for_each(|y| s.line.extend_from_slice(row(y)));
|
||||
(0..bottom).for_each(|_| s.line.extend_from_slice(row(rows - 1)));
|
||||
let mut res = vec![0.0f32; oh * w];
|
||||
line_passes(&mut res, w, &radii, &mut s);
|
||||
line_passes(&mut res, w, &radii, &mut s, ctl);
|
||||
if ctl.cancelled() {
|
||||
return (t, Vec::new());
|
||||
}
|
||||
unpremultiply(&mut res, n, alpha);
|
||||
(t, res)
|
||||
});
|
||||
@@ -1180,4 +1188,61 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A column strip's passes stop between passes once cancelled, not only before the strip
|
||||
/// (#6205); uncancelled, every pass runs.
|
||||
#[test]
|
||||
fn line_passes_stop_between_passes_when_cancelled() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let (radii, w, len) = ([2usize, 3, 2], 4usize, 50usize);
|
||||
let reach: usize = radii.iter().sum();
|
||||
let line: Vec<f32> = (0..(len + 2 * reach) * w).map(|i| (i % 7) as f32 + 1.0).collect();
|
||||
let run = |ctl: &Interrupt| {
|
||||
let mut s = Scratch { line: line.clone(), ..Scratch::default() };
|
||||
let mut dst = vec![0.0f32; len * w];
|
||||
line_passes(&mut dst, w, &radii, &mut s, ctl);
|
||||
dst
|
||||
};
|
||||
assert!(run(&Interrupt::NONE).iter().all(|v| *v > 0.0));
|
||||
let calls = AtomicUsize::new(0);
|
||||
let cancel = || calls.fetch_add(1, Ordering::SeqCst) >= 1;
|
||||
// Cancelled after the first pass: the last pass (the one writing `dst`) never runs.
|
||||
assert!(run(&Interrupt::cancel_only(&cancel)).iter().all(|v| *v == 0.0));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
/// A box blur cancelled at any of its checks returns `None` (never a half-blurred result);
|
||||
/// one that is asked but never cancelled is bit-identical to the uninterruptible run.
|
||||
#[test]
|
||||
fn box_blur_honours_a_cancel_at_every_check() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let (n, src) = (4usize, Rect::new(0, 0, 150, 90));
|
||||
let px: Vec<f32> = (0..src.width() as usize * src.height() as usize * n).map(|i| (i as f32 * 0.13).sin() * 0.5 + 0.5).collect();
|
||||
let read = |r: Rect, buf: &mut Vec<f32>| {
|
||||
buf.clear();
|
||||
let (x0, x1) = ((r.x0 * n as i32) as usize, (r.x1 * n as i32) as usize);
|
||||
let row = r.y0 as usize * src.width() as usize * n;
|
||||
buf.extend_from_slice(&px[row + x0..row + x1]);
|
||||
};
|
||||
let Some(boxes) = box_widths(&FilterParams::GaussianBlur { radius: 12.0 }) else { panic!("box passes") };
|
||||
let out = Rect::new(-5, 3, 155, 80);
|
||||
let want = box_blur(src, &read, out, n, true, &boxes, &Interrupt::NONE).unwrap();
|
||||
let checks = AtomicUsize::new(0);
|
||||
let count = || {
|
||||
checks.fetch_add(1, Ordering::SeqCst);
|
||||
false
|
||||
};
|
||||
let got = box_blur(src, &read, out, n, true, &boxes, &Interrupt::cancel_only(&count)).unwrap();
|
||||
assert_eq!(got.len(), want.len());
|
||||
for ((ta, a), (tb, b)) in got.iter().zip(&want) {
|
||||
assert_eq!(ta, tb);
|
||||
assert!(a.iter().zip(b).all(|(x, y)| x.to_bits() == y.to_bits()), "strip {ta:?} differs");
|
||||
}
|
||||
let total = checks.load(Ordering::SeqCst);
|
||||
for k in (0..total).step_by((total / 40).max(1)) {
|
||||
let seen = AtomicUsize::new(0);
|
||||
let cancel = || seen.fetch_add(1, Ordering::SeqCst) >= k;
|
||||
assert!(box_blur(src, &read, out, n, true, &boxes, &Interrupt::cancel_only(&cancel)).is_none(), "cancelled at check {k}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+125
-22
@@ -189,6 +189,11 @@ 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);
|
||||
@@ -201,31 +206,48 @@ 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 offs: Vec<(i32, i32)> = (-ri..=ri)
|
||||
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)
|
||||
.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 mut q = [0.0f32; MAXC];
|
||||
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 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]| (0..cc).any(|c| (q[c] - me[c]).abs() > t);
|
||||
let differs = |q: &[f32]| q[..cc].iter().zip(&me).any(|(q, m)| (q - m).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 edge = [(1, 0), (0, 1), (-1, 0), (0, -1)].into_iter().any(|(dx, dy)| differs(sample(at(x + dx, y + dy))));
|
||||
let a = if ctx.alpha { px[n - 1] } else { 1.0 };
|
||||
if edge {
|
||||
px[..cc].copy_from_slice(&white[..cc]);
|
||||
@@ -237,18 +259,28 @@ 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) in &offs {
|
||||
for (c, v) in q.iter_mut().enumerate().take(n) {
|
||||
*v = src.get(x + dx, y + dy, c);
|
||||
}
|
||||
if differs(&q[..n]) {
|
||||
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) {
|
||||
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] };
|
||||
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;
|
||||
}
|
||||
}
|
||||
wsum += 1.0;
|
||||
}
|
||||
@@ -262,6 +294,77 @@ 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> {
|
||||
@@ -335,7 +438,7 @@ pub(crate) fn lens_blur(src: &Image, out: Rect, ctx: &Ctx, spec: &LensSpec) -> V
|
||||
}
|
||||
src.get(x, y, n - 1)
|
||||
}
|
||||
DepthSource::LayerMask => spec.depth_map?.get(x, y, 0),
|
||||
DepthSource::LayerMask | DepthSource::Channel => spec.depth_map?.get(x, y, 0),
|
||||
};
|
||||
Some(if spec.invert { 1.0 - d } else { d })
|
||||
};
|
||||
|
||||
@@ -67,22 +67,16 @@ impl Encoding {
|
||||
pub(crate) fn of(gamma: f32) -> Self {
|
||||
Encoding(if gamma.is_finite() && gamma > 0.0 { gamma.clamp(0.5, 4.0) } else { 0.0 })
|
||||
}
|
||||
/// A value in this encoding as linear light.
|
||||
pub(crate) fn decode(self, v: f32) -> f32 {
|
||||
if self.0 > 0.0 { v.max(0.0).powf(self.0) } else { srgb_to_linear(v) }
|
||||
}
|
||||
/// Linear light back into this encoding, the inverse of [`Encoding::decode`].
|
||||
pub(crate) fn encode(self, v: f32) -> f32 {
|
||||
if self.0 > 0.0 { v.max(0.0).powf(1.0 / self.0) } else { linear_to_srgb(v) }
|
||||
}
|
||||
}
|
||||
|
||||
fn srgb_encode(v: f32) -> f32 {
|
||||
linear_to_srgb(v)
|
||||
}
|
||||
|
||||
fn srgb_decode(v: f32) -> f32 {
|
||||
srgb_to_linear(v)
|
||||
}
|
||||
|
||||
fn luma_linear(c: [f32; 3]) -> f32 {
|
||||
luma(c)
|
||||
}
|
||||
|
||||
@@ -173,6 +173,39 @@ fn up(p: &Plane, w: usize, h: usize) -> Plane {
|
||||
Plane { w, h, v: out }
|
||||
}
|
||||
|
||||
/// The source rows of [`up`]`(p, w, h)` at output row `y`, one per tap (`None` where the
|
||||
/// zero-stuffed row is all zeros).
|
||||
fn up_rows(p: &Plane, h: usize, y: usize) -> [Option<usize>; 5] {
|
||||
let lh = p.h.min(h.div_ceil(2));
|
||||
std::array::from_fn(|k| {
|
||||
let m = mirror(y as isize + k as isize - 2, h);
|
||||
(m.is_multiple_of(2) && m / 2 < lh).then_some(m / 2)
|
||||
})
|
||||
}
|
||||
|
||||
/// One sample of [`up`]`(p, w, h)`: column `x` of the output row whose [`up_rows`] are `rows`.
|
||||
/// It adds the same terms in the same order as `up`, so it gives the same bits.
|
||||
fn up_at(p: &Plane, w: usize, rows: &[Option<usize>; 5], x: usize) -> f32 {
|
||||
let lw = p.w.min(w.div_ceil(2));
|
||||
let col = |j: usize| -> f32 { (0..5).filter_map(|k| rows[k].map(|r| TAPS[k] * (p.v[r * p.w + j] * 4.0))).sum() };
|
||||
if x >= 2 && x + 2 < w {
|
||||
// Away from the edges no tap mirrors.
|
||||
(0..5)
|
||||
.filter_map(|k| {
|
||||
let i = x + k - 2;
|
||||
(i.is_multiple_of(2) && i / 2 < lw).then(|| TAPS[k] * col(i / 2))
|
||||
})
|
||||
.sum()
|
||||
} else {
|
||||
(0..5)
|
||||
.filter_map(|k| {
|
||||
let m = mirror(x as isize + k as isize - 2, w);
|
||||
(m.is_multiple_of(2) && m / 2 < lw).then(|| TAPS[k] * col(m / 2))
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Levels down to about 2 pixels on the short side.
|
||||
fn depth(w: usize, h: usize) -> usize {
|
||||
let mut s = w.min(h);
|
||||
@@ -217,14 +250,14 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
|
||||
});
|
||||
let gr = gaussian(m, n);
|
||||
for l in 0..n - 1 {
|
||||
let coarse = up(&gr[l + 1], gr[l].w, gr[l].h);
|
||||
let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w);
|
||||
let (gl, fine, lw, lh, coarse) = (&g[l].v, &gr[l].v, g[l].w, g[l].h, &gr[l + 1]);
|
||||
rows(&mut lap[l], lw, |y, row| {
|
||||
let taps = up_rows(coarse, lh, y);
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let i = y * lw + x;
|
||||
let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0);
|
||||
if wgt > 0.0 {
|
||||
*o += wgt * (fine[i] - coarse.v[i]);
|
||||
*o += wgt * (fine[i] - up_at(coarse, lw, &taps, x));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -244,8 +277,10 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
|
||||
/// The `q` quantile (0 … 1) of `v`.
|
||||
fn quantile(v: &[f32], q: f32) -> f32 {
|
||||
let mut s = v.to_vec();
|
||||
s.sort_by(f32::total_cmp);
|
||||
s[((s.len() - 1) as f32 * q).round() as usize]
|
||||
// Selecting the one element is linear, a full sort is not (5 sorts of the work copy were a
|
||||
// fifteenth of a render). `total_cmp` ties are bit-identical, so it is the sorted element.
|
||||
let i = ((s.len() - 1) as f32 * q).round() as usize;
|
||||
*s.select_nth_unstable_by(i, f32::total_cmp).1
|
||||
}
|
||||
|
||||
/// 1st and 99th percentiles of the log luminance on a copy at most 256 pixels across.
|
||||
@@ -444,11 +479,33 @@ mod tests {
|
||||
assert_eq!(blur_step(&p, step).v, blur_step_reference(&p, step).v, "blur {w}×{h} step {step}");
|
||||
}
|
||||
for (uw, uh) in [(w * 2, h * 2), (w * 2 - 1, h * 2 - 1), (w * 2, h * 2 - 1), (w.max(2) * 2 - 3, h * 2)] {
|
||||
assert_eq!(up(&p, uw, uh).v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}");
|
||||
let full = up(&p, uw, uh);
|
||||
assert_eq!(full.v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}");
|
||||
// The filter samples `up` one pixel at a time; every sample has the same bits.
|
||||
for y in 0..uh {
|
||||
let taps = up_rows(&p, uh, y);
|
||||
for x in 0..uw {
|
||||
assert_eq!(up_at(&p, uw, &taps, x).to_bits(), full.v[y * uw + x].to_bits(), "up_at {w}×{h} → {uw}×{uh} at {x},{y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantile_is_the_sorted_element() {
|
||||
let p = noise_plane(301, 211, 9);
|
||||
let mut v = p.v.clone();
|
||||
// Ties and signed zeros, which `total_cmp` orders by their bits.
|
||||
v.extend([0.0, -0.0, 0.0, -0.0, 3.5, 3.5, f32::MIN_POSITIVE]);
|
||||
let mut sorted = v.clone();
|
||||
sorted.sort_by(f32::total_cmp);
|
||||
for q in [0.0, 0.001, 0.01, 0.25, 0.5, 0.99, 1.0] {
|
||||
let want = sorted[((sorted.len() - 1) as f32 * q).round() as usize];
|
||||
assert_eq!(quantile(&v, q).to_bits(), want.to_bits(), "q {q}");
|
||||
}
|
||||
}
|
||||
|
||||
fn grey(w: usize, h: usize, f: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> {
|
||||
(0..w * h).map(|i| f(i % w, i / w)).map(|v| [v, v, v, 1.0]).collect()
|
||||
}
|
||||
|
||||
@@ -369,14 +369,14 @@ pub(crate) fn apply(px: &mut [[f32; 4]], width: usize, w: f32, b: f32, enc: Enco
|
||||
if per_channel {
|
||||
for c in 0..3 {
|
||||
let over = (lin[c] - 1.0).max(0.0);
|
||||
q[c] = enc.encode(super::srgb_decode(grey(super::srgb_encode(lin[c].min(1.0)))) + over);
|
||||
q[c] = enc.encode(super::srgb_to_linear(grey(super::linear_to_srgb(lin[c].min(1.0)))) + over);
|
||||
}
|
||||
} else {
|
||||
let y = super::luma_linear(lin).max(0.0);
|
||||
if y <= 1e-7 {
|
||||
continue;
|
||||
}
|
||||
let y2 = super::srgb_decode(grey(super::srgb_encode(y.min(1.0)))) + (y - 1.0).max(0.0);
|
||||
let y2 = super::srgb_to_linear(grey(super::linear_to_srgb(y.min(1.0)))) + (y - 1.0).max(0.0);
|
||||
let k = y2 / y;
|
||||
for c in 0..3 {
|
||||
q[c] = enc.encode(lin[c] * k);
|
||||
|
||||
@@ -45,12 +45,9 @@ pub const PATCH_RADIUS: usize = 15;
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
/// The next value of the SplitMix64 sequence.
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
crate::photo_util::splitmix64(&mut self.0)
|
||||
}
|
||||
fn unit(&mut self) -> f64 {
|
||||
(self.next() >> 11) as f64 / (1u64 << 53) as f64
|
||||
|
||||
@@ -65,15 +65,13 @@ const SOURCE_MOMENTS_MAX: usize = 1 << 20;
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Rng(u64);
|
||||
impl Rng {
|
||||
/// A generator whose sequence is fixed by `seed`.
|
||||
pub(crate) fn new(seed: u64) -> Self {
|
||||
Rng(seed ^ 0x9E37_79B9_7F4A_7C15)
|
||||
}
|
||||
/// The next value of the SplitMix64 sequence.
|
||||
pub(crate) fn next(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
crate::photo_util::splitmix64(&mut self.0)
|
||||
}
|
||||
/// Skip `n` numbers (the same state as after `n` calls of [`Rng::next`]).
|
||||
pub(crate) fn skip(&mut self, n: u64) {
|
||||
|
||||
+43
-5
@@ -411,6 +411,14 @@ 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,
|
||||
@@ -482,7 +490,8 @@ pub enum FilterParams {
|
||||
distribution: Distribution,
|
||||
monochromatic: bool,
|
||||
seed: u32,
|
||||
/// Depth for [`DepthSource::LayerMask`] (channel 0, document coordinates), supplied by the caller.
|
||||
/// Depth for [`DepthSource::LayerMask`] and [`DepthSource::Channel`] (channel 0, document
|
||||
/// coordinates), supplied by the caller.
|
||||
#[serde(skip)]
|
||||
depth_map: Option<std::sync::Arc<Image>>,
|
||||
},
|
||||
@@ -605,7 +614,13 @@ 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 { .. })
|
||||
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 { .. })
|
||||
}
|
||||
|
||||
/// Human-readable name.
|
||||
@@ -652,6 +667,8 @@ 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",
|
||||
@@ -696,7 +713,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 { .. } => Halo::Radius(0),
|
||||
FilterParams::Fibers { .. } | FilterParams::LensFlare { .. } | FilterParams::Clouds { .. } => 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()),
|
||||
@@ -744,8 +761,8 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
|
||||
FilterParams::AddNoise { amount, distribution, monochromatic, seed } => noise::add(src, out, ctx, *amount, *distribution, *monochromatic, *seed),
|
||||
FilterParams::Median { radius } => noise::median(src, out, *radius, None),
|
||||
FilterParams::DustAndScratches { radius, threshold } => noise::median(src, out, *radius, Some(*threshold)),
|
||||
FilterParams::Minimum { radius, preserve } => other::min_max_preserve(src, out, *radius, false, *preserve),
|
||||
FilterParams::Maximum { radius, preserve } => other::min_max_preserve(src, out, *radius, true, *preserve),
|
||||
FilterParams::Minimum { radius, preserve } => min_max_ink(src, out, ctx, *radius, false, *preserve),
|
||||
FilterParams::Maximum { radius, preserve } => min_max_ink(src, out, ctx, *radius, true, *preserve),
|
||||
FilterParams::Offset { horizontal, vertical, undefined } => other::offset(src, out, ctx, *horizontal, *vertical, *undefined),
|
||||
FilterParams::Mosaic { cell_size } => stylize::mosaic(src, out, ctx, *cell_size),
|
||||
FilterParams::Emboss { angle, height, amount } => stylize::emboss(src, out, ctx, *angle, *height, *amount),
|
||||
@@ -816,6 +833,7 @@ 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))
|
||||
}
|
||||
@@ -909,6 +927,23 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimum / Maximum in the document's polarity. CMYK samples are ink, so there Maximum spreads
|
||||
/// paper white and Minimum spreads ink (as Photoshop does): the colour channels run inverted,
|
||||
/// alpha keeps its usual polarity.
|
||||
fn min_max_ink(src: &Image, out: Rect, ctx: &Ctx, radius: f32, max: bool, preserve: Preserve) -> Vec<f32> {
|
||||
if !fxutil::subtractive(ctx) {
|
||||
return other::min_max_preserve(src, out, radius, max, preserve);
|
||||
}
|
||||
let n = src.ch.max(1);
|
||||
let cc = if ctx.alpha { n - 1 } else { n };
|
||||
let flip = |data: &mut [f32]| data.chunks_exact_mut(n).for_each(|px| px.iter_mut().take(cc).for_each(|v| *v = 1.0 - *v));
|
||||
let mut inv = src.clone();
|
||||
flip(&mut inv.data);
|
||||
let mut res = other::min_max_preserve(&inv, out, radius, max, preserve);
|
||||
flip(&mut res);
|
||||
res
|
||||
}
|
||||
|
||||
/// Output tile size used by [`apply`].
|
||||
pub const TILE: i32 = 256;
|
||||
|
||||
@@ -972,6 +1007,7 @@ fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 {
|
||||
}
|
||||
|
||||
/// [`apply`] / [`apply_in`] with an explicit tile size (tests use it to check tile independence).
|
||||
/// Nonpositive tile sizes are treated as one pixel.
|
||||
pub fn apply_tiled(
|
||||
surface: &Surface,
|
||||
params: &FilterParams,
|
||||
@@ -1014,6 +1050,8 @@ fn apply_tiled_with_impl(
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
prepare_fast_paths: bool,
|
||||
) -> Option<Surface> {
|
||||
// Every dispatch path needs a positive step, including Motion Blur's fallback.
|
||||
let tile = tile.max(1);
|
||||
if ctl.cancelled() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -654,6 +654,7 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
|
||||
return out;
|
||||
}
|
||||
let maxd = f64::from(field.max_displacement()).ceil() as i32 + 3;
|
||||
let has_freeze = field.freeze.iter().any(|&f| f > 0.0);
|
||||
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;
|
||||
@@ -670,7 +671,15 @@ 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 d = field.sample_cubic(gx, gy, &wx, &wy);
|
||||
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;
|
||||
}
|
||||
}
|
||||
if d[0] != 0.0 || d[1] != 0.0 {
|
||||
moved = true;
|
||||
}
|
||||
@@ -1376,6 +1385,42 @@ 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>>| {
|
||||
|
||||
@@ -89,10 +89,7 @@ fn composite_area(
|
||||
let i = ((y - r.y0) as usize * w + (x - r.x0) as usize) * n;
|
||||
let px = &mut region[i..i + n];
|
||||
let d = photocraft_raster::to_rgba(&fmt, px);
|
||||
let mut o = blend::composite(blend_mode, d, color(x, y), k);
|
||||
if lock_transparency {
|
||||
o[3] = d[3];
|
||||
}
|
||||
let o = if lock_transparency { blend::composite_atop(blend_mode, d, color(x, y), k) } else { blend::composite(blend_mode, d, color(x, y), k) };
|
||||
from_rgba_into(&fmt, o, &mut enc);
|
||||
// A colour picked as CMYK inks replaces fully covered pixels of a CMYK target as it is.
|
||||
if let Some(i) = inks
|
||||
@@ -296,6 +293,22 @@ mod tests {
|
||||
assert_eq!(s.pixel(5, 1)[3], 0.0, "outside the selection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_gradient_blends_source_atop_on_semi_transparent_pixels() {
|
||||
// Locked half-transparent white, flat black gradient at 50%: colour halfway, alpha kept (#5783).
|
||||
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
|
||||
for mode in [BlendMode::Normal, BlendMode::Multiply] {
|
||||
let mut s = Surface::new(fmt);
|
||||
let a = Rect::new(0, 0, 4, 1);
|
||||
s.fill_rect(a, &[1.0, 1.0, 1.0, 0.5]);
|
||||
let black = [0.0, 0.0, 0.0, 1.0];
|
||||
paint_gradient(&mut s, a, (0.0, 0.0), (4.0, 0.0), GradientShape::Linear, &[(0.0, black), (1.0, black)], false, 0.5, mode, false, None, true);
|
||||
let p = s.rgba(2, 0);
|
||||
assert!((p[0] - 0.5).abs() < 0.01 && (p[3] - 0.5).abs() < 0.01, "{fmt:?} {mode:?} {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_fills_contiguous_region() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
|
||||
+111
-45
@@ -1170,10 +1170,28 @@ fn band_row(band: &PyramidBand, l: usize, ch: usize, py: usize) -> Option<(usize
|
||||
})
|
||||
}
|
||||
|
||||
/// Estimated bytes of band construction [`multiband`] keeps in flight at once. Images are built
|
||||
/// and accumulated in batches under it, so its peak memory doesn't grow with the image count
|
||||
/// (#7758). Four 24 MP RGB sources (~1.2 GB each) fit in one batch: building bands side by side
|
||||
/// uses a many-core machine better than one band's row-parallel work, and one at a time made the
|
||||
/// usual 2–4 image Photomerge 12–36% slower on a 32-thread desktop.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const BAND_BUDGET: usize = 5 << 30;
|
||||
/// Without threads the bands of a batch are built one after another anyway, so each is added as
|
||||
/// soon as it is built (the browser's address space is 4 GB).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const BAND_BUDGET: usize = 0;
|
||||
|
||||
/// Multi-band blend of `imgs` into a `w × h` panorama (origin 0,0) with one weight map per image
|
||||
/// over the same grid as its ROI (`weights[i]` has `w_i × h_i` entries). Returns the colour
|
||||
/// channels (`ch` per pixel) and the total coverage.
|
||||
pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], levels: usize) -> (Vec<f32>, Vec<f32>) {
|
||||
multiband_batched(w, h, imgs, weights, levels, BAND_BUDGET)
|
||||
}
|
||||
|
||||
/// [`multiband`], building at most `budget` estimated bytes of bands at a time (at least one
|
||||
/// image). Each pixel still adds the bands in image order, so the result doesn't depend on it.
|
||||
fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], levels: usize, budget: usize) -> (Vec<f32>, Vec<f32>) {
|
||||
let ch = imgs.first().map_or(1, |i| i.ch);
|
||||
let levels = levels.max(1);
|
||||
let align = 1i32 << (levels - 1);
|
||||
@@ -1187,13 +1205,22 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
|
||||
let mut wsum: Vec<Vec<f32>> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh]).collect();
|
||||
let margin = align * 4;
|
||||
// Each image on its ROI grown by a margin and aligned to the coarsest level's grid.
|
||||
let bands = par_map(imgs.len(), |k| {
|
||||
let im = &imgs[k];
|
||||
let grid = |im: &RoiImage| {
|
||||
let gx0 = ((im.x0 - margin).max(0) / align) * align;
|
||||
let gy0 = ((im.y0 - margin).max(0) / align) * align;
|
||||
let gx1 = (im.x0 + im.w as i32 + margin).min(w as i32);
|
||||
let gy1 = (im.y0 + im.h as i32 + margin).min(h as i32);
|
||||
let (gw, gh) = ((gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize);
|
||||
(gx0, gy0, (gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize)
|
||||
};
|
||||
// Building one band holds its grid's colour and weight planes about three times over: the
|
||||
// pyramids are 4/3 of a plane, and the grid copy, coverage and `expand` scratch come on top.
|
||||
let cost = |im: &RoiImage| {
|
||||
let (_, _, gw, gh) = grid(im);
|
||||
gw.saturating_mul(gh).saturating_mul(ch + 1).saturating_mul(4 * 3)
|
||||
};
|
||||
let band = |k: usize| -> PyramidBand {
|
||||
let im = &imgs[k];
|
||||
let (gx0, gy0, gw, gh) = grid(im);
|
||||
// Each grid pixel takes the image pixel that lands on it (every image pixel lands on at
|
||||
// most one), so rows of the grid are independent and fill in parallel.
|
||||
let src_of = |gx: usize, gy: usize| -> Option<usize> {
|
||||
@@ -1225,68 +1252,85 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
|
||||
}
|
||||
});
|
||||
let bin: Vec<f32> = par_map1(&cov, |a| if a > 0.5 { 1.0 } else { 0.0 });
|
||||
drop(cov);
|
||||
push_pull(gw, gh, ch, &mut px, &bin);
|
||||
// Laplacian of the colours, Gaussian of the weights.
|
||||
drop(bin);
|
||||
// Laplacian of the colours, Gaussian of the weights. Planes move into the pyramids rather
|
||||
// than being copied: with one image built at a time, a copy is a serial pass over it.
|
||||
let mut lap: Vec<(usize, usize, Vec<f32>)> = Vec::new();
|
||||
let mut gau: Vec<Vec<f32>> = Vec::new();
|
||||
let (mut cw, mut chh, mut cur, mut cwt) = (gw, gh, px, wt);
|
||||
for l in 0..levels {
|
||||
if l + 1 == levels {
|
||||
lap.push((cw, chh, cur.clone()));
|
||||
gau.push(cwt.clone());
|
||||
lap.push((cw, chh, cur));
|
||||
gau.push(cwt);
|
||||
break;
|
||||
}
|
||||
let (nw, nh, down) = reduce(cw, chh, ch, &cur);
|
||||
let up = expand(nw, nh, ch, &down, cw, chh);
|
||||
lap.push((cw, chh, par_zip(&cur, &up, |a, b| a - b)));
|
||||
gau.push(cwt.clone());
|
||||
let (_, _, wdown) = reduce(cw, chh, 1, &cwt);
|
||||
gau.push(cwt);
|
||||
cw = nw;
|
||||
chh = nh;
|
||||
cur = down;
|
||||
cwt = wdown;
|
||||
}
|
||||
(gx0, gy0, lap, gau)
|
||||
});
|
||||
// Accumulate the bands. Each pixel adds the bands in the same order as the serial loop (per
|
||||
// level, bands one after another), but rows of a level are independent, so they run in
|
||||
// parallel: every row walks the bands itself.
|
||||
for l in 0..levels {
|
||||
let pw = sizes[l].0;
|
||||
par_rows(&mut wsum[l], pw, 1, |py, row| {
|
||||
for b in &bands {
|
||||
let Some((ox, lw, g, _)) = band_row(b, l, ch, py) else { continue };
|
||||
for x in 0..lw {
|
||||
let px = ox + x;
|
||||
if px >= pw {
|
||||
break;
|
||||
}
|
||||
let k = g[x];
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
row[px] += k;
|
||||
}
|
||||
}
|
||||
});
|
||||
par_rows(&mut acc[l], pw, ch, |py, row| {
|
||||
for b in &bands {
|
||||
let Some((ox, lw, g, band)) = band_row(b, l, ch, py) else { continue };
|
||||
for x in 0..lw {
|
||||
let px = ox + x;
|
||||
if px >= pw {
|
||||
break;
|
||||
}
|
||||
let k = g[x];
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
for c in 0..ch {
|
||||
row[px * ch + c] += k * band[x * ch + c];
|
||||
};
|
||||
// Build the bands a batch at a time and add each batch before building the next. Each pixel
|
||||
// adds the bands in the same order as the serial loop (per level, bands one after another;
|
||||
// batches follow each other in image order), but rows of a level are independent, so they
|
||||
// run in parallel: every row walks the batch's bands itself.
|
||||
let mut start = 0;
|
||||
while start < imgs.len() {
|
||||
let mut end = start + 1;
|
||||
let mut bytes = cost(&imgs[start]);
|
||||
while let Some(im) = imgs.get(end)
|
||||
&& bytes.saturating_add(cost(im)) <= budget
|
||||
{
|
||||
bytes = bytes.saturating_add(cost(im));
|
||||
end += 1;
|
||||
}
|
||||
let bands = par_map(end - start, |j| band(start + j));
|
||||
for l in 0..levels {
|
||||
let pw = sizes[l].0;
|
||||
par_rows(&mut wsum[l], pw, 1, |py, row| {
|
||||
for b in &bands {
|
||||
let Some((ox, lw, g, _)) = band_row(b, l, ch, py) else { continue };
|
||||
for x in 0..lw {
|
||||
let px = ox + x;
|
||||
if px >= pw {
|
||||
break;
|
||||
}
|
||||
let k = g[x];
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
row[px] += k;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
par_rows(&mut acc[l], pw, ch, |py, row| {
|
||||
for b in &bands {
|
||||
let Some((ox, lw, g, band)) = band_row(b, l, ch, py) else { continue };
|
||||
for x in 0..lw {
|
||||
let px = ox + x;
|
||||
if px >= pw {
|
||||
break;
|
||||
}
|
||||
let k = g[x];
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
for c in 0..ch {
|
||||
row[px * ch + c] += k * band[x * ch + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
for l in 0..levels {
|
||||
let pw = sizes[l].0;
|
||||
@@ -1910,6 +1954,28 @@ mod parallel_tests {
|
||||
assert_eq!(multiband(9, 7, &[], &[], 3).0.len(), ref_multiband(9, 7, &[], &[], 3).0.len());
|
||||
}
|
||||
|
||||
/// Batching the bands (#7758) changes when they are built, not the result. Budgets from none
|
||||
/// (one image per batch) up to everything in steps of a fifth pass through every split of the
|
||||
/// three images.
|
||||
#[test]
|
||||
fn multiband_batches_match_the_serial_code() {
|
||||
let budgets: Vec<usize> =
|
||||
std::iter::successors(Some(1024.0f64), |b| Some(b * 1.2)).take_while(|b| *b < 4e6).map(|b| b as usize).chain([0, usize::MAX]).collect();
|
||||
for (n, &(w, h)) in [(40usize, 30usize), (97, 61), (201, 129)].iter().enumerate() {
|
||||
for ch in [1usize, 3] {
|
||||
let (imgs, wts) = rois(w, h, ch, n as u64 * 17 + ch as u64);
|
||||
for levels in [1usize, 3, 5] {
|
||||
let want = ref_multiband(w, h, &imgs, &wts, levels);
|
||||
for &budget in &budgets {
|
||||
let got = multiband_batched(w, h, &imgs, &wts, levels, budget);
|
||||
assert_eq!(bits(&got.0), bits(&want.0), "colours {w}x{h}x{ch} levels {levels} budget {budget}");
|
||||
assert_eq!(bits(&got.1), bits(&want.1), "coverage {w}x{h}x{ch} levels {levels} budget {budget}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hashes recorded from the code before it was made parallel (plain f32 arithmetic only, so
|
||||
/// they hold on every platform).
|
||||
#[test]
|
||||
|
||||
@@ -182,6 +182,9 @@ pub enum DepthSource {
|
||||
None,
|
||||
Transparency,
|
||||
LayerMask,
|
||||
/// An alpha channel of the document (Photoshop lists them after Layer Mask), supplied by the
|
||||
/// caller like the layer mask.
|
||||
Channel,
|
||||
}
|
||||
|
||||
/// Built-in Shape Blur kernels.
|
||||
|
||||
@@ -159,15 +159,21 @@ pub(crate) fn par_map<T: Send>(n: usize, f: impl Fn(usize) -> T + Sync + Send) -
|
||||
(0..n).map(f).collect()
|
||||
}
|
||||
|
||||
/// Advances `state` one SplitMix64 step and returns the mixed output.
|
||||
pub(crate) fn splitmix64(state: &mut u64) -> u64 {
|
||||
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = *state;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// Deterministic 64-bit generator (SplitMix64).
|
||||
pub(crate) struct Rng(pub u64);
|
||||
impl Rng {
|
||||
/// The next value of the SplitMix64 sequence.
|
||||
pub fn next(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
splitmix64(&mut self.0)
|
||||
}
|
||||
pub fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
@@ -183,15 +189,7 @@ pub(crate) fn hash01(x: i64, y: i64, seed: u64) -> f32 {
|
||||
(z >> 40) as f32 / (1u64 << 24) as f32
|
||||
}
|
||||
|
||||
/// sRGB transfer function, decode (extended linearly below 0 and as a power above 1).
|
||||
pub(crate) fn srgb_to_linear(v: f32) -> f32 {
|
||||
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
|
||||
/// sRGB transfer function, encode.
|
||||
pub(crate) fn linear_to_srgb(v: f32) -> f32 {
|
||||
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
|
||||
}
|
||||
pub(crate) use photocraft_color::convert::{linear_to_srgb, srgb_to_linear};
|
||||
|
||||
/// Bilinear sample of channel `c` of a `w × h × ch` buffer, clamped to the edges.
|
||||
pub(crate) fn bilinear(buf: &[f32], w: usize, h: usize, ch: usize, c: usize, x: f32, y: f32) -> f32 {
|
||||
@@ -217,6 +215,41 @@ pub(crate) fn catmull_rom(t: f32) -> [f32; 4] {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The shared step and [`Rng`] reproduce the inlined copies they replaced, bit for bit.
|
||||
#[test]
|
||||
fn splitmix64_output_is_unchanged() {
|
||||
/// The SplitMix64 step as each generator inlined it before.
|
||||
fn inlined(state: &mut u64) -> u64 {
|
||||
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = *state;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
let mut zero = 0;
|
||||
assert_eq!([0; 3].map(|_| splitmix64(&mut zero)), [0xE220_A839_7B1D_CDAF, 0x6E78_9E6A_A1B9_65F4, 0x06C4_5D18_8009_454F]);
|
||||
for seed in [0, 1, 42, 0x2545_F491_4F6C_DD1D, u64::MAX] {
|
||||
let (mut shared, mut copy, mut rng) = (seed, seed, Rng(seed));
|
||||
for _ in 0..10_000 {
|
||||
let want = inlined(&mut copy);
|
||||
assert_eq!(splitmix64(&mut shared), want);
|
||||
assert_eq!(rng.next(), want);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared sRGB functions match the removed copies bit for bit.
|
||||
#[test]
|
||||
fn srgb_transfer_output_is_unchanged() {
|
||||
let decode = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) };
|
||||
let encode = |v: f32| if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 };
|
||||
for i in -2000..=6000 {
|
||||
let v = i as f32 / 4000.0;
|
||||
assert_eq!(srgb_to_linear(v).to_bits(), decode(v).to_bits(), "decode {v}");
|
||||
assert_eq!(linear_to_srgb(v).to_bits(), encode(v).to_bits(), "encode {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dense_and_sparse_solvers_agree() {
|
||||
// Overdetermined line fit y = 2x + 1.
|
||||
|
||||
@@ -562,6 +562,46 @@ pub fn to_bitmap(gray: &mut [f32], w: usize, method: BitmapMethod) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The threshold map of Bitmap's Custom Pattern from a pattern's lightness (0..=1, row-major):
|
||||
/// pattern pixels ranked by lightness, ties sharing their average rank, the lightest pixel
|
||||
/// thresholding lowest (it turns white first). A map of `n` pixels thus spreads `n` evenly
|
||||
/// spaced levels `(k + 0.5) / n`, whatever the pattern's own contrast, as Photoshop's does
|
||||
/// (measured: a 2x2 pattern of 32/96/160/224, a 3x1 of 50/128/200 and a 7x1 ramp all give
|
||||
/// exactly those levels).
|
||||
pub fn custom_pattern_thresholds(lightness: &[f32]) -> Vec<f32> {
|
||||
let n = lightness.len();
|
||||
let key = |i: usize| lightness.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let mut order: Vec<usize> = (0..n).collect();
|
||||
order.sort_by(|a, b| key(*a).total_cmp(&key(*b)));
|
||||
let mut out = vec![0.5; n];
|
||||
let mut start = 0;
|
||||
while start < n {
|
||||
let v = order.get(start).map(|i| key(*i));
|
||||
let end = (start..n).find(|&j| order.get(j).map(|i| key(*i)) != v).unwrap_or(n);
|
||||
// Ranks start..end share their mean.
|
||||
let rank = (start + end - 1) as f32 / 2.0;
|
||||
let t = 1.0 - (rank + 0.5) / n as f32;
|
||||
for i in order.get(start..end).unwrap_or_default() {
|
||||
if let Some(o) = out.get_mut(*i) {
|
||||
*o = t;
|
||||
}
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bitmap's Custom Pattern: each gray value (0..=1, row-major `w` wide) turns white where it
|
||||
/// reaches the threshold `map` (`mw` x `mh`, from [`custom_pattern_thresholds`]) tiled from the
|
||||
/// image's top-left corner, black elsewhere. A map that doesn't fit `mw` x `mh` thresholds at 50%.
|
||||
pub fn to_bitmap_custom(gray: &mut [f32], w: usize, map: &[f32], mw: usize, mh: usize) {
|
||||
let fits = w > 0 && mw > 0 && mh > 0 && mw.checked_mul(mh) == Some(map.len());
|
||||
for (i, v) in gray.iter_mut().enumerate() {
|
||||
let t = if fits { map.get((i / w) % mh * mw + (i % w) % mw).copied().unwrap_or(0.5) } else { 0.5 };
|
||||
*v = if *v >= t { 1.0 } else { 0.0 };
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -722,4 +762,41 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first 8-bit gray level (of a 0..=255 ramp) that `t` turns white.
|
||||
fn first_white(t: f32) -> u32 {
|
||||
(0..=255).find(|v| *v as f32 / 255.0 >= t).unwrap_or(256)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_pattern_thresholds_match_photoshop() {
|
||||
// Photoshop (Image › Mode › Bitmap, Custom Pattern) on a 0..255 ramp: the first white
|
||||
// level per pattern pixel, measured against defined patterns.
|
||||
let l = |v: &[u8]| v.iter().map(|v| f32::from(*v) / 255.0).collect::<Vec<_>>();
|
||||
let ramp7 = custom_pattern_thresholds(&l(&[0, 40, 80, 120, 160, 200, 240]));
|
||||
assert_eq!(ramp7.iter().map(|t| first_white(*t)).collect::<Vec<_>>(), [237, 201, 164, 128, 92, 55, 19]);
|
||||
let five = custom_pattern_thresholds(&l(&[10, 200, 90, 250, 150]));
|
||||
assert_eq!(five.iter().map(|t| first_white(*t)).collect::<Vec<_>>(), [230, 77, 179, 26, 128]);
|
||||
// Equal pixels share their rank: 100, 100 both sit at 50%.
|
||||
let ties = custom_pattern_thresholds(&l(&[100, 100, 200, 50]));
|
||||
assert_eq!(ties.iter().map(|t| first_white(*t)).collect::<Vec<_>>(), [128, 128, 32, 224]);
|
||||
assert!(custom_pattern_thresholds(&[]).is_empty());
|
||||
assert_eq!(custom_pattern_thresholds(&[f32::NAN, 0.3]).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_pattern_tiles_from_the_top_left() {
|
||||
// A 2x1 map: even columns need 75%, odd ones 25%.
|
||||
let map = [0.75, 0.25];
|
||||
let mut g = vec![0.5; 6];
|
||||
to_bitmap_custom(&mut g, 3, &map, 2, 1);
|
||||
assert_eq!(g, [0.0, 1.0, 0.0, 0.0, 1.0, 0.0]);
|
||||
// A map that doesn't match its size, or an empty image width, is a 50% threshold.
|
||||
let mut g = vec![0.4, 0.6];
|
||||
to_bitmap_custom(&mut g, 2, &map, 3, 1);
|
||||
assert_eq!(g, [0.0, 1.0]);
|
||||
let mut g = vec![0.6];
|
||||
to_bitmap_custom(&mut g, 0, &map, 2, 1);
|
||||
assert_eq!(g, [1.0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Render: Fibers, Lens Flare, Lighting Effects.
|
||||
//! Render: Clouds, Difference Clouds, Fibers, Lens Flare, Lighting Effects.
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
@@ -18,6 +18,57 @@ 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.
|
||||
@@ -32,8 +83,9 @@ 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 as f32, y as f32);
|
||||
let (xf, yf) = ((x - ctx.bounds.x0) as f32, (y - ctx.bounds.y0) as f32);
|
||||
let mut sum = 0.0;
|
||||
let mut amp = 1.0;
|
||||
let mut norm = 0.0;
|
||||
|
||||
@@ -274,15 +274,13 @@ pub fn composite(surface: &mut Surface, prims: &[Prim], clip: Rect, selection: O
|
||||
pub struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
/// A generator whose sequence is fixed by `seed` and `salt`.
|
||||
pub fn new(seed: u32, salt: u64) -> Rng {
|
||||
Rng(((seed as u64) << 32) ^ salt.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0x2545_F491_4F6C_DD1D)
|
||||
}
|
||||
/// The next value of the SplitMix64 sequence.
|
||||
pub fn next_u64(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
crate::photo_util::splitmix64(&mut self.0)
|
||||
}
|
||||
/// Uniform in [0, 1).
|
||||
pub fn f(&mut self) -> f32 {
|
||||
|
||||
+29
-12
@@ -124,12 +124,13 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usi
|
||||
let k: Vec<f32> = (-r..=r).map(|i| (-((i * i) as f32) / (2.0 * sigma * sigma).max(1e-6)).exp()).collect();
|
||||
let ks: f32 = k.iter().sum();
|
||||
let k: Vec<f32> = k.into_iter().map(|v| v / ks).collect();
|
||||
// Horizontal pass over the window rows extended vertically by r.
|
||||
// Horizontal pass over the window rows extended vertically by r. Rows are independent, so
|
||||
// both passes run them in parallel: a 2500 px Blur dab is ~10⁹ multiply-adds (#5426).
|
||||
let th = oh + 2 * r as usize;
|
||||
let stride = ow * ch;
|
||||
let mut tmp = vec![0.0f32; stride * th];
|
||||
let mut row = vec![0.0f32; (ow + 2 * r as usize) * ch];
|
||||
for ty in 0..th {
|
||||
let scratch = || vec![0.0f32; (ow + 2 * r as usize) * ch];
|
||||
let horizontal = |row: &mut Vec<f32>, (ty, dst): (usize, &mut [f32])| {
|
||||
let y = (y0 as i32 + ty as i32 - r).clamp(0, h as i32 - 1) as usize;
|
||||
// Clamp and premultiply each source pixel once, rather than once for every kernel tap.
|
||||
for (rx, px) in row.chunks_exact_mut(ch).enumerate() {
|
||||
@@ -145,7 +146,6 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usi
|
||||
}
|
||||
}
|
||||
}
|
||||
let dst = &mut tmp[ty * stride..(ty + 1) * stride];
|
||||
// Contiguous accumulation can be vectorized for any channel layout. Each output still
|
||||
// sums the same products in the same kernel order as the scalar convolution.
|
||||
for (i, kv) in k.iter().enumerate() {
|
||||
@@ -153,26 +153,43 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usi
|
||||
*d += s * kv;
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
tmp.par_chunks_mut(stride).enumerate().for_each_init(scratch, horizontal);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let mut row = scratch();
|
||||
tmp.chunks_mut(stride).enumerate().for_each(|item| horizontal(&mut row, item));
|
||||
}
|
||||
let mut out = vec![0.0f32; ow * oh * ch];
|
||||
for oy in 0..oh {
|
||||
let dst = &mut out[oy * stride..(oy + 1) * stride];
|
||||
let vertical = |(oy, dst): (usize, &mut [f32])| {
|
||||
for (i, kv) in k.iter().enumerate() {
|
||||
for (d, s) in dst.iter_mut().zip(&tmp[(oy + i) * stride..(oy + i + 1) * stride]) {
|
||||
*d += s * kv;
|
||||
}
|
||||
}
|
||||
for ox in 0..ow {
|
||||
let o = (oy * ow + ox) * ch;
|
||||
if let Some(a) = alpha {
|
||||
let av = out[o + a];
|
||||
for c in 0..ch {
|
||||
if let Some(a) = alpha {
|
||||
for (ox, px) in dst.chunks_exact_mut(ch).enumerate() {
|
||||
let av = px[a];
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
if c != a {
|
||||
out[o + c] = if av > 1e-6 { out[o + c] / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] };
|
||||
*v = if av > 1e-6 { *v / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
out.par_chunks_mut(stride).enumerate().for_each(vertical);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
out.chunks_mut(stride).enumerate().for_each(vertical);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -126,7 +126,9 @@ pub fn object_select(sampler: &dyn Sampler, canvas: Rect, rect: Rect, work_px: u
|
||||
}
|
||||
let (fg, bg) = grabcut(&img, &mut trimap, 8)?;
|
||||
let low: Vec<bool> = trimap.iter().map(|t| is_fg(*t)).collect();
|
||||
let low = super::clean_mask(&low, lw, lh, 0.1, 0.002);
|
||||
let low = super::clean_mask(&low, lw, lh, 0.1, 0.0);
|
||||
// Every enclosed hole, however small, fills only when it looks like the object, so background seen through it stays open.
|
||||
let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.0, 0.05);
|
||||
super::finish_region(sampler, window, step, &low, lw, lh, Some((&fg, &bg)))
|
||||
}
|
||||
|
||||
@@ -209,7 +211,9 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u
|
||||
let mut seeds = vec![false; w * h];
|
||||
seeds[sy * w + sx] = true;
|
||||
let low = super::keep_seeded(&low, &seeds, w, h);
|
||||
let low = super::clean_mask(&low, w, h, 0.1, 0.01);
|
||||
let low = super::clean_mask(&low, w, h, 0.1, 0.0);
|
||||
// As in the rectangle mode: enclosed holes fill only when they look like the object.
|
||||
let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.0, 0.05);
|
||||
if !low.iter().any(|v| *v) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -912,3 +912,61 @@ fn select_subject_dump_pictures() {
|
||||
write("ring", &img, None);
|
||||
write("ring-selected", &img, reg.as_ref());
|
||||
}
|
||||
|
||||
/// A dark square on a light background with a square hole that shows the background colour.
|
||||
fn square_with_hole(hole: usize) -> RgbImage {
|
||||
let (w, h) = (400usize, 300usize);
|
||||
let mut rng = Rng::new(11);
|
||||
let (x0, y0) = (100usize, 50usize);
|
||||
RgbImage::from_fn(w, h, |x, y| {
|
||||
let n = 0.02 * rng.normal();
|
||||
let in_square = x >= x0 && x < x0 + 200 && y >= y0 && y < y0 + 200;
|
||||
let in_hole = x >= x0 + 96 && x < x0 + 96 + hole && y >= y0 + 96 && y < y0 + 96 + hole;
|
||||
let c = if in_square && !in_hole { [0.1, 0.12, 0.15] } else { [0.9, 0.9, 0.85] };
|
||||
c.map(|v: f32| (v + n).clamp(0.0, 1.0))
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_select_keeps_small_background_hole_open() {
|
||||
let img = square_with_hole(8);
|
||||
let s = ImageSampler { img: &img, origin: (0, 0) };
|
||||
let canvas = Rect::new(0, 0, 400, 300);
|
||||
let reg = grabcut::object_select(&s, canvas, Rect::new(80, 30, 320, 270), 1_000_000).unwrap();
|
||||
assert!(reg.at(150, 100) >= 0.5, "the square itself is selected");
|
||||
assert!(reg.at(200, 150) < 0.5, "background seen through the object stays unselected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_at_keeps_small_background_hole_open() {
|
||||
let img = square_with_hole(16);
|
||||
let s = ImageSampler { img: &img, origin: (0, 0) };
|
||||
let canvas = Rect::new(0, 0, 400, 300);
|
||||
let reg = grabcut::object_at(&s, canvas, 130.0, 80.0, 1_000_000).unwrap();
|
||||
assert!(reg.at(150, 100) >= 0.5, "the square itself is selected");
|
||||
assert!(reg.at(203, 153) < 0.5, "background seen through the object stays unselected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hole_fill_without_specks_fills_object_coloured_holes_only() {
|
||||
// The hole check Object Selection uses: no hole fills just for being small, a speck of the object's colour still does.
|
||||
let (w, h) = (40usize, 40usize);
|
||||
let mut rng = Rng::new(5);
|
||||
let in_square = |x: usize, y: usize| (5..35).contains(&x) && (5..35).contains(&y);
|
||||
let bg_hole = |x: usize, y: usize| (10..12).contains(&x) && (10..12).contains(&y);
|
||||
let fg_hole = |x: usize, y: usize| (25..27).contains(&x) && (25..27).contains(&y);
|
||||
let img = RgbImage::from_fn(w, h, |x, y| {
|
||||
let n = 0.02 * rng.normal();
|
||||
let c = if in_square(x, y) && !bg_hole(x, y) { [0.1, 0.12, 0.15] } else { [0.9, 0.9, 0.85] };
|
||||
c.map(|v: f32| (v + n).clamp(0.0, 1.0))
|
||||
});
|
||||
let mask: Vec<bool> = (0..w * h).map(|i| in_square(i % w, i / w) && !bg_hole(i % w, i / w) && !fg_hole(i % w, i / w)).collect();
|
||||
// The colour models, as GrabCut would fit them: the object colour vs. the backdrop colour.
|
||||
let dark: Vec<bool> = (0..w * h).map(|i| in_square(i % w, i / w) && !bg_hole(i % w, i / w)).collect();
|
||||
let (fs, bs): (Vec<_>, Vec<_>) = img.px.iter().zip(&dark).partition(|(_, m)| **m);
|
||||
let fit = |v: Vec<(&[f32; 3], &bool)>| Gmm::fit(&v.iter().map(|(p, _)| **p).collect::<Vec<_>>(), 3, gmm::DEFAULT_REG).unwrap();
|
||||
let (fg, bg) = (fit(fs), fit(bs));
|
||||
let out = fill_holes_by_model(&mask, &img, &fg, &bg, 0.0, 0.05);
|
||||
assert!(out[25 * w + 25], "a speck of the object's colour fills");
|
||||
assert!(!out[10 * w + 10], "a speck of the background's colour stays open");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! 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}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,27 @@ fn radial_blur_quality_defaults_for_older_serialized_parameters() {
|
||||
assert_eq!(decoded, FilterParams::RadialBlur { amount: 20.0, method: RadialMethod::Spin, quality: RadialQuality::Good, center_x: 0.5, center_y: 0.5 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonpositive_tile_sizes_match_one_pixel_tiles() {
|
||||
let area = Rect::new(0, 0, 4, 3);
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let source = pattern(depth, area);
|
||||
for params in all_filters().into_iter().chain([FilterParams::ShapeBlur { radius: 3.0, shape: BlurShape::Star }]) {
|
||||
let expected = apply_tiled(&source, ¶ms, area, area, None, 1, Some(area));
|
||||
for tile in [0, -64, i32::MIN] {
|
||||
let actual = apply_tiled(&source, ¶ms, area, area, None, tile, Some(area));
|
||||
assert_eq!(actual.read_region(area), expected.read_region(area), "{}: {depth:?}, tile={tile}", params.label());
|
||||
let cancellable = apply_tiled_with(&source, ¶ms, area, area, None, tile, Some(area), &photocraft_raster::Interrupt::NONE)
|
||||
.expect("a nonpositive tile size is normalized, not rejected");
|
||||
assert_eq!(cancellable.read_region(area), expected.read_region(area), "{}: {depth:?}, tile={tile}", params.label());
|
||||
let cancelled = photocraft_raster::Interrupt::cancel_only(&|| true);
|
||||
assert!(apply_tiled_with(&source, ¶ms, area, area, None, tile, Some(area), &cancelled).is_none());
|
||||
}
|
||||
}
|
||||
assert_eq!(source.read_region(area), pattern(depth, area).read_region(area));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_filter_is_tile_independent() {
|
||||
let s = pattern(SampleType::U8, R);
|
||||
@@ -248,6 +269,32 @@ fn minimum_maximum_spread() {
|
||||
assert!((mn.pixel(10, 10)[0] - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// CMYK samples are ink, so Maximum spreads paper white and Minimum spreads ink, as Photoshop
|
||||
/// does: Maximum r 1 removes a 1-px C 100% line on white, Minimum widens it to 3 px. Alpha keeps
|
||||
/// the usual polarity (Maximum grows opaque areas, Minimum grows transparent ones).
|
||||
#[test]
|
||||
fn minimum_maximum_treat_cmyk_as_ink() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let mut s = Surface::new(PixelFormat::new(ColorMode::Cmyk, depth, true));
|
||||
s.fill_rect(R, &[0.0, 0.0, 0.0, 0.0, 1.0]);
|
||||
s.fill_rect(Rect::new(20, 0, 21, 30), &[1.0, 0.0, 0.0, 0.0, 1.0]);
|
||||
s.write_pixel(5, 5, &[0.0, 0.0, 0.0, 0.0, 0.0]);
|
||||
for preserve in [Preserve::Squareness, Preserve::Roundness] {
|
||||
let mx = run(&s, &FilterParams::Maximum { radius: 1.0, preserve });
|
||||
for x in 17..24 {
|
||||
assert!(mx.pixel(x, 15)[0].abs() < 1e-6, "{depth:?} {preserve:?} Maximum C at x {x}: {:?}", mx.pixel(x, 15));
|
||||
}
|
||||
assert!((mx.pixel(5, 5)[4] - 1.0).abs() < 1e-6, "{depth:?} {preserve:?} Maximum grows alpha");
|
||||
let mn = run(&s, &FilterParams::Minimum { radius: 1.0, preserve });
|
||||
for x in 17..24 {
|
||||
let want = if (19..=21).contains(&x) { 1.0 } else { 0.0 };
|
||||
assert!((mn.pixel(x, 15)[0] - want).abs() < 1e-6, "{depth:?} {preserve:?} Minimum C at x {x}: {:?}", mn.pixel(x, 15));
|
||||
}
|
||||
assert!(mn.pixel(6, 5)[4].abs() < 1e-6, "{depth:?} {preserve:?} Minimum shrinks alpha");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_roundness_grows_a_point_into_a_disc() {
|
||||
// One white pixel on black: Maximum spreads it over the structuring element itself.
|
||||
|
||||
@@ -65,6 +65,35 @@ pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue:
|
||||
mask
|
||||
}
|
||||
|
||||
/// Replace Color's Result swatch: `color` after the hue (±180°), saturation and lightness (±100)
|
||||
/// shift, as a fully matched pixel of that colour becomes.
|
||||
pub fn replace_color_result(color: [f32; 3], hue: f32, saturation: f32, lightness: f32) -> [f32; 3] {
|
||||
let mut px = [[color[0], color[1], color[2], 1.0]];
|
||||
replace_color_masked(&mut px, &[1.0], hue, saturation, lightness);
|
||||
[px[0][0], px[0][1], px[0][2]]
|
||||
}
|
||||
|
||||
/// The hue (±180°), saturation and lightness (±100) shift that turns `from` into `to`, as
|
||||
/// Photoshop's Result Color Picker sets the sliders. A shift the sliders can't reach (a grey has
|
||||
/// no hue to turn, black or white no saturation to give) goes as far as they do.
|
||||
pub fn replace_color_shift(from: [f32; 3], to: [f32; 3]) -> (f32, f32, f32) {
|
||||
let [fh, fs, fl] = rgb_to_hsl(from);
|
||||
let [th, ts, tl] = rgb_to_hsl(to);
|
||||
// Hue turns the short way round; a grey target keeps the hue where it is.
|
||||
let hue = if ts <= 0.0 { 0.0 } else { ((th - fh + 0.5).rem_euclid(1.0) - 0.5) * 360.0 };
|
||||
// Inverse of the shift in `replace_color_masked`: up moves toward 1, down toward 0.
|
||||
let amount = |from: f32, to: f32| {
|
||||
if to >= from {
|
||||
if from >= 1.0 { 0.0 } else { (to - from) / (1.0 - from) * 100.0 }
|
||||
} else if from <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(to / from - 1.0) * 100.0
|
||||
}
|
||||
};
|
||||
(hue.clamp(-180.0, 180.0), amount(fs, ts).clamp(-100.0, 100.0), amount(fl, tl).clamp(-100.0, 100.0))
|
||||
}
|
||||
|
||||
/// Replace Color's hue, saturation and lightness shift, applied to each pixel as far as `mask`
|
||||
/// (`0..=1`, one per pixel: the colour match, localized or not) covers it.
|
||||
pub fn replace_color_masked(px: &mut [[f32; 4]], mask: &[f32], hue: f32, saturation: f32, lightness: f32) {
|
||||
@@ -277,6 +306,28 @@ pub fn hdr_toning(px: &mut [[f32; 4]], w: usize, h: usize, p: &HdrToning) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #6278: the Result swatch is the sampled colour after the shift, and a picked Result sets
|
||||
/// the sliders that give it.
|
||||
#[test]
|
||||
fn replace_color_result_and_shift_round_trip() {
|
||||
let from = [0.8, 0.2, 0.2];
|
||||
let near = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 2e-3);
|
||||
assert!(near(replace_color_result(from, 0.0, 0.0, 0.0), from), "no shift, no change");
|
||||
for to in [[0.2, 0.3, 0.9], [0.9, 0.85, 0.1], [0.4, 0.1, 0.1], [0.95, 0.6, 0.6], [0.1, 0.6, 0.3]] {
|
||||
let (h, s, l) = replace_color_shift(from, to);
|
||||
assert!((-180.0..=180.0).contains(&h) && (-100.0..=100.0).contains(&s) && (-100.0..=100.0).contains(&l));
|
||||
assert!(near(replace_color_result(from, h, s, l), to), "{to:?}: ({h}, {s}, {l}) gives {:?}", replace_color_result(from, h, s, l));
|
||||
}
|
||||
// Red to blue turns the short way (−120°, not +240°).
|
||||
assert!((replace_color_shift([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]).0 + 120.0).abs() < 0.5);
|
||||
// A grey target: saturation all the way down, the hue left alone.
|
||||
let (h, s, _) = replace_color_shift(from, [0.5, 0.5, 0.5]);
|
||||
assert_eq!((h, s), (0.0, -100.0));
|
||||
// Black has no saturation to raise: the slider stays put rather than dividing by zero.
|
||||
let (h, s, l) = replace_color_shift([0.0, 0.0, 0.0], [0.2, 0.6, 0.3]);
|
||||
assert!(h.is_finite() && s.is_finite() && l > 0.0);
|
||||
}
|
||||
|
||||
fn ramp(w: usize, h: usize) -> Vec<[f32; 4]> {
|
||||
(0..w * h)
|
||||
.map(|i| {
|
||||
|
||||
@@ -254,6 +254,21 @@ 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;
|
||||
@@ -264,11 +279,13 @@ 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 = (1 << 20) as f64;
|
||||
let lim = f64::from(WARP_REACH);
|
||||
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;
|
||||
@@ -294,7 +311,7 @@ fn warp(
|
||||
converted = src.try_convert(fmt)?;
|
||||
&converted
|
||||
};
|
||||
let mut out = Surface::new(fmt);
|
||||
let out = Surface::new(fmt);
|
||||
if src_rect.is_empty() || clip.is_some_and(|c| c.is_empty()) {
|
||||
return Ok(out);
|
||||
}
|
||||
@@ -333,7 +350,12 @@ fn warp(
|
||||
// A source straddling the horizon draws only its front side: the inverse w at a pixel has the
|
||||
// sign of the forward w at the source point it samples, and the other side would show as a
|
||||
// mirrored copy wrapped round infinity (#4863).
|
||||
let front = side.is_none().then(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64]));
|
||||
// A clip wholly on one side of the inverse horizon only samples that side, so that side is drawn
|
||||
// (a Perspective Crop low on a converging photo has most of the layer beyond its horizon, #7557).
|
||||
let clip_w = clip.map(|c| [(c.x0, c.y0), (c.x1, c.y0), (c.x1, c.y1), (c.x0, c.y1)].map(|(x, y)| inv.w(x as f64, y as f64)));
|
||||
let clip_side = clip_w.and_then(|cw| [1.0, -1.0].into_iter().find(|&s| cw.iter().all(|&w| s * w > 0.0)));
|
||||
let front =
|
||||
side.is_none().then(|| clip_side.unwrap_or_else(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64])));
|
||||
let bounds = corner_bounds(&corners);
|
||||
let dst = match (clip, bounds) {
|
||||
// The warped corners only bound the result while the whole source is in front of the horizon.
|
||||
@@ -361,7 +383,7 @@ fn warp(
|
||||
return Ok(None);
|
||||
}
|
||||
let k = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
// The last few percent are the writes.
|
||||
// Short of 100 %: the result is still pruned and handed back after the last tile.
|
||||
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));
|
||||
@@ -498,33 +520,29 @@ fn warp(
|
||||
}
|
||||
Ok(any.then_some((*t, outp)))
|
||||
};
|
||||
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);
|
||||
// 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(())
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
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(())
|
||||
})?;
|
||||
tiles.into_par_iter().try_for_each(|tile| warp_and_write(&tile))?;
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
for tile in tiles {
|
||||
if let Some(result) = work(&tile)? {
|
||||
done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(result);
|
||||
}
|
||||
warp_and_write(&tile)?;
|
||||
}
|
||||
let done = done.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if ctl.cancelled() {
|
||||
return Ok(out);
|
||||
}
|
||||
for (r, v) in done {
|
||||
out.try_write_region(r, &v)?;
|
||||
return Ok(Surface::new(fmt));
|
||||
}
|
||||
let mut out = out.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
out.prune();
|
||||
Ok(out)
|
||||
}
|
||||
@@ -546,6 +564,8 @@ 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);
|
||||
@@ -558,6 +578,20 @@ 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);
|
||||
@@ -713,6 +747,22 @@ mod tests {
|
||||
assert_eq!(ok.front_side([0.0, 0.0, 100.0, 100.0]), ok.w(50.0, 50.0).signum());
|
||||
}
|
||||
|
||||
/// #7557: a Perspective Crop quad low on a strongly converging photo puts the horizon across
|
||||
/// the layer with most of it (and its centre) on the far side. The clip maps wholly into the
|
||||
/// quad, so its side is the one drawn, not the source's majority.
|
||||
#[test]
|
||||
fn a_clip_wholly_on_one_side_draws_that_side() {
|
||||
let mut s = rgba();
|
||||
s.fill_rect(Rect::new(0, 0, 100, 100), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let q = [[40.0, 70.0], [60.0, 70.0], [90.0, 95.0], [10.0, 95.0]];
|
||||
let h = quad_to_rect(&q, 50.0, 39.0).unwrap();
|
||||
assert!(h.front_side([0.0, 0.0, 100.0, 100.0]) * h.w(50.0, 80.0) < 0.0, "the source's majority is behind the quad's side");
|
||||
for interp in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] {
|
||||
let w = warp_surface_clipped(&s, s.content_bounds(), &h, interp, Rect::new(0, 0, 50, 39)).unwrap();
|
||||
assert_eq!(w.rgba(25, 20), [1.0, 0.0, 0.0, 1.0], "{interp:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_and_translation_are_exact() {
|
||||
let mut s = rgba();
|
||||
|
||||
+106
-12
@@ -345,21 +345,41 @@ 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 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));
|
||||
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);
|
||||
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;
|
||||
@@ -376,7 +396,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);
|
||||
let s = sample(&src_copy, sx - 0.5, sy - 0.5, n);
|
||||
sample(&src_copy, &win, sx - 0.5, sy - 0.5, &mut corners, &mut s);
|
||||
for c in 0..n {
|
||||
px[c] += (s[c] - px[c]) * k;
|
||||
}
|
||||
@@ -390,11 +410,42 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
|
||||
dabs
|
||||
}
|
||||
|
||||
fn sample(s: &Surface, x: f64, y: f64, n: usize) -> Vec<f32> {
|
||||
/// 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();
|
||||
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 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()
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -487,4 +538,47 @@ 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]);
|
||||
}
|
||||
}
|
||||
|
||||
+100
-10
@@ -14,8 +14,9 @@
|
||||
//! later (lower-right) cell wins. Work is split per destination tile (parallel on native).
|
||||
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
use photocraft_geom::{Rect, TileCoord};
|
||||
use photocraft_raster::{Interrupt, Surface, Tile};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::transform::{Interp, bicubic};
|
||||
|
||||
@@ -74,6 +75,16 @@ 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));
|
||||
@@ -103,7 +114,7 @@ pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) ->
|
||||
tris.push([a, a + w1 + 1, a + w1]);
|
||||
}
|
||||
}
|
||||
warp_triangles(src, src_rect, &verts, &tris, interp)
|
||||
triangles(src, src_rect, &verts, &tris, interp, ctl)
|
||||
}
|
||||
|
||||
/// Probe lines per axis [`mapped_lengths`] follows through the map.
|
||||
@@ -148,6 +159,12 @@ 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 {
|
||||
@@ -207,7 +224,16 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
|
||||
}
|
||||
let n = fmt.channels();
|
||||
let a = n - 1;
|
||||
let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(Rect, Vec<f32>)> {
|
||||
// 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;
|
||||
}
|
||||
@@ -296,6 +322,11 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
|
||||
f64::from(px[((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n + c])
|
||||
}
|
||||
};
|
||||
// Offset of pixel (x, y) when the `taps` x `taps` block from it lies inside `foot`, so
|
||||
// the samplers can index `px` directly instead of bounds-checking every tap.
|
||||
let base = |x: i32, y: i32, taps: i32| -> Option<usize> {
|
||||
(x >= foot.x0 && y >= foot.y0 && x + taps <= foot.x1 && y + taps <= foot.y1).then(|| ((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n)
|
||||
};
|
||||
let mut outp = vec![0.0f32; w * h * n];
|
||||
let mut any = false;
|
||||
let mut acc = [0.0f64; 8];
|
||||
@@ -313,6 +344,17 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
|
||||
Interp::Bilinear => {
|
||||
let (ix, iy) = (u.floor() as i32, v.floor() as i32);
|
||||
let (fx, fy) = (u - f64::from(ix), v - f64::from(iy));
|
||||
if let Some(o) = base(ix, iy, 2) {
|
||||
for (dy, wy) in [(0, 1.0 - fy), (1, fy)] {
|
||||
for (dx, wx) in [(0, 1.0 - fx), (1, fx)] {
|
||||
let p = &px[o + (dy * fw + dx) * n..][..n];
|
||||
for (s, v) in acc.iter_mut().zip(p) {
|
||||
*s += f64::from(*v) * wx * wy;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (dy, wy) in [(0, 1.0 - fy), (1, fy)] {
|
||||
for (dx, wx) in [(0, 1.0 - fx), (1, fx)] {
|
||||
for (c, s) in acc.iter_mut().enumerate().take(n) {
|
||||
@@ -327,6 +369,21 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
|
||||
let (u, v) = (snap(u), snap(v));
|
||||
let (ix, iy) = (u.floor() as i32, v.floor() as i32);
|
||||
let (wx, wy) = (bicubic(u - f64::from(ix)), bicubic(v - f64::from(iy)));
|
||||
if let Some(o) = base(ix - 1, iy - 1, 4) {
|
||||
for (j, wyj) in wy.iter().enumerate() {
|
||||
for (i, wxi) in wx.iter().enumerate() {
|
||||
let k = wxi * wyj;
|
||||
if k == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let p = &px[o + (j * fw + i) * n..][..n];
|
||||
for (s, v) in acc.iter_mut().zip(p) {
|
||||
*s += f64::from(*v) * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (j, wyj) in wy.iter().enumerate() {
|
||||
for (i, wxi) in wx.iter().enumerate() {
|
||||
let k = wxi * wyj;
|
||||
@@ -378,19 +435,30 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
|
||||
}
|
||||
outp[b + a] = al as f32;
|
||||
}
|
||||
any.then_some((*t, outp))
|
||||
if !any {
|
||||
return None;
|
||||
}
|
||||
// Encoded (and dropped if fully transparent) here, in parallel, rather than one tile at
|
||||
// a time once all are done. `t` lies in one tile.
|
||||
let mut one = Surface::new(fmt);
|
||||
one.write_region(*t, &outp);
|
||||
one.prune();
|
||||
if one.tile_count() == 0 {
|
||||
return None;
|
||||
}
|
||||
one.take_tiles(*t).pop()
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let done: Vec<(Rect, Vec<f32>)> = {
|
||||
let done: Vec<(TileCoord, Arc<Tile>)> = {
|
||||
use rayon::prelude::*;
|
||||
tiles.par_iter().zip(bins.par_iter()).filter_map(work).collect()
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let done: Vec<(Rect, Vec<f32>)> = tiles.iter().zip(bins.iter()).filter_map(work).collect();
|
||||
for (r, v) in done {
|
||||
out.write_region(r, &v);
|
||||
let done: Vec<(TileCoord, Arc<Tile>)> = tiles.iter().zip(bins.iter()).filter_map(work).collect();
|
||||
if ctl.cancelled() {
|
||||
return out;
|
||||
}
|
||||
out.prune();
|
||||
out.put_tiles(done);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -463,6 +531,28 @@ 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
|
||||
|
||||
@@ -30,3 +30,7 @@ cap-std = "4"
|
||||
[dev-dependencies]
|
||||
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"] }
|
||||
|
||||
# Reading back an extended attribute the write path should have preserved (#7095).
|
||||
[target.'cfg(unix)'.dev-dependencies]
|
||||
rustix = { version = "1.1", default-features = false, features = ["std", "fs"] }
|
||||
|
||||
@@ -19,7 +19,8 @@ pub struct Opened {
|
||||
/// Decode a document that was read through a filesystem capability.
|
||||
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<Opened, AutomationError> {
|
||||
if Path::new(name).extension().is_some_and(|extension| extension.eq_ignore_ascii_case(photocraft_format::EXTENSION)) {
|
||||
return Ok(Opened { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false });
|
||||
let document = photocraft_format::load_from_bytes(bytes)?;
|
||||
return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false });
|
||||
}
|
||||
let r = photocraft_io::import(name, bytes)?;
|
||||
Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only })
|
||||
@@ -29,7 +30,8 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<Opened, AutomationError> {
|
||||
/// natively; everything else goes through `photocraft-io` (PSD, PNG, …).
|
||||
pub fn open(path: &Path) -> Result<Opened, AutomationError> {
|
||||
if path.is_dir() {
|
||||
return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new(), source_read_only: false });
|
||||
let document = photocraft_format::load_path(path)?;
|
||||
return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false });
|
||||
}
|
||||
let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
|
||||
@@ -72,6 +72,22 @@ impl Headless {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `body` with document `index` active (the active one when `None`), then make the
|
||||
/// previously active document active again, on success and on error alike: an `index`
|
||||
/// targets one call and must not retarget the next automation command (#7025).
|
||||
fn on_doc<T>(&mut self, index: Option<usize>, body: impl FnOnce(&mut Self) -> Result<T, AutomationError>) -> Result<T, AutomationError> {
|
||||
let i = self.doc_index(index)?;
|
||||
let previously_active = self.session.active_index();
|
||||
if previously_active != Some(i) && !self.session.set_active(i) {
|
||||
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
|
||||
}
|
||||
let result = body(self);
|
||||
if let Some(active) = previously_active.filter(|&active| self.session.active_index() != Some(active)) {
|
||||
self.session.set_active(active);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Open a file and make it the active document.
|
||||
pub fn open(&mut self, path: &Path) -> Result<Value, AutomationError> {
|
||||
let requested = path.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?;
|
||||
@@ -113,27 +129,25 @@ impl Headless {
|
||||
/// automation (#2810). `path` resolves beneath the read root the way `open` resolves one,
|
||||
/// and `params` carries the command's own options (`scale`, `fit`, `center`).
|
||||
pub fn place(&mut self, index: Option<usize>, path: &str, params: &Value) -> Result<Value, AutomationError> {
|
||||
let i = self.doc_index(index)?;
|
||||
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
|
||||
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
|
||||
}
|
||||
let mut opts = params.as_object().cloned().unwrap_or_default();
|
||||
opts.insert("path".into(), json!(path));
|
||||
// Where ambient paths are allowed the command reads the file itself, as everywhere else.
|
||||
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
|
||||
return self.command_run("file.placeEmbedded", Value::Object(opts));
|
||||
}
|
||||
let bytes = match &self.filesystem {
|
||||
Filesystem::Workspace(workspace) => workspace.read(path)?,
|
||||
_ => {
|
||||
return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into()));
|
||||
self.on_doc(index, |this| {
|
||||
let mut opts = params.as_object().cloned().unwrap_or_default();
|
||||
opts.insert("path".into(), json!(path));
|
||||
// Where ambient paths are allowed the command reads the file itself, as everywhere else.
|
||||
if matches!(&this.filesystem, Filesystem::TrustedLocal) {
|
||||
return this.command_run("file.placeEmbedded", Value::Object(opts));
|
||||
}
|
||||
};
|
||||
let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path);
|
||||
self.sync_jobs();
|
||||
// The bytes are already in memory and the path resolved beneath the root capability, so
|
||||
// placing them needs no filesystem check of its own (`place_bytes` is the command body).
|
||||
Ok(file_cmds::place_bytes(&mut self.session, name, bytes, None, &Value::Object(opts))?)
|
||||
let bytes = match &this.filesystem {
|
||||
Filesystem::Workspace(workspace) => workspace.read(path)?,
|
||||
_ => {
|
||||
return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into()));
|
||||
}
|
||||
};
|
||||
let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path);
|
||||
this.sync_jobs();
|
||||
// The bytes are already in memory and the path resolved beneath the root capability, so
|
||||
// placing them needs no filesystem check of its own (`place_bytes` is the command body).
|
||||
Ok(file_cmds::place_bytes(&mut this.session, name, bytes, None, &Value::Object(opts))?)
|
||||
})
|
||||
}
|
||||
|
||||
/// Export each exported layer of a document as its own file beneath the write root: the
|
||||
@@ -143,25 +157,23 @@ impl Headless {
|
||||
/// already exists, where the ambient command would, and it creates the directory before the
|
||||
/// export runs, so a refused export can leave an empty directory behind — never a file.
|
||||
pub fn export_layers(&mut self, index: Option<usize>, dir: &str, params: &Value) -> Result<Value, AutomationError> {
|
||||
let i = self.doc_index(index)?;
|
||||
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
|
||||
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
|
||||
}
|
||||
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
|
||||
let mut opts = params.as_object().cloned().unwrap_or_default();
|
||||
opts.insert("dir".into(), json!(dir));
|
||||
return self.command_run("file.export.layersToFiles", Value::Object(opts));
|
||||
}
|
||||
self.sync_jobs();
|
||||
let workspace = match &self.filesystem {
|
||||
Filesystem::Workspace(workspace) => workspace,
|
||||
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
|
||||
};
|
||||
let mut outputs = workspace.export_sequence(dir)?;
|
||||
let written = file_cmds::export_layers_with(&self.session, params, |name, bytes| {
|
||||
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
|
||||
});
|
||||
exported(dir, written, &mut outputs)
|
||||
self.on_doc(index, |this| {
|
||||
if matches!(&this.filesystem, Filesystem::TrustedLocal) {
|
||||
let mut opts = params.as_object().cloned().unwrap_or_default();
|
||||
opts.insert("dir".into(), json!(dir));
|
||||
return this.command_run("file.export.layersToFiles", Value::Object(opts));
|
||||
}
|
||||
this.sync_jobs();
|
||||
let workspace = match &this.filesystem {
|
||||
Filesystem::Workspace(workspace) => workspace,
|
||||
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
|
||||
};
|
||||
let mut outputs = workspace.export_sequence(dir)?;
|
||||
let written = file_cmds::export_layers_with(&this.session, params, |name, bytes| {
|
||||
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
|
||||
});
|
||||
exported(dir, written, &mut outputs)
|
||||
})
|
||||
}
|
||||
|
||||
/// Export each data set of a document as its own flattened file beneath the write root: the
|
||||
@@ -169,25 +181,23 @@ impl Headless {
|
||||
/// batch is planned first and colliding names are refused before any file is written; the
|
||||
/// directory itself is created before that, so a refused batch can leave it empty behind.
|
||||
pub fn export_data_sets(&mut self, index: Option<usize>, dir: &str, params: &Value) -> Result<Value, AutomationError> {
|
||||
let i = self.doc_index(index)?;
|
||||
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
|
||||
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
|
||||
}
|
||||
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
|
||||
let mut opts = params.as_object().cloned().unwrap_or_default();
|
||||
opts.insert("dir".into(), json!(dir));
|
||||
return self.command_run("file.export.dataSetsAsFiles", Value::Object(opts));
|
||||
}
|
||||
self.sync_jobs();
|
||||
let workspace = match &self.filesystem {
|
||||
Filesystem::Workspace(workspace) => workspace,
|
||||
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
|
||||
};
|
||||
let mut outputs = workspace.export_sequence(dir)?;
|
||||
let written = photocraft_engine::export_data_sets_with(&self.session, params, |name, bytes| {
|
||||
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
|
||||
});
|
||||
exported(dir, written, &mut outputs)
|
||||
self.on_doc(index, |this| {
|
||||
if matches!(&this.filesystem, Filesystem::TrustedLocal) {
|
||||
let mut opts = params.as_object().cloned().unwrap_or_default();
|
||||
opts.insert("dir".into(), json!(dir));
|
||||
return this.command_run("file.export.dataSetsAsFiles", Value::Object(opts));
|
||||
}
|
||||
this.sync_jobs();
|
||||
let workspace = match &this.filesystem {
|
||||
Filesystem::Workspace(workspace) => workspace,
|
||||
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
|
||||
};
|
||||
let mut outputs = workspace.export_sequence(dir)?;
|
||||
let written = photocraft_engine::export_data_sets_with(&this.session, params, |name, bytes| {
|
||||
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
|
||||
});
|
||||
exported(dir, written, &mut outputs)
|
||||
})
|
||||
}
|
||||
|
||||
/// Import a data set CSV from the read root: the capability-scoped
|
||||
@@ -195,24 +205,22 @@ impl Headless {
|
||||
/// in automation (#3216). The file is read beneath the root the way `open` reads one and
|
||||
/// handed to the command's own parser over the text it already has.
|
||||
pub fn import_data_sets(&mut self, index: Option<usize>, path: &str, params: &Value) -> Result<Value, AutomationError> {
|
||||
let i = self.doc_index(index)?;
|
||||
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
|
||||
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
|
||||
}
|
||||
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
|
||||
let mut opts = params.as_object().cloned().unwrap_or_default();
|
||||
opts.insert("path".into(), json!(path));
|
||||
return self.command_run("file.import.variableDataSets", Value::Object(opts));
|
||||
}
|
||||
let text = match &self.filesystem {
|
||||
Filesystem::Workspace(workspace) => {
|
||||
let bytes = workspace.read(path)?;
|
||||
String::from_utf8(bytes).map_err(|e| AutomationError::BadRequest(format!("automation read `{path}` is not valid UTF-8: {e}")))?
|
||||
self.on_doc(index, |this| {
|
||||
if matches!(&this.filesystem, Filesystem::TrustedLocal) {
|
||||
let mut opts = params.as_object().cloned().unwrap_or_default();
|
||||
opts.insert("path".into(), json!(path));
|
||||
return this.command_run("file.import.variableDataSets", Value::Object(opts));
|
||||
}
|
||||
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())),
|
||||
};
|
||||
self.sync_jobs();
|
||||
Ok(photocraft_engine::import_data_sets_text(&mut self.session, &text, params)?)
|
||||
let text = match &this.filesystem {
|
||||
Filesystem::Workspace(workspace) => {
|
||||
let bytes = workspace.read(path)?;
|
||||
String::from_utf8(bytes).map_err(|e| AutomationError::BadRequest(format!("automation read `{path}` is not valid UTF-8: {e}")))?
|
||||
}
|
||||
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())),
|
||||
};
|
||||
this.sync_jobs();
|
||||
Ok(photocraft_engine::import_data_sets_text(&mut this.session, &text, params)?)
|
||||
})
|
||||
}
|
||||
|
||||
/// Save (`.pcraft` or any export format by extension). With no path,
|
||||
@@ -266,14 +274,12 @@ impl Headless {
|
||||
format.map(|f| f.trim_start_matches('.').to_ascii_lowercase()).or_else(|| target.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()));
|
||||
if ext.as_deref().is_some_and(|e| file_cmds::holds(&doc, e)) {
|
||||
// Saving by index must not retarget the next automation command.
|
||||
let previously_active = self.session.active_index();
|
||||
self.session.set_active(i);
|
||||
if let Some(st) = self.session.active_mut() {
|
||||
st.saved_to(target.to_string_lossy().into_owned());
|
||||
}
|
||||
if let Some(active) = previously_active {
|
||||
self.session.set_active(active);
|
||||
}
|
||||
self.on_doc(Some(i), |this| {
|
||||
if let Some(st) = this.session.active_mut() {
|
||||
st.saved_to(target.to_string_lossy().into_owned());
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "path": target.to_string_lossy(), "warnings": warnings }))
|
||||
}
|
||||
|
||||
@@ -114,10 +114,20 @@ impl Headless {
|
||||
let mut opts =
|
||||
photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() };
|
||||
opts.encode.dpx_bits = photocraft_engine::file_cmds::dpx_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
|
||||
if let Some(q) = p.get("quality").and_then(Value::as_u64) {
|
||||
opts.encode.jpeg_quality = q.clamp(1, 100) as u8;
|
||||
opts.encode.webp_quality = q.clamp(1, 100) as u8;
|
||||
opts.encode.webp_lossless = false;
|
||||
opts.encode.dds = photocraft_engine::file_cmds::dds_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?.unwrap_or_default();
|
||||
match p.get("quality") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(v) => {
|
||||
// Like the CLI's `--quality`: never ignore or clamp a bad value.
|
||||
let q = v
|
||||
.as_u64()
|
||||
.filter(|q| (1..=100).contains(q))
|
||||
.and_then(|q| u8::try_from(q).ok())
|
||||
.ok_or_else(|| bad(format!("`quality` must be a whole number from 1 to 100, got {v}")))?;
|
||||
opts.encode.jpeg_quality = q;
|
||||
opts.encode.webp_quality = q;
|
||||
opts.encode.webp_lossless = false;
|
||||
}
|
||||
}
|
||||
let path = str_of(&p, "path").map(PathBuf::from);
|
||||
self.save(index_of(&p)?, path.as_deref(), str_of(&p, "format"), &opts)
|
||||
@@ -576,6 +586,26 @@ mod tests {
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// Regression: `quality` 85.0 or "85" was ignored (a lossless WebP, reply ok) and 0 or 500 was
|
||||
/// clamped, where the CLI refuses anything but a whole number from 1 to 100.
|
||||
#[test]
|
||||
fn doc_save_rejects_a_bad_quality_instead_of_ignoring_or_clamping_it() {
|
||||
let dir = std::env::temp_dir().join(format!("pc-rpc-save-quality-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("x.webp");
|
||||
let mut h = Headless::trusted_local();
|
||||
h.handle("doc.new", json!({"width": 16, "height": 16})).unwrap();
|
||||
for q in [json!(85.0), json!("85"), json!(0), json!(500), json!(-5), json!(true)] {
|
||||
let err = h.handle("doc.save", json!({"path": path.to_string_lossy(), "quality": q})).unwrap_err().to_string();
|
||||
assert!(err.contains("quality"), "{q}: {err}");
|
||||
assert!(!path.exists(), "{q} wrote a file");
|
||||
}
|
||||
h.handle("doc.save", json!({"path": path.to_string_lossy(), "quality": 85})).unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert_eq!(bytes.get(12..16), Some(&b"VP8 "[..]), "a WebP saved with a quality is lossy");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_preview_rejects_oversized_full_size_and_numeric_wraparound() {
|
||||
let mut h = Headless::new();
|
||||
@@ -756,6 +786,43 @@ mod tests {
|
||||
std::fs::remove_dir_all(base).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_place_and_data_set_methods_keep_the_active_document() {
|
||||
let base = std::env::temp_dir().join(format!("pc-doc-index-active-{}", std::process::id()));
|
||||
let root = base.join("workspace");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let img = photocraft_codecs::Image::from_u8(4, 4, photocraft_codecs::ChannelLayout::Rgb, vec![42; 4 * 4 * 3]).unwrap();
|
||||
std::fs::write(root.join("logo.png"), photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
|
||||
std::fs::write(root.join("sets.csv"), "Data Set,show\none,true\ntwo,false\n").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": "first"})).unwrap();
|
||||
h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white", "name": "second"})).unwrap();
|
||||
let layer = h.handle("doc.inspect", json!({})).unwrap()["layers"][0]["id"].as_u64().expect("the layer id");
|
||||
h.handle("engine.execute", json!({"command": "image.variables.define", "params": {"defs": [{"name": "show", "layer": layer, "type": "visibility"}]}}))
|
||||
.unwrap();
|
||||
h.handle("doc.select", json!({"index": 0})).unwrap();
|
||||
|
||||
let steps = [
|
||||
("doc.place", json!({"path": "logo.png", "index": 1}), true),
|
||||
("doc.importDataSets", json!({"path": "sets.csv", "index": 1}), true),
|
||||
("doc.exportLayers", json!({"dir": "layers", "index": 1}), true),
|
||||
("doc.exportDataSets", json!({"dir": "sets", "index": 1}), true),
|
||||
// A failing call restores the active document too.
|
||||
("doc.importDataSets", json!({"path": "missing.csv", "index": 1}), false),
|
||||
];
|
||||
for (method, params, succeeds) in steps {
|
||||
let result = h.handle(method, params.clone());
|
||||
assert_eq!(result.is_ok(), succeeds, "{method} {params}: {result:?}");
|
||||
assert_eq!(h.session.active_index(), Some(0), "{method} {params} must not select document 1");
|
||||
}
|
||||
assert_eq!(h.session.documents()[0].doc.layer_count(), 1, "nothing was placed into the active document");
|
||||
assert_eq!(h.session.documents()[1].doc.layer_count(), 2, "the placed layer is in document 1");
|
||||
assert_eq!(std::fs::read_dir(root.join("sets")).unwrap().count(), 2, "document 1's data sets were exported");
|
||||
// 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();
|
||||
|
||||
@@ -97,11 +97,15 @@ pub fn validate_token(token: &str) -> Result<(), AutomationError> {
|
||||
}
|
||||
|
||||
/// Compare fixed-width tokens without an early exit on a mismatching byte.
|
||||
///
|
||||
/// Hex letters match in either case: [`validate_token`] accepts uppercase, and the server keeps
|
||||
/// the token lowercased, so a client sending the token exactly as it was configured must still
|
||||
/// authenticate (#6425). Only `A`–`Z` fold, so a non-hex byte never matches a hex digit.
|
||||
pub fn token_matches(expected: &str, supplied: &str) -> bool {
|
||||
if expected.len() != TOKEN_HEX_LEN || supplied.len() != TOKEN_HEX_LEN {
|
||||
return false;
|
||||
}
|
||||
expected.bytes().zip(supplied.bytes()).fold(0u8, |different, (a, b)| different | (a ^ b)) == 0
|
||||
expected.bytes().zip(supplied.bytes()).fold(0u8, |different, (a, b)| different | (a.to_ascii_lowercase() ^ b.to_ascii_lowercase())) == 0
|
||||
}
|
||||
|
||||
/// Validate the first TCP frame without exposing any control method before authentication.
|
||||
@@ -368,6 +372,45 @@ mod tests {
|
||||
assert_eq!(reply["result"]["authenticated"], true);
|
||||
}
|
||||
|
||||
fn auth_line(token: &str) -> String {
|
||||
json!({"id": 1, "method": AUTH_METHOD, "params": {"token": token}}).to_string()
|
||||
}
|
||||
|
||||
/// #6425: `validate_token` accepts uppercase hex and the server keeps the token lowercased, so a
|
||||
/// client sending the token exactly as it was configured was refused.
|
||||
#[test]
|
||||
fn a_token_authenticates_in_any_letter_case() {
|
||||
let configured = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
|
||||
let mixed = "0123456789AbCdEf0123456789aBcDeF0123456789ABCDEF0123456789abcdef";
|
||||
let expected = server_token(Some(configured), None).unwrap();
|
||||
for supplied in [configured, &configured.to_ascii_lowercase(), mixed] {
|
||||
let (reply, authenticated) = authentication_reply(&auth_line(supplied), &expected);
|
||||
assert!(authenticated, "{supplied}: {reply}");
|
||||
assert_eq!(reply["result"]["authenticated"], true);
|
||||
}
|
||||
// The comparison is case-insensitive in both directions.
|
||||
assert!(token_matches(configured, &configured.to_ascii_lowercase()));
|
||||
|
||||
// A different token, or a non-hex character that only folds to a hex one, still fails.
|
||||
let other = "1123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
|
||||
let (_, authenticated) = authentication_reply(&auth_line(other), &expected);
|
||||
assert!(!authenticated);
|
||||
let not_hex = "0123456789ABCDEG0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
|
||||
assert!(!token_matches(&expected, not_hex));
|
||||
}
|
||||
|
||||
/// #6425: the same for a token file whose contents are uppercase.
|
||||
#[test]
|
||||
fn an_uppercase_token_file_authenticates_its_exact_contents() {
|
||||
let path = std::env::temp_dir().join(format!("photocraft-control-token-upper-{}-{}.txt", std::process::id(), generate_token().unwrap()));
|
||||
let contents = generate_token().unwrap().to_ascii_uppercase();
|
||||
create_token_file(&path, &contents).unwrap();
|
||||
let expected = server_token(None, Some(&path));
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
let (reply, authenticated) = authentication_reply(&auth_line(&contents), &expected.unwrap());
|
||||
assert!(authenticated, "{reply}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_auth_on_an_authenticated_connection_says_so() {
|
||||
let line = json!({"id": 3, "method": AUTH_METHOD, "params": {"token": "anything"}}).to_string();
|
||||
@@ -437,16 +480,23 @@ mod tests {
|
||||
#[test]
|
||||
fn an_authenticated_connection_outlives_the_authentication_deadline() {
|
||||
let (server, mut client) = tcp_pair();
|
||||
// The test thread writes the first line before the deadline starts. The sender writes the
|
||||
// second line once the connection has authenticated and the deadline has passed.
|
||||
client.write_all(b"one\n").unwrap();
|
||||
let limit = Duration::from_secs(1);
|
||||
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, limit));
|
||||
let deadline = Instant::now() + limit;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let sender = std::thread::spawn(move || {
|
||||
client.write_all(b"one\n").unwrap();
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
rx.recv().unwrap();
|
||||
std::thread::sleep((deadline + Duration::from_millis(300)).saturating_duration_since(Instant::now()));
|
||||
client.write_all(b"two\n").unwrap();
|
||||
});
|
||||
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200)));
|
||||
let mut line = String::new();
|
||||
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
|
||||
assert_eq!(line, "one\n");
|
||||
reader.get_mut().authenticated().unwrap();
|
||||
tx.send(()).unwrap();
|
||||
// Sent 300 ms after the deadline: only the idle timeout applies now.
|
||||
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
|
||||
assert_eq!(line, "two\n");
|
||||
|
||||
@@ -170,6 +170,13 @@ pub struct SaveParams {
|
||||
/// DPX: 8, 10, 12 or 16 bits per sample. Unset: 8 for an 8-bit document, else 16.
|
||||
#[serde(default, rename = "dpxBits")]
|
||||
pub dpx_bits: Option<u8>,
|
||||
/// DDS: "none" (uncompressed, the default), "bc1" (DXT1: alpha on or off), "bc2" (DXT3) or
|
||||
/// "bc3" (DXT5).
|
||||
#[serde(default, rename = "ddsCompression")]
|
||||
pub dds_compression: Option<String>,
|
||||
/// DDS: also write the full mip chain, each level a box filter of the one above.
|
||||
#[serde(default, rename = "ddsMipmaps")]
|
||||
pub dds_mipmaps: bool,
|
||||
#[serde(default)]
|
||||
pub index: Option<usize>,
|
||||
}
|
||||
@@ -1001,10 +1008,18 @@ impl PhotocraftMcp {
|
||||
Some((_, Some(bits))) => Some(bits),
|
||||
Some((b, None)) => return Ok(fail(format!("`dpxBits` must be 8, 10, 12 or 16, got {b}"))),
|
||||
};
|
||||
if let Some(q) = p.quality.filter(|q| !(1..=100).contains(q)) {
|
||||
return Ok(fail(format!("`quality` must be a whole number from 1 to 100, got {q}")));
|
||||
}
|
||||
let dds_compression = match p.dds_compression.as_deref().map(|c| (c, photocraft_io::DdsCompression::from_name(c))) {
|
||||
None => None,
|
||||
Some((_, Some(c))) => Some(c),
|
||||
Some((c, None)) => return Ok(fail(format!("`ddsCompression` must be none, bc1, bc2 or bc3, got {c:?}"))),
|
||||
};
|
||||
if let Some(b) = self.bridge_client() {
|
||||
// The bridge forwards to the running app's `app.save`, which takes only an optional
|
||||
// path (without one it writes back to the document's own layered file, as headless
|
||||
// does) and the Targa bits, so the other options are headless-only. Saying so beats
|
||||
// does), the Targa bits and the DDS options, so the other options are headless-only. Saying so beats
|
||||
// saving with defaults while the caller believes their quality or format was applied.
|
||||
let unsupported: Vec<&str> = [
|
||||
p.format.is_some().then_some("format"),
|
||||
@@ -1026,15 +1041,22 @@ impl PhotocraftMcp {
|
||||
if let Some(bits) = tga_bits {
|
||||
params["tgaBits"] = json!(bits.bits());
|
||||
}
|
||||
if let Some(c) = dds_compression {
|
||||
params["ddsCompression"] = json!(c.name());
|
||||
}
|
||||
if p.dds_mipmaps {
|
||||
params["ddsMipmaps"] = json!(true);
|
||||
}
|
||||
return to_result(b.call("app.save", params).await);
|
||||
}
|
||||
let Some(r) = self
|
||||
.headless_op(move |h| {
|
||||
let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, tga_bits, ..Default::default() };
|
||||
opts.encode.dpx_bits = dpx_bits;
|
||||
opts.encode.dds = photocraft_io::DdsOptions { compression: dds_compression.unwrap_or_default(), mipmaps: p.dds_mipmaps };
|
||||
if let Some(q) = p.quality {
|
||||
opts.encode.jpeg_quality = q.clamp(1, 100);
|
||||
opts.encode.webp_quality = q.clamp(1, 100);
|
||||
opts.encode.jpeg_quality = q;
|
||||
opts.encode.webp_quality = q;
|
||||
opts.encode.webp_lossless = false;
|
||||
}
|
||||
let path = p.path.map(PathBuf::from);
|
||||
@@ -1175,7 +1197,7 @@ mod tests {
|
||||
break;
|
||||
}
|
||||
assert!(t.elapsed().as_secs() < 60, "doc_inspect never saw the finished job: {inspected}");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,13 @@ impl AuthorizedWorkspace {
|
||||
/// as [`photocraft_format::atomic_write`], through the directory capability). On failure
|
||||
/// the previous file is untouched and the temporary file is removed.
|
||||
///
|
||||
/// Replacing a file carries its extended attributes — macOS Finder tags, Linux `user.*` —
|
||||
/// onto the file that takes its place (#7095), through the two open descriptors rather than
|
||||
/// their paths, so nothing here reaches outside the write root to finish a write. The rest
|
||||
/// of what [`photocraft_format::atomic_write`] preserves does not follow: its permission
|
||||
/// step is tracked in #4963, and the Windows `ReplaceFileW` swap is path-based, which this
|
||||
/// path may not use.
|
||||
///
|
||||
/// The parent directory must already exist. `cap-std` performs path
|
||||
/// resolution and file creation relative to the held directory handle, so
|
||||
/// a non-existent final target is supported without ambient path access.
|
||||
@@ -85,10 +92,20 @@ impl AuthorizedWorkspace {
|
||||
let tmp = parent.join(photocraft_format::atomic::temp_name(&leaf));
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
let written = root.dir.open_with(&tmp, &options).and_then(|mut file| {
|
||||
let written = root.dir.open_with(&tmp, &options).and_then(|file| {
|
||||
let mut file = file.into_std();
|
||||
file.write_all(bytes)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::fd::AsFd;
|
||||
// Best effort, and only when there is a file being replaced: a save still
|
||||
// succeeds for a document whose Finder tag could not come along.
|
||||
if let Ok(old) = root.dir.open(&relative) {
|
||||
let _ = photocraft_format::atomic::meta::copy_xattrs(old.as_fd(), file.as_fd());
|
||||
}
|
||||
}
|
||||
// Falls back to a plain fsync on shares that refuse a full flush (#1336).
|
||||
photocraft_format::atomic::sync_file(&file.into_std())
|
||||
photocraft_format::atomic::sync_file(&file)
|
||||
});
|
||||
let renamed = written.and_then(|()| photocraft_format::atomic::retry_rename(RenameRetry::platform(), || root.dir.rename(&tmp, &root.dir, &relative)));
|
||||
if let Err(e) = renamed {
|
||||
@@ -323,6 +340,8 @@ 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"],
|
||||
_ => &[],
|
||||
};
|
||||
@@ -339,6 +358,13 @@ 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")
|
||||
@@ -572,6 +598,32 @@ mod tests {
|
||||
assert!(temp_files(&inside).is_empty());
|
||||
}
|
||||
|
||||
/// #7095: replacing a file through the automation path keeps the old file's extended
|
||||
/// attributes, the same as a save through `atomic_write` does.
|
||||
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
|
||||
#[test]
|
||||
fn write_keeps_the_replaced_files_extended_attributes() {
|
||||
#[cfg(target_os = "macos")]
|
||||
const TAG: &str = "com.apple.metadata:_kMDItemUserTags";
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
const TAG: &str = "user.photocraft.test";
|
||||
|
||||
let (inside, _, workspace) = roots("xattr");
|
||||
let path = inside.join("doc.psd");
|
||||
workspace.write("doc.psd", b"original").unwrap();
|
||||
if rustix::fs::setxattr(&path, TAG, b"tagged", rustix::fs::XattrFlags::empty()).is_err() {
|
||||
return; // a file system without extended attributes: nothing to assert
|
||||
}
|
||||
|
||||
workspace.write("doc.psd", b"replacement, longer").unwrap();
|
||||
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"replacement, longer");
|
||||
let mut buf = [0u8; 64];
|
||||
let len = rustix::fs::getxattr(&path, TAG, buf.as_mut_slice()).expect("the attribute was dropped");
|
||||
assert_eq!(&buf[..len], b"tagged");
|
||||
assert!(temp_files(&inside).is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn write_into_read_only_folder_keeps_the_original() {
|
||||
@@ -716,6 +768,12 @@ 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());
|
||||
@@ -844,7 +902,9 @@ mod tests {
|
||||
}
|
||||
|
||||
fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
|
||||
if matches!(id, "prefs.get" | "prefs.reset") {
|
||||
// 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") {
|
||||
return false;
|
||||
}
|
||||
// These registry descriptions refer to document vector paths, not host files.
|
||||
|
||||
@@ -318,6 +318,10 @@ async fn errors_are_tool_errors_not_crashes() {
|
||||
assert!(text(&r).contains("bridge"));
|
||||
let r = call(&client, "doc_export", json!({})).await;
|
||||
assert_eq!(r.is_error, Some(true));
|
||||
// Quality 0 is refused, not clamped to 1 (serde already refuses 500, -1 and "85").
|
||||
let r = call(&client, "doc_save", json!({"path": "q.jpg", "quality": 0})).await;
|
||||
assert_eq!(r.is_error, Some(true));
|
||||
assert!(text(&r).contains("from 1 to 100"), "{}", text(&r));
|
||||
// Server still alive.
|
||||
json_of(&call(&client, "session_list", json!({})).await);
|
||||
client.cancel().await.unwrap();
|
||||
@@ -415,6 +419,45 @@ async fn headless_doc_save_writes_targa_bits() {
|
||||
cleanup(&dir);
|
||||
}
|
||||
|
||||
/// `ddsCompression` and `ddsMipmaps` reach the app's `app.save` through the bridge; an unknown
|
||||
/// compression is refused before anything is sent.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_doc_save_forwards_dds_options() {
|
||||
let (addr, app) = fake_app().await;
|
||||
let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await;
|
||||
let saved = json_of(&call(&client, "doc_save", json!({"path": "out.dds", "ddsCompression": "DXT5", "ddsMipmaps": true})).await);
|
||||
assert_eq!(saved, json!({"saved": {"path": "out.dds", "ddsCompression": "bc3", "ddsMipmaps": true}}));
|
||||
let r = call(&client, "doc_save", json!({"path": "out.dds", "ddsCompression": "bc7"})).await;
|
||||
assert_eq!(r.is_error, Some(true), "{}", text(&r));
|
||||
assert!(text(&r).contains("ddsCompression"), "{}", text(&r));
|
||||
client.cancel().await.unwrap();
|
||||
app.abort();
|
||||
}
|
||||
|
||||
/// Headless `doc_save`/`doc_export` write the DDS compression (the FourCC, bytes 84–87) and mip
|
||||
/// count (bytes 28–31) asked for; unset, an uncompressed texture without mipmaps.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn headless_doc_save_writes_dds_options() {
|
||||
let dir = tmp("dds-options");
|
||||
let client = connect(headless_in(&dir)).await;
|
||||
call(&client, "doc_new", json!({"width": 16, "height": 8, "background": "white"})).await;
|
||||
for (tool, params, fourcc, mips) in [
|
||||
("doc_export", json!({"path": "plain.dds"}), [0; 4], 1),
|
||||
("doc_export", json!({"path": "bc1.dds", "ddsCompression": "bc1"}), *b"DXT1", 1),
|
||||
("doc_save", json!({"path": "bc3.dds", "ddsCompression": "bc3", "ddsMipmaps": true}), *b"DXT5", 5),
|
||||
] {
|
||||
let r = call(&client, tool, params.clone()).await;
|
||||
assert_ne!(r.is_error, Some(true), "{params}: {}", text(&r));
|
||||
let bytes = std::fs::read(dir.join(params["path"].as_str().unwrap())).unwrap();
|
||||
assert_eq!((&bytes[84..88], u32::from_le_bytes(bytes[28..32].try_into().unwrap())), (&fourcc[..], mips), "{params}");
|
||||
}
|
||||
let r = call(&client, "doc_export", json!({"path": "bad.dds", "ddsCompression": "bc7"})).await;
|
||||
assert_eq!(r.is_error, Some(true), "{}", text(&r));
|
||||
assert!(!dir.join("bad.dds").exists());
|
||||
client.cancel().await.unwrap();
|
||||
cleanup(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_response_budget_drops_connection_without_retrying_the_operation() {
|
||||
use photocraft_automation::{BridgeClient, budgets::MAX_RESPONSE_BYTES};
|
||||
|
||||
@@ -257,3 +257,26 @@ fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache()
|
||||
assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage");
|
||||
}
|
||||
}
|
||||
|
||||
/// Choosing another Displace map through `setParams` embeds it (#7423), so the new map is read
|
||||
/// before the edit: a path outside the workspace is refused before that read, and the filter
|
||||
/// keeps the map it had.
|
||||
#[test]
|
||||
fn a_new_displace_map_outside_the_workspace_is_refused_before_it_is_read() {
|
||||
let f = Fixture::new();
|
||||
let (mut h, layer) = f.setup(false);
|
||||
let params = |h: &Headless| {
|
||||
let d = h.session.active().unwrap();
|
||||
let LayerContent::Smart(sm) = &d.doc.layer(layer).unwrap().content else { panic!("smart object") };
|
||||
sm.smart_filters[0].params.clone()
|
||||
};
|
||||
let before = params(&h);
|
||||
let map = photocraft_io::smart_map::displace_embedded_map(&before).expect("the open document's map, embedded");
|
||||
assert!(map.data.iter().all(|v| *v < 0.9), "the grey map");
|
||||
let doc = h.session.active().unwrap().doc.clone();
|
||||
let edit = json!({"layer": layer.0, "params": {"mapPath": f.outside("white.png")}});
|
||||
assert_denied(h.session.execute("layer.smartFilter.setParams", edit.clone()));
|
||||
assert_denied(h.handle("engine.execute", json!({"command": "layer.smartFilter.setParams", "params": edit})));
|
||||
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
|
||||
assert_eq!(params(&h), before, "no white map was read into the filter");
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou
|
||||
* `Transform::proof(src, proof, display, intent, bpc, simulate_paper)`: soft-proof chain.
|
||||
* `GamutCheck::new(src, proof, ΔE)`: out-of-gamut test (gamut warning).
|
||||
* `Lut3d::from_transform(&t, 33).to_rgba16f_bytes()`: display transform as a 3D texture.
|
||||
* `Transform::gpu_matrix_shaper()`: accurate RGB input curves + matrix + output curves for
|
||||
display shaders. Float tables retain near-black detail; unsupported chains return `None`
|
||||
for the 3D LUT fallback. No GPU dependency is required by this crate.
|
||||
* `transform::cached(src, dst, opts)`: process-wide transform cache.
|
||||
* `Profile::to_bytes()`: original bytes of parsed profiles (byte-exact), or an ICC v4.3 encoding.
|
||||
* `Profile::same_colors(&other)`: the same colour space whatever the encoding (every sample of
|
||||
@@ -36,6 +39,10 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou
|
||||
tetrahedral interpolation (4D: linear on the first input between two tetrahedra); purely
|
||||
analytic chains (e.g. Lab ↔ RGB) are evaluated exactly. Buffers ≥ 16k pixels run on rayon
|
||||
(native only).
|
||||
* GPU matrix/TRC display transforms reuse the 4097-entry input curves, 3×3 matrix plus
|
||||
offsets, and 4096-entry output curves indexed by `x^(1/4)`. Each channel is interpolated
|
||||
separately using float texels; SDR input/output are clamped to `[0, 1]`. A final RGB
|
||||
33³ table cannot resolve steep inverse gamma curves near black, even with float texels.
|
||||
* Rendering intents: AToB0/1/2 and BToA0/1/2 by intent with AToB0/BToA0 fallback; absolute
|
||||
colorimetric scales by media white (`wtpt`; v2 display profiles treated as D50; a zero,
|
||||
negative or non-finite white is refused with `CmsError::Invalid`); BPC uses
|
||||
|
||||
@@ -261,13 +261,60 @@ fn lab() -> Profile {
|
||||
/// with `PHOTOCRAFT_REGEN_PROFILES=1 cargo test -p photocraft-cms --release regen`).
|
||||
pub const COATED_CMYK_ICC: &[u8] = include_bytes!("../profiles/photocraft-coated-cmyk.icc");
|
||||
|
||||
/// Rec. 709 / sRGB primaries (xy).
|
||||
const BT709: [[f64; 2]; 3] = [[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]];
|
||||
/// DCI-P3 primaries (xy), used with D65 by Display P3.
|
||||
const P3: [[f64; 2]; 3] = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]];
|
||||
/// Rec. 2020 primaries (xy).
|
||||
const BT2020: [[f64; 2]; 3] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]];
|
||||
|
||||
/// Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below.
|
||||
fn rec709_trc() -> Curve {
|
||||
let a = 1.099;
|
||||
Curve::Parametric { kind: 3, p: [1.0 / 0.45, 1.0 / a, (a - 1.0) / a, 1.0 / 4.5, 0.081, 0.0, 0.0] }
|
||||
}
|
||||
|
||||
/// The RGB profile for an ITU-T H.273 (CICP) colour description, as a HEIF `nclx` or a video
|
||||
/// stream declares it: BT.709, BT.2020 or P3-D65 primaries (codes 1, 9, 12) with the sRGB,
|
||||
/// BT.709, linear or gamma 2.2/2.8 transfer (13; 1, 6, 14, 15; 8; 4; 5). `linear` means the
|
||||
/// samples were already decoded to linear light, which is how PQ and HLG (16, 18) images are
|
||||
/// held, since no ICC curve represents those transfers. `None` for anything else.
|
||||
pub fn cicp_rgb(primaries: u16, transfer: u16, linear: bool) -> Option<Profile> {
|
||||
let (prim, name) = match primaries {
|
||||
1 => (BT709, "BT.709"),
|
||||
9 => (BT2020, "Rec. ITU-R BT.2020"),
|
||||
12 => (P3, "Display P3"),
|
||||
_ => return None,
|
||||
};
|
||||
let transfer = if linear { 8 } else { transfer };
|
||||
let builtin = match (primaries, transfer) {
|
||||
(1, 13) => Some(Builtin::Srgb),
|
||||
(1, 8) => Some(Builtin::LinearSrgb),
|
||||
(12, 13) => Some(Builtin::DisplayP3),
|
||||
(9, 1 | 6 | 14 | 15) => Some(Builtin::Rec2020),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(b) = builtin {
|
||||
return Some(b.profile().clone());
|
||||
}
|
||||
let (trc, curve) = match transfer {
|
||||
8 => (Curve::Gamma(1.0), "linear"),
|
||||
13 => (srgb_trc(), "sRGB curve"),
|
||||
1 | 6 | 14 | 15 => (rec709_trc(), "BT.709 curve"),
|
||||
4 => (Curve::Gamma(2.2), "gamma 2.2"),
|
||||
5 => (Curve::Gamma(2.8), "gamma 2.8"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(rgb(&format!("{name} {curve} (PhotoCraft)"), prim, d65(), trc))
|
||||
}
|
||||
|
||||
fn build(b: Builtin) -> Profile {
|
||||
let (srgb_p, p3, adobe, romm, bt2020, ap1) = (
|
||||
[[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]],
|
||||
[[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]],
|
||||
BT709,
|
||||
P3,
|
||||
[[0.64, 0.33], [0.21, 0.71], [0.15, 0.06]],
|
||||
[[0.7347, 0.2653], [0.1596, 0.8404], [0.0366, 0.0001]],
|
||||
[[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]],
|
||||
BT2020,
|
||||
[[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]],
|
||||
);
|
||||
match b {
|
||||
@@ -279,12 +326,7 @@ fn build(b: Builtin) -> Profile {
|
||||
// ACEScg is scene-linear on the AP1 primaries with the ACES white point (~D60,
|
||||
// xy 0.32168/0.33767, the ACES 2065-1 neutral).
|
||||
Builtin::AcesCg => rgb(b.description(), ap1, math::xy_to_xyz([0.32168, 0.33767]), Curve::Gamma(1.0)),
|
||||
Builtin::Rec2020 => {
|
||||
// Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below.
|
||||
let a = 1.099;
|
||||
let trc = Curve::Parametric { kind: 3, p: [1.0 / 0.45, 1.0 / a, (a - 1.0) / a, 1.0 / 4.5, 0.081, 0.0, 0.0] };
|
||||
rgb(b.description(), bt2020, d65(), trc)
|
||||
}
|
||||
Builtin::Rec2020 => rgb(b.description(), bt2020, d65(), rec709_trc()),
|
||||
Builtin::GrayGamma22 => gray(b.description(), Curve::Gamma(2.2)),
|
||||
Builtin::SGray => gray(b.description(), srgb_trc()),
|
||||
Builtin::DotGain20Compat => gray(b.description(), Curve::Gamma(1.732)),
|
||||
@@ -294,3 +336,30 @@ fn build(b: Builtin) -> Profile {
|
||||
Builtin::CoatedCmyk => Profile::parse(COATED_CMYK_ICC).unwrap_or_else(|_| crate::synth::coated_cmyk()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cicp_maps_onto_the_builtins_and_linear_bt2020() {
|
||||
let desc = |p: Option<Profile>| p.map(|p| p.description);
|
||||
assert_eq!(desc(cicp_rgb(1, 13, false)), Some(Builtin::Srgb.description().to_string()));
|
||||
assert_eq!(desc(cicp_rgb(12, 13, false)), Some(Builtin::DisplayP3.description().to_string()));
|
||||
assert_eq!(desc(cicp_rgb(9, 14, false)), Some(Builtin::Rec2020.description().to_string()));
|
||||
assert_eq!(desc(cicp_rgb(1, 18, true)), Some(Builtin::LinearSrgb.description().to_string()));
|
||||
|
||||
// An HLG BT.2020 image decoded to linear light: BT.2020's colorants, a linear curve.
|
||||
let hlg = cicp_rgb(9, 18, true).unwrap();
|
||||
assert_eq!(hlg.trc, Some([Curve::Gamma(1.0), Curve::Gamma(1.0), Curve::Gamma(1.0)]));
|
||||
assert_eq!(hlg.matrix, Builtin::Rec2020.profile().matrix);
|
||||
assert!(hlg.description.contains("BT.2020") && hlg.description.contains("linear"), "{}", hlg.description);
|
||||
assert!(Profile::parse(&hlg.to_bytes()).is_ok(), "encodes as a valid ICC profile");
|
||||
|
||||
// HLG and PQ have no ICC curve; unknown primaries have no profile.
|
||||
assert!(cicp_rgb(9, 18, false).is_none());
|
||||
assert!(cicp_rgb(9, 16, false).is_none());
|
||||
assert!(cicp_rgb(2, 13, false).is_none());
|
||||
assert!(cicp_rgb(u16::MAX, u16::MAX, true).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Separable RGB display transforms without a coarse 3D interpolation near black.
|
||||
|
||||
/// RGB input curves, a 3×3 matrix with offsets, and RGB output curves for a GPU display
|
||||
/// transform. Values are floats independent of the document's pixel depth.
|
||||
///
|
||||
/// The input table is sampled linearly at `clamp(rgb, 0, 1) * INPUT_INTERVALS`. Apply
|
||||
/// [`Self::matrix_rows`] to that result, then sample the output table at
|
||||
/// `clamp(linear_rgb, 0, 1)^(1/4) * (OUTPUT_ENTRIES - 1)`. Interpolate each channel
|
||||
/// separately between its two adjacent table entries. These nonuniform output samples
|
||||
/// retain shadow detail where a pure gamma curve's inverse has an infinite slope.
|
||||
///
|
||||
/// Tables contain RGB values and a padding alpha of 1, suitable for RGBA32Float texels.
|
||||
/// Use unfiltered texture loads and manual interpolation if float filtering is unavailable.
|
||||
/// This representation covers SDR display output; input and output are clamped to `[0, 1]`.
|
||||
/// Construction validates all sizes and values; arbitrary CLUT/proofing chains retain the
|
||||
/// [`crate::Lut3d`] fallback.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GpuMatrixShaper {
|
||||
matrix: [[f32; 4]; 3],
|
||||
input: Vec<[f32; 4]>,
|
||||
output: Vec<[f32; 4]>,
|
||||
}
|
||||
|
||||
impl GpuMatrixShaper {
|
||||
/// The input table has one more entry than this number of equal intervals.
|
||||
pub const INPUT_INTERVALS: usize = 4096;
|
||||
/// Output entries are spaced uniformly in the fourth root of the linear value.
|
||||
pub const OUTPUT_ENTRIES: usize = 4096;
|
||||
|
||||
/// Row-major matrix: each row contains three RGB coefficients followed by its offset.
|
||||
pub fn matrix_rows(&self) -> [[f32; 4]; 3] {
|
||||
self.matrix
|
||||
}
|
||||
|
||||
/// RGB input curves at `i / INPUT_INTERVALS`, with padding alpha 1.
|
||||
pub fn input_table(&self) -> &[[f32; 4]] {
|
||||
&self.input
|
||||
}
|
||||
|
||||
/// RGB output curves at `(i / (OUTPUT_ENTRIES - 1))^4`, with padding alpha 1.
|
||||
pub fn output_table(&self) -> &[[f32; 4]] {
|
||||
&self.output
|
||||
}
|
||||
|
||||
pub(crate) fn from_parts(matrix: [[f32; 4]; 3], input: &[Vec<f32>], output: Option<&[Vec<f32>]>) -> Option<Self> {
|
||||
if !matrix.iter().flatten().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let input = pack_table(if input.is_empty() { None } else { Some(input) }, Self::INPUT_INTERVALS + 1, |u| u)?;
|
||||
let output = pack_table(output, Self::OUTPUT_ENTRIES, |u| u * u * u * u)?;
|
||||
let maxima: [f64; 3] = std::array::from_fn(|channel| input.iter().map(|entry| f64::from(entry[channel]).abs()).fold(0.0, f64::max));
|
||||
for row in matrix {
|
||||
let magnitude =
|
||||
row[..3].iter().zip(maxima).map(|(coefficient, maximum)| f64::from(*coefficient).abs() * maximum).sum::<f64>() + f64::from(row[3]).abs();
|
||||
if magnitude > f64::from(f32::MAX) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(Self { matrix, input, output })
|
||||
}
|
||||
}
|
||||
|
||||
fn pack_table(tables: Option<&[Vec<f32>]>, entries: usize, identity: impl Fn(f32) -> f32) -> Option<Vec<[f32; 4]>> {
|
||||
if let Some(tables) = tables
|
||||
&& (tables.len() != 3 || tables.iter().any(|t| t.len() != entries))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut packed: Vec<[f32; 4]> = Vec::new();
|
||||
// Both lengths are fixed constants, but a failed allocation still means the caller
|
||||
// should retain its existing fallback rather than abort while opening a document.
|
||||
packed.try_reserve_exact(entries).ok()?;
|
||||
for i in 0..entries {
|
||||
let rgb = match tables {
|
||||
Some(tables) => [*tables.first()?.get(i)?, *tables.get(1)?.get(i)?, *tables.get(2)?.get(i)?],
|
||||
None => [identity(i as f32 / (entries - 1) as f32); 3],
|
||||
};
|
||||
if !rgb.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
if let Some(previous) = packed.last()
|
||||
&& rgb.iter().zip(previous).any(|(next, before)| !(*next - *before).is_finite())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
packed.push([rgb[0], rgb[1], rgb[2], 1.0]);
|
||||
}
|
||||
Some(packed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const IDENTITY: [[f32; 4]; 3] = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]];
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_tables_and_nonfinite_values() {
|
||||
let inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3];
|
||||
let outputs = vec![vec![0.0; GpuMatrixShaper::OUTPUT_ENTRIES]; 3];
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs)).is_some());
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs[..2], Some(&outputs)).is_none());
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs[..2])).is_none());
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&[])).is_none());
|
||||
let mut invalid = inputs.clone();
|
||||
invalid[0].pop();
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none());
|
||||
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
|
||||
let mut invalid = inputs.clone();
|
||||
invalid[1][123] = v;
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none());
|
||||
let mut invalid = outputs.clone();
|
||||
invalid[2][321] = v;
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&invalid)).is_none());
|
||||
let mut invalid = IDENTITY;
|
||||
invalid[1][3] = v;
|
||||
assert!(GpuMatrixShaper::from_parts(invalid, &inputs, Some(&outputs)).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_finite_data_that_overflows_shader_arithmetic() {
|
||||
let mut matrix = IDENTITY;
|
||||
matrix[0] = [f32::MAX; 4];
|
||||
assert!(GpuMatrixShaper::from_parts(matrix, &[], None).is_none());
|
||||
let mut inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3];
|
||||
inputs[0][100] = -f32::MAX;
|
||||
inputs[0][101] = f32::MAX;
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, None).is_none());
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
//! * [`builtin`] ships sRGB, Display P3, Adobe RGB (1998)-compatible, ProPhoto-compatible,
|
||||
//! linear sRGB, Rec. 2020, Gray 2.2, sGray, Lab D50 and a synthetic coated CMYK profile.
|
||||
//! * [`Lut3d`] samples a display transform into a 3D texture for the GPU canvas.
|
||||
//! * [`GpuMatrixShaper`] preserves near-black precision for separable RGB display transforms.
|
||||
//!
|
||||
//! Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2) specifications,
|
||||
//! CIE 15, and Adobe's published black point compensation paper. This crate depends on no
|
||||
@@ -19,6 +20,7 @@ pub mod builtin;
|
||||
pub mod clut;
|
||||
pub mod curve;
|
||||
pub mod gamut;
|
||||
mod gpu_matrix_shaper;
|
||||
pub mod lut3d;
|
||||
pub mod lutfile;
|
||||
pub mod math;
|
||||
@@ -32,6 +34,7 @@ pub use builtin::Builtin;
|
||||
pub use clut::Clut;
|
||||
pub use curve::Curve;
|
||||
pub use gamut::GamutCheck;
|
||||
pub use gpu_matrix_shaper::GpuMatrixShaper;
|
||||
pub use lut3d::Lut3d;
|
||||
pub use profile::{ColorSpace, Pcs, Profile, ProfileClass};
|
||||
pub use transform::{SampleKind, Transform, TransformOptions, black_point, cached};
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::curve::Curve;
|
||||
use crate::math;
|
||||
use crate::pipeline::{Pipeline, Stage};
|
||||
use crate::profile::{ColorSpace, Pcs, Profile, ProfileClass};
|
||||
use crate::{CmsError, Intent};
|
||||
use crate::{CmsError, GpuMatrixShaper, Intent};
|
||||
|
||||
/// Sample storage of a byte buffer for [`Transform::convert_bytes_many`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -39,9 +39,9 @@ impl Default for TransformOptions {
|
||||
/// Grid sizes of the device-link tables built for integer (and fast float) evaluation.
|
||||
pub const GRID_3D: usize = 33;
|
||||
pub const GRID_4D: usize = 17;
|
||||
const TABLE_1D: usize = 4096;
|
||||
const TABLE_1D: usize = GpuMatrixShaper::INPUT_INTERVALS;
|
||||
/// Resolution of output shaper tables (indexed by `x^(1/4)`).
|
||||
const OUT_TABLE: usize = 4096;
|
||||
const OUT_TABLE: usize = GpuMatrixShaper::OUTPUT_ENTRIES;
|
||||
|
||||
/// ICC v4 perceptual reference medium black (ICC.1:2010 §6.3.4.4), XYZ.
|
||||
const PERCEPTUAL_BLACK: [f64; 3] = [0.00336, 0.0034731, 0.00287];
|
||||
@@ -226,6 +226,31 @@ impl Transform {
|
||||
&self.pipeline
|
||||
}
|
||||
|
||||
/// A separable RGB transform for accurate GPU display conversion, when available.
|
||||
///
|
||||
/// Reuses the input/matrix/output acceleration of the integer conversion path instead
|
||||
/// of interpolating final encoded RGB over a coarse 3D grid. Returns `None` for other
|
||||
/// channel counts, CLUTs, proofing stages, or invalid/unavailable table data, so callers
|
||||
/// can keep their existing display-LUT fallback. This is an SDR representation; see
|
||||
/// [`GpuMatrixShaper`] for the precise sampling and clamping contract.
|
||||
pub fn gpu_matrix_shaper(&self) -> Option<GpuMatrixShaper> {
|
||||
if self.inputs != 3 || self.outputs != 3 {
|
||||
return None;
|
||||
}
|
||||
let Core::Shaper { curves, matrix, .. } = &self.core else { return None };
|
||||
let mut rows = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]];
|
||||
if let Some((nr, nc, coefficients, offsets)) = matrix {
|
||||
if *nr != 3 || *nc != 3 || coefficients.len() != 9 || offsets.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
for (row, (coefficients, offset)) in rows.iter_mut().zip(coefficients.as_chunks::<3>().0.iter().zip(offsets)) {
|
||||
let [r, g, b] = coefficients;
|
||||
*row = [*r, *g, *b, *offset];
|
||||
}
|
||||
}
|
||||
GpuMatrixShaper::from_parts(rows, curves, self.post.as_ref().map(|p| p.tables.as_slice()))
|
||||
}
|
||||
|
||||
/// Exact evaluation of one colour (`inputs` → `outputs` normalised values).
|
||||
#[inline]
|
||||
pub fn eval(&self, input: &[f32], out: &mut [f32]) {
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Independent ICC matrix/TRC reference for display shadows.
|
||||
//!
|
||||
//! Fixtures are generated here from the published sRGB equation and a synthetic monitor,
|
||||
//! with no photograph or device profile. The expected values implement ICC.1:2010 Annex F.3
|
||||
//! directly in f64, without Transform, Curve evaluation, or photocraft_cms::math:
|
||||
//! https://www.color.org/specification/ICC1v43_2010-12.pdf
|
||||
|
||||
use photocraft_cms::pipeline::{Pipeline, Stage};
|
||||
use photocraft_cms::{Builtin, Clut, Curve, GpuMatrixShaper, Intent, Lut3d, Profile, Transform, TransformOptions};
|
||||
|
||||
const SOURCE_MATRIX: [[f64; 3]; 3] = [[0.4360747, 0.3850649, 0.1430804], [0.2225045, 0.7168786, 0.0606169], [0.0139322, 0.0971045, 0.7141733]];
|
||||
// Deliberately synthetic, D50-referenced wider-gamut monitor; not a user's measured profile.
|
||||
const MONITOR_MATRIX: [[f64; 3]; 3] = [[0.55, 0.27, 0.1442], [0.25, 0.69, 0.06], [0.01, 0.06, 0.7549]];
|
||||
const GAMMA: f64 = 2.2;
|
||||
const SOURCE_ENTRIES: usize = 1024;
|
||||
const DISPLAY_TOLERANCE: f64 = 1.0 / 255.0;
|
||||
|
||||
fn fixed16(x: f64) -> f64 {
|
||||
(x * 65536.0).round() / 65536.0
|
||||
}
|
||||
|
||||
fn srgb_sample(i: usize) -> f64 {
|
||||
let x = i as f64 / (SOURCE_ENTRIES - 1) as f64;
|
||||
let linear = if x <= 0.04045 { x / 12.92 } else { ((x + 0.055) / 1.055).powf(2.4) };
|
||||
(linear * 65535.0).round() / 65535.0
|
||||
}
|
||||
|
||||
fn synthetic_profiles() -> (Profile, Profile) {
|
||||
let mut source = Builtin::Srgb.profile().clone();
|
||||
source.description = "Synthetic sampled sRGB for display regression".into();
|
||||
source.matrix = Some(SOURCE_MATRIX);
|
||||
source.trc = Some(std::array::from_fn(|_| Curve::Table((0..SOURCE_ENTRIES).map(|i| srgb_sample(i) as f32).collect())));
|
||||
let source = source.with_encoded_bytes();
|
||||
let mut monitor = Builtin::Srgb.profile().clone();
|
||||
monitor.description = "Synthetic gamma 2.2 display for regression".into();
|
||||
monitor.matrix = Some(MONITOR_MATRIX);
|
||||
monitor.trc = Some(std::array::from_fn(|_| Curve::Parametric { kind: 0, p: [GAMMA, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] }));
|
||||
let monitor = monitor.with_encoded_bytes();
|
||||
// Exercise actual ICC serialization/parsing, including fixed-point matrix/TRC values.
|
||||
(Profile::parse(&source.to_bytes()).unwrap(), Profile::parse(&monitor.to_bytes()).unwrap())
|
||||
}
|
||||
|
||||
fn synthetic_transform(bpc: bool) -> Transform {
|
||||
let (source, monitor) = synthetic_profiles();
|
||||
Transform::new(&source, &monitor, Intent::RelativeColorimetric, bpc).unwrap()
|
||||
}
|
||||
|
||||
fn reference(rgb: [f32; 3]) -> [f64; 3] {
|
||||
let linear = rgb.map(|x| {
|
||||
let p = f64::from(x.clamp(0.0, 1.0)) * (SOURCE_ENTRIES - 1) as f64;
|
||||
let i = (p as usize).min(SOURCE_ENTRIES - 2);
|
||||
srgb_sample(i) + (srgb_sample(i + 1) - srgb_sample(i)) * (p - i as f64)
|
||||
});
|
||||
let xyz = SOURCE_MATRIX.map(|row| row.iter().zip(linear).map(|(m, x)| fixed16(*m) * x).sum::<f64>());
|
||||
// Solve M_monitor * linear_monitor = XYZ using elimination, rather than the production
|
||||
// matrix inverse/multiplication implementation.
|
||||
let mut a: [[f64; 4]; 3] = std::array::from_fn(|r| [fixed16(MONITOR_MATRIX[r][0]), fixed16(MONITOR_MATRIX[r][1]), fixed16(MONITOR_MATRIX[r][2]), xyz[r]]);
|
||||
for pivot in 0..3 {
|
||||
let divisor = a[pivot][pivot];
|
||||
for value in &mut a[pivot][pivot..] {
|
||||
*value /= divisor;
|
||||
}
|
||||
let normalized = a[pivot];
|
||||
for (r, row) in a.iter_mut().enumerate() {
|
||||
if r != pivot {
|
||||
let factor = row[pivot];
|
||||
for (value, coefficient) in row.iter_mut().zip(normalized).skip(pivot) {
|
||||
*value -= factor * coefficient;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
std::array::from_fn(|c| a[c][3].clamp(0.0, 1.0).powf(1.0 / fixed16(GAMMA)))
|
||||
}
|
||||
|
||||
/// CPU interpretation of the public GPU data layout; expected colours come only from
|
||||
/// `reference`, so this tests the sampled representation, not Transform against itself.
|
||||
fn sample_gpu(gpu: &GpuMatrixShaper, rgb: [f32; 3]) -> [f32; 3] {
|
||||
fn interpolate(table: &[[f32; 4]], channel: usize, coordinate: f32) -> f32 {
|
||||
let i = (coordinate as usize).min(table.len() - 2);
|
||||
let f = coordinate - i as f32;
|
||||
table[i][channel] + (table[i + 1][channel] - table[i][channel]) * f
|
||||
}
|
||||
let linear: [f32; 3] = std::array::from_fn(|c| interpolate(gpu.input_table(), c, rgb[c].clamp(0.0, 1.0) * GpuMatrixShaper::INPUT_INTERVALS as f32));
|
||||
let rows = gpu.matrix_rows();
|
||||
std::array::from_fn(|c| {
|
||||
let row = rows[c];
|
||||
let value = row[0] * linear[0] + row[1] * linear[1] + row[2] * linear[2] + row[3];
|
||||
let coordinate = value.clamp(0.0, 1.0).sqrt().sqrt() * (GpuMatrixShaper::OUTPUT_ENTRIES - 1) as f32;
|
||||
interpolate(gpu.output_table(), c, coordinate).clamp(0.0, 1.0)
|
||||
})
|
||||
}
|
||||
|
||||
fn check_reference(gpu: &GpuMatrixShaper, rgb: [f32; 3]) {
|
||||
let actual = sample_gpu(gpu, rgb);
|
||||
let expected = reference(rgb);
|
||||
for c in 0..3 {
|
||||
assert!((f64::from(actual[c]) - expected[c]).abs() <= DISPLAY_TOLERANCE, "input {rgb:?}, channel {c}: GPU {actual:?}, ICC {expected:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampled_srgb_to_gamma_display_preserves_neutral_and_colored_shadows() {
|
||||
for bpc in [false, true] {
|
||||
let transform = synthetic_transform(bpc);
|
||||
let gpu = transform.gpu_matrix_shaper().unwrap();
|
||||
assert_eq!(gpu.input_table().len(), GpuMatrixShaper::INPUT_INTERVALS + 1);
|
||||
assert_eq!(gpu.output_table().len(), GpuMatrixShaper::OUTPUT_ENTRIES);
|
||||
assert!(gpu.input_table().iter().chain(gpu.output_table()).all(|v| v[3] == 1.0));
|
||||
for v in 0..=255 {
|
||||
check_reference(&gpu, [v as f32 / 255.0; 3]);
|
||||
}
|
||||
// Includes coloured shadows within the old LUT's very first 0..8/255 cell.
|
||||
for r in 0..=16 {
|
||||
for g in 0..=16 {
|
||||
for b in 0..=16 {
|
||||
check_reference(&gpu, [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for rgb in [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.9, 0.6, 0.3]] {
|
||||
check_reference(&gpu, rgb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coarse_final_rgb_lut_fails_the_same_icc_shadow_reference() {
|
||||
let transform = synthetic_transform(false);
|
||||
let gpu = transform.gpu_matrix_shaper().unwrap();
|
||||
let coarse = Lut3d::from_transform(&transform, 33);
|
||||
let rgb = [2.0 / 255.0; 3];
|
||||
let expected = reference(rgb);
|
||||
let old = coarse.sample(rgb);
|
||||
// Even float LUT nodes fail, before the additional rounding of RGBA8 upload. This
|
||||
// guards the cause of the original bug rather than a particular observed screenshot.
|
||||
assert!(old.iter().zip(expected).all(|(a, e)| e - f64::from(*a) > 2.0 / 255.0), "old {old:?}, reference {expected:?}");
|
||||
check_reference(&gpu, rgb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_display_representation_accepts_u8_u16_and_float_samples() {
|
||||
let transform = synthetic_transform(false);
|
||||
let gpu = transform.gpu_matrix_shaper().unwrap();
|
||||
for v in [0_u8, 1, 2, 7, 8, 16, 32, 128, 255] {
|
||||
check_reference(&gpu, [v as f32 / 255.0; 3]);
|
||||
}
|
||||
for v in [0_u16, 1, 2, 15, 63, 257, 514, 1028, 4096, 32768, 65535] {
|
||||
check_reference(&gpu, [v as f32 / 65535.0; 3]);
|
||||
}
|
||||
for rgb in [[0.000003, 0.00001, 0.00002], [0.00123, 0.00456, 0.00789], [0.2, 0.45, 0.8], [-0.5, 0.0, 2.0]] {
|
||||
check_reference(&gpu, rgb);
|
||||
}
|
||||
assert_eq!(sample_gpu(&gpu, [0.0; 3]), [0.0; 3]);
|
||||
// White differs very slightly from 1 because the synthetic matrices use ICC s15Fixed16.
|
||||
check_reference(&gpu, [1.0; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_curves_and_matrix_use_identity_without_losing_offsets() {
|
||||
let identity = Transform::from_pipeline(Pipeline::new(3, Vec::new()), TransformOptions::default()).gpu_matrix_shaper().unwrap();
|
||||
for rgb in [[0.0; 3], [1.0; 3], [0.001, 0.2, 0.8], [-2.0, 0.5, 2.0]] {
|
||||
for (actual, input) in sample_gpu(&identity, rgb).into_iter().zip(rgb) {
|
||||
assert!((actual - input.clamp(0.0, 1.0)).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
let matrix = Stage::Matrix { rows: 3, cols: 3, m: vec![0.8, 0.0, 0.0, 0.0, 0.7, 0.0, 0.0, 0.0, 0.6], offset: vec![0.01, 0.02, 0.03] };
|
||||
let transform = Transform::from_pipeline(Pipeline::new(3, vec![matrix]), TransformOptions::default()).gpu_matrix_shaper().unwrap();
|
||||
let input = [0.1, 0.2, 0.3];
|
||||
let output = sample_gpu(&transform, input);
|
||||
for (actual, expected) in output.into_iter().zip([0.09, 0.16, 0.21]) {
|
||||
assert!((actual - expected).abs() < 1e-6, "{output:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_clut_proof_and_non_rgb_transforms_retain_fallback() {
|
||||
let identity_clut = Clut::sample(vec![2; 3], 3, |input, output| output.copy_from_slice(input));
|
||||
let clut = Transform::from_pipeline(Pipeline::new(3, vec![Stage::Clut(identity_clut)]), TransformOptions::default());
|
||||
assert!(clut.gpu_matrix_shaper().is_none());
|
||||
let srgb = Builtin::Srgb.profile();
|
||||
let lab = Transform::new(Builtin::LabD50.profile(), srgb, Intent::RelativeColorimetric, false).unwrap();
|
||||
assert!(lab.gpu_matrix_shaper().is_none());
|
||||
let gray = Transform::new(Builtin::GrayGamma22.profile(), srgb, Intent::RelativeColorimetric, false).unwrap();
|
||||
assert!(gray.gpu_matrix_shaper().is_none());
|
||||
let proof = Transform::proof(srgb, Builtin::DisplayP3.profile(), srgb, Intent::RelativeColorimetric, false, false).unwrap();
|
||||
assert!(proof.gpu_matrix_shaper().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_matrix_storage_is_not_exposed_to_the_gpu() {
|
||||
for (coefficients, offsets) in [(vec![2.0; 8], vec![0.0; 3]), (vec![2.0; 9], vec![0.0; 2]), (vec![f64::INFINITY; 9], vec![0.0; 3])] {
|
||||
let transform =
|
||||
Transform::from_pipeline(Pipeline::new(3, vec![Stage::Matrix { rows: 3, cols: 3, m: coefficients, offset: offsets }]), TransformOptions::default());
|
||||
assert!(transform.gpu_matrix_shaper().is_none());
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -40,7 +40,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
|
||||
| BMP | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` |
|
||||
| TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` (read) / built in (RLE write, one scanline per packet) |
|
||||
| ICO | yes | yes | U8 | RGBA (at most 256×256) | yes | no | no | no | no | no | no | `image` |
|
||||
| DDS | yes | **no** | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | n/a | built in |
|
||||
| DDS | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | yes (BC1–BC3, when `EncodeOptions::dds` asks for one) | built in |
|
||||
| Netpbm (PBM/PGM/PPM/PAM/PFM) | yes | yes | U8, U16, F32 (float: PFM gray/RGB only) | all six (PAM `CMYK`/`CMYK_ALPHA`) | yes (PAM) | no | no | no | no | no | no | built in |
|
||||
| QOI | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` |
|
||||
| OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` |
|
||||
@@ -66,6 +66,8 @@ the same.
|
||||
|
||||
Palette BMP and PNG pixels decode as RGB(A) in `Image`; `bmp_palette` and `png_palette` expose the
|
||||
embedded table (and a PNG's transparent entry) so `photocraft-io` can open them in Indexed Color mode.
|
||||
`bmp_palette` returns the colours the header declares, not the decoder's 256-entry padding.
|
||||
`encode_bmp_indexed` writes the inverse: indices plus a table as an 8-bit palette BMP.
|
||||
|
||||
## Documented asymmetries and limitations
|
||||
|
||||
@@ -73,7 +75,15 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
|
||||
(8, 16, 24 or 32 bits, RGB or luminance, with or without alpha) or BC1/BC2/BC3 (DXT1/DXT3/DXT5),
|
||||
with the legacy header or the DX10 one. Everything opens as U8; the other mip levels are
|
||||
ignored. Cubemaps, texture arrays, volume textures, premultiplied DXT2/DXT4 and other formats
|
||||
(BC4–BC7, float) are refused with `CodecError::Unsupported`. Writing is not provided.
|
||||
(BC4–BC7, float) are refused with `CodecError::Unsupported`. Writing (`EncodeOptions::dds`) makes
|
||||
a 2D texture with the legacy header: uncompressed by default (BGRA, BGRX, or luminance with or
|
||||
without alpha, so it reads back exactly), or BC1/BC2/BC3. The block encoder is our own, from
|
||||
Microsoft's public block-compression documentation: RGB565 endpoints fitted along each block's
|
||||
principal axis and refined by least squares, indices picked against the palette the reader
|
||||
decodes; BC1 turns texels under half opacity into its transparent black, and BC3 picks
|
||||
whichever alpha mode fits. `mipmaps` adds the full chain down to 1×1, each level a box filter
|
||||
of the one above (the last column and row of an odd-sized level average three pixels, so none
|
||||
is dropped). Cubemaps, arrays, BC4–BC7 and sRGB (DX10-only) formats aren't written.
|
||||
|
||||
* **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3`
|
||||
opens as RGB and any other plane count opens the first plane with `DecodeWarning::MorePages`.
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! ITU-T H.273 colour descriptions (CICP: coding-independent code points), as HEIF `colr`
|
||||
//! `nclx` boxes and video streams declare them, and the HDR transfer functions of ITU-R BT.2100.
|
||||
//!
|
||||
//! PQ and HLG can't be written as an ICC tone curve, so an image using them is decoded here to
|
||||
//! linear light (32-bit float) instead: diffuse white (BT.2408's 203 cd/m²) becomes 1.0 and the
|
||||
//! highlights above it are kept as values over 1.0, as Photoshop holds HDR in a 32-bit document.
|
||||
//! The caller tags the result with a linear profile on the declared primaries.
|
||||
|
||||
use crate::error::CodecError;
|
||||
use crate::image::{Image, SampleType};
|
||||
|
||||
/// A colour description: the four H.273 code points as written in the file.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Cicp {
|
||||
/// Colour primaries: 1 BT.709, 9 BT.2020, 12 P3-D65…
|
||||
pub primaries: u16,
|
||||
/// Transfer characteristics: 13 sRGB, 1/6/14/15 BT.709, 8 linear, 16 PQ, 18 HLG…
|
||||
pub transfer: u16,
|
||||
/// Matrix coefficients.
|
||||
pub matrix: u16,
|
||||
/// Full-range samples rather than studio swing.
|
||||
pub full_range: bool,
|
||||
}
|
||||
|
||||
impl Cicp {
|
||||
/// SMPTE ST 2084 / BT.2100 perceptual quantizer.
|
||||
pub const TRANSFER_PQ: u16 = 16;
|
||||
/// ARIB STD-B67 / BT.2100 hybrid log-gamma.
|
||||
pub const TRANSFER_HLG: u16 = 18;
|
||||
|
||||
/// PQ or HLG: samples that [`linearize`] decodes to linear light.
|
||||
pub fn is_hdr(self) -> bool {
|
||||
matches!(self.transfer, Cicp::TRANSFER_PQ | Cicp::TRANSFER_HLG)
|
||||
}
|
||||
}
|
||||
|
||||
/// The luminance that becomes 1.0: BT.2408's HDR reference (diffuse) white.
|
||||
pub const REFERENCE_WHITE_NITS: f32 = 203.0;
|
||||
/// The nominal display peak HLG is rendered for (BT.2100's reference display).
|
||||
pub const HLG_PEAK_NITS: f32 = 1000.0;
|
||||
/// PQ's absolute peak.
|
||||
pub const PQ_PEAK_NITS: f32 = 10_000.0;
|
||||
|
||||
/// BT.2100 HLG inverse OETF: non-linear signal `E'` (0..1) to normalised scene light `E` (0..1).
|
||||
pub fn hlg_inverse_oetf(e: f32) -> f32 {
|
||||
const A: f32 = 0.178_832_77;
|
||||
const B: f32 = 1.0 - 4.0 * A;
|
||||
const C: f32 = 0.559_910_7;
|
||||
let e = if e.is_finite() { e.clamp(0.0, 1.0) } else { 0.0 };
|
||||
if e <= 0.5 { e * e / 3.0 } else { (((e - C) / A).exp() + B) / 12.0 }
|
||||
}
|
||||
|
||||
/// BT.2100 HLG signal to display light relative to [`REFERENCE_WHITE_NITS`]: the inverse OETF,
|
||||
/// then the OOTF for a [`HLG_PEAK_NITS`] display (system gamma 1.2, zero black level), whose
|
||||
/// luminance uses the weights of the `luma` primaries.
|
||||
pub fn hlg_to_linear(rgb: [f32; 3], luma: [f32; 3]) -> [f32; 3] {
|
||||
let s = rgb.map(hlg_inverse_oetf);
|
||||
let ys = luma[0] * s[0] + luma[1] * s[1] + luma[2] * s[2];
|
||||
// γ = 1.2 + 0.42·log10(Lw / 1000) is exactly 1.2 at 1000 cd/m².
|
||||
let gain = if ys > 0.0 { HLG_PEAK_NITS * ys.powf(0.2) / REFERENCE_WHITE_NITS } else { 0.0 };
|
||||
s.map(|v| v * gain)
|
||||
}
|
||||
|
||||
/// SMPTE ST 2084 (PQ) signal `E'` (0..1) to display light relative to [`REFERENCE_WHITE_NITS`].
|
||||
pub fn pq_to_linear(e: f32) -> f32 {
|
||||
const M1: f32 = 2610.0 / 16384.0;
|
||||
const M2: f32 = 2523.0 / 4096.0 * 128.0;
|
||||
const C1: f32 = 3424.0 / 4096.0;
|
||||
const C2: f32 = 2413.0 / 4096.0 * 32.0;
|
||||
const C3: f32 = 2392.0 / 4096.0 * 32.0;
|
||||
let e = if e.is_finite() { e.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let p = e.powf(1.0 / M2);
|
||||
let y = ((p - C1).max(0.0) / (C2 - C3 * p)).powf(1.0 / M1);
|
||||
y * PQ_PEAK_NITS / REFERENCE_WHITE_NITS
|
||||
}
|
||||
|
||||
/// Relative luminance weights of the RGB primaries `code` (BT.2020's for anything else, the
|
||||
/// space BT.2100 defines HLG in).
|
||||
fn luma_weights(code: u16) -> [f32; 3] {
|
||||
match code {
|
||||
1 => [0.2126, 0.7152, 0.0722],
|
||||
12 => [0.2290, 0.6917, 0.0793],
|
||||
_ => [0.2627, 0.6780, 0.0593],
|
||||
}
|
||||
}
|
||||
|
||||
/// `img` (RGB or RGBA, decoded with `cicp`'s PQ or HLG transfer) as linear light in 32-bit float,
|
||||
/// alpha unchanged. Its ICC profile is dropped: whatever it said described the encoded samples,
|
||||
/// not linear light. Anything else comes back unchanged.
|
||||
pub fn linearize(img: Image, cicp: Cicp) -> Result<Image, CodecError> {
|
||||
let layout = img.layout();
|
||||
if !cicp.is_hdr() || !layout.is_rgb() {
|
||||
return Ok(img);
|
||||
}
|
||||
let (w, h) = img.dimensions();
|
||||
let ch = layout.channels();
|
||||
let mut v = img.to_normalized();
|
||||
let luma = luma_weights(cicp.primaries);
|
||||
for px in v.chunks_exact_mut(ch) {
|
||||
let [r, g, b, ..] = *px else { continue };
|
||||
let out = if cicp.transfer == Cicp::TRANSFER_HLG { hlg_to_linear([r, g, b], luma) } else { [r, g, b].map(pq_to_linear) };
|
||||
px[..3].copy_from_slice(&out);
|
||||
}
|
||||
let mut out = Image::from_raw(w, h, layout, SampleType::F32, v.iter().flat_map(|x| x.to_ne_bytes()).collect())?;
|
||||
out.meta = img.meta;
|
||||
out.warnings = img.warnings;
|
||||
out.cicp = Some(cicp);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::image::ChannelLayout;
|
||||
|
||||
const BT2020: [f32; 3] = [0.2627, 0.6780, 0.0593];
|
||||
|
||||
fn close(a: f32, b: f32, tol: f32) -> bool {
|
||||
(a - b).abs() <= tol
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hlg_inverse_oetf_matches_bt2100() {
|
||||
assert_eq!(hlg_inverse_oetf(0.0), 0.0);
|
||||
// The two segments meet at E' = 0.5, E = 1/12.
|
||||
assert!(close(hlg_inverse_oetf(0.5), 1.0 / 12.0, 1e-6));
|
||||
assert!(close(hlg_inverse_oetf(0.500_001), 1.0 / 12.0, 1e-5));
|
||||
assert!(close(hlg_inverse_oetf(1.0), 1.0, 1e-4));
|
||||
// BT.2408's HLG reference white, 75 % signal, is E ≈ 0.265.
|
||||
assert!(close(hlg_inverse_oetf(0.75), 0.2650, 1e-3));
|
||||
for bad in [f32::NAN, f32::INFINITY, -1.0] {
|
||||
assert!(hlg_inverse_oetf(bad).is_finite());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hlg_reference_white_is_one_and_highlights_are_kept() {
|
||||
// BT.2408: a 75 % HLG grey on a 1000 cd/m² display is 203 cd/m², our 1.0.
|
||||
let white = hlg_to_linear([0.75; 3], BT2020);
|
||||
assert!(white.iter().all(|&v| close(v, 1.0, 0.005)), "{white:?}");
|
||||
// Peak signal is the display peak: 1000 / 203.
|
||||
let peak = hlg_to_linear([1.0; 3], BT2020);
|
||||
assert!(peak.iter().all(|&v| close(v, 1000.0 / 203.0, 0.01)), "{peak:?}");
|
||||
// 50 % grey: 1000 · (1/12)^1.2 ≈ 50.7 cd/m².
|
||||
let mid = hlg_to_linear([0.5; 3], BT2020);
|
||||
assert!(mid.iter().all(|&v| close(v * 203.0, 50.71, 0.1)), "{mid:?}");
|
||||
assert_eq!(hlg_to_linear([0.0; 3], BT2020), [0.0; 3]);
|
||||
// A saturated colour keeps its hue: only red lit stays only red.
|
||||
let red = hlg_to_linear([0.75, 0.0, 0.0], BT2020);
|
||||
assert!(red[0] > 0.0 && red[1] == 0.0 && red[2] == 0.0, "{red:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pq_matches_st2084() {
|
||||
assert_eq!(pq_to_linear(0.0), 0.0);
|
||||
assert!(close(pq_to_linear(1.0), 10_000.0 / 203.0, 0.05));
|
||||
// 203 cd/m² is PQ signal ≈ 0.5807 (BT.2408).
|
||||
assert!(close(pq_to_linear(0.5807), 1.0, 0.005), "{}", pq_to_linear(0.5807));
|
||||
assert!(pq_to_linear(f32::NAN).is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linearize_decodes_hlg_to_float_and_keeps_alpha() {
|
||||
let hlg = Cicp { primaries: 9, transfer: Cicp::TRANSFER_HLG, matrix: 9, full_range: true };
|
||||
let three_quarters = (0.75f32 * 65535.0).round() as u16;
|
||||
let img = Image::from_u16(2, 1, ChannelLayout::Rgba, &[three_quarters, three_quarters, three_quarters, 65535, 0, 0, 0, 32768])
|
||||
.unwrap()
|
||||
.with_icc(Some(b"encoded-profile".to_vec()));
|
||||
let out = linearize(img, hlg).unwrap();
|
||||
assert_eq!((out.sample_type(), out.layout(), out.icc.as_deref(), out.cicp), (SampleType::F32, ChannelLayout::Rgba, None, Some(hlg)));
|
||||
let v = out.to_normalized();
|
||||
assert!(v[..3].iter().all(|&x| close(x, 1.0, 0.005)), "{v:?}");
|
||||
assert!(close(v[3], 1.0, 1e-6) && v[4..7] == [0.0; 3] && close(v[7], 0.5, 1e-3), "{v:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linearize_leaves_sdr_untouched() {
|
||||
let srgb = Cicp { primaries: 1, transfer: 13, matrix: 6, full_range: false };
|
||||
let img = Image::from_u16(1, 1, ChannelLayout::Rgb, &[1, 2, 3]).unwrap();
|
||||
assert_eq!(linearize(img.clone(), srgb).unwrap(), img);
|
||||
}
|
||||
}
|
||||
+276
-35
@@ -1,13 +1,24 @@
|
||||
//! DirectDraw Surface (DDS) reader: the top mip level of a 2D texture, either uncompressed
|
||||
//! (8 to 32 bits per pixel described by channel masks, RGB or luminance) or block-compressed
|
||||
//! BC1/BC2/BC3 (DXT1/DXT3/DXT5), with or without the DX10 header. Read-only. Cubemaps,
|
||||
//! texture arrays and volume textures are refused rather than flattened to one face or slice.
|
||||
//! DirectDraw Surface (DDS) textures, after Microsoft's public DDS documentation ("DDS",
|
||||
//! "DDS_HEADER", "DDS_PIXELFORMAT", "DDS_HEADER_DXT10").
|
||||
//!
|
||||
//! Reading: the top mip level of a 2D texture, either uncompressed (8 to 32 bits per pixel
|
||||
//! described by channel masks, RGB or luminance) or block-compressed BC1/BC2/BC3
|
||||
//! (DXT1/DXT3/DXT5), with or without the DX10 header. Cubemaps, texture arrays and volume
|
||||
//! textures are refused rather than flattened to one face or slice.
|
||||
//!
|
||||
//! Writing: a 2D texture, uncompressed (BGRA or BGRX, luminance with or without alpha) or
|
||||
//! BC1/BC2/BC3, optionally with its full mip chain. The legacy header describes all of these,
|
||||
//! so no DX10 header is written; formats only DXGI can name (BC4–BC7, sRGB) aren't written.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use super::dds_bc::{self, Block, Mask};
|
||||
use crate::SampleType;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::format::Format;
|
||||
use crate::image::{ChannelLayout, Image};
|
||||
use crate::options::Limits;
|
||||
use crate::options::{DdsCompression, EncodeOptions, Limits};
|
||||
|
||||
/// `DDS ` plus the 124-byte DDS_HEADER.
|
||||
const HEADER: usize = 128;
|
||||
@@ -224,27 +235,12 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
Image::from_u8(width, height, layout, pixels)
|
||||
}
|
||||
|
||||
fn rgb565(c: u16) -> [u8; 3] {
|
||||
let scale = |v: u16, max: u16| ((u32::from(v) * 255 + u32::from(max) / 2) / u32::from(max)) as u8;
|
||||
[scale(c >> 11, 31), scale((c >> 5) & 63, 63), scale(c & 31, 31)]
|
||||
}
|
||||
|
||||
/// A BC1 colour block. BC2 and BC3 colour blocks always have four colours; only BC1's
|
||||
/// `c0 <= c1` order selects three colours and transparent black.
|
||||
fn bc1(block: &[u8], one_bit_alpha: bool) -> [[u8; 4]; 16] {
|
||||
let byte = |i: usize| block.get(i).copied().unwrap_or(0);
|
||||
let (c0, c1) = (u16::from_le_bytes([byte(0), byte(1)]), u16::from_le_bytes([byte(2), byte(3)]));
|
||||
let (a, b) = (rgb565(c0), rgb565(c1));
|
||||
let mix = |wa: u32, wb: u32| {
|
||||
let d = wa + wb;
|
||||
let at = |i: usize| ((wa * u32::from(a[i]) + wb * u32::from(b[i]) + d / 2) / d) as u8;
|
||||
[at(0), at(1), at(2), 255]
|
||||
};
|
||||
let palette = if c0 > c1 || !one_bit_alpha {
|
||||
[[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(2, 1), mix(1, 2)]
|
||||
} else {
|
||||
[[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(1, 1), [0; 4]]
|
||||
};
|
||||
let palette = dds_bc::colour_palette(c0, c1, c0 > c1 || !one_bit_alpha);
|
||||
let indices = u32::from_le_bytes([byte(4), byte(5), byte(6), byte(7)]);
|
||||
std::array::from_fn(|t| palette[(indices >> (2 * t)) as usize & 3])
|
||||
}
|
||||
@@ -259,21 +255,9 @@ fn bc2_alpha(block: &[u8]) -> [u8; 16] {
|
||||
/// 0 and 255 when `a0 <= a1`.
|
||||
fn bc3_alpha(block: &[u8]) -> [u8; 16] {
|
||||
let byte = |i: usize| block.get(i).copied().unwrap_or(0);
|
||||
let (a0, a1) = (u32::from(byte(0)), u32::from(byte(1)));
|
||||
let mut palette = [a0, a1, 0, 0, 0, 0, 0, 255];
|
||||
if a0 > a1 {
|
||||
for (k, v) in palette.iter_mut().enumerate().skip(2) {
|
||||
let k = k as u32;
|
||||
*v = ((8 - k) * a0 + (k - 1) * a1 + 3) / 7;
|
||||
}
|
||||
} else {
|
||||
for (k, v) in palette.iter_mut().enumerate().take(6).skip(2) {
|
||||
let k = k as u32;
|
||||
*v = ((6 - k) * a0 + (k - 1) * a1 + 2) / 5;
|
||||
}
|
||||
}
|
||||
let palette = dds_bc::alpha_palette(byte(0), byte(1));
|
||||
let bits = u64::from_le_bytes([byte(2), byte(3), byte(4), byte(5), byte(6), byte(7), 0, 0]);
|
||||
std::array::from_fn(|t| palette[((bits >> (3 * t)) & 7) as usize] as u8)
|
||||
std::array::from_fn(|t| palette[((bits >> (3 * t)) & 7) as usize])
|
||||
}
|
||||
|
||||
fn with_alpha(mut texels: [[u8; 4]; 16], alpha: [u8; 16]) -> [[u8; 4]; 16] {
|
||||
@@ -282,3 +266,260 @@ fn with_alpha(mut texels: [[u8; 4]; 16], alpha: [u8; 16]) -> [[u8; 4]; 16] {
|
||||
}
|
||||
texels
|
||||
}
|
||||
|
||||
const DDSD_CAPS: u32 = 0x1;
|
||||
const DDSD_HEIGHT: u32 = 0x2;
|
||||
const DDSD_WIDTH: u32 = 0x4;
|
||||
const DDSD_PITCH: u32 = 0x8;
|
||||
const DDSD_PIXELFORMAT: u32 = 0x1000;
|
||||
const DDSD_MIPMAPCOUNT: u32 = 0x2_0000;
|
||||
const DDSD_LINEARSIZE: u32 = 0x8_0000;
|
||||
const DDSCAPS_COMPLEX: u32 = 0x8;
|
||||
const DDSCAPS_TEXTURE: u32 = 0x1000;
|
||||
const DDSCAPS_MIPMAP: u32 = 0x40_0000;
|
||||
|
||||
fn encode_err(reason: impl std::fmt::Display) -> CodecError {
|
||||
CodecError::encode(Format::Dds, reason)
|
||||
}
|
||||
|
||||
/// How a written texture stores its pixels.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Store {
|
||||
/// 8-bit luminance.
|
||||
Luminance,
|
||||
/// 8-bit luminance, then 8-bit alpha.
|
||||
LuminanceAlpha,
|
||||
/// Blue, green, red and an unused byte (written as 255, so a reader that takes it for
|
||||
/// alpha still sees an opaque texture).
|
||||
Bgrx,
|
||||
Bgra,
|
||||
Bc1,
|
||||
Bc2,
|
||||
Bc3,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// The storage for an image of `layout`, as [`crate::fidelity::plan`] chose it.
|
||||
fn new(compression: DdsCompression, layout: ChannelLayout) -> Result<Store, CodecError> {
|
||||
Ok(match (compression, layout) {
|
||||
(DdsCompression::None, ChannelLayout::Gray) => Store::Luminance,
|
||||
(DdsCompression::None, ChannelLayout::GrayA) => Store::LuminanceAlpha,
|
||||
(DdsCompression::None, ChannelLayout::Rgb) => Store::Bgrx,
|
||||
(DdsCompression::None, ChannelLayout::Rgba) => Store::Bgra,
|
||||
(DdsCompression::Bc1, ChannelLayout::Rgba) => Store::Bc1,
|
||||
(DdsCompression::Bc2, ChannelLayout::Rgba) => Store::Bc2,
|
||||
(DdsCompression::Bc3, ChannelLayout::Rgba) => Store::Bc3,
|
||||
(_, layout) => return Err(encode_err(format!("{layout:?} pixels can't be written as `{}`", compression.name()))),
|
||||
})
|
||||
}
|
||||
|
||||
/// Bytes per pixel, or per 4×4 block for a block compression.
|
||||
fn unit(self) -> usize {
|
||||
match self {
|
||||
Store::Luminance => 1,
|
||||
Store::LuminanceAlpha => 2,
|
||||
Store::Bgrx | Store::Bgra => 4,
|
||||
Store::Bc1 => 8,
|
||||
Store::Bc2 | Store::Bc3 => 16,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_block(self) -> bool {
|
||||
matches!(self, Store::Bc1 | Store::Bc2 | Store::Bc3)
|
||||
}
|
||||
|
||||
/// Bytes of one row of pixels (or of 4×4 blocks) of a level `width` wide.
|
||||
fn row_bytes(self, width: usize) -> Option<usize> {
|
||||
if self.is_block() { width.div_ceil(4).checked_mul(self.unit()) } else { width.checked_mul(self.unit()) }
|
||||
}
|
||||
|
||||
fn level_bytes(self, width: usize, height: usize) -> Option<usize> {
|
||||
let rows = if self.is_block() { height.div_ceil(4) } else { height };
|
||||
self.row_bytes(width)?.checked_mul(rows)
|
||||
}
|
||||
|
||||
/// DDS_PIXELFORMAT: flags, FourCC, bits per pixel and the red, green, blue and alpha masks.
|
||||
fn pixel_format(self) -> (u32, [u8; 4], u32, [u32; 4]) {
|
||||
match self {
|
||||
Store::Luminance => (DDPF_LUMINANCE, [0; 4], 8, [0xff, 0, 0, 0]),
|
||||
Store::LuminanceAlpha => (DDPF_LUMINANCE | DDPF_ALPHAPIXELS, [0; 4], 16, [0xff, 0, 0, 0xff00]),
|
||||
Store::Bgrx => (DDPF_RGB, [0; 4], 32, [0xff_0000, 0xff00, 0xff, 0]),
|
||||
Store::Bgra => (DDPF_RGB | DDPF_ALPHAPIXELS, [0; 4], 32, [0xff_0000, 0xff00, 0xff, 0xff00_0000]),
|
||||
Store::Bc1 => (DDPF_FOURCC, *b"DXT1", 0, [0; 4]),
|
||||
Store::Bc2 => (DDPF_FOURCC, *b"DXT3", 0, [0; 4]),
|
||||
Store::Bc3 => (DDPF_FOURCC, *b"DXT5", 0, [0; 4]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Levels in a full mip chain: the larger side halves down to 1.
|
||||
fn mip_levels(width: u32, height: u32) -> u32 {
|
||||
32 - width.max(height).max(1).leading_zeros()
|
||||
}
|
||||
|
||||
fn half((w, h): (usize, usize)) -> (usize, usize) {
|
||||
((w / 2).max(1), (h / 2).max(1))
|
||||
}
|
||||
|
||||
pub(crate) fn encode(image: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
let img = image.converted(plan.layout, SampleType::U8);
|
||||
let pixels = img.as_u8().ok_or_else(|| encode_err("the image was not converted to 8 bits per channel"))?;
|
||||
let store = Store::new(opts.dds.compression, img.layout())?;
|
||||
let (width, height) = img.dimensions();
|
||||
let levels = if opts.dds.mipmaps { mip_levels(width, height) } else { 1 };
|
||||
let top = (width as usize, height as usize);
|
||||
// Every level's size, checked before anything is allocated.
|
||||
let mut sizes = Vec::new();
|
||||
let mut size = top;
|
||||
for _ in 0..levels {
|
||||
sizes.push(store.level_bytes(size.0, size.1).ok_or_else(|| encode_err("texture size overflow"))?);
|
||||
size = half(size);
|
||||
}
|
||||
let total = sizes.iter().try_fold(HEADER, |total, &s| total.checked_add(s)).ok_or_else(|| encode_err("texture size overflow"))?;
|
||||
let header = header(width, height, store, levels, sizes.first().copied().unwrap_or(0))?;
|
||||
let mut out = crate::options::alloc_zeroed::<u8>(total)?;
|
||||
out.get_mut(..HEADER).ok_or_else(|| encode_err("output buffer too small"))?.copy_from_slice(&header);
|
||||
|
||||
let channels = img.layout().channels();
|
||||
let mut level = Cow::Borrowed(pixels);
|
||||
let (mut size, mut at) = (top, HEADER);
|
||||
for (i, &bytes) in sizes.iter().enumerate() {
|
||||
let dst = out.get_mut(at..at + bytes).ok_or_else(|| encode_err("output buffer too small"))?;
|
||||
write_level(store, &level, size, dst)?;
|
||||
at += bytes;
|
||||
if i + 1 < sizes.len() {
|
||||
let next = half(size);
|
||||
level = Cow::Owned(downsample(&level, size, next, channels)?);
|
||||
size = next;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The `DDS ` signature and DDS_HEADER. `top` is the top level's size in bytes.
|
||||
fn header(width: u32, height: u32, store: Store, levels: u32, top: usize) -> Result<[u8; HEADER], CodecError> {
|
||||
let mut b = [0u8; HEADER];
|
||||
let mut put = |at: usize, v: u32| {
|
||||
if let Some(d) = b.get_mut(at..at + 4) {
|
||||
d.copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
};
|
||||
// Uncompressed: the pitch of one row. Compressed: the whole top level (its "linear size").
|
||||
let (size_flag, pitch_or_size) = if store.is_block() { (DDSD_LINEARSIZE, Some(top)) } else { (DDSD_PITCH, store.row_bytes(width as usize)) };
|
||||
let pitch_or_size =
|
||||
pitch_or_size.and_then(|v| u32::try_from(v).ok()).ok_or_else(|| encode_err(format!("a {width}x{height} level is too large for a DDS header")))?;
|
||||
let mipmapped = levels > 1;
|
||||
put(4, 124);
|
||||
put(8, DDSD_CAPS | DDSD_HEIGHT | DDSD_WIDTH | DDSD_PIXELFORMAT | size_flag | if mipmapped { DDSD_MIPMAPCOUNT } else { 0 });
|
||||
put(12, height);
|
||||
put(16, width);
|
||||
put(20, pitch_or_size);
|
||||
put(28, levels);
|
||||
let (flags, fourcc, bits, masks) = store.pixel_format();
|
||||
put(76, 32);
|
||||
put(80, flags);
|
||||
put(84, u32::from_le_bytes(fourcc));
|
||||
put(88, bits);
|
||||
for (i, mask) in masks.into_iter().enumerate() {
|
||||
put(92 + 4 * i, mask);
|
||||
}
|
||||
put(108, DDSCAPS_TEXTURE | if mipmapped { DDSCAPS_COMPLEX | DDSCAPS_MIPMAP } else { 0 });
|
||||
b[..4].copy_from_slice(b"DDS ");
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
/// Runs `f` on each `len`-byte row of `out` with its index, in parallel on native targets.
|
||||
fn rows(out: &mut [u8], len: usize, f: impl Fn(usize, &mut [u8]) + Send + Sync) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
out.par_chunks_mut(len.max(1)).enumerate().for_each(|(i, row)| f(i, row));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
for (i, row) in out.chunks_mut(len.max(1)).enumerate() {
|
||||
f(i, row);
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes one level of `width` × `height` pixels (in the image's layout) into `dst`.
|
||||
fn write_level(store: Store, pixels: &[u8], (width, height): (usize, usize), dst: &mut [u8]) -> Result<(), CodecError> {
|
||||
let row_bytes = store.row_bytes(width).ok_or_else(|| encode_err("texture size overflow"))?;
|
||||
match store {
|
||||
Store::Luminance | Store::LuminanceAlpha => {
|
||||
// The image's gray (and alpha) samples, in the same order.
|
||||
let src = pixels.get(..dst.len()).ok_or_else(|| encode_err("the level is smaller than its size"))?;
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
Store::Bgrx | Store::Bgra => {
|
||||
let channels = if store == Store::Bgra { 4 } else { 3 };
|
||||
rows(dst, row_bytes, |y, row| {
|
||||
let src = pixels.get(y * width * channels..).unwrap_or_default();
|
||||
for (d, s) in row.as_chunks_mut::<4>().0.iter_mut().zip(src.chunks_exact(channels)) {
|
||||
let byte = |i: usize| s.get(i).copied().unwrap_or(255);
|
||||
*d = [byte(2), byte(1), byte(0), byte(3)];
|
||||
}
|
||||
});
|
||||
}
|
||||
Store::Bc1 | Store::Bc2 | Store::Bc3 => {
|
||||
let (texels, _) = pixels.as_chunks::<4>();
|
||||
rows(dst, row_bytes, |by, row| {
|
||||
for (bx, d) in row.chunks_exact_mut(store.unit()).enumerate() {
|
||||
let (block, inside) = gather(texels, (width, height), bx, by);
|
||||
let encoded: &[u8] = match store {
|
||||
Store::Bc1 => &dds_bc::encode_bc1(&block, inside),
|
||||
Store::Bc2 => &dds_bc::encode_bc2(&block, inside),
|
||||
_ => &dds_bc::encode_bc3(&block, inside),
|
||||
};
|
||||
for (d, s) in d.iter_mut().zip(encoded) {
|
||||
*d = *s;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Block (`bx`, `by`)'s texels, and which of them lie inside the image.
|
||||
fn gather(texels: &[[u8; 4]], (width, height): (usize, usize), bx: usize, by: usize) -> (Block, Mask) {
|
||||
let mut block = [[0u8; 4]; 16];
|
||||
let mut inside: Mask = 0;
|
||||
for (t, texel) in block.iter_mut().enumerate() {
|
||||
let (x, y) = (bx * 4 + t % 4, by * 4 + t / 4);
|
||||
if x < width
|
||||
&& y < height
|
||||
&& let Some(p) = texels.get(y * width + x)
|
||||
{
|
||||
*texel = *p;
|
||||
inside |= 1 << t;
|
||||
}
|
||||
}
|
||||
(block, inside)
|
||||
}
|
||||
|
||||
/// The next mip level: each pixel the average of the 2×2 pixels it covers, or of 2×3, 3×2 or
|
||||
/// 3×3 along the last column and row of an odd-sized level, so no pixel is dropped.
|
||||
fn downsample(src: &[u8], (w, h): (usize, usize), (nw, nh): (usize, usize), channels: usize) -> Result<Vec<u8>, CodecError> {
|
||||
let len = nw.checked_mul(nh).and_then(|n| n.checked_mul(channels)).ok_or_else(|| encode_err("mip level size overflow"))?;
|
||||
let mut dst = crate::options::alloc_zeroed::<u8>(len)?;
|
||||
let span = |i: usize, n: usize, size: usize| 2 * i..=if i + 1 == n { size.saturating_sub(1) } else { 2 * i + 1 };
|
||||
rows(&mut dst, nw * channels, |y, row| {
|
||||
let ys = span(y, nh, h);
|
||||
for (x, px) in row.chunks_exact_mut(channels.max(1)).enumerate() {
|
||||
let xs = span(x, nw, w);
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
let (mut sum, mut n) = (0u32, 0u32);
|
||||
for sy in ys.clone() {
|
||||
for sx in xs.clone() {
|
||||
if let Some(&s) = src.get((sy * w + sx) * channels + c) {
|
||||
sum += u32::from(s);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
*v = ((sum + n / 2) / n.max(1)) as u8;
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(dst)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
//! BC1, BC2 and BC3 (DXT1, DXT3, DXT5) blocks, after Microsoft's public block compression
|
||||
//! documentation ("Block Compression (Direct3D 10)" and "Texture Block Compression in Direct3D
|
||||
//! 11"): the palettes the DDS reader decodes with, and the block encoder the DDS writer uses.
|
||||
//!
|
||||
//! The encoder fits a block's colours with two RGB565 endpoints along their principal axis,
|
||||
//! refines them by least squares, and scores every candidate against the exact palette the
|
||||
//! reader rebuilds, so the indices it picks are the best ones for what a decoder shows.
|
||||
|
||||
/// The 16 texels of a 4×4 block, row by row, as RGBA.
|
||||
pub(crate) type Block = [[u8; 4]; 16];
|
||||
|
||||
/// Bit `t` set for each texel `t` of a block that counts: edge blocks of a texture whose size is
|
||||
/// not a multiple of four have texels outside the image.
|
||||
pub(crate) type Mask = u16;
|
||||
|
||||
pub(crate) fn rgb565(c: u16) -> [u8; 3] {
|
||||
let scale = |v: u16, max: u16| ((u32::from(v) * 255 + u32::from(max) / 2) / u32::from(max)) as u8;
|
||||
[scale(c >> 11, 31), scale((c >> 5) & 63, 63), scale(c & 31, 31)]
|
||||
}
|
||||
|
||||
/// The colours a colour block's indices select. Four-colour mode (BC1 with `c0 > c1`, and every
|
||||
/// BC2 and BC3 block) adds two colours at thirds; three-colour mode adds the midpoint and
|
||||
/// transparent black.
|
||||
pub(crate) fn colour_palette(c0: u16, c1: u16, four: bool) -> [[u8; 4]; 4] {
|
||||
let (a, b) = (rgb565(c0), rgb565(c1));
|
||||
let mix = |wa: u32, wb: u32| {
|
||||
let d = wa + wb;
|
||||
let at = |i: usize| ((wa * u32::from(a[i]) + wb * u32::from(b[i]) + d / 2) / d) as u8;
|
||||
[at(0), at(1), at(2), 255]
|
||||
};
|
||||
if four {
|
||||
[[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(2, 1), mix(1, 2)]
|
||||
} else {
|
||||
[[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(1, 1), [0; 4]]
|
||||
}
|
||||
}
|
||||
|
||||
/// A BC3 alpha block's eight values: six interpolated at sevenths when `a0 > a1`, else four
|
||||
/// interpolated at fifths followed by 0 and 255.
|
||||
pub(crate) fn alpha_palette(a0: u8, a1: u8) -> [u8; 8] {
|
||||
let (a0, a1) = (u32::from(a0), u32::from(a1));
|
||||
let mut palette = [a0, a1, 0, 0, 0, 0, 0, 255];
|
||||
if a0 > a1 {
|
||||
for (k, v) in palette.iter_mut().enumerate().skip(2) {
|
||||
let k = k as u32;
|
||||
*v = ((8 - k) * a0 + (k - 1) * a1 + 3) / 7;
|
||||
}
|
||||
} else {
|
||||
for (k, v) in palette.iter_mut().enumerate().take(6).skip(2) {
|
||||
let k = k as u32;
|
||||
*v = ((6 - k) * a0 + (k - 1) * a1 + 2) / 5;
|
||||
}
|
||||
}
|
||||
palette.map(|v| v as u8)
|
||||
}
|
||||
|
||||
/// A BC1 block. Texels under half opacity become BC1's transparent black, which needs the
|
||||
/// three-colour mode; an opaque block takes whichever mode fits its colours better.
|
||||
pub(crate) fn encode_bc1(block: &Block, inside: Mask) -> [u8; 8] {
|
||||
let transparent = select(block, inside, |t| t[3] < 128);
|
||||
colour_block(block, inside & !transparent, transparent, true)
|
||||
}
|
||||
|
||||
/// A BC2 block: four bits of alpha per texel, then a four-colour block.
|
||||
pub(crate) fn encode_bc2(block: &Block, inside: Mask) -> [u8; 16] {
|
||||
let bits = block.iter().enumerate().fold(0u64, |bits, (t, texel)| bits | ((u64::from(texel[3]) * 15 + 127) / 255) << (4 * t));
|
||||
join(bits.to_le_bytes(), colour_block(block, inside, 0, false))
|
||||
}
|
||||
|
||||
/// A BC3 block: an interpolated alpha block, then a four-colour block.
|
||||
pub(crate) fn encode_bc3(block: &Block, inside: Mask) -> [u8; 16] {
|
||||
join(alpha_block(block, inside), colour_block(block, inside, 0, false))
|
||||
}
|
||||
|
||||
fn join(a: [u8; 8], b: [u8; 8]) -> [u8; 16] {
|
||||
std::array::from_fn(|i| if i < 8 { a.get(i) } else { b.get(i - 8) }.copied().unwrap_or(0))
|
||||
}
|
||||
|
||||
/// The texels of `inside` for which `f` holds.
|
||||
fn select(block: &Block, inside: Mask, f: impl Fn(&[u8; 4]) -> bool) -> Mask {
|
||||
block.iter().enumerate().filter(|(t, texel)| inside >> t & 1 != 0 && f(texel)).fold(0, |m, (t, _)| m | 1 << t)
|
||||
}
|
||||
|
||||
fn rgb_of(texel: &[u8; 4]) -> [f32; 3] {
|
||||
[f32::from(texel[0]), f32::from(texel[1]), f32::from(texel[2])]
|
||||
}
|
||||
|
||||
/// RGB565 of a colour, rounding each channel to the nearest level.
|
||||
fn to565(c: [f32; 3]) -> u16 {
|
||||
let q = |v: f32, max: f32| (v.clamp(0.0, 255.0) * max / 255.0).round() as u16;
|
||||
q(c[0], 31.0) << 11 | q(c[1], 63.0) << 5 | q(c[2], 31.0)
|
||||
}
|
||||
|
||||
fn distance(a: &[u8; 4], b: &[u8; 4]) -> u32 {
|
||||
(0..3).map(|i| u32::from(a[i].abs_diff(b[i])).pow(2)).sum()
|
||||
}
|
||||
|
||||
/// A colour block (endpoints, then 2-bit indices) for the `opaque` texels' colours, with the
|
||||
/// `transparent` ones on BC1's transparent index. `bc1` allows the three-colour mode; BC2 and
|
||||
/// BC3 always decode four colours.
|
||||
fn colour_block(block: &Block, opaque: Mask, transparent: Mask, bc1: bool) -> [u8; 8] {
|
||||
let colours: Vec<[f32; 3]> = block.iter().enumerate().filter(|(t, _)| opaque >> t & 1 != 0).map(|(_, texel)| rgb_of(texel)).collect();
|
||||
if colours.is_empty() {
|
||||
// Nothing visible: every texel on the transparent index (or index 0 of black).
|
||||
let indices = if transparent != 0 { u32::MAX } else { 0 };
|
||||
return pack(0, 0, indices);
|
||||
}
|
||||
let (lo, hi) = principal_endpoints(&colours);
|
||||
let mut candidates = vec![(to565(lo), to565(hi))];
|
||||
let modes: &[bool] = if transparent != 0 {
|
||||
&[false]
|
||||
} else if bc1 {
|
||||
&[true, false]
|
||||
} else {
|
||||
&[true]
|
||||
};
|
||||
let mut best: Option<(u32, [u8; 8])> = None;
|
||||
// Each refinement starts from the best block so far: its indices give the least-squares
|
||||
// endpoints, which become one more candidate.
|
||||
for round in 0..3 {
|
||||
for &(e0, e1) in &candidates {
|
||||
for &four in modes {
|
||||
let (bytes, error) = fit(block, opaque, transparent, e0, e1, four);
|
||||
if best.is_none_or(|(e, _)| error < e) {
|
||||
best = Some((error, bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((error, bytes)) = best else { break };
|
||||
if error == 0 || round == 2 {
|
||||
break;
|
||||
}
|
||||
candidates = least_squares(block, opaque, &bytes).into_iter().collect();
|
||||
if candidates.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
best.map_or([0; 8], |(_, bytes)| bytes)
|
||||
}
|
||||
|
||||
/// Orders endpoints `e0`, `e1` for the mode (`c0 > c1` for four colours, `c0 <= c1` for three)
|
||||
/// and picks each texel's nearest palette colour. Returns the block and its squared error.
|
||||
fn fit(block: &Block, opaque: Mask, transparent: Mask, e0: u16, e1: u16, four: bool) -> ([u8; 8], u32) {
|
||||
let (c0, c1) = if four == (e0 >= e1) { (e0, e1) } else { (e1, e0) };
|
||||
// Equal endpoints can't be in four-colour order; the palette is then one colour either way.
|
||||
let palette = colour_palette(c0, c1, four && c0 > c1);
|
||||
let choices = if four && c0 > c1 { 4 } else { 3 };
|
||||
let mut indices = 0u32;
|
||||
let mut error = 0u32;
|
||||
for (t, texel) in block.iter().enumerate() {
|
||||
let index = if transparent >> t & 1 != 0 {
|
||||
3
|
||||
} else if opaque >> t & 1 != 0 {
|
||||
let (i, d) = palette.iter().take(choices).enumerate().map(|(i, p)| (i, distance(texel, p))).min_by_key(|&(_, d)| d).unwrap_or((0, 0));
|
||||
error += d;
|
||||
i as u32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
indices |= index << (2 * t);
|
||||
}
|
||||
(pack(c0, c1, indices), error)
|
||||
}
|
||||
|
||||
fn pack(c0: u16, c1: u16, indices: u32) -> [u8; 8] {
|
||||
let (c0, c1, i) = (c0.to_le_bytes(), c1.to_le_bytes(), indices.to_le_bytes());
|
||||
[c0[0], c0[1], c1[0], c1[1], i[0], i[1], i[2], i[3]]
|
||||
}
|
||||
|
||||
/// The ends of the colours' spread along their principal axis (the covariance's dominant
|
||||
/// eigenvector, by power iteration), clamped to the RGB cube.
|
||||
fn principal_endpoints(colours: &[[f32; 3]]) -> ([f32; 3], [f32; 3]) {
|
||||
let n = colours.len().max(1) as f32;
|
||||
let mean = [0, 1, 2].map(|i| colours.iter().map(|c| c[i]).sum::<f32>() / n);
|
||||
let mut cov = [[0f32; 3]; 3];
|
||||
for c in colours {
|
||||
let d = [c[0] - mean[0], c[1] - mean[1], c[2] - mean[2]];
|
||||
for (i, row) in cov.iter_mut().enumerate() {
|
||||
for (j, v) in row.iter_mut().enumerate() {
|
||||
*v += d[i] * d[j];
|
||||
}
|
||||
}
|
||||
}
|
||||
let times = |a: [f32; 3]| [0, 1, 2].map(|i| cov[i][0] * a[0] + cov[i][1] * a[1] + cov[i][2] * a[2]);
|
||||
let iterate = |start: usize| {
|
||||
let mut axis = [0f32; 3];
|
||||
axis[start] = 1.0;
|
||||
for _ in 0..8 {
|
||||
let next = times(axis);
|
||||
let len = next.iter().map(|v| v * v).sum::<f32>().sqrt();
|
||||
if !len.is_normal() {
|
||||
break;
|
||||
}
|
||||
axis = next.map(|v| v / len);
|
||||
}
|
||||
axis
|
||||
};
|
||||
// From each channel's axis, keeping the one the colours spread along most: a channel that
|
||||
// varies on its own is already an eigenvector, so a single start there would never leave it
|
||||
// even when the others vary more together.
|
||||
let spread = |a: [f32; 3]| times(a).iter().zip(a).map(|(x, y)| x * y).sum::<f32>();
|
||||
let axis = [0, 1, 2].map(iterate).into_iter().max_by(|&a, &b| spread(a).total_cmp(&spread(b))).unwrap_or([1.0, 0.0, 0.0]);
|
||||
let along = |c: &[f32; 3]| (c[0] - mean[0]) * axis[0] + (c[1] - mean[1]) * axis[1] + (c[2] - mean[2]) * axis[2];
|
||||
let (lo, hi) = colours.iter().map(along).fold((f32::MAX, f32::MIN), |(lo, hi), t| (lo.min(t), hi.max(t)));
|
||||
let at = |t: f32| [0, 1, 2].map(|i| (mean[i] + t * axis[i]).clamp(0.0, 255.0));
|
||||
if lo > hi { (mean, mean) } else { (at(lo), at(hi)) }
|
||||
}
|
||||
|
||||
/// The endpoints that minimise the squared error for the indices `bytes` already chose: each
|
||||
/// texel's index fixes its weights between the endpoints, giving 2×2 normal equations per
|
||||
/// channel. `None` when the indices don't constrain both endpoints.
|
||||
fn least_squares(block: &Block, opaque: Mask, bytes: &[u8; 8]) -> Option<(u16, u16)> {
|
||||
let (c0, c1) = (u16::from_le_bytes([bytes[0], bytes[1]]), u16::from_le_bytes([bytes[2], bytes[3]]));
|
||||
let indices = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
|
||||
let four = c0 > c1;
|
||||
// How much of c0 each index holds.
|
||||
let weight = |i: u32| match (four, i) {
|
||||
(_, 0) => Some(1.0f32),
|
||||
(_, 1) => Some(0.0),
|
||||
(true, 2) => Some(2.0 / 3.0),
|
||||
(true, _) => Some(1.0 / 3.0),
|
||||
(false, 2) => Some(0.5),
|
||||
(false, _) => None,
|
||||
};
|
||||
let (mut aa, mut ab, mut bb) = (0f32, 0f32, 0f32);
|
||||
let (mut ax, mut bx) = ([0f32; 3], [0f32; 3]);
|
||||
for (t, texel) in block.iter().enumerate() {
|
||||
if opaque >> t & 1 == 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(a) = weight(indices >> (2 * t) & 3) else { continue };
|
||||
let b = 1.0 - a;
|
||||
let c = rgb_of(texel);
|
||||
aa += a * a;
|
||||
ab += a * b;
|
||||
bb += b * b;
|
||||
for ((x0, x1), v) in ax.iter_mut().zip(bx.iter_mut()).zip(c) {
|
||||
*x0 += a * v;
|
||||
*x1 += b * v;
|
||||
}
|
||||
}
|
||||
let det = aa * bb - ab * ab;
|
||||
if det.abs() < 1e-3 {
|
||||
return None;
|
||||
}
|
||||
let e0 = [0, 1, 2].map(|i| (ax[i] * bb - bx[i] * ab) / det);
|
||||
let e1 = [0, 1, 2].map(|i| (bx[i] * aa - ax[i] * ab) / det);
|
||||
Some((to565(e0), to565(e1)))
|
||||
}
|
||||
|
||||
/// A BC3 alpha block: whichever of the two modes (eight values between the extremes, or six
|
||||
/// between the extremes other than 0 and 255, plus exact 0 and 255) fits better.
|
||||
fn alpha_block(block: &Block, inside: Mask) -> [u8; 8] {
|
||||
let alphas = || block.iter().enumerate().filter(move |(t, _)| inside >> t & 1 != 0).map(|(_, texel)| texel[3]);
|
||||
let range = |values: &mut dyn Iterator<Item = u8>| values.fold(None, |r: Option<(u8, u8)>, a| Some(r.map_or((a, a), |(lo, hi)| (lo.min(a), hi.max(a)))));
|
||||
let (lo, hi) = range(&mut alphas()).unwrap_or((0, 0));
|
||||
// Eight values need a0 > a1; equal ends fall to the six-value mode, which starts with a0 too.
|
||||
let eight = alpha_fit(block, inside, hi, lo);
|
||||
let (mid_lo, mid_hi) = range(&mut alphas().filter(|a| !matches!(a, 0 | 255))).unwrap_or((0, 0));
|
||||
let six = alpha_fit(block, inside, mid_lo, mid_hi);
|
||||
if six.1 < eight.1 { six.0 } else { eight.0 }
|
||||
}
|
||||
|
||||
fn alpha_fit(block: &Block, inside: Mask, a0: u8, a1: u8) -> ([u8; 8], u32) {
|
||||
let palette = alpha_palette(a0, a1);
|
||||
let mut bits = 0u64;
|
||||
let mut error = 0u32;
|
||||
for (t, texel) in block.iter().enumerate() {
|
||||
if inside >> t & 1 == 0 {
|
||||
continue;
|
||||
}
|
||||
let (i, d) = palette.iter().enumerate().map(|(i, &p)| (i, u32::from(p.abs_diff(texel[3])).pow(2))).min_by_key(|&(_, d)| d).unwrap_or((0, 0));
|
||||
error += d;
|
||||
bits |= (i as u64) << (3 * t);
|
||||
}
|
||||
let b = bits.to_le_bytes();
|
||||
([a0, a1, b[0], b[1], b[2], b[3], b[4], b[5]], error)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn decode_colour(bytes: &[u8; 8], bc1: bool) -> Block {
|
||||
let (c0, c1) = (u16::from_le_bytes([bytes[0], bytes[1]]), u16::from_le_bytes([bytes[2], bytes[3]]));
|
||||
let palette = colour_palette(c0, c1, c0 > c1 || !bc1);
|
||||
let indices = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
|
||||
std::array::from_fn(|t| palette[(indices >> (2 * t)) as usize & 3])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_blocks_land_on_the_nearest_565_colour() {
|
||||
for colour in [[0, 0, 0, 255], [255, 255, 255, 255], [12, 200, 77, 255]] {
|
||||
let block = [colour; 16];
|
||||
let out = decode_colour(&encode_bc1(&block, u16::MAX), true);
|
||||
for texel in out {
|
||||
assert!(texel.iter().zip(colour).all(|(a, b)| a.abs_diff(b) <= 4), "{texel:?} for {colour:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_colour_blocks_are_exact_when_the_colours_are_565() {
|
||||
let (a, b) = ([255, 0, 0, 255], [0, 0, 255, 255]);
|
||||
let block: Block = std::array::from_fn(|t| if t % 3 == 0 { a } else { b });
|
||||
for bc1 in [true, false] {
|
||||
let bytes = if bc1 { encode_bc1(&block, u16::MAX) } else { colour_block(&block, u16::MAX, 0, false) };
|
||||
assert_eq!(decode_colour(&bytes, bc1), block);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bc1_punches_through_texels_under_half_alpha() {
|
||||
let block: Block = std::array::from_fn(|t| if t < 8 { [200, 100, 50, 255] } else { [9, 9, 9, 127] });
|
||||
let bytes = encode_bc1(&block, u16::MAX);
|
||||
let (c0, c1) = (u16::from_le_bytes([bytes[0], bytes[1]]), u16::from_le_bytes([bytes[2], bytes[3]]));
|
||||
assert!(c0 <= c1, "three-colour mode");
|
||||
let out = decode_colour(&bytes, true);
|
||||
assert!(out[..8].iter().all(|t| t[3] == 255) && out[8..].iter().all(|t| *t == [0; 4]), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_colour_blocks_keep_c0_above_c1() {
|
||||
// A ramp needs all four colours; BC2 and BC3 blocks must not rely on the order, but
|
||||
// older decoders read c0 <= c1 as three colours, so the encoder never writes that.
|
||||
let block: Block = std::array::from_fn(|t| [(t * 16) as u8, 0, 0, 255]);
|
||||
let bytes = colour_block(&block, u16::MAX, 0, false);
|
||||
assert!(u16::from_le_bytes([bytes[0], bytes[1]]) > u16::from_le_bytes([bytes[2], bytes[3]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_blocks_pick_the_mode_that_keeps_0_and_255() {
|
||||
// 0, 255 and a cluster in between: the six-value mode stores 0 and 255 exactly.
|
||||
let block: Block = std::array::from_fn(|t| [0, 0, 0, [0, 255, 100, 110][t % 4]]);
|
||||
let bytes = alpha_block(&block, u16::MAX);
|
||||
assert!(bytes[0] <= bytes[1], "six-value mode");
|
||||
let palette = alpha_palette(bytes[0], bytes[1]);
|
||||
let bits = u64::from_le_bytes([bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], 0, 0]);
|
||||
for (t, texel) in block.iter().enumerate() {
|
||||
let got = palette[(bits >> (3 * t)) as usize & 7];
|
||||
assert!(got.abs_diff(texel[3]) <= 2, "texel {t}: {got} for {}", texel[3]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_principal_axis_is_found_when_one_channel_varies_on_its_own() {
|
||||
// Red alternates on its own (variance 64); green and blue alternate together (variance 49
|
||||
// each, 98 along their shared axis), uncorrelated with red. Starting on red alone would
|
||||
// keep red's axis and flatten green and blue; the block must do better than its bounding box.
|
||||
let block: Block = std::array::from_fn(|t| {
|
||||
let gb = if (t / 2) % 2 == 0 { 121 } else { 135 };
|
||||
[if t % 2 == 0 { 120 } else { 136 }, gb, gb, 255]
|
||||
});
|
||||
let error = |bytes: &[u8; 8]| decode_colour(bytes, false).iter().zip(&block).map(|(a, b)| distance(a, b)).sum::<u32>();
|
||||
let (_, bounding_box) = fit(&block, u16::MAX, 0, to565([120.0, 121.0, 121.0]), to565([136.0, 135.0, 135.0]), true);
|
||||
let ours = error(&colour_block(&block, u16::MAX, 0, false));
|
||||
assert!(ours < bounding_box, "{ours} vs the bounding box's {bounding_box}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texels_outside_the_image_are_ignored() {
|
||||
// Only the top-left texel is inside: its colour alone decides the endpoints.
|
||||
let mut block: Block = [[0, 255, 0, 255]; 16];
|
||||
block[0] = [255, 0, 255, 255];
|
||||
let out = decode_colour(&encode_bc1(&block, 1), true);
|
||||
assert_eq!(out[0], [255, 0, 255, 255]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! The colour table of a GIF, read from its blocks without decoding pixels (GIF89a
|
||||
//! specification: Logical Screen Descriptor, Global and Local Color Tables, Graphic Control
|
||||
//! Extension).
|
||||
//!
|
||||
//! A GIF is always palette-based, so it opens in Indexed Color with the table of the frame that
|
||||
//! [`crate::decode`] keeps, the first: its Local Color Table when it has one, else the Global
|
||||
//! one, and the transparent entry that frame's Graphic Control Extension names.
|
||||
|
||||
/// Colours of the table the first frame uses, and its transparent entry. `None` for anything
|
||||
/// that isn't a well-formed GIF up to its first image, or a first frame without a table.
|
||||
pub(crate) fn palette(b: &[u8]) -> Option<(Vec<[u8; 3]>, Option<u8>)> {
|
||||
if !(b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a")) {
|
||||
return None;
|
||||
}
|
||||
// Logical Screen Descriptor: width, height, packed fields, background, aspect.
|
||||
let packed = *b.get(10)?;
|
||||
let mut at = 13;
|
||||
let table = |at: usize, packed: u8| -> Option<Vec<[u8; 3]>> {
|
||||
let n = 2usize << (packed & 0x07);
|
||||
let bytes = b.get(at..at.checked_add(n.checked_mul(3)?)?)?;
|
||||
Some(bytes.as_chunks::<3>().0.to_vec())
|
||||
};
|
||||
let global = if packed & 0x80 != 0 {
|
||||
let t = table(at, packed)?;
|
||||
at += t.len() * 3;
|
||||
Some(t)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut transparent = None;
|
||||
loop {
|
||||
match *b.get(at)? {
|
||||
// Extension: label, then data sub-blocks up to a zero-length one.
|
||||
0x21 => {
|
||||
let label = *b.get(at + 1)?;
|
||||
at += 2;
|
||||
// Graphic Control Extension: block size 4, packed (bit 0: transparent colour
|
||||
// given), delay (2 bytes), transparent colour index.
|
||||
if label == 0xF9 && *b.get(at)? == 4 {
|
||||
let flags = *b.get(at + 1)?;
|
||||
transparent = (flags & 0x01 != 0).then_some(*b.get(at + 4)?);
|
||||
}
|
||||
loop {
|
||||
let size = usize::from(*b.get(at)?);
|
||||
at = at.checked_add(1 + size)?;
|
||||
if size == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Image Descriptor: position and size (8 bytes), packed fields, then the Local Color
|
||||
// Table if bit 7 is set. This is the first frame: its table is the one used.
|
||||
0x2C => {
|
||||
let packed = *b.get(at + 9)?;
|
||||
let colors = if packed & 0x80 != 0 { table(at + 10, packed)? } else { global? };
|
||||
let transparent = transparent.filter(|i| usize::from(*i) < colors.len());
|
||||
return Some((colors, transparent));
|
||||
}
|
||||
// Trailer, or anything else: no image.
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::palette;
|
||||
|
||||
/// A GIF up to its first image: `global` table entries, optional GCE transparency, optional
|
||||
/// local table.
|
||||
fn gif(global: &[[u8; 3]], transparent: Option<u8>, local: Option<&[[u8; 3]]>) -> Vec<u8> {
|
||||
let size_bits = |n: usize| (n.max(2).next_power_of_two().trailing_zeros() - 1) as u8;
|
||||
let mut b = b"GIF89a".to_vec();
|
||||
b.extend([4, 0, 4, 0]);
|
||||
b.push(if global.is_empty() { 0 } else { 0x80 | size_bits(global.len()) });
|
||||
b.extend([0, 0]);
|
||||
let pad = |b: &mut Vec<u8>, t: &[[u8; 3]]| {
|
||||
let n = 2usize << size_bits(t.len());
|
||||
for k in 0..n {
|
||||
b.extend(t.get(k).copied().unwrap_or([0, 0, 0]));
|
||||
}
|
||||
};
|
||||
if !global.is_empty() {
|
||||
pad(&mut b, global);
|
||||
}
|
||||
// A comment extension first, to skip.
|
||||
b.extend([0x21, 0xFE, 3, b'h', b'i', b'!', 0]);
|
||||
if let Some(i) = transparent {
|
||||
b.extend([0x21, 0xF9, 4, 0x01, 0, 0, i, 0]);
|
||||
}
|
||||
b.extend([0x2C, 0, 0, 0, 0, 4, 0, 4, 0]);
|
||||
match local {
|
||||
Some(t) => {
|
||||
b.push(0x80 | size_bits(t.len()));
|
||||
pad(&mut b, t);
|
||||
}
|
||||
None => b.push(0),
|
||||
}
|
||||
b.extend([2, 2, 0x4C, 0x01, 0, 0x3B]);
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_frames_table_and_transparent_entry() {
|
||||
let g = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 255]];
|
||||
assert_eq!(palette(&gif(&g, None, None)), Some((g.to_vec(), None)));
|
||||
assert_eq!(palette(&gif(&g, Some(2), None)), Some((g.to_vec(), Some(2))));
|
||||
// A local table wins over the global one.
|
||||
let l = [[1, 2, 3], [4, 5, 6]];
|
||||
assert_eq!(palette(&gif(&g, Some(1), Some(&l))), Some((l.to_vec(), Some(1))));
|
||||
// A transparent index past the table is no transparent entry.
|
||||
assert_eq!(palette(&gif(&l, Some(9), None)), Some((l.to_vec(), None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_table_or_not_a_gif_is_none() {
|
||||
assert_eq!(palette(&gif(&[], None, None)), None, "no table at all");
|
||||
assert_eq!(palette(b"\x89PNG\r\n\x1a\n"), None);
|
||||
assert_eq!(palette(b""), None);
|
||||
let whole = gif(&[[9, 9, 9], [8, 8, 8]], Some(0), None);
|
||||
for cut in 0..whole.len() - 6 {
|
||||
// Cut short anywhere before the image descriptor's fields: never a panic.
|
||||
let _ = palette(&whole[..cut]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ fn err(e: photocraft_heif::Error) -> CodecError {
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
pub(crate) fn decode(bytes: &[u8], limits: &Limits, keep_orientation: bool) -> Result<Image, CodecError> {
|
||||
use crate::cicp::Cicp;
|
||||
use crate::image::{ChannelLayout, Metadata, SampleType};
|
||||
|
||||
// The container alone: the declared size is checked before any pixel is decoded.
|
||||
@@ -51,12 +52,21 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits, keep_orientation: bool) -> R
|
||||
let sample = if decoded.sixteen_bit { SampleType::U16 } else { SampleType::U8 };
|
||||
let mut img = Image::from_raw(decoded.width, decoded.height, layout, sample, decoded.data)?;
|
||||
img.icc = decoded.icc;
|
||||
img.cicp = decoded.nclx.map(|n| Cicp { primaries: n.primaries, transfer: n.transfer, matrix: n.matrix, full_range: n.full_range });
|
||||
img.meta = Metadata {
|
||||
exif: decoded.exif.map(|e| if keep_orientation { e } else { crate::orientation::upright_exif(&e).into_owned() }),
|
||||
xmp: decoded.xmp.map(|x| if keep_orientation { x } else { crate::orientation::upright_xmp(&x).into_owned() }),
|
||||
..Default::default()
|
||||
};
|
||||
Ok(img)
|
||||
// A 10-bit HDR HEIC (BT.2100 HLG or PQ) opens as linear light in 32-bit float (#6752): no
|
||||
// ICC curve can describe those transfers, and an 8/16-bit document would clip the highlights.
|
||||
match img.cicp.filter(|c| c.is_hdr()) {
|
||||
Some(c) => {
|
||||
limits.check(decoded.width, decoded.height, layout, SampleType::F32)?;
|
||||
crate::cicp::linearize(img, c)
|
||||
}
|
||||
None => Ok(img),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
//! these wins: the APP13 Photoshop ResolutionInfo, the XMP `tiff:` resolution, the EXIF IFD0
|
||||
//! resolution, and last the JFIF density (a unit-0 JFIF density is only an aspect ratio and
|
||||
//! never counts). Cameras record their resolution in EXIF only, and many tools write a default
|
||||
//! 72 into JFIF, so JFIF can't outrank EXIF (see [`crate::resolution`]). On export the JFIF
|
||||
//! density, the EXIF and the XMP resolution are all written as the image's resolution.
|
||||
//! 72 into JFIF, so JFIF can't outrank EXIF (see [`crate::resolution`]). On export RGB and gray
|
||||
//! images carry density in JFIF; CMYK omits JFIF APP0, which is invalid for four components, and
|
||||
//! carries an available density in EXIF instead.
|
||||
|
||||
use zune_core::bytestream::ZCursor;
|
||||
use zune_core::colorspace::ColorSpace;
|
||||
@@ -33,6 +34,8 @@ pub(crate) struct JpegMeta {
|
||||
pub exif: Option<Vec<u8>>,
|
||||
pub xmp: Option<String>,
|
||||
pub dpi: Option<(f32, f32)>,
|
||||
/// The APP13 Photoshop image resource blocks (`8BIM` ...), segments joined, without the header.
|
||||
pub photoshop: Option<Vec<u8>>,
|
||||
pub adobe_transform: Option<u8>,
|
||||
/// (width, height, components) from the first SOFn.
|
||||
pub frame: Option<(u32, u32, u8)>,
|
||||
@@ -108,6 +111,7 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta {
|
||||
.or_else(|| m.xmp.as_deref().and_then(xmp_resolution))
|
||||
.or_else(|| m.exif.as_deref().and_then(exif_resolution))
|
||||
.or(jfif_dpi);
|
||||
m.photoshop = Some(photoshop).filter(|p| !p.is_empty());
|
||||
if !icc_parts.is_empty() {
|
||||
icc_parts.sort_by_key(|p| p.0);
|
||||
let total = icc_parts[0].1 as usize;
|
||||
@@ -226,7 +230,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
}
|
||||
let mut img = Image::from_raw(w, h, layout, SampleType::U8, px)?;
|
||||
img.icc = meta.icc;
|
||||
img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, ..Default::default() };
|
||||
img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, photoshop_resources: meta.photoshop, ..Default::default() };
|
||||
if end == DataEnd::Truncated {
|
||||
img.warnings.push(DecodeWarning::Truncated { format: F });
|
||||
}
|
||||
@@ -260,7 +264,8 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
enc.set_sampling_factor(sampling);
|
||||
let e = |e: jpeg_encoder::EncodingError| CodecError::encode(F, e);
|
||||
if opts.embed_metadata {
|
||||
if let Some((x, y)) = img.meta.dpi
|
||||
if img.layout() != ChannelLayout::Cmyk
|
||||
&& let Some((x, y)) = img.meta.dpi
|
||||
&& x >= 1.0
|
||||
&& y >= 1.0
|
||||
{
|
||||
@@ -268,7 +273,16 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
}
|
||||
// Metadata too large for its one segment is left out (and reported by the fidelity
|
||||
// warnings) rather than failing the whole export.
|
||||
if let Some(seg) = img.meta.exif.as_deref().and_then(|x| exif_segment(x, img.meta.dpi)) {
|
||||
let exif = if img.layout() == ChannelLayout::Cmyk {
|
||||
match img.meta.exif.as_deref() {
|
||||
Some(exif) => exif_segment_with_resolution(exif, img.meta.dpi),
|
||||
None if usable_dpi(img.meta.dpi) => exif_segment_with_resolution(&[], img.meta.dpi),
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
img.meta.exif.as_deref().and_then(|x| exif_segment(x, img.meta.dpi))
|
||||
};
|
||||
if let Some(seg) = exif {
|
||||
enc.add_app_segment(1, &seg).map_err(e)?;
|
||||
}
|
||||
if let Some(seg) = img.meta.xmp.as_deref().and_then(|x| xmp_segment(x, img.meta.dpi)) {
|
||||
@@ -281,9 +295,58 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
enc.add_icc_profile(icc).map_err(e)?;
|
||||
}
|
||||
enc.encode(img.data(), w16, h16, ct).map_err(e)?;
|
||||
if img.layout() == ChannelLayout::Cmyk {
|
||||
strip_jfif_app0(&mut out);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// JFIF APP0 requires one or three image components. A four-component CMYK JPEG must not carry
|
||||
/// that marker; keep Adobe APP14, which identifies the CMYK encoding instead.
|
||||
fn strip_jfif_app0(bytes: &mut Vec<u8>) {
|
||||
if !bytes.starts_with(&[0xff, 0xd8]) {
|
||||
return;
|
||||
}
|
||||
let mut i = 2usize;
|
||||
loop {
|
||||
if bytes.get(i) != Some(&0xff) {
|
||||
return;
|
||||
}
|
||||
let Some(&marker) = bytes.get(i.saturating_add(1)) else { return };
|
||||
if matches!(marker, 0xda | 0xd9) {
|
||||
return;
|
||||
}
|
||||
if marker == 0xff {
|
||||
i = i.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
if marker == 0x01 || (0xd0..=0xd8).contains(&marker) {
|
||||
i = i.saturating_add(2);
|
||||
continue;
|
||||
}
|
||||
let Some(length_bytes) = i.checked_add(2).and_then(|at| bytes.get(at..at.checked_add(2)?)) else { return };
|
||||
let Ok(length_bytes) = <[u8; 2]>::try_from(length_bytes) else { return };
|
||||
let length = usize::from(u16::from_be_bytes(length_bytes));
|
||||
if length < 2 {
|
||||
return;
|
||||
}
|
||||
let Some(end) = i.checked_add(2).and_then(|at| at.checked_add(length)) else { return };
|
||||
if end > bytes.len() {
|
||||
return;
|
||||
}
|
||||
if marker == 0xe0 && bytes.get(i.saturating_add(4)..i.saturating_add(9)) == Some(b"JFIF\0") {
|
||||
bytes.drain(i..end);
|
||||
} else {
|
||||
i = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return whether both resolution values are finite and within the supported metadata range.
|
||||
fn usable_dpi(dpi: Option<(f32, f32)>) -> bool {
|
||||
dpi.is_some_and(|(x, y)| x.is_finite() && y.is_finite() && (1.0..=1_000_000.0).contains(&x) && (1.0..=1_000_000.0).contains(&y))
|
||||
}
|
||||
|
||||
/// The most data one APP segment holds (its 16-bit length counts its own two bytes).
|
||||
const MAX_SEGMENT: usize = 65533;
|
||||
|
||||
@@ -294,12 +357,18 @@ const LAYERED_ONLY_XMP: [&str; 2] = ["DocumentAncestors", "TextLayers"];
|
||||
|
||||
/// The EXIF APP1 segment, or `None` when it doesn't fit in one segment.
|
||||
pub(crate) fn exif_segment(exif: &[u8], dpi: Option<(f32, f32)>) -> Option<Vec<u8>> {
|
||||
// The pixels are written as they are shown: never let a viewer rotate them again; and the
|
||||
// resolution is the one written to JFIF, not the source's (#1691).
|
||||
// The pixels are written as they are shown: never let a viewer rotate them again; and store
|
||||
// the output resolution, not the source's (#1691).
|
||||
let seg = [EXIF_HEADER, export_exif(exif, dpi).as_ref()].concat();
|
||||
(seg.len() <= MAX_SEGMENT).then_some(seg)
|
||||
}
|
||||
|
||||
/// Build an EXIF APP1 segment, adding missing resolution tags when the source metadata is valid.
|
||||
fn exif_segment_with_resolution(exif: &[u8], dpi: Option<(f32, f32)>) -> Option<Vec<u8>> {
|
||||
let seg = [EXIF_HEADER, crate::resolution::export_exif_adding_missing_resolution(exif, dpi).as_ref()].concat();
|
||||
(seg.len() <= MAX_SEGMENT).then_some(seg)
|
||||
}
|
||||
|
||||
/// The XMP APP1 segment. When the packet doesn't fit in one segment, the layered-document
|
||||
/// properties are left out; `None` when it still doesn't fit.
|
||||
pub(crate) fn xmp_segment(xmp: &str, dpi: Option<(f32, f32)>) -> Option<Vec<u8>> {
|
||||
@@ -344,3 +413,74 @@ fn remove_element(xmp: &str, name: &str) -> String {
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Format, Metadata};
|
||||
|
||||
/// Build a little-endian TIFF with an empty IFD0 for metadata tests.
|
||||
fn empty_tiff() -> Vec<u8> {
|
||||
// Little-endian TIFF with an empty IFD0 and no next IFD.
|
||||
b"II*\0\x08\0\0\0\0\0\0\0\0\0\0\0".to_vec()
|
||||
}
|
||||
|
||||
/// Return whether the JPEG begins with a JFIF APP0 segment.
|
||||
fn has_jfif_app0(bytes: &[u8]) -> bool {
|
||||
bytes.get(2..11) == Some(b"\xff\xe0\0\x10JFIF\0")
|
||||
}
|
||||
|
||||
/// CMYK JPEG output must omit JFIF while retaining Adobe's CMYK marker.
|
||||
#[test]
|
||||
fn cmyk_jpeg_omits_jfif_but_keeps_adobe_cmyk_marker() {
|
||||
let img = Image::from_raw(2, 1, ChannelLayout::Cmyk, SampleType::U8, vec![0, 32, 64, 96, 255, 224, 192, 160]).unwrap();
|
||||
let bytes = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
|
||||
let meta = scan_metadata(&bytes);
|
||||
assert!(!has_jfif_app0(&bytes));
|
||||
assert_eq!(meta.frame, Some((2, 1, 4)));
|
||||
assert_eq!(meta.adobe_transform, Some(0));
|
||||
assert_eq!(crate::decode_as(Format::Jpeg, &bytes).unwrap().layout(), ChannelLayout::Cmyk);
|
||||
}
|
||||
|
||||
/// CMYK JPEG output stores image resolution in EXIF when the source has no EXIF.
|
||||
#[test]
|
||||
fn cmyk_jpeg_preserves_dpi_without_source_exif() {
|
||||
let mut img = Image::from_raw(1, 1, ChannelLayout::Cmyk, SampleType::U8, vec![0, 0, 0, 0]).unwrap();
|
||||
img.meta.dpi = Some((300.0, 150.0));
|
||||
let bytes = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
|
||||
assert!(!has_jfif_app0(&bytes));
|
||||
assert_eq!(crate::decode_as(Format::Jpeg, &bytes).unwrap().meta.dpi, Some((300.0, 150.0)));
|
||||
}
|
||||
|
||||
/// CMYK JPEG output adds missing EXIF resolution tags to existing metadata.
|
||||
#[test]
|
||||
fn cmyk_jpeg_preserves_dpi_with_source_exif_without_resolution_tags() {
|
||||
let mut img = Image::from_raw(1, 1, ChannelLayout::Cmyk, SampleType::U8, vec![0, 0, 0, 0]).unwrap();
|
||||
img.meta = Metadata { exif: Some(empty_tiff()), dpi: Some((240.0, 120.0)), ..Default::default() };
|
||||
let bytes = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
|
||||
assert_eq!(crate::decode_as(Format::Jpeg, &bytes).unwrap().meta.dpi, Some((240.0, 120.0)));
|
||||
}
|
||||
|
||||
/// Adding missing resolution tags makes them readable from the rewritten IFD0.
|
||||
#[test]
|
||||
fn exif_with_resolution_adds_missing_ifd0_entries() {
|
||||
let exif = empty_tiff();
|
||||
let rewritten = crate::resolution::export_exif_adding_missing_resolution(&exif, Some((240.0, 120.0)));
|
||||
|
||||
assert_eq!(crate::resolution::exif_resolution(&rewritten), Some((240.0, 120.0)));
|
||||
}
|
||||
|
||||
/// RGB JPEG output continues to store image resolution in JFIF density fields.
|
||||
#[test]
|
||||
fn rgb_jpeg_keeps_jfif_density() {
|
||||
let mut img = Image::from_raw(1, 1, ChannelLayout::Rgb, SampleType::U8, vec![12, 34, 56]).unwrap();
|
||||
img.meta.dpi = Some((96.0, 96.0));
|
||||
let bytes = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
|
||||
assert!(has_jfif_app0(&bytes));
|
||||
assert_eq!(scan_metadata(&bytes).dpi, Some((96.0, 96.0)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
pub(crate) mod dds;
|
||||
pub(crate) mod dds_bc;
|
||||
pub(crate) mod deep_exr;
|
||||
pub(crate) mod dpx;
|
||||
pub(crate) mod exr;
|
||||
pub(crate) mod exr_cryptomatte;
|
||||
pub(crate) mod fits;
|
||||
pub(crate) mod gif_frames;
|
||||
pub(crate) mod gif_palette;
|
||||
pub(crate) mod heif;
|
||||
pub(crate) mod jpeg;
|
||||
pub(crate) mod pcx;
|
||||
|
||||
+244
-24
@@ -7,7 +7,7 @@ use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits, PngCompression, alloc_zeroed};
|
||||
use crate::options::{EncodeOptions, Limits, PngCompression, PngStereo, alloc_zeroed};
|
||||
|
||||
const F: Format = Format::Png;
|
||||
pub(crate) const XMP_KEYWORD: &str = "XML:com.adobe.xmp";
|
||||
@@ -127,6 +127,38 @@ pub fn palette(bytes: &[u8]) -> Result<Option<PngPalette>, CodecError> {
|
||||
Ok(Some(PngPalette { colors, transparent }))
|
||||
}
|
||||
|
||||
/// The stereo pairing a PNG declares in its `sTER` chunk (PNG Extensions 1.4.0, §3.6), read
|
||||
/// from the headers without decoding pixels. `None` when the file has no CRC-valid `sTER` with a
|
||||
/// defined mode byte. The first valid chunk wins, as a later one would contradict it.
|
||||
pub fn stereo(bytes: &[u8]) -> Option<PngStereo> {
|
||||
let mut rest = bytes.get(8..)?;
|
||||
while let Some((length, tail)) = rest.split_first_chunk::<4>() {
|
||||
let Some((kind, tail)) = tail.split_first_chunk::<4>() else { break };
|
||||
let length = u32::from_be_bytes(*length);
|
||||
if length > i32::MAX as u32 {
|
||||
break;
|
||||
}
|
||||
let Ok(length) = usize::try_from(length) else { break };
|
||||
let Some(data) = tail.get(..length) else { break };
|
||||
let Some((checksum, next)) = tail.get(length..).and_then(|t| t.split_first_chunk::<4>()) else { break };
|
||||
rest = next;
|
||||
if kind == b"IEND" {
|
||||
break;
|
||||
}
|
||||
if kind != b"sTER" || data.len() != 1 {
|
||||
continue;
|
||||
}
|
||||
let mut crc = flate2::Crc::new();
|
||||
crc.update(kind);
|
||||
crc.update(data);
|
||||
if crc.sum() != u32::from_be_bytes(*checksum) {
|
||||
continue;
|
||||
}
|
||||
return PngStereo::from_mode(data[0]);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn map_png_err(e: png::DecodingError) -> CodecError {
|
||||
match e {
|
||||
png::DecodingError::LimitsExceeded => CodecError::LimitExceeded("PNG decoder memory limit".into()),
|
||||
@@ -346,6 +378,10 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
}
|
||||
}
|
||||
let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?;
|
||||
if let Some(stereo) = opts.png_stereo {
|
||||
// Ancillary chunk before IDAT, as the spec recommends for `sTER`.
|
||||
writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?;
|
||||
}
|
||||
if opts.png_interlaced {
|
||||
let bpp = img.layout().channels() * img.sample_type().bytes();
|
||||
let idat = adam7_idat(&data, w as usize, h as usize, bpp, opts.png_compression)?;
|
||||
@@ -505,8 +541,15 @@ fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], o
|
||||
}
|
||||
};
|
||||
let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::<u64>();
|
||||
let mut best = (cost(row), 0u8);
|
||||
let mut best = cost(row);
|
||||
let start = out.len();
|
||||
out.push(0);
|
||||
out.extend_from_slice(row);
|
||||
for kind in 1u8..=4 {
|
||||
if best == 0 {
|
||||
break;
|
||||
}
|
||||
let mut c = 0u64;
|
||||
for i in 0..n {
|
||||
let x = row[i];
|
||||
scratch[i] = match kind {
|
||||
@@ -515,27 +558,17 @@ fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], o
|
||||
3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8),
|
||||
_ => x.wrapping_sub(paeth(left(i), up(i), ul(i))),
|
||||
};
|
||||
c += u64::from((scratch[i] as i8).unsigned_abs());
|
||||
if c >= best {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let c = cost(&scratch[..n]);
|
||||
if c < best.0 {
|
||||
best = (c, kind);
|
||||
if c < best {
|
||||
best = c;
|
||||
out[start] = kind;
|
||||
out[start + 1..].copy_from_slice(&scratch[..n]);
|
||||
}
|
||||
}
|
||||
out.push(best.1);
|
||||
if best.1 == 0 {
|
||||
out.extend_from_slice(row);
|
||||
return;
|
||||
}
|
||||
for i in 0..n {
|
||||
let x = row[i];
|
||||
scratch[i] = match best.1 {
|
||||
1 => x.wrapping_sub(left(i)),
|
||||
2 => x.wrapping_sub(up(i)),
|
||||
3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8),
|
||||
_ => x.wrapping_sub(paeth(left(i), up(i), ul(i))),
|
||||
};
|
||||
}
|
||||
out.extend_from_slice(&scratch[..n]);
|
||||
}
|
||||
|
||||
const ADLER_MOD: u32 = 65_521;
|
||||
@@ -574,6 +607,7 @@ pub fn encode_indexed(
|
||||
palette: &[[u8; 3]],
|
||||
transparent: Option<u8>,
|
||||
dpi: Option<(f32, f32)>,
|
||||
stereo: Option<PngStereo>,
|
||||
) -> Result<Vec<u8>, CodecError> {
|
||||
if width == 0 || height == 0 || indices.len() != width as usize * height as usize {
|
||||
return Err(CodecError::InvalidImage("index data does not match the size".into()));
|
||||
@@ -603,12 +637,95 @@ pub fn encode_indexed(
|
||||
{
|
||||
let encoder = png::Encoder::with_info(&mut out, info).map_err(|e| CodecError::encode(F, e))?;
|
||||
let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?;
|
||||
if let Some(stereo) = stereo {
|
||||
writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?;
|
||||
}
|
||||
writer.write_image_data(indices).map_err(|e| CodecError::encode(F, e))?;
|
||||
writer.finish().map_err(|e| CodecError::encode(F, e))?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stereo_tests {
|
||||
use super::*;
|
||||
|
||||
/// One CRC-valid chunk as it appears in a file.
|
||||
fn chunk(kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(kind);
|
||||
out.extend_from_slice(data);
|
||||
let mut crc = flate2::Crc::new();
|
||||
crc.update(kind);
|
||||
crc.update(data);
|
||||
out.extend_from_slice(&crc.sum().to_be_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn png_with(stereo: &[u8]) -> Vec<u8> {
|
||||
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
out.extend_from_slice(&chunk(b"IHDR", &[0, 0, 0, 2, 0, 0, 0, 1, 8, 2, 0, 0, 0]));
|
||||
if !stereo.is_empty() {
|
||||
out.extend_from_slice(&chunk(b"sTER", stereo));
|
||||
}
|
||||
out.extend_from_slice(&chunk(b"IDAT", &[0x78, 0x01, 0x01, 0x00, 0xff, 0xff, 0, 3, 1, 0, 0]));
|
||||
out.extend_from_slice(&chunk(b"IEND", &[]));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_both_defined_views() {
|
||||
assert_eq!(stereo(&png_with(&[0])), Some(PngStereo::Horizontal));
|
||||
assert_eq!(stereo(&png_with(&[1])), Some(PngStereo::Vertical));
|
||||
assert_eq!(stereo(&png_with(&[])), None);
|
||||
// Reserved and unknown mode bytes are not a pairing we can keep.
|
||||
assert_eq!(stereo(&png_with(&[2])), None);
|
||||
assert_eq!(stereo(&png_with(&[255])), None);
|
||||
// A two-byte payload is not sTER data.
|
||||
assert_eq!(stereo(&png_with(&[0, 0])), None);
|
||||
assert_eq!(stereo(b"not a png"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_crc_is_not_a_stereo_mark() {
|
||||
let mut bytes = png_with(&[0]);
|
||||
let kind = bytes.windows(4).position(|w| w == b"sTER").unwrap();
|
||||
// The CRC trails the type and the one-byte payload of the chunk.
|
||||
bytes[kind + 8] ^= 0xff;
|
||||
assert_eq!(stereo(&bytes), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_writes_the_chunk_before_idat_and_it_reads_back() {
|
||||
let img = Image::from_raw(2, 1, ChannelLayout::Rgb, SampleType::U8, vec![9; 6]).unwrap();
|
||||
for view in [PngStereo::Horizontal, PngStereo::Vertical] {
|
||||
let opts = EncodeOptions { png_stereo: Some(view), ..Default::default() };
|
||||
let bytes = crate::encode(&img, Format::Png, &opts).unwrap();
|
||||
assert_eq!(stereo(&bytes), Some(view));
|
||||
let back = decode(&bytes, &Limits::default()).unwrap();
|
||||
assert_eq!(back.data(), img.data());
|
||||
let ter = bytes.windows(4).position(|w| w == b"sTER").unwrap();
|
||||
let idat = bytes.windows(4).position(|w| w == b"IDAT").unwrap();
|
||||
assert!(ter < idat, "sTER before IDAT");
|
||||
}
|
||||
let plain = crate::encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(stereo(&plain), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interlaced_and_indexed_encoders_keep_the_mark() {
|
||||
let img = Image::from_raw(2, 2, ChannelLayout::Rgb, SampleType::U8, vec![9; 12]).unwrap();
|
||||
let opts = EncodeOptions { png_stereo: Some(PngStereo::Vertical), png_interlaced: true, ..Default::default() };
|
||||
let bytes = crate::encode(&img, Format::Png, &opts).unwrap();
|
||||
assert_eq!(stereo(&bytes), Some(PngStereo::Vertical));
|
||||
let pal = [[255, 0, 0], [0, 0, 255]];
|
||||
let bytes = encode_indexed(2, 1, &[0, 1], &pal, None, None, Some(PngStereo::Horizontal)).unwrap();
|
||||
assert_eq!(stereo(&bytes), Some(PngStereo::Horizontal));
|
||||
assert_eq!(palette(&bytes).unwrap().map(|p| p.colors), Some(pal.to_vec()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod indexed_tests {
|
||||
use super::*;
|
||||
@@ -617,14 +734,14 @@ mod indexed_tests {
|
||||
fn png8_roundtrips_through_the_decoder() {
|
||||
let pal = [[255, 0, 0], [0, 0, 255], [0, 0, 0]];
|
||||
let idx = [0u8, 1, 2, 1, 0, 2];
|
||||
let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), None).unwrap();
|
||||
let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), None, None).unwrap();
|
||||
let img = decode(&bytes, &Limits::default()).unwrap();
|
||||
assert_eq!(img.dimensions(), (3, 2));
|
||||
let px = img.convert(ChannelLayout::Rgba, SampleType::U8);
|
||||
assert_eq!(&px.data()[..8], &[255, 0, 0, 255, 0, 0, 255, 255]);
|
||||
assert_eq!(px.data()[11], 0, "transparent entry");
|
||||
assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None).is_err());
|
||||
assert!(encode_indexed(3, 3, &idx, &pal, None, None).is_err());
|
||||
assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None, None).is_err());
|
||||
assert!(encode_indexed(3, 3, &idx, &pal, None, None, None).is_err());
|
||||
}
|
||||
|
||||
fn png(color: png::ColorType, trns: Option<&[u8]>) -> Vec<u8> {
|
||||
@@ -643,7 +760,7 @@ mod indexed_tests {
|
||||
|
||||
#[test]
|
||||
fn palette_reads_the_table_and_its_one_transparent_entry() {
|
||||
let bytes = encode_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]], Some(1), None).unwrap();
|
||||
let bytes = encode_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]], Some(1), None, None).unwrap();
|
||||
let colors = vec![[1, 2, 3], [4, 5, 6]];
|
||||
assert_eq!(palette(&bytes).unwrap(), Some(PngPalette { colors: colors.clone(), transparent: Some(1) }));
|
||||
assert_eq!(palette(&png(png::ColorType::Indexed, None)).unwrap(), Some(PngPalette { colors, transparent: None }));
|
||||
@@ -659,6 +776,109 @@ mod indexed_tests {
|
||||
mod parallel_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual 24 MP release benchmark"]
|
||||
fn benchmark_filter_selection() {
|
||||
use std::{hint::black_box, time::Instant};
|
||||
for pattern in ["zero", "identical", "varied"] {
|
||||
let row: Vec<u8> = (0..6000 * 4).map(|i| if pattern == "zero" { 0 } else { (i * 17 + i / 13) as u8 }).collect();
|
||||
let prev: Vec<_> = row.iter().map(|v| if pattern == "identical" { *v } else { v.wrapping_add(3) }).collect();
|
||||
let mut scratch = vec![0; row.len()];
|
||||
let mut out = Vec::with_capacity(row.len() + 1);
|
||||
for trial in 0..6 {
|
||||
type Filter = fn(&[u8], Option<&[u8]>, usize, &mut [u8], &mut Vec<u8>);
|
||||
let mut paths: [(&str, Filter); 2] = [("main", previous_filter_row), ("split", filter_row)];
|
||||
if trial % 2 == 1 {
|
||||
paths.reverse();
|
||||
}
|
||||
for (name, path) in paths {
|
||||
let t = Instant::now();
|
||||
for _ in 0..4000 {
|
||||
out.clear();
|
||||
black_box(path)(black_box(&row), Some(black_box(&prev)), 4, &mut scratch, &mut out);
|
||||
black_box(&out);
|
||||
}
|
||||
println!("filter,{pattern},{trial},{name},{:.6}", t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The main-branch implementation before this refactor.
|
||||
fn previous_filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], out: &mut Vec<u8>) {
|
||||
let n = row.len();
|
||||
let up = |i: usize| prev.map_or(0, |p| p[i]);
|
||||
let left = |i: usize| if i >= bpp { row[i - bpp] } else { 0 };
|
||||
let ul = |i: usize| if i >= bpp { prev.map_or(0, |p| p[i - bpp]) } else { 0 };
|
||||
let paeth = |a: u8, b: u8, c: u8| {
|
||||
let p = i16::from(a) + i16::from(b) - i16::from(c);
|
||||
let (pa, pb, pc) = ((p - i16::from(a)).abs(), (p - i16::from(b)).abs(), (p - i16::from(c)).abs());
|
||||
if pa <= pb && pa <= pc {
|
||||
a
|
||||
} else if pb <= pc {
|
||||
b
|
||||
} else {
|
||||
c
|
||||
}
|
||||
};
|
||||
let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::<u64>();
|
||||
let mut best = (cost(row), 0u8);
|
||||
for kind in 1u8..=4 {
|
||||
for i in 0..n {
|
||||
let x = row[i];
|
||||
scratch[i] = match kind {
|
||||
1 => x.wrapping_sub(left(i)),
|
||||
2 => x.wrapping_sub(up(i)),
|
||||
3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8),
|
||||
_ => x.wrapping_sub(paeth(left(i), up(i), ul(i))),
|
||||
};
|
||||
}
|
||||
let c = cost(&scratch[..n]);
|
||||
if c < best.0 {
|
||||
best = (c, kind);
|
||||
}
|
||||
}
|
||||
out.push(best.1);
|
||||
if best.1 == 0 {
|
||||
out.extend_from_slice(row);
|
||||
return;
|
||||
}
|
||||
for i in 0..n {
|
||||
let x = row[i];
|
||||
scratch[i] = match best.1 {
|
||||
1 => x.wrapping_sub(left(i)),
|
||||
2 => x.wrapping_sub(up(i)),
|
||||
3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8),
|
||||
_ => x.wrapping_sub(paeth(left(i), up(i), ul(i))),
|
||||
};
|
||||
}
|
||||
out.extend_from_slice(&scratch[..n]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_filter_preserves_minimum_cost_and_ties() {
|
||||
for bpp in [1, 2, 3, 4, 6, 8] {
|
||||
for seed in 0..32usize {
|
||||
let row: Vec<u8> = (0..137).map(|i| (i * seed + i / 5) as u8).collect();
|
||||
let shifted: Vec<_> = row.iter().map(|v| v.wrapping_add(seed as u8)).collect();
|
||||
for prev in [None, Some(row.as_slice()), Some(shifted.as_slice())] {
|
||||
let mut expected = vec![99];
|
||||
let mut actual = expected.clone();
|
||||
let mut scratch = vec![0; row.len()];
|
||||
previous_filter_row(&row, prev, bpp, &mut scratch, &mut expected);
|
||||
filter_row(&row, prev, bpp, &mut scratch, &mut actual);
|
||||
assert_eq!(actual, expected, "bpp={bpp}, seed={seed}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
filter_row(&[0; 16], None, 4, &mut [0; 16], &mut out);
|
||||
assert_eq!(out, vec![0; 17]);
|
||||
out.clear();
|
||||
filter_row(&[1; 16], Some(&[0; 16]), 1, &mut [0; 16], &mut out);
|
||||
assert_eq!(out, [vec![1, 1], vec![0; 15]].concat());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adler_combine_matches_one_pass() {
|
||||
let a: Vec<u8> = (0..100_000u32).map(|i| (i * 7 + i / 13) as u8).collect();
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
//! the same reader ([`super::tiff_ifd::File`]): reading a TIFF builds a block from the file's own
|
||||
//! directories, writing one copies the block's directories into the file. Values are copied as
|
||||
//! bytes (re-ordered when the byte orders differ), so tags PhotoCraft doesn't know survive.
|
||||
//! Classic TIFF only on the reading side: an EXIF block has 4-byte offsets.
|
||||
//! Classic TIFF only on the reading side: an EXIF block has 4-byte offsets. What is copied is
|
||||
//! bounded by the stream's own size and a fixed cap, because entries of a hostile file can
|
||||
//! overlap (see [`read`]).
|
||||
|
||||
use tiff::encoder::{DirectoryEncoder, TiffEncoder, TiffKind};
|
||||
use tiff::tags::{Tag, Type};
|
||||
@@ -18,6 +20,10 @@ use super::tiff_ifd::{File, Ifd};
|
||||
pub(crate) const TAG_EXIF_IFD: u16 = 34665;
|
||||
/// IFD0's pointer to the GPS directory.
|
||||
pub(crate) const TAG_GPS_IFD: u16 = 34853;
|
||||
/// The Exif directory's PixelXDimension and PixelYDimension: the size of the image the
|
||||
/// directory describes.
|
||||
const TAG_PIXEL_X_DIMENSION: u16 = 40962;
|
||||
const TAG_PIXEL_Y_DIMENSION: u16 = 40963;
|
||||
/// The Exif directory's pointer to the Interoperability directory (not carried: it holds only
|
||||
/// the interoperability index, and a copied pointer would point nowhere).
|
||||
const TAG_INTEROP_IFD: u16 = 40965;
|
||||
@@ -27,6 +33,11 @@ const TAG_INTEROP_IFD: u16 = 40965;
|
||||
const IFD0_TAGS: [u16; 7] = [270, 271, 272, 305, 306, 315, 33432];
|
||||
/// At most this many entries are copied from one directory.
|
||||
const MAX_FIELDS: usize = 1024;
|
||||
/// At most this many value bytes are copied from one stream, over all its directories. Real EXIF
|
||||
/// is far smaller (the largest Nikon maker note seen is under 1 MB); the cap leaves room for
|
||||
/// that and for a few such notes. A stream never yields more than its own length either (see
|
||||
/// [`read`]).
|
||||
const MAX_COPIED_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
/// One entry with its value bytes in the byte order of the stream it came from.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -53,13 +64,22 @@ impl ExifDirs {
|
||||
}
|
||||
}
|
||||
|
||||
fn fields(f: &File<'_>, ifd: &Ifd, keep: impl Fn(u16) -> bool) -> Vec<Field> {
|
||||
ifd.entries
|
||||
.iter()
|
||||
.filter(|e| keep(e.tag) && !matches!(e.tag, TAG_EXIF_IFD | TAG_GPS_IFD | TAG_INTEROP_IFD))
|
||||
.filter_map(|e| Some(Field { tag: e.tag, ty: e.ty, count: u32::try_from(e.count).ok()?, value: f.data(e)?.to_vec() }))
|
||||
.take(MAX_FIELDS)
|
||||
.collect()
|
||||
/// The entries of `ifd` that `keep` accepts, with their values copied. An entry's value only has
|
||||
/// to lie inside the file, so entries may overlap: `budget` is the number of value bytes that may
|
||||
/// still be copied (shared by all the directories of one stream), and an entry whose value does
|
||||
/// not fit in what is left is skipped, so later, smaller entries can still be kept.
|
||||
fn fields(f: &File<'_>, ifd: &Ifd, budget: &mut usize, keep: impl Fn(u16) -> bool) -> Vec<Field> {
|
||||
let mut out = Vec::new();
|
||||
for e in ifd.entries.iter().filter(|e| keep(e.tag) && !matches!(e.tag, TAG_EXIF_IFD | TAG_GPS_IFD | TAG_INTEROP_IFD)) {
|
||||
if out.len() >= MAX_FIELDS {
|
||||
break;
|
||||
}
|
||||
let (Ok(count), Some(value)) = (u32::try_from(e.count), f.data(e)) else { continue };
|
||||
let Some(left) = budget.checked_sub(value.len()) else { continue };
|
||||
*budget = left;
|
||||
out.push(Field { tag: e.tag, ty: e.ty, count, value: value.to_vec() });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option<Ifd> {
|
||||
@@ -69,14 +89,37 @@ fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option<Ifd> {
|
||||
|
||||
/// The EXIF parts of the classic TIFF stream `b` (a TIFF file, or an EXIF block without its
|
||||
/// `Exif\0\0` prefix): IFD0's descriptive tags and the Exif and GPS directories.
|
||||
///
|
||||
/// The values copied add up to at most [`MAX_COPIED_BYTES`] and to at most `b.len()`: the values
|
||||
/// of a well-formed stream are distinct bytes of it, while a hostile one can have many entries
|
||||
/// point at the same bytes and so ask for far more than its size. Entries that do not fit in what
|
||||
/// is left are left out (the small descriptive directories come first, so a huge maker note
|
||||
/// cannot crowd out the GPS position).
|
||||
pub(crate) fn read(b: &[u8]) -> Option<ExifDirs> {
|
||||
let f = File::parse(b)?;
|
||||
read_file(File::parse(b)?)
|
||||
}
|
||||
|
||||
/// The EXIF parts of a camera raw built on TIFF (NEF, ARW, CR2, DNG, Panasonic RW2), as
|
||||
/// [`read`] gives them, without the Exif directory's PixelXDimension and PixelYDimension: in a
|
||||
/// raw they give the sensor's or the preview's size, not the developed image's.
|
||||
pub(crate) fn read_raw(b: &[u8]) -> Option<ExifDirs> {
|
||||
let mut dirs = read_file(File::parse_raw(b)?)?;
|
||||
dirs.exif.retain(|f| !matches!(f.tag, TAG_PIXEL_X_DIMENSION | TAG_PIXEL_Y_DIMENSION));
|
||||
Some(dirs)
|
||||
}
|
||||
|
||||
fn read_file(f: File<'_>) -> Option<ExifDirs> {
|
||||
let b = f.b;
|
||||
if f.big {
|
||||
return None;
|
||||
}
|
||||
let ifd0 = f.ifd(f.first_ifd)?;
|
||||
let dir = |tag: u16| sub(&f, &ifd0, tag).map(|d| fields(&f, &d, |_| true)).unwrap_or_default();
|
||||
Some(ExifDirs { le: f.le, ifd0: fields(&f, &ifd0, |t| IFD0_TAGS.contains(&t)), exif: dir(TAG_EXIF_IFD), gps: dir(TAG_GPS_IFD) })
|
||||
let mut budget = MAX_COPIED_BYTES.min(b.len());
|
||||
let ifd0_fields = fields(&f, &ifd0, &mut budget, |t| IFD0_TAGS.contains(&t));
|
||||
let mut dir = |tag: u16| sub(&f, &ifd0, tag).map(|d| fields(&f, &d, &mut budget, |_| true)).unwrap_or_default();
|
||||
let gps = dir(TAG_GPS_IFD);
|
||||
let exif = dir(TAG_EXIF_IFD);
|
||||
Some(ExifDirs { le: f.le, ifd0: ifd0_fields, exif, gps })
|
||||
}
|
||||
|
||||
/// Bytes per element whose order a byte-order change reverses: a RATIONAL is two LONGs.
|
||||
|
||||
@@ -119,6 +119,20 @@ impl<'a> File<'a> {
|
||||
Some(f)
|
||||
}
|
||||
|
||||
/// [`parse`](Self::parse), also taking a Panasonic RW2 raw, whose classic little-endian TIFF
|
||||
/// header has 0x55 (`IIU\0`) in place of 42.
|
||||
pub fn parse_raw(b: &'a [u8]) -> Option<Self> {
|
||||
if let Some(f) = Self::parse(b) {
|
||||
return Some(f);
|
||||
}
|
||||
let mut f = File { b, le: true, big: false, first_ifd: 0 };
|
||||
if b.get(0..2)? != b"II" || f.u16_at(2)? != 0x55 {
|
||||
return None;
|
||||
}
|
||||
f.first_ifd = u64::from(f.u32_at(4)?);
|
||||
Some(f)
|
||||
}
|
||||
|
||||
/// Length of the header; no directory can start inside it.
|
||||
pub fn header_len(&self) -> u64 {
|
||||
if self.big { 16 } else { 8 }
|
||||
|
||||
@@ -35,7 +35,51 @@ fn map_err(f: Format, e: image::ImageError) -> CodecError {
|
||||
|
||||
pub(crate) fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecError> {
|
||||
let decoder = image::codecs::bmp::BmpDecoder::new(Cursor::new(bytes)).map_err(|e| map_err(Format::Bmp, e))?;
|
||||
Ok(decoder.get_palette().map(|palette| palette.to_vec()))
|
||||
let mut palette = decoder.get_palette().map(|palette| palette.to_vec());
|
||||
// The decoder always hands back 256 entries (it pads a short table with black to survive
|
||||
// corrupt files); the file's own count is what the document's colour table should hold.
|
||||
if let (Some(p), Some(n)) = (palette.as_mut(), bmp_palette_len(bytes)) {
|
||||
p.truncate(n.max(1));
|
||||
}
|
||||
Ok(palette)
|
||||
}
|
||||
|
||||
/// How many colours a palette BMP's header declares: `biClrUsed`, or 2^bits when it is 0.
|
||||
/// `None` for a header this does not recognise, which leaves the table as decoded.
|
||||
fn bmp_palette_len(bytes: &[u8]) -> Option<usize> {
|
||||
let u16_at = |at: usize| bytes.get(at..at + 2).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes);
|
||||
let u32_at = |at: usize| bytes.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes);
|
||||
let (bit_count, used) = match u32_at(14)? {
|
||||
// OS/2 BITMAPCOREHEADER: no colour count, the bit depth sets the table size.
|
||||
12 => (u16_at(24)?, 0),
|
||||
n if n >= 40 => (u16_at(28)?, u32_at(46)?),
|
||||
_ => return None,
|
||||
};
|
||||
if !(1..=8).contains(&bit_count) {
|
||||
return None;
|
||||
}
|
||||
let max = 1usize << bit_count;
|
||||
Some(if used == 0 { max } else { (used as usize).min(max) })
|
||||
}
|
||||
|
||||
/// Writes an 8-bit palette BMP: `indices` row-major, `palette` RGB entries (1..=256). The
|
||||
/// counterpart of [`bmp_palette`], for Indexed Color documents.
|
||||
pub(crate) fn encode_bmp_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3]]) -> Result<Vec<u8>, CodecError> {
|
||||
let f = Format::Bmp;
|
||||
if width == 0 || height == 0 || u64::from(width) * u64::from(height) != indices.len() as u64 {
|
||||
return Err(CodecError::InvalidImage("index data does not match the size".into()));
|
||||
}
|
||||
if palette.is_empty() || palette.len() > 256 {
|
||||
return Err(CodecError::InvalidImage("a palette BMP holds 1 to 256 colours".into()));
|
||||
}
|
||||
if indices.iter().any(|&i| usize::from(i) >= palette.len()) {
|
||||
return Err(CodecError::InvalidImage("palette index out of range".into()));
|
||||
}
|
||||
let mut out = Cursor::new(Vec::new());
|
||||
image::codecs::bmp::BmpEncoder::new(&mut out)
|
||||
.encode_with_palette(indices, width, height, image::ExtendedColorType::L8, Some(palette))
|
||||
.map_err(|e| CodecError::encode(f, e))?;
|
||||
Ok(out.into_inner())
|
||||
}
|
||||
|
||||
/// The `image` decoder for `f` with our limits, its header read and its size checked.
|
||||
@@ -183,3 +227,45 @@ pub(crate) fn encode(f: Format, src: &Image, plan: Plan, opts: &EncodeOptions) -
|
||||
}
|
||||
Ok(cursor.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod indexed_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn palette_bmp_roundtrips_its_table_and_pixels() {
|
||||
// 3 px wide: rows are padded to 4 bytes, so a wrong stride shows in the second row.
|
||||
let pal = [[255, 0, 0], [0, 0, 255], [10, 20, 30]];
|
||||
let idx = [0u8, 1, 2, 2, 1, 0];
|
||||
let bytes = encode_bmp_indexed(3, 2, &idx, &pal).unwrap();
|
||||
assert_eq!(bmp_palette(&bytes).unwrap().as_deref(), Some(&pal[..]), "the table survives, not a 256-entry grey ramp");
|
||||
let img = decode(Format::Bmp, &bytes, &Limits::default()).unwrap();
|
||||
assert_eq!(img.dimensions(), (3, 2));
|
||||
let rgb = img.convert(ChannelLayout::Rgb, SampleType::U8);
|
||||
let want: Vec<u8> = idx.iter().flat_map(|&i| pal[usize::from(i)]).collect();
|
||||
assert_eq!(rgb.data(), want.as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_len_follows_the_header() {
|
||||
let mut bytes = encode_bmp_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]]).unwrap();
|
||||
assert_eq!(bmp_palette_len(&bytes), Some(2));
|
||||
// biClrUsed = 0 means the full 2^bits table.
|
||||
bytes[46..50].copy_from_slice(&0u32.to_le_bytes());
|
||||
assert_eq!(bmp_palette_len(&bytes), Some(256));
|
||||
// A short or unknown header leaves the decoded table alone instead of panicking.
|
||||
assert_eq!(bmp_palette_len(&bytes[..30]), None);
|
||||
bytes[14..18].copy_from_slice(&7u32.to_le_bytes());
|
||||
assert_eq!(bmp_palette_len(&bytes), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_bmp_rejects_bad_input() {
|
||||
let pal = [[0, 0, 0], [255, 255, 255]];
|
||||
assert!(encode_bmp_indexed(3, 2, &[0; 5], &pal).is_err(), "size mismatch");
|
||||
assert!(encode_bmp_indexed(0, 2, &[], &pal).is_err(), "empty");
|
||||
assert!(encode_bmp_indexed(2, 1, &[0, 2], &pal).is_err(), "index past the table");
|
||||
assert!(encode_bmp_indexed(1, 1, &[0], &[]).is_err(), "no colours");
|
||||
assert!(encode_bmp_indexed(1, 1, &[0], &[[0; 3]; 257]).is_err(), "more than 256 colours");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::fmt;
|
||||
|
||||
use crate::format::{Format, caps};
|
||||
use crate::image::{ChannelLayout, Image, SampleType};
|
||||
use crate::options::EncodeOptions;
|
||||
use crate::options::{DdsCompression, EncodeOptions};
|
||||
|
||||
/// Something that will be lost or altered when writing an image to a format.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -165,6 +165,8 @@ pub(crate) fn plan(image: &Image, format: Format, opts: &EncodeOptions) -> Plan
|
||||
},
|
||||
// CIELab has no float form: float Lab is written with 16 bits.
|
||||
Format::Tiff if layout.is_lab() && sample.is_float() => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: SampleType::U16 },
|
||||
// A block-compressed DDS stores RGBA whatever the source; uncompressed keeps gray and RGB.
|
||||
Format::Dds if opts.dds.compression.is_block() => Plan { layout: ChannelLayout::Rgba, sample: SampleType::U8 },
|
||||
_ => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: pick_sample(sample, c.depths) },
|
||||
}
|
||||
}
|
||||
@@ -211,7 +213,8 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
|
||||
if sl.has_alpha() && !p.layout.has_alpha() && image.has_translucency() {
|
||||
w.push(W::AlphaDiscarded);
|
||||
}
|
||||
if format == Format::Gif && p.layout.has_alpha() && image.has_translucency() {
|
||||
let binary_alpha = format == Format::Gif || (format == Format::Dds && opts.dds.compression == DdsCompression::Bc1);
|
||||
if binary_alpha && p.layout.has_alpha() && image.has_translucency() {
|
||||
w.push(W::AlphaBinarized);
|
||||
}
|
||||
if sl.is_cmyk() && !p.layout.is_cmyk() {
|
||||
@@ -227,7 +230,7 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
|
||||
// Compression
|
||||
if format == Format::Gif {
|
||||
w.push(W::PaletteQuantized);
|
||||
} else if c.lossy || (format == Format::WebP && !opts.webp_lossless) {
|
||||
} else if c.lossy || (format == Format::WebP && !opts.webp_lossless) || (format == Format::Dds && opts.dds.compression.is_block()) {
|
||||
w.push(W::LossyCompression);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ pub enum Format {
|
||||
Pcx,
|
||||
/// X PixMap icons (XPM2 plain text and XPM3 C-string arrays).
|
||||
Xpm,
|
||||
/// DirectDraw Surface: the top mip level of a 2D texture, uncompressed or BC1–BC3.
|
||||
/// DirectDraw Surface: a 2D texture, uncompressed or BC1–BC3. Reading takes the top mip
|
||||
/// level; writing can add the full mip chain ([`crate::DdsOptions`]).
|
||||
Dds,
|
||||
Qoi,
|
||||
OpenExr,
|
||||
@@ -71,10 +72,6 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
|
||||
Format::Pcx,
|
||||
"PCX is supported for opening legacy paint and clipart assets. New files should use PNG; writing obsolete indexed and planar variants is intentionally not provided.",
|
||||
),
|
||||
(
|
||||
Format::Dds,
|
||||
"DDS is supported for opening game and 3D textures. Writing one well means choosing a block compression and generating mip levels, which belongs in a texture export, not Save As.",
|
||||
),
|
||||
(
|
||||
Format::Avif,
|
||||
"AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
|
||||
@@ -255,7 +252,8 @@ pub fn caps(format: Format) -> FormatCaps {
|
||||
Format::Pnm => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, ..base },
|
||||
Format::Pcx => FormatCaps { read: true, write: false, layouts: &[L::Rgb], alpha: false, ..base },
|
||||
Format::Xpm => FormatCaps { read: true, write: false, layouts: &[L::Rgba], ..base },
|
||||
Format::Dds => FormatCaps { read: true, write: false, layouts: RGB_GRAY, ..base },
|
||||
// Uncompressed by default; a block compression is lossy (see `fidelity_warnings_with`).
|
||||
Format::Dds => FormatCaps { layouts: RGB_GRAY, ..base },
|
||||
Format::Qoi => base,
|
||||
Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base },
|
||||
Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base },
|
||||
|
||||
@@ -454,6 +454,9 @@ pub struct Image {
|
||||
data: Vec<u8>,
|
||||
/// Embedded ICC profile (must describe `layout`'s colour model).
|
||||
pub icc: Option<Vec<u8>>,
|
||||
/// The file's CICP colour description (a HEIF `nclx`), when it has one. Only an image whose
|
||||
/// `icc` is `None` relies on it.
|
||||
pub cicp: Option<crate::cicp::Cicp>,
|
||||
pub meta: Metadata,
|
||||
/// What the decoder noticed about the file: empty for a complete, single image and for
|
||||
/// images built in memory. Encoders ignore it.
|
||||
@@ -468,7 +471,7 @@ impl Image {
|
||||
/// A zero-filled image, or an error if the byte size overflows `usize`.
|
||||
pub fn new(width: u32, height: u32, layout: ChannelLayout, sample: SampleType) -> Result<Self, CodecError> {
|
||||
let len = byte_len(width, height, layout, sample).ok_or_else(|| CodecError::InvalidImage("image size overflows usize".into()))?;
|
||||
Ok(Image { width, height, layout, sample, data: vec![0; len], icc: None, meta: Metadata::default(), warnings: Vec::new() })
|
||||
Ok(Image { width, height, layout, sample, data: vec![0; len], icc: None, cicp: None, meta: Metadata::default(), warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Wrap raw interleaved, native-endian bytes.
|
||||
@@ -477,7 +480,7 @@ impl Image {
|
||||
if data.len() != expected {
|
||||
return Err(CodecError::InvalidImage(format!("buffer has {} bytes, expected {expected} for {width}x{height} {layout:?} {sample:?}", data.len())));
|
||||
}
|
||||
Ok(Image { width, height, layout, sample, data, icc: None, meta: Metadata::default(), warnings: Vec::new() })
|
||||
Ok(Image { width, height, layout, sample, data, icc: None, cicp: None, meta: Metadata::default(), warnings: Vec::new() })
|
||||
}
|
||||
|
||||
pub fn from_u8(width: u32, height: u32, layout: ChannelLayout, data: Vec<u8>) -> Result<Self, CodecError> {
|
||||
@@ -646,6 +649,7 @@ impl Image {
|
||||
sample,
|
||||
data,
|
||||
icc: if layout.same_model(self.layout) { self.icc.clone() } else { None },
|
||||
cicp: if layout.same_model(self.layout) { self.cicp } else { None },
|
||||
meta: self.meta.clone(),
|
||||
warnings: self.warnings.clone(),
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod cicp;
|
||||
mod codecs;
|
||||
mod error;
|
||||
pub mod exif_color;
|
||||
@@ -25,10 +26,10 @@ pub mod orientation;
|
||||
pub mod resolution;
|
||||
pub mod source_settings;
|
||||
pub mod web;
|
||||
|
||||
pub use crate::cicp::Cicp;
|
||||
pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color};
|
||||
pub use crate::codecs::gif_frames::{AnimationFrame, MAX_GIF_FRAMES};
|
||||
pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette};
|
||||
pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette, stereo as png_stereo};
|
||||
pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian};
|
||||
pub use crate::codecs::tiff_ifd::{TiffInfo, TiffPage, TiffPageKind};
|
||||
pub use crate::error::CodecError;
|
||||
@@ -38,7 +39,9 @@ pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect,
|
||||
pub use crate::image::{
|
||||
ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType,
|
||||
};
|
||||
pub use crate::options::{DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
|
||||
pub use crate::options::{
|
||||
DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, PngStereo, TiffCompression,
|
||||
};
|
||||
pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp};
|
||||
pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution};
|
||||
pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding};
|
||||
@@ -126,11 +129,23 @@ pub fn probe_size(bytes: &[u8]) -> Result<(u32, u32), CodecError> {
|
||||
Ok(if (5..=8).contains(&o) { (h, w) } else { (w, h) })
|
||||
}
|
||||
|
||||
/// The colour table of a GIF's first frame (its Local Color Table, else the Global one) and the
|
||||
/// entry its Graphic Control Extension makes transparent, read without decoding pixels.
|
||||
pub fn gif_palette(bytes: &[u8]) -> Option<(Vec<[u8; 3]>, Option<u8>)> {
|
||||
crate::codecs::gif_palette::palette(bytes)
|
||||
}
|
||||
|
||||
/// Returns the embedded colour table for an indexed BMP, if it has one.
|
||||
pub fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecError> {
|
||||
via_image::bmp_palette(bytes)
|
||||
}
|
||||
|
||||
/// Encodes `indices` (row-major, one byte per pixel) as an 8-bit palette BMP with the colour
|
||||
/// table `palette` (1 to 256 entries). Used for Indexed Color documents; BMP has no transparency.
|
||||
pub fn encode_bmp_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3]]) -> Result<Vec<u8>, CodecError> {
|
||||
via_image::encode_bmp_indexed(width, height, indices, palette)
|
||||
}
|
||||
|
||||
/// Lists the pages (image directories) of a TIFF or BigTIFF file without decoding pixels: the
|
||||
/// main IFD chain and its SubIFDs, each with its size, kind (page, reduced-resolution copy or
|
||||
/// mask) and storage. [`TiffInfo::default_page`] is the page [`decode`] opens.
|
||||
@@ -164,6 +179,18 @@ pub fn tiff_page_orientation(bytes: &[u8], page: usize) -> u16 {
|
||||
tiff::orientation(bytes, Some(page))
|
||||
}
|
||||
|
||||
/// The EXIF block (`Metadata::exif`) of a camera raw built on TIFF (NEF, ARW, CR2, DNG,
|
||||
/// Panasonic RW2), for the image developed from it: IFD0's descriptive tags (make, model,
|
||||
/// date…) and the Exif and GPS directories, as a TIFF opens with. Orientation is not part of
|
||||
/// it, nor the Exif directory's pixel dimensions (the sensor's or the preview's size). When those
|
||||
/// were all its Exif directory held, the block is IFD0's tags alone, so the camera's make and
|
||||
/// model are still kept. `None` when the file is not built on a classic TIFF stream or has none
|
||||
/// of these tags.
|
||||
pub fn raw_exif(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let dirs = crate::codecs::tiff_exif::read_raw(bytes).filter(|d| d.has_directories() || !d.ifd0.is_empty())?;
|
||||
Some(crate::codecs::tiff_exif::block(&dirs))
|
||||
}
|
||||
|
||||
/// The limits check and the orientation, shared by every decoder.
|
||||
fn finish_decode(img: Image, o: u16, opts: &DecodeOptions) -> Result<Image, CodecError> {
|
||||
let l = &opts.limits;
|
||||
@@ -252,7 +279,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
|
||||
Format::Pnm => pnm::encode(image, plan, opts),
|
||||
Format::Pcx => Err(CodecError::unsupported(format, "PCX is import-only; save as PNG instead")),
|
||||
Format::Xpm => Err(CodecError::unsupported(format, "XPM is import-only; save as PNG instead")),
|
||||
Format::Dds => Err(CodecError::unsupported(format, "DDS is import-only; save as PNG instead")),
|
||||
Format::Dds => dds::encode(image, plan, opts),
|
||||
Format::Fits => fits::encode(image, plan, opts),
|
||||
Format::Dpx => dpx::encode(image, plan, opts),
|
||||
Format::Cineon => Err(CodecError::unsupported(format, "Cineon is import-only; save as DPX, TIFF or EXR instead")),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user