Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 86125ed8f1 Cargo.lock: photocraft-ipp at 0.10.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:20:59 -04:00
Brandon ThomasandClaude Opus 5.5 5b38133785 Release 0.10.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:20:41 -04:00
554 changed files with 3902 additions and 46623 deletions

No files matched your search

-6
View File
@@ -1,6 +0,0 @@
# 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]"
-30
View File
@@ -1,30 +0,0 @@
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)"
+1 -1
View File
@@ -361,7 +361,7 @@ jobs:
cache-on-failure: true
- name: Install trunk
run: |
curl --proto '=https' -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
curl -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
+13 -3
View File
@@ -18,6 +18,16 @@ concurrency:
cancel-in-progress: true
jobs:
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
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
-20
View File
@@ -1,20 +0,0 @@
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
+4 -4
View File
@@ -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 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` |
| [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` |
## 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, Windows barrel button); the one isolated unsafe crate
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2); 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) 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.
- **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.
- **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- 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.
**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.
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).
+2 -4
View File
@@ -27,7 +27,7 @@ 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`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.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/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
@@ -38,12 +38,11 @@ next to them.
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush`, scaled to 70%, inside a dashed upright rounded "C" selection | Lucide Icons and Contributors; selection by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush` plus a dashed selection arc around the bristles | Lucide Icons and Contributors; arc by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/pen-freeform.svg`, `assets/icons/pen-curvature.svg` | Freeform Pen and Curvature Pen tool glyphs: Lucide `pen-tool` scaled down, plus a freehand squiggle or a curve through two points | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
@@ -110,7 +109,6 @@ 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) |
Generated
+49 -97
View File
@@ -849,7 +849,7 @@ dependencies = [
"maybe-owned",
"rustix 1.1.5",
"rustix-linux-procfs",
"windows-sys 0.61.2",
"windows-sys 0.60.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 0.20.0",
"roxmltree",
"tempfile",
"winprint",
"winsafe",
@@ -1896,7 +1896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -1934,18 +1934,6 @@ 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"
@@ -2116,7 +2104,7 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
"roxmltree 0.20.0",
"roxmltree",
]
[[package]]
@@ -2141,17 +2129,9 @@ 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]]
@@ -3053,7 +3033,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3715,7 +3695,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"png 0.18.1",
"thiserror 2.0.21",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -4461,7 +4441,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4638,7 +4618,7 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"arboard",
"async-io",
@@ -4683,7 +4663,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4693,7 +4673,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4706,7 +4686,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"base64 0.22.1",
"cap-std",
@@ -4719,7 +4699,6 @@ dependencies = [
"photocraft-geom",
"photocraft-io",
"rmcp",
"rustix 1.1.5",
"schemars",
"serde",
"serde_json",
@@ -4729,7 +4708,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
@@ -4743,7 +4722,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"moxcms",
"rayon",
@@ -4751,7 +4730,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"exr",
"flate2",
@@ -4772,7 +4751,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"photocraft-cms",
"serde",
@@ -4780,7 +4759,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"criterion",
"photocraft-color",
@@ -4795,7 +4774,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4806,7 +4785,7 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"blake3",
"craft-print",
@@ -4832,23 +4811,20 @@ dependencies = [
"photocraft-text",
"photocraft-vector",
"rayon",
"roxmltree 0.20.0",
"same-file",
"roxmltree",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.21",
"wat",
"winsafe",
]
[[package]]
name = "photocraft-format"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"blake3",
"crc32fast",
"exacl",
"flate2",
"libc",
"photocraft-codecs",
@@ -4862,23 +4838,21 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"winsafe",
]
[[package]]
name = "photocraft-geom"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"half",
"log",
"photocraft-cms",
"photocraft-color",
"photocraft-compose",
"photocraft-doc",
@@ -4892,14 +4866,14 @@ dependencies = [
[[package]]
name = "photocraft-heif"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"blake3",
"craft-docpath",
@@ -4922,25 +4896,25 @@ dependencies = [
"photocraft-vector",
"proptest",
"resvg",
"roxmltree 0.20.0",
"roxmltree",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "photocraft-ipp"
version = "0.12.0"
version = "0.10.0"
[[package]]
name = "photocraft-ops"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4951,7 +4925,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4966,7 +4940,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4977,18 +4951,17 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
"proptest",
"rayon",
"serde",
]
[[package]]
name = "photocraft-raw"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"flate2",
"image",
@@ -5003,7 +4976,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -5015,7 +4988,7 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"memory-stats",
"photocraft-color",
@@ -5026,7 +4999,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"flate2",
"parley",
@@ -5035,13 +5008,12 @@ dependencies = [
"photocraft-geom",
"photocraft-psd",
"photocraft-raster",
"rayon",
"skrifa",
]
[[package]]
name = "photocraft-ui-egui"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"base64 0.22.1",
"craft-print",
@@ -5083,7 +5055,7 @@ dependencies = [
[[package]]
name = "photocraft-vector"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -5094,7 +5066,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"eframe",
"egui",
@@ -5116,7 +5088,7 @@ dependencies = [
[[package]]
name = "photocraft-web-boot"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"flate2",
"js-sys",
@@ -5509,7 +5481,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5970,15 +5942,6 @@ 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"
@@ -6037,7 +6000,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -6104,7 +6067,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -6742,7 +6705,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6969,10 +6932,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -7372,7 +7335,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7479,7 +7442,7 @@ dependencies = [
"kurbo 0.11.3",
"log",
"pico-args",
"roxmltree 0.20.0",
"roxmltree",
"rustybuzz",
"simplecss",
"siphasher",
@@ -8132,7 +8095,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -8712,7 +8675,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.12.0"
version = "0.10.0"
dependencies = [
"serde",
"serde_json",
@@ -8725,17 +8688,6 @@ 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
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.12.0"
version = "0.10.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+2 -2
View File
@@ -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 --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"; \
curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \
curl -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"; \
+1 -4
View File
@@ -20,9 +20,6 @@
<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">
@@ -202,7 +199,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/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.
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.
</td>
<td width="33%" valign="top">
<h4>🪄 The everyday essentials</h4>
-8
View File
@@ -12,14 +12,6 @@ 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
+9 -75
View File
@@ -15,21 +15,19 @@ 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>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
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>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
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>]
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>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
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>]
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
@@ -71,23 +69,18 @@ struct Subcommand {
}
const SUBCOMMANDS: &[Subcommand] = &[
Subcommand {
name: "convert",
values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
bare: &["--tiff-layers", "--dds-mipmaps"],
run: convert,
},
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert },
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
Subcommand {
name: "run",
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
bare: &["--tiff-layers", "--dds-mipmaps"],
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
bare: &["--tiff-layers"],
run: run_cmds,
},
Subcommand {
name: "batch",
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
bare: &["--in-place", "--tiff-layers"],
run: batch,
},
Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet },
@@ -226,11 +219,6 @@ fn export_opts(a: &Args) -> Result<ExportOptions, String> {
o.encode.dpx_bits =
Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?);
}
if let Some(c) = a.get("--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)
}
@@ -456,32 +444,11 @@ 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, mut set, warnings) = load_actions_file(actions_path, a.get("--action"))?;
set.iter_mut().for_each(strip_shell_steps);
let (actions, set, warnings) = load_actions_file(actions_path, a.get("--action"))?;
for w in &warnings {
let _ = writeln!(err, "warning: {w}");
}
@@ -784,37 +751,4 @@ 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);
}
}
+5 -48
View File
@@ -163,29 +163,19 @@ 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, 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)")),
("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")),
];
for (name, bytes, warning, export) in cases {
for (name, bytes, warning) 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"))));
let expected: Vec<String> = warning.into_iter().chain(export).map(|w| format!("warning: {w}")).collect();
assert_eq!(
err.trim(),
expected.join(
"
"
),
"{name}"
);
assert_eq!(err.trim(), warning.map(|w| format!("warning: {w}")).unwrap_or_default(), "{name}");
}
std::fs::remove_dir_all(d).unwrap();
}
@@ -504,39 +494,6 @@ 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]
+1 -1
View File
@@ -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", "ReadableStreamDefaultReader", "WritableStream", "WritableStreamDefaultWriter", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "WritableStream", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
+6 -117
View File
@@ -8,10 +8,7 @@
//! 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. 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.
//! In one that can't, this module inflates them itself, a little slower.
//!
//! 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.
@@ -39,34 +36,6 @@ 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>>);
@@ -100,11 +69,11 @@ mod boot {
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{
Headers, ReadableStream, ReadableStreamDefaultReader, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions,
TransformStream, TransformStreamDefaultController, Transformer, WritableStream, WritableStreamDefaultWriter,
Headers, ReadableStream, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, TransformStream,
TransformStreamDefaultController, Transformer, WritableStream,
};
use super::{HEAD, Inflater, Payload};
use super::Inflater;
/// Where packaging puts the app, next to this module's own files.
const PARTS: &str = "./app/parts.txt";
@@ -203,71 +172,11 @@ 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();
@@ -278,10 +187,6 @@ 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();
@@ -353,7 +258,7 @@ fn main() {
#[cfg(test)]
mod tests {
use super::{HEAD, Inflater, MARKER, Payload, parts, payload};
use super::{Inflater, MARKER, parts};
fn gzip(bytes: &[u8]) -> Vec<u8> {
let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
@@ -412,22 +317,6 @@ 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");
+3 -5
View File
@@ -19,9 +19,9 @@ 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.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico",
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "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", "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",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
@@ -427,8 +427,6 @@ 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())
-8
View File
@@ -11,14 +11,6 @@ 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")
+13 -60
View File
@@ -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: 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`]).
//! One exception: on Windows the platform convention keeps crash-recovery autosaves out of the
//! roaming profile ([`recovery_dir`]).
use std::ffi::OsString;
use std::path::{Path, PathBuf};
@@ -77,35 +77,18 @@ pub fn recovery_dir() -> Option<PathBuf> {
})
}
/// 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.
/// 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.
pub fn resolve_recovery(data: &DataDir, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
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));
}
}
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));
}
data.dir.as_ref().map(|d| d.join(RECOVERY))
}
@@ -640,34 +623,6 @@ 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();
@@ -829,7 +784,6 @@ 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");
@@ -912,7 +866,6 @@ 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");
+14 -115
View File
@@ -70,61 +70,25 @@ pub fn start() {
/// Whether the portal's `FileChooser` interface is on the session bus: `Some(false)` only when the
/// bus or the interface is definitely absent, `None` for anything inconclusive (a slow reply).
///
/// Asks the portal object to introspect itself rather than reading the interface's `version`
/// property: xdg-desktop-portal exports `FileChooser` only when a backend offers it, and without
/// one a property read fails with `InvalidArgs` ("No such interface"), an error the old check
/// didn't count as absent (#5739). The interface list answers the question directly.
#[cfg(target_os = "linux")]
fn file_chooser_portal() -> Option<bool> {
let Ok(conn) = zbus::blocking::connection::Builder::session().and_then(|b| b.method_timeout(std::time::Duration::from_secs(5)).build()) else {
return Some(false);
};
let reply = conn.call_method(
Some("org.freedesktop.portal.Desktop"),
"/org/freedesktop/portal/desktop",
Some("org.freedesktop.DBus.Introspectable"),
"Introspect",
&(),
);
match reply {
Ok(message) => match message.body().deserialize::<String>() {
Ok(xml) => Some(interface_listed(&xml)),
Err(_) => None,
},
Err(e) => classify_error(&e),
}
}
/// Whether introspection XML lists the portal's `FileChooser` interface (not a longer name such as
/// `FileChooser2`).
#[cfg(target_os = "linux")]
fn interface_listed(xml: &str) -> bool {
["\"", "'"].iter().any(|quote| xml.contains(&format!("<interface name={quote}org.freedesktop.portal.FileChooser{quote}")))
}
/// What a failed call says about the interface: `Some(false)` when the D-Bus error means the
/// service, object or interface isn't there, `None` for anything else (a timeout, a dropped
/// connection, an unknown error), which counts as present.
#[cfg(target_os = "linux")]
fn classify_error(e: &zbus::Error) -> Option<bool> {
use zbus::fdo::Error as Fdo;
// Names a bus daemon or portal answers with when the thing asked about is absent.
const ABSENT: [&str; 7] = ["ServiceUnknown", "NameHasNoOwner", "UnknownObject", "UnknownInterface", "UnknownMethod", "UnknownProperty", "InvalidArgs"];
match e {
zbus::Error::FDO(fdo) => matches!(
**fdo,
Fdo::ServiceUnknown(_)
| Fdo::NameHasNoOwner(_)
| Fdo::UnknownObject(_)
| Fdo::UnknownInterface(_)
| Fdo::UnknownMethod(_)
| Fdo::UnknownProperty(_)
| Fdo::InvalidArgs(_)
)
.then_some(false),
zbus::Error::MethodError(name, _, _) => ABSENT.iter().any(|n| name.as_str().ends_with(n)).then_some(false),
_ => None,
let proxy = zbus::blocking::proxy::Builder::<zbus::blocking::Proxy<'_>>::new(&conn)
.destination("org.freedesktop.portal.Desktop")
.and_then(|b| b.path("/org/freedesktop/portal/desktop"))
.and_then(|b| b.interface("org.freedesktop.portal.FileChooser"))
.and_then(|b| b.cache_properties(zbus::proxy::CacheProperties::No).build())
.ok()?;
match proxy.get_property::<u32>("version") {
Ok(_) => Some(true),
Err(zbus::Error::MethodError(name, _, _)) => {
let name = name.as_str();
let absent = ["ServiceUnknown", "UnknownObject", "UnknownInterface", "UnknownProperty"].iter().any(|n| name.ends_with(n));
absent.then_some(false)
}
Err(_) => None,
}
}
@@ -162,69 +126,4 @@ mod tests {
assert!(!on_path("kdialog", &path));
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(target_os = "linux")]
mod portal {
use super::super::*;
const WITH_FILE_CHOOSER: &str = r#"<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" "http://www.freedesktop.org/standards/dbus/introspect.dtd">
<node>
<interface name="org.freedesktop.DBus.Peer"/>
<interface name="org.freedesktop.portal.Settings"><property name="version" type="u" access="read"/></interface>
<interface name="org.freedesktop.portal.FileChooser">
<method name="OpenFile"/>
</interface>
</node>"#;
const WITHOUT_FILE_CHOOSER: &str = r#"<node>
<interface name="org.freedesktop.DBus.Peer"/>
<interface name="org.freedesktop.portal.Settings"/>
</node>"#;
fn method_error(name: &'static str) -> zbus::Error {
let call = zbus::message::Message::method_call("/a", "M").unwrap().build(&()).unwrap();
let reply = zbus::message::Message::error(&call.header(), name).unwrap().build(&"detail").unwrap();
zbus::Error::MethodError(name.try_into().unwrap(), Some("detail".into()), reply)
}
#[test]
fn the_interface_list_says_whether_a_file_chooser_is_offered() {
assert!(interface_listed(WITH_FILE_CHOOSER));
assert!(interface_listed("<interface name='org.freedesktop.portal.FileChooser'>"), "single quotes");
assert!(!interface_listed(WITHOUT_FILE_CHOOSER), "portal without a backend");
assert!(!interface_listed(r#"<interface name="org.freedesktop.portal.FileChooser2">"#), "longer name");
assert!(!interface_listed(""));
}
#[test]
fn dbus_errors_for_a_missing_interface_mean_absent() {
// zbus's fdo proxies hand D-Bus errors back as `Error::FDO`, a plain method call as
// `MethodError`; both forms count. The portal answers `InvalidArgs` ("No such interface")
// when no backend offers the interface (#5739).
for e in [
zbus::fdo::Error::InvalidArgs("No such interface".into()),
zbus::fdo::Error::UnknownInterface("x".into()),
zbus::fdo::Error::ServiceUnknown("x".into()),
zbus::fdo::Error::NameHasNoOwner("x".into()),
zbus::fdo::Error::UnknownObject("x".into()),
] {
let desc = format!("{e:?}");
assert_eq!(classify_error(&zbus::Error::FDO(Box::new(e))), Some(false), "{desc}");
}
}
#[test]
fn method_error_names_still_count_as_absent() {
for name in ["org.freedesktop.DBus.Error.ServiceUnknown", "org.freedesktop.DBus.Error.InvalidArgs", "org.freedesktop.DBus.Error.UnknownMethod"] {
assert_eq!(classify_error(&method_error(name)), Some(false), "{name}");
}
assert_eq!(classify_error(&method_error("org.freedesktop.DBus.Error.AccessDenied")), None);
}
#[test]
fn inconclusive_errors_are_not_absent() {
assert_eq!(classify_error(&zbus::Error::FDO(Box::new(zbus::fdo::Error::NoReply("slow".into())))), None);
assert_eq!(classify_error(&zbus::Error::FDO(Box::new(zbus::fdo::Error::Failed("x".into())))), None);
assert_eq!(classify_error(&zbus::Error::InputOutput(std::sync::Arc::new(std::io::Error::other("gone")))), None);
}
}
}
+16 -214
View File
@@ -12,10 +12,8 @@
//! - 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, and when DXGI lists an
//! Intel adapter the instance leaves Vulkan out, so the first start doesn't load the Intel Vulkan
//! driver either. `WGPU_BACKEND` still overrides everything, and `--safe-gpu` forces the CPU path
//! for one launch.
//! where it can be reset). With `auto`, Intel adapters on Windows use DX12. `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
@@ -206,28 +204,6 @@ 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
@@ -252,8 +228,6 @@ 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
@@ -285,59 +259,19 @@ 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). Adapters the user asked for
/// win over the rest when any of them can present; otherwise the ranking decides (see
/// [`wanted_missed`]).
/// The adapter to use among `adapters` (`None` when there is none).
pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option<usize> {
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 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())
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok).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, or `vulkan_left_out`: [`windows_auto_backends`] left Vulkan out of
/// the instance).
pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os, vulkan_left_out: bool) -> bool {
/// 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
&& (vulkan_left_out || adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan))
&& adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)
}
/// wgpu compiles indirect-call validation shaders inside `request_device`; Metal's compiler
@@ -351,7 +285,7 @@ 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, wanted: Option<String>, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, 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;
@@ -364,13 +298,7 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted:
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 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)));
let select = 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<'_>>| {
@@ -383,20 +311,15 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted:
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, vulkan_left_out) {
if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os) {
*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());
}
@@ -520,13 +443,6 @@ 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 {
@@ -688,82 +604,7 @@ mod tests {
}
fn cand(vendor: u32, device_type: wgpu::DeviceType, backend: wgpu::Backend) -> Candidate {
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);
Candidate { vendor, device_type, backend, surface_ok: true }
}
#[test]
@@ -772,13 +613,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, false));
assert!(intel_dx12_applied(&intel, i, Os::Windows));
// 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, false));
assert!(!intel_dx12_applied(&nv, i, Os::Windows));
// 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));
@@ -789,45 +630,6 @@ mod tests {
assert_eq!(pick(&[], Auto, Os::Windows), None);
}
/// #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!(intel_dx12_applied(&listed, i, Os::Windows, true));
assert!(!intel_dx12_applied(&listed, i, Os::Windows, false));
}
#[test]
fn cpu_prefers_software_adapters() {
use wgpu::{Backend as B, DeviceType as T};
@@ -1092,7 +894,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, None, Arc::new(Mutex::new(None)), Arc::default());
configure(&mut setup, &plan, Os::Windows, 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 {
@@ -1116,13 +918,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, None, Arc::new(Mutex::new(None)), Arc::default());
configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, 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, None, Arc::new(Mutex::new(None)), Arc::default());
configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, 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));
}
-3
View File
@@ -420,7 +420,6 @@ 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");
@@ -439,7 +438,6 @@ 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));
@@ -463,7 +461,6 @@ 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
+105 -294
View File
@@ -71,7 +71,8 @@ mod services;
// macOS gets one instance from LaunchServices (open-documents Apple events).
#[cfg(any(not(target_os = "macos"), test))]
mod single_instance;
// Tablet support (macOS AppKit monitor, Linux X11/Wayland, Windows barrel button).
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
#[cfg(any(target_os = "macos", target_os = "linux", test))]
mod tablet;
mod ui_state;
mod window_frame;
@@ -204,131 +205,6 @@ 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};
@@ -358,152 +234,124 @@ 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 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()
};
let mut control_port: Option<u16> = None;
let mut usage_errors: Vec<String> = Vec::new();
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) => launch.control_port = Some(port),
Err(error) => launch.errors.push(error),
Ok(port) => control_port = Some(port),
Err(error) => usage_errors.push(error),
}
}
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(());
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()),
}
}
// 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 {
// 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 {
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.
@@ -515,11 +363,6 @@ 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.
@@ -631,11 +474,6 @@ 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).
@@ -687,9 +525,7 @@ 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.
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());
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, 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()) {
@@ -785,16 +621,9 @@ 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 {
// 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(),
});
app.perf.gpu_info.fallback = Some("No compatible hardware graphics adapter; using software graphics.".into());
}
if !software_window
&& plan.backend != photocraft_engine::prefs::GpuBackend::Cpu
@@ -871,13 +700,8 @@ 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() {
@@ -943,19 +767,6 @@ 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]
-1
View File
@@ -495,7 +495,6 @@ 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}");
+1 -6
View File
@@ -36,22 +36,17 @@ 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 messages {
for msg in zbus::blocking::MessageIterator::from(&server) {
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") {
+32 -157
View File
@@ -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::{Cell, RefCell};
use std::cell::RefCell;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
@@ -20,9 +20,9 @@ mod recovery_tests;
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico",
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "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", "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",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
@@ -36,48 +36,16 @@ 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()
}
/// 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", "ase"]),
("Keyboard Shortcuts", &["kys"]),
];
/// 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.
fn open_filters() -> Vec<(&'static str, Vec<String>)> {
std::iter::once(("All Formats", open_filter_extensions(OPEN_EXTS)))
.chain(OPEN_FILTERS.iter().map(|(name, exts)| (*name, open_filter_extensions(exts))))
.collect()
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)),
]
}
/// File › Save As formats: (filter name, extensions). The filter matching the suggested name's
@@ -100,8 +68,6 @@ 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
@@ -194,7 +160,10 @@ 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))
open_filters()
.into_iter()
.fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions))
.add_filter("Paint.NET", &open_filter_extensions(&["pdn"]))
};
if multiple {
let picked = dialog.pick_files();
@@ -275,7 +244,7 @@ fn path_of(file: &rfd::FileHandle) -> String {
}
/// Runs `future` to completion on this thread, sleeping until it is woken.
pub(crate) fn block_on<T>(future: impl Future<Output = T>) -> T {
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>) {
@@ -296,7 +265,7 @@ pub(crate) 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 and Linux, crash-recovery autosaves; see [`crate::app_dirs`].
/// except, on Windows, crash-recovery autosaves; see [`crate::app_dirs`].
pub fn config_dir() -> Option<PathBuf> {
crate::app_dirs::config_dir()
}
@@ -311,9 +280,8 @@ pub fn presets_dir() -> Option<PathBuf> {
config_dir().map(|d| d.join("Presets"))
}
/// 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`]).
/// Crash-recovery autosaves: under the settings directory, except on Windows, where they stay
/// out of the roaming profile (see [`crate::app_dirs::recovery_dir`]).
fn recovery_dir() -> Option<PathBuf> {
crate::app_dirs::recovery_dir()
}
@@ -496,7 +464,6 @@ 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())?;
@@ -570,47 +537,26 @@ 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().map_err(|_| "clipboard is busy".to_string())?;
let mut slot = clip.try_borrow_mut().ok()?;
let cb = match slot.as_mut() {
Some(c) => c,
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())?)
}
None => slot.insert(arboard::Clipboard::new().ok()?),
};
// Copied files first: Finder also puts the file's icon on the clipboard as an
// image, which would otherwise paste instead of the file.
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 Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) {
return Some(img);
}
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()),
}
}
if let Ok(img) = cb.get_image() {
return Some((img.width as u32, img.height as u32, img.bytes.into_owned()));
}
#[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()))),
@@ -749,57 +695,6 @@ 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() {
@@ -834,26 +729,6 @@ mod tests {
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() {
@@ -969,7 +844,7 @@ mod tests {
assert!(format.caps().write, "{name}: .{ext} can't be written");
}
}
for ext in ["ico", "gif", "bmp", "dds"] {
for ext in ["ico", "gif", "bmp"] {
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");
+6 -29
View File
@@ -1,12 +1,10 @@
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79), and
//! barrel button on Windows (#5905).
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79).
//!
//! 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.
//! 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.
//!
//! 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
@@ -21,36 +19,15 @@ 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.
+4 -117
View File
@@ -20,11 +20,6 @@
//! 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.
@@ -34,20 +29,7 @@ pub use platform::service;
mod platform {
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
use photocraft_ui_egui::WindowFrameFn;
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,
}
use winsafe::{HWND, HwndPlace, POINT, SIZE, co};
/// 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.
@@ -117,11 +99,8 @@ mod platform {
return;
};
let (m, w) = (mi.rcMonitor, mi.rcWork);
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())
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))
else {
return;
};
@@ -174,69 +153,6 @@ 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.
@@ -246,7 +162,7 @@ mod platform {
#[cfg(test)]
mod tests {
use super::{Edge, borderless, clamped_rect, short_of_autohide_taskbars, taskbar_edge};
use super::{borderless, clamped_rect};
use winsafe::co;
#[test]
@@ -305,35 +221,6 @@ 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");
}
}
}
-13
View File
@@ -1,13 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<ellipse cx="12" cy="12" rx="10" ry="7" stroke-dasharray="3.5 3.23" />
</svg>

Before

Width:  |  Height:  |  Size: 281 B

+4 -4
View File
@@ -9,8 +9,8 @@
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14 3H7a4 4 0 0 0-4 4v10a4 4 0 0 0 4 4h7" stroke-dasharray="2 3" />
<path d="m14 10 2.1 2.1" />
<path d="M10.85 17.7A2.45 2.45 0 1 0 8.4 15.25a1.834 1.834 0 0 1-.496 1.254A.7 .7 0 0 0 8.4 17.7z" />
<path d="M13.278 14.922 21.265 6.937a.7 .7 0 0 0-2.101-2.101L11.177 12.822" />
<path d="m11 10 3 3" />
<path d="M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z" />
<path d="M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031" />
<path d="M6.5 12a5.5 5.5 0 1 0 5.5 5.5" stroke-dasharray="2 3" />
</svg>

Before

Width:  |  Height:  |  Size: 502 B

After

Width:  |  Height:  |  Size: 466 B

-3
View File
@@ -5,9 +5,6 @@
- **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
-1
View File
@@ -19,7 +19,6 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_
- Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink.
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened.
- TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit.
- 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
-26
View File
@@ -159,35 +159,9 @@ 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
+1 -8
View File
@@ -139,18 +139,11 @@ 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, 4 levels of spot colours defined through spot colours, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient
nodes per curve, 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
+17 -93
View File
@@ -6,103 +6,10 @@ 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" => {
@@ -111,6 +18,23 @@ fn leaf_outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<
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),
+1 -11
View File
@@ -298,7 +298,6 @@ 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,
@@ -306,16 +305,7 @@ 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(),
geometry_budget: crate::geometry::Budget::default(),
pixels_left: limits.max_pixel_bytes,
}
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: limits.max_pixel_bytes }
}
/// Admit a layer's decoded pixels before they are allocated (#4960). `rgba` is the layer's
+1 -16
View File
@@ -134,19 +134,8 @@ 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>> {
@@ -177,12 +166,8 @@ fn color_at(r: &mut Reader, id: ObjId, depth: usize) -> 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_at(r, c, depth + 1)).or_else(|| s.obj(id, b"base").and_then(|c| color_at(r, c, depth + 1)));
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)));
}
b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 },
_ => {
-171
View File
@@ -538,174 +538,3 @@ 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);
}
}
-212
View File
@@ -1,212 +0,0 @@
//! #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]);
}
-42
View File
@@ -1,42 +0,0 @@
//! 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));
}
+4 -69
View File
@@ -212,17 +212,12 @@ 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`. 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) {
/// 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) {
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);
@@ -286,7 +281,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, ctl);
line_passes(dst, n, &radii, &mut s);
}
});
// Vertical passes, strip by strip, the same way down the columns.
@@ -305,10 +300,7 @@ 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, ctl);
if ctl.cancelled() {
return (t, Vec::new());
}
line_passes(&mut res, w, &radii, &mut s);
unpremultiply(&mut res, n, alpha);
(t, res)
});
@@ -1188,61 +1180,4 @@ 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}");
}
}
}
+1 -1
View File
@@ -335,7 +335,7 @@ pub(crate) fn lens_blur(src: &Image, out: Rect, ctx: &Ctx, spec: &LensSpec) -> V
}
src.get(x, y, n - 1)
}
DepthSource::LayerMask | DepthSource::Channel => spec.depth_map?.get(x, y, 0),
DepthSource::LayerMask => spec.depth_map?.get(x, y, 0),
};
Some(if spec.invert { 1.0 - d } else { d })
};
+8 -2
View File
@@ -67,16 +67,22 @@ 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)
}
+6 -63
View File
@@ -173,39 +173,6 @@ 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);
@@ -250,14 +217,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 (gl, fine, lw, lh, coarse) = (&g[l].v, &gr[l].v, g[l].w, g[l].h, &gr[l + 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);
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] - up_at(coarse, lw, &taps, x));
*o += wgt * (fine[i] - coarse.v[i]);
}
}
});
@@ -277,10 +244,8 @@ 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();
// 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
s.sort_by(f32::total_cmp);
s[((s.len() - 1) as f32 * q).round() as usize]
}
/// 1st and 99th percentiles of the log luminance on a copy at most 256 pixels across.
@@ -479,33 +444,11 @@ 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)] {
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}");
}
}
assert_eq!(up(&p, uw, uh).v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}");
}
}
}
#[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()
}
+2 -2
View File
@@ -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_to_linear(grey(super::linear_to_srgb(lin[c].min(1.0)))) + over);
q[c] = enc.encode(super::srgb_decode(grey(super::srgb_encode(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_to_linear(grey(super::linear_to_srgb(y.min(1.0)))) + (y - 1.0).max(0.0);
let y2 = super::srgb_decode(grey(super::srgb_encode(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);
-164
View File
@@ -566,133 +566,6 @@ pub fn level_transform(ch: usize, from: &[f32], to: &[f32], a: Adapt) -> Vec<(f3
.collect()
}
/// One stroke of the Content-Aware Fill workspace's Sampling Brush: a hard round brush of
/// diameter `size` along `points` (document px), adding to the sampling area or taking from it.
#[derive(Clone, Debug, PartialEq)]
pub struct SamplingStroke {
pub add: bool,
pub size: f64,
pub points: Vec<[f64; 2]>,
}
impl SamplingStroke {
/// Bounds of the pixels the stroke covers.
pub fn bounds(&self) -> Rect {
let r = (self.size / 2.0).max(0.5);
self.points.iter().fold(Rect::EMPTY, |acc, p| {
let b = Rect::new(
(p[0] - r).floor() as i32,
(p[1] - r).floor() as i32,
((p[0] + r).ceil() as i32).saturating_add(1),
((p[1] + r).ceil() as i32).saturating_add(1),
);
if acc.is_empty() { b } else { acc.union(&b) }
})
}
}
/// Squared distance from `p` to the segment `a`–`b`.
fn seg_dist2(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l2 = dx * dx + dy * dy;
let t = if l2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };
let (qx, qy) = (a[0] + t * dx - p[0], a[1] + t * dy - p[1]);
qx * qx + qy * qy
}
/// Paint `strokes` in order into `mask` (row-major over `area`): a pixel whose centre lies within
/// half the brush size of a stroke's path is set (adding) or cleared (subtracting). A mask that
/// does not cover `area` exactly is left as it is.
pub fn paint_strokes(mask: &mut [bool], area: Rect, strokes: &[SamplingStroke]) {
let w = area.width() as usize;
if mask.len() != w * area.height() as usize {
return;
}
for s in strokes {
let r = (s.size / 2.0).max(0.5);
let r2 = r.powi(2);
let segs: Vec<([f64; 2], [f64; 2])> = match s.points.as_slice() {
[] => continue,
[p] => vec![(*p, *p)],
pts => pts.windows(2).map(|p| (p[0], p[1])).collect(),
};
// A stroke covers the union of its segments' pixels: each segment only looks at its own
// bounds.
for (a, b) in segs {
let bounds = Rect::new(
(a[0].min(b[0]) - r).floor() as i32,
(a[1].min(b[1]) - r).floor() as i32,
((a[0].max(b[0]) + r).ceil() as i32).saturating_add(1),
((a[1].max(b[1]) + r).ceil() as i32).saturating_add(1),
)
.intersect(&area);
for y in bounds.y0..bounds.y1 {
for x in bounds.x0..bounds.x1 {
let c = [f64::from(x) + 0.5, f64::from(y) + 0.5];
if seg_dist2(c, a, b) <= r2
&& let Some(m) = mask.get_mut((y - area.y0) as usize * w + (x - area.x0) as usize)
{
*m = s.add;
}
}
}
}
}
}
/// The pixels of `area` (row-major) whose centres lie inside the polygon `pts` (even-odd rule):
/// what a lasso outlines.
pub fn polygon_mask(area: Rect, pts: &[[f64; 2]]) -> Vec<bool> {
let (w, h) = (area.width() as usize, area.height() as usize);
let mut out = vec![false; w * h];
if pts.len() < 3 {
return out;
}
let mut xs: Vec<f64> = Vec::new();
for row in 0..h {
let cy = f64::from(area.y0) + row as f64 + 0.5;
xs.clear();
for i in 0..pts.len() {
let (a, b) = (pts[i], pts[(i + 1) % pts.len()]);
if (a[1] <= cy) != (b[1] <= cy) {
xs.push(a[0] + (cy - a[1]) / (b[1] - a[1]) * (b[0] - a[0]));
}
}
xs.sort_by(f64::total_cmp);
for pair in xs.as_chunks::<2>().0 {
// Pixel centres x + 0.5 within [pair0, pair1).
let x0 = ((pair[0] - 0.5).ceil() as i64 - i64::from(area.x0)).clamp(0, w as i64) as usize;
let x1 = ((pair[1] - 0.5).ceil() as i64 - i64::from(area.x0)).clamp(0, w as i64) as usize;
out[row * w + x0..row * w + x1.max(x0)].iter_mut().for_each(|v| *v = true);
}
}
out
}
/// Grow (`by` > 0) or shrink (`by` < 0) a mask by `|by|` pixels (Euclidean): the lasso's Expand
/// and Contract.
pub fn grow_mask(mask: &[bool], w: usize, h: usize, by: i32) -> Vec<bool> {
if by == 0 || w == 0 || h == 0 {
return mask.to_vec();
}
let r = f32::from(u16::try_from(by.unsigned_abs()).unwrap_or(u16::MAX));
if by > 0 {
let d = crate::selection::edt(mask, w, h);
d.iter().map(|v| *v <= r).collect()
} else {
let outside: Vec<bool> = mask.iter().map(|v| !v).collect();
let d = crate::selection::edt(&outside, w, h);
d.iter().map(|v| *v > r).collect()
}
}
/// The Sampling Brush's starting size for a selection with bounds `hole`: a tenth of the sampling
/// window's side, as Photoshop opens the workspace (20 px for a 50 px selection, 40 for 100).
pub fn default_brush_size(hole: Rect) -> f64 {
let (w, h) = (f64::from(hole.width().max(50)), f64::from(hole.height().max(50)));
(0.4 * (w * h).sqrt()).round()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -704,43 +577,6 @@ mod tests {
(img, hole)
}
#[test]
fn strokes_paint_their_path_and_leave_a_mismatched_mask_alone() {
let area = Rect::new(10, 20, 50, 40);
let (w, h) = (40, 20);
let strokes = [
SamplingStroke { add: true, size: 6.0, points: vec![[12.0, 25.0], [40.0, 25.0], [45.0, 35.0]] },
SamplingStroke { add: false, size: 3.0, points: vec![[30.0, 25.0]] },
SamplingStroke { add: true, size: 2.0, points: Vec::new() },
];
let mut mask = vec![false; w * h];
paint_strokes(&mut mask, area, &strokes);
// The same pixels as testing every pixel against every segment of each stroke in turn.
let mut want = vec![false; w * h];
for s in &strokes {
let r2 = (s.size / 2.0).max(0.5).powi(2);
let segs: Vec<_> = if s.points.len() == 1 { vec![(s.points[0], s.points[0])] } else { s.points.windows(2).map(|p| (p[0], p[1])).collect() };
for (i, m) in want.iter_mut().enumerate() {
let c = [f64::from(area.x0) + (i % w) as f64 + 0.5, f64::from(area.y0) + (i / w) as f64 + 0.5];
if segs.iter().any(|(a, b)| seg_dist2(c, *a, *b) <= r2) {
*m = s.add;
}
}
}
assert_eq!(mask, want);
assert!(mask[5 * w + 10] && !mask[5 * w + 20], "added along the path, cleared under the subtracting dab");
// A mask shorter or longer than the area is not touched (and nothing panics).
for len in [0, 7, w * h - 1, w * h + 3] {
let mut short = vec![false; len];
paint_strokes(&mut short, area, &strokes);
assert!(short.iter().all(|v| !v), "len {len}");
}
// Hostile coordinates stay inside the area.
let mut m = vec![false; w * h];
let wild = [SamplingStroke { add: true, size: 5000.0, points: vec![[f64::MAX, -1e300], [f64::NAN, 1e7]] }];
paint_strokes(&mut m, area, &wild);
}
#[test]
fn cancelled_fill_stops_and_progress_reaches_one() {
use std::sync::atomic::{AtomicU32, Ordering};
+19 -36
View File
@@ -28,47 +28,32 @@ pub(crate) fn centre(b: Rect) -> (f32, f32, f32) {
(b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0, w.min(h) / 2.0)
}
/// The bounds' centre and half width and height: Twirl, Pinch and Spherize act inside the ellipse
/// inscribed in the bounds, as Photoshop's do, so on a wide or tall image the effect reaches all
/// four edges instead of stopping at a circle fitted to the shorter side (#5643). A point maps to
/// unit-circle coordinates `(u, v)` and back.
fn ellipse(b: Rect) -> (f32, f32, f32, f32) {
let (w, h) = (b.width() as f32, b.height() as f32);
(b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0, w / 2.0, h / 2.0)
}
pub(crate) fn twirl(src: &Image, out: Rect, ctx: &Ctx, angle: f32) -> Vec<f32> {
let (cx, cy, hx, hy) = ellipse(ctx.bounds);
let (cx, cy, rr) = centre(ctx.bounds);
let a = angle.to_radians();
remap(src, out, ctx, Edge::Repeat, |x, y| {
if hx <= 0.0 || hy <= 0.0 {
let (dx, dy) = (x - cx, y - cy);
let r = (dx * dx + dy * dy).sqrt();
if r >= rr || rr <= 0.0 {
return (x, y);
}
let (u, v) = ((x - cx) / hx, (y - cy) / hy);
let r = (u * u + v * v).sqrt();
if r >= 1.0 {
return (x, y);
}
let t = a * (1.0 - r);
let t = a * (1.0 - r / rr);
let (s, c) = t.sin_cos();
(cx + (u * c - v * s) * hx, cy + (u * s + v * c) * hy)
(cx + dx * c - dy * s, cy + dx * s + dy * c)
})
}
pub(crate) fn pinch(src: &Image, out: Rect, ctx: &Ctx, amount: f32) -> Vec<f32> {
let (cx, cy, hx, hy) = ellipse(ctx.bounds);
let (cx, cy, rr) = centre(ctx.bounds);
let a = (amount / 100.0).clamp(-1.0, 1.0);
remap(src, out, ctx, Edge::Repeat, |x, y| {
if hx <= 0.0 || hy <= 0.0 {
let (dx, dy) = (x - cx, y - cy);
let r = (dx * dx + dy * dy).sqrt();
if r >= rr || r <= 0.0 {
return (x, y);
}
let (u, v) = ((x - cx) / hx, (y - cy) / hy);
let r = (u * u + v * v).sqrt();
if r >= 1.0 || r <= 0.0 {
return (x, y);
}
let k = r.powf(1.0 - a * 0.5) / r;
(cx + u * k * hx, cy + v * k * hy)
let rs = rr * (r / rr).powf(1.0 - a * 0.5);
(cx + dx * rs / r, cy + dy * rs / r)
})
}
@@ -79,22 +64,20 @@ fn sphere_1d(rn: f32, a: f32) -> f32 {
}
pub(crate) fn spherize(src: &Image, out: Rect, ctx: &Ctx, amount: f32, mode: SpherizeMode) -> Vec<f32> {
let (cx, cy, hx, hy) = ellipse(ctx.bounds);
let b = ctx.bounds;
let (cx, cy, rr) = centre(b);
let (hx, hy) = (b.width() as f32 / 2.0, b.height() as f32 / 2.0);
let a = (amount / 100.0).clamp(-1.0, 1.0);
remap(src, out, ctx, Edge::Repeat, |x, y| {
let (dx, dy) = (x - cx, y - cy);
match mode {
SpherizeMode::Normal => {
if hx <= 0.0 || hy <= 0.0 {
let r = (dx * dx + dy * dy).sqrt();
if r >= rr || r <= 0.0 {
return (x, y);
}
let (u, v) = (dx / hx, dy / hy);
let r = (u * u + v * v).sqrt();
if r >= 1.0 || r <= 0.0 {
return (x, y);
}
let k = sphere_1d(r, a) / r;
(cx + u * k * hx, cy + v * k * hy)
let rs = rr * sphere_1d(r / rr, a);
(cx + dx * rs / r, cy + dy * rs / r)
}
SpherizeMode::HorizontalOnly => {
if dx.abs() >= hx {
+5 -2
View File
@@ -45,9 +45,12 @@ pub const PATCH_RADIUS: usize = 15;
struct Rng(u64);
impl Rng {
/// The next value of the SplitMix64 sequence.
fn next(&mut self) -> u64 {
crate::photo_util::splitmix64(&mut self.0)
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)
}
fn unit(&mut self) -> f64 {
(self.next() >> 11) as f64 / (1u64 << 53) as f64
+5 -3
View File
@@ -65,13 +65,15 @@ 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 {
crate::photo_util::splitmix64(&mut self.0)
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)
}
/// Skip `n` numbers (the same state as after `n` calls of [`Rng::next`]).
pub(crate) fn skip(&mut self, n: u64) {
+3 -24
View File
@@ -482,8 +482,7 @@ pub enum FilterParams {
distribution: Distribution,
monochromatic: bool,
seed: u32,
/// Depth for [`DepthSource::LayerMask`] and [`DepthSource::Channel`] (channel 0, document
/// coordinates), supplied by the caller.
/// Depth for [`DepthSource::LayerMask`] (channel 0, document coordinates), supplied by the caller.
#[serde(skip)]
depth_map: Option<std::sync::Arc<Image>>,
},
@@ -745,8 +744,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 } => min_max_ink(src, out, ctx, *radius, false, *preserve),
FilterParams::Maximum { radius, preserve } => min_max_ink(src, out, ctx, *radius, true, *preserve),
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::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),
@@ -910,23 +909,6 @@ 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;
@@ -990,7 +972,6 @@ 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,
@@ -1033,8 +1014,6 @@ 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;
}
+3 -7
View File
@@ -8,8 +8,7 @@ use crate::image::Image;
use crate::{Ctx, HsbModel};
/// Custom: `Σ kernel·pixel / scale + offset/255` per colour channel (alpha
/// kept). Kernels shorter than 25 entries are zero-padded. CMYK samples are
/// ink, so there it runs on `1 - ink` and a positive offset brightens.
/// kept). Kernels shorter than 25 entries are zero-padded.
pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f32, offset: f32) -> Vec<f32> {
let n = src.ch;
let cc = ncol(ctx, n);
@@ -17,7 +16,6 @@ pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f
(0..25).filter_map(|i| kernel.get(i).copied().filter(|v| *v != 0.0).map(|v| (i as i32 % 5 - 2, i as i32 / 5 - 2, v))).collect();
let s = if scale == 0.0 { 1.0 } else { scale };
let off = offset / 255.0;
let sub = crate::fxutil::subtractive(ctx);
let mut res = src.crop(out);
if k.is_empty() {
return res;
@@ -25,10 +23,8 @@ pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f
for (i, px) in res.chunks_exact_mut(n).enumerate() {
let (x, y) = xy(out, i);
for (c, v) in px.iter_mut().enumerate().take(cc) {
// CMYK: convolve brightness (1 - ink), then back to ink.
let lum = |v: f32| if sub { 1.0 - v } else { v };
let sum: f32 = k.iter().map(|&(dx, dy, w)| lum(src.get(x + dx, y + dy, c)) * w).sum();
*v = lum(sum / s + off);
let sum: f32 = k.iter().map(|&(dx, dy, w)| src.get(x + dx, y + dy, c) * w).sum();
*v = sum / s + off;
}
}
res
+4 -17
View File
@@ -89,7 +89,10 @@ 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 o = if lock_transparency { blend::composite_atop(blend_mode, d, color(x, y), k) } else { blend::composite(blend_mode, d, color(x, y), k) };
let mut o = blend::composite(blend_mode, d, color(x, y), k);
if lock_transparency {
o[3] = d[3];
}
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
@@ -293,22 +296,6 @@ 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);
+45 -111
View File
@@ -1170,28 +1170,10 @@ 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);
@@ -1205,22 +1187,13 @@ fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>]
let mut wsum: Vec<Vec<f32>> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh]).collect();
let margin = align * 4;
// Each image on its ROI grown by a margin and aligned to the coarsest level's grid.
let grid = |im: &RoiImage| {
let bands = par_map(imgs.len(), |k| {
let im = &imgs[k];
let gx0 = ((im.x0 - margin).max(0) / align) * align;
let gy0 = ((im.y0 - margin).max(0) / align) * align;
let gx1 = (im.x0 + im.w as i32 + margin).min(w as i32);
let gy1 = (im.y0 + im.h as i32 + margin).min(h as i32);
(gx0, gy0, (gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize)
};
// Building one band holds its grid's colour and weight planes about three times over: the
// pyramids are 4/3 of a plane, and the grid copy, coverage and `expand` scratch come on top.
let cost = |im: &RoiImage| {
let (_, _, gw, gh) = grid(im);
gw.saturating_mul(gh).saturating_mul(ch + 1).saturating_mul(4 * 3)
};
let band = |k: usize| -> PyramidBand {
let im = &imgs[k];
let (gx0, gy0, gw, gh) = grid(im);
let (gw, gh) = ((gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize);
// 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> {
@@ -1252,85 +1225,68 @@ fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>]
}
});
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);
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.
// Laplacian of the colours, Gaussian of the weights.
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));
gau.push(cwt);
lap.push((cw, chh, cur.clone()));
gau.push(cwt.clone());
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)
};
// 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;
});
// 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];
}
}
});
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;
@@ -1954,28 +1910,6 @@ 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]
-3
View File
@@ -182,9 +182,6 @@ 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.
+14 -47
View File
@@ -159,21 +159,15 @@ 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 {
splitmix64(&mut self.0)
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)
}
pub fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
@@ -189,7 +183,15 @@ pub(crate) fn hash01(x: i64, y: i64, seed: u64) -> f32 {
(z >> 40) as f32 / (1u64 << 24) as f32
}
pub(crate) use photocraft_color::convert::{linear_to_srgb, srgb_to_linear};
/// 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 }
}
/// 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 {
@@ -215,41 +217,6 @@ 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.
-77
View File
@@ -562,46 +562,6 @@ 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::*;
@@ -762,41 +722,4 @@ 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]);
}
}
+2 -3
View File
@@ -146,9 +146,8 @@ pub(crate) fn lens_flare(src: &Image, out: Rect, ctx: &Ctx, brightness: f32, cen
let mut c = rgba(ctx, px);
for k in 0..3 {
let a = (f[k] * bright).max(0.0);
// Screen (c + a·(1 − c)) keeps display-referred values below white; a 32-bit
// value already above white is left as is rather than pulled down towards it.
c[k] += a.min(1.0) * (1.0 - c[k]).max(0.0);
// Screen keeps highlights below white for display-referred values.
c[k] = 1.0 - (1.0 - c[k]) * (1.0 - a.min(1.0));
}
set_rgba(ctx, px, c);
}
+5 -3
View File
@@ -274,13 +274,15 @@ 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 {
crate::photo_util::splitmix64(&mut self.0)
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)
}
/// Uniform in [0, 1).
pub fn f(&mut self) -> f32 {
+12 -29
View File
@@ -124,13 +124,12 @@ 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. Rows are independent, so
// both passes run them in parallel: a 2500 px Blur dab is ~10⁹ multiply-adds (#5426).
// Horizontal pass over the window rows extended vertically by r.
let th = oh + 2 * r as usize;
let stride = ow * ch;
let mut tmp = vec![0.0f32; stride * th];
let scratch = || vec![0.0f32; (ow + 2 * r as usize) * ch];
let horizontal = |row: &mut Vec<f32>, (ty, dst): (usize, &mut [f32])| {
let mut row = vec![0.0f32; (ow + 2 * r as usize) * ch];
for ty in 0..th {
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() {
@@ -146,6 +145,7 @@ 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,43 +153,26 @@ 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];
let vertical = |(oy, dst): (usize, &mut [f32])| {
for oy in 0..oh {
let dst = &mut out[oy * stride..(oy + 1) * stride];
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;
}
}
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() {
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 c != a {
*v = if av > 1e-6 { *v / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] };
out[o + c] = if av > 1e-6 { out[o + c] / 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
}
+2 -6
View File
@@ -126,9 +126,7 @@ 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.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);
let low = super::clean_mask(&low, lw, lh, 0.1, 0.002);
super::finish_region(sampler, window, step, &low, lw, lh, Some((&fg, &bg)))
}
@@ -211,9 +209,7 @@ 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.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);
let low = super::clean_mask(&low, w, h, 0.1, 0.01);
if !low.iter().any(|v| *v) {
return None;
}
-58
View File
@@ -912,61 +912,3 @@ 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");
}
+8 -60
View File
@@ -69,23 +69,6 @@ fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 {
h / n.log2()
}
/// `x` to the power `k` by repeated squaring. Every step is one IEEE 754 multiplication, so the
/// result has the same bits on every target. On Windows (MSVC), `f32::powi` with an exponent the
/// compiler cannot see as a constant calls the C runtime's `powf`, which rounds differently.
fn ipow(x: f32, k: u32) -> f32 {
let (mut base, mut k, mut acc) = (x, k, 1.0f32);
loop {
if k & 1 != 0 {
acc *= base;
}
k >>= 1;
if k == 0 {
return acc;
}
base *= base;
}
}
fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 {
let n = v.len() as f32;
if v.is_empty() {
@@ -114,7 +97,7 @@ fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 {
_ => {}
}
let mean = v.iter().sum::<f32>() / n;
let m = |k: u32| v.iter().map(|x| ipow(x - mean, k)).sum::<f32>() / n;
let m = |k: i32| v.iter().map(|x| (x - mean).powi(k)).sum::<f32>() / n;
match s {
Stat::Mean => mean,
Stat::Variance => m(2),
@@ -122,7 +105,7 @@ fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 {
Stat::Skewness => {
let sd = m(2).sqrt();
// Map −2..2 to 0..1 (0.5 = symmetric).
if sd < 1e-6 { 0.5 } else { (m(3) / ipow(sd, 3) / 4.0 + 0.5).clamp(0.0, 1.0) }
if sd < 1e-6 { 0.5 } else { (m(3) / sd.powi(3) / 4.0 + 0.5).clamp(0.0, 1.0) }
}
Stat::Kurtosis => {
let var = m(2);
@@ -141,27 +124,6 @@ const CHUNK: usize = 4096;
/// Combines `layers` (each `len` pixels) with statistic `s`. Colour results retain their float
/// range; quantisation/clipping belongs to the destination surface, not the statistic.
pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> {
// Pick the statistic once, outside the pixel loop: each arm passes `stat` a constant mode, so
// the compiler builds one loop per statistic with the `match` folded away. Matching on `s`
// three times per pixel inside one shared loop made one thread 5–15% slower than the old
// serial loop (Mean, Variance, StdDev, Skewness, Kurtosis); this alone cuts Mean's
// instructions by a fifth.
match s {
Stat::Entropy => combine_with(layers, |v, sc| stat(v, Stat::Entropy, sc)),
Stat::Kurtosis => combine_with(layers, |v, sc| stat(v, Stat::Kurtosis, sc)),
Stat::Maximum => combine_with(layers, |v, sc| stat(v, Stat::Maximum, sc)),
Stat::Mean => combine_with(layers, |v, sc| stat(v, Stat::Mean, sc)),
Stat::Median => combine_with(layers, |v, sc| stat(v, Stat::Median, sc)),
Stat::Minimum => combine_with(layers, |v, sc| stat(v, Stat::Minimum, sc)),
Stat::Range => combine_with(layers, |v, sc| stat(v, Stat::Range, sc)),
Stat::Skewness => combine_with(layers, |v, sc| stat(v, Stat::Skewness, sc)),
Stat::StandardDeviation => combine_with(layers, |v, sc| stat(v, Stat::StandardDeviation, sc)),
Stat::Summation => combine_with(layers, |v, sc| stat(v, Stat::Summation, sc)),
Stat::Variance => combine_with(layers, |v, sc| stat(v, Stat::Variance, sc)),
}
}
fn combine_with(layers: &[Vec<[f32; 4]>], f: impl Fn(&mut [f32], &mut EntropyScratch) -> f32 + Sync + Send) -> Vec<[f32; 4]> {
let len = layers.iter().map(Vec::len).max().unwrap_or(0);
let mut out = vec![[0.0f32; 4]; len];
// Every output pixel depends only on the input pixels at the same index, so chunks of the
@@ -171,24 +133,22 @@ fn combine_with(layers: &[Vec<[f32; 4]>], f: impl Fn(&mut [f32], &mut EntropyScr
crate::photo_util::par_rows(&mut out, CHUNK, 1, |chunk, outs| {
// Scratch is per chunk, not per pixel: the old loop's three `Vec`s were already reused
// across pixels, so keep that, and give each task its own set.
// Sized to the layer count up front and filled by index (`k` samples per pixel): a
// `push` per sample re-checked the capacity and stored the length every time; indexing
// cuts the loop's instructions by another third.
let mut samples: [Vec<f32>; 3] = [0, 1, 2].map(|_| vec![0.0; layers.len()]);
let mut samples: [Vec<f32>; 3] = [0, 1, 2].map(|_| Vec::with_capacity(layers.len()));
let mut scratch = EntropyScratch::new();
let base = chunk * CHUNK;
for (j, o) in outs.iter_mut().enumerate() {
let i = base + j;
let mut k = 0;
for v in samples.iter_mut() {
v.clear();
}
let mut alpha = 0.0f32;
for l in layers {
if let Some(p) = l.get(i)
&& p[3] > 0.0
{
for c in 0..3 {
samples[c][k] = p[c];
samples[c].push(p[c]);
}
k += 1;
alpha = alpha.max(p[3]);
}
}
@@ -196,7 +156,7 @@ fn combine_with(layers: &[Vec<[f32; 4]>], f: impl Fn(&mut [f32], &mut EntropyScr
continue;
}
for c in 0..3 {
o[c] = f(&mut samples[c][..k], &mut scratch);
o[c] = stat(&mut samples[c], s, &mut scratch);
}
o[3] = alpha;
}
@@ -497,16 +457,4 @@ mod tests {
fn no_layers_gives_an_empty_result() {
assert!(combine(&[], Stat::Median).is_empty());
}
/// [`ipow`] multiplies in the order `x·x²` for the cube and `x²·x²` for the fourth power.
#[test]
fn ipow_squares_and_multiplies() {
for x in [0.58f32, -0.12, -2.6, 2.4, -0.6, 1.0e-3, 0.0] {
assert_eq!(ipow(x, 0).to_bits(), 1.0f32.to_bits());
assert_eq!(ipow(x, 1).to_bits(), x.to_bits());
assert_eq!(ipow(x, 2).to_bits(), (x * x).to_bits());
assert_eq!(ipow(x, 3).to_bits(), (x * (x * x)).to_bits());
assert_eq!(ipow(x, 4).to_bits(), ((x * x) * (x * x)).to_bits());
}
}
}
-65
View File
@@ -90,27 +90,6 @@ 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, &params, area, area, None, 1, Some(area));
for tile in [0, -64, i32::MIN] {
let actual = apply_tiled(&source, &params, 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, &params, 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, &params, 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);
@@ -269,32 +248,6 @@ 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.
@@ -461,24 +414,6 @@ fn twirl_keeps_centre_and_outside() {
assert_ne!(out.pixel(20, 12), s.pixel(20, 12));
}
/// #5643: on a wide image Twirl, Pinch and Spherize fill the ellipse inscribed in the bounds, as
/// Photoshop's do, not just a circle fitted to the height: a point near the left and right edges
/// on the middle row changes, the corners don't, and a square still uses its inscribed circle.
#[test]
fn round_distortions_fill_the_inscribed_ellipse() {
let b = Rect::new(0, 0, 80, 40);
let s = pattern(SampleType::F32, b);
for p in [FilterParams::Twirl { angle: 180.0 }, FilterParams::Pinch { amount: 80.0 }, FilterParams::Spherize { amount: 80.0, mode: SpherizeMode::Normal }] {
let out = apply(&s, &p, b, b, None);
// 30 px left of centre on the middle row: outside the old 20 px circle, inside the ellipse.
assert_ne!(out.pixel(10, 21), s.pixel(10, 21), "{p:?}");
assert_ne!(out.pixel(69, 18), s.pixel(69, 18), "{p:?}");
for (x, y) in [(0, 0), (79, 0), (0, 39), (79, 39)] {
assert_eq!(out.pixel(x, y), s.pixel(x, y), "{p:?}: corner ({x}, {y}) is outside the ellipse");
}
}
}
#[test]
fn motion_blur_spreads_along_angle_only() {
let mut s = flat(SampleType::F32, R, [0.0, 0.0, 0.0, 1.0]);
-51
View File
@@ -435,35 +435,6 @@ fn lens_flare_is_brightest_at_its_centre() {
assert!(out.pixel(38, 2)[0] >= 0.2 - 1e-6, "screen never darkens");
}
#[test]
fn lens_flare_never_darkens_32_bit_values_above_one() {
// #5785: the screen formula turned 2.0 into 1.5 (and 1.0 at the core).
let p = FilterParams::LensFlare { brightness: 150.0, center_x: 0.25, center_y: 0.5, lens: LensType::Zoom };
let out = run(&flat(SampleType::F32, R, [2.0, 2.0, 2.0, 1.0]), &p);
for px in out.read_region(R).as_chunks::<4>().0 {
assert!(px[..3].iter().all(|&v| v >= 2.0), "{px:?}");
}
}
#[test]
fn lens_flare_8_bit_result_is_the_screen_blend() {
// The flare amount per pixel, read from a black 32-bit image (screen over 0 is the amount).
let p = FilterParams::LensFlare { brightness: 150.0, center_x: 0.25, center_y: 0.5, lens: LensType::Zoom };
let amount = run(&flat(SampleType::F32, R, [0.0, 0.0, 0.0, 1.0]), &p).read_region(R);
let src = pattern(SampleType::U8, R);
let base = src.read_region(R);
let expected: Vec<f32> = base
.as_chunks::<4>()
.0
.iter()
.zip(amount.as_chunks::<4>().0)
.flat_map(|(c, a)| [0, 1, 2].map(|k| 1.0 - (1.0 - c[k]) * (1.0 - a[k].min(1.0))).into_iter().chain([c[3]]))
.collect();
let mut want = Surface::new(fmt(SampleType::U8));
want.write_region(R, &expected);
assert_eq!(run(&src, &p).read_region(R), want.read_region(R));
}
#[test]
fn lighting_point_light_falls_off() {
let s = flat(SampleType::F32, R, [0.8, 0.8, 0.8, 1.0]);
@@ -637,28 +608,6 @@ fn custom_kernel_shifts_and_offsets() {
assert!((off.pixel(5, 5)[0] - 0.4).abs() < 1e-5);
}
/// CMYK samples are ink: Custom's positive offset brightens (removes ink), as in Photoshop
/// (identity, scale 1, offset +50: white stays white, C=100% becomes C≈80%). Alpha is untouched.
/// Photoshop's CMYK depths; float is unclamped on both polarities (RGB white + offset exceeds 1).
#[test]
fn custom_offset_brightens_cmyk() {
let mut id = vec![0.0; 25];
id[12] = 1.0;
let p = FilterParams::Custom { kernel: id, scale: 1.0, offset: 50.0 };
for depth in [SampleType::U8, SampleType::U16] {
let f = PixelFormat::new(ColorMode::Cmyk, depth, true);
let mut s = Surface::new(f);
s.fill_rect(R, &[0.0, 0.0, 0.0, 0.0, 0.6]);
let w = run(&s, &p).pixel(5, 5);
assert!(w[..4].iter().all(|v| v.abs() < 1e-6) && (w[4] - 0.6).abs() < 0.01, "{depth:?} white: {w:?}");
let mut s = Surface::new(f);
s.fill_rect(R, &[1.0, 0.5, 0.0, 0.0, 1.0]);
let c = run(&s, &p).pixel(5, 5);
let want = [1.0 - 50.0 / 255.0, 0.5 - 50.0 / 255.0, 0.0, 0.0, 1.0];
assert!(c.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{depth:?} C100: {c:?}");
}
}
#[test]
fn hsb_filter_encodes_and_decodes() {
let s = flat(SampleType::F32, R, [1.0, 0.0, 0.0, 1.0]);
-51
View File
@@ -65,35 +65,6 @@ 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) {
@@ -306,28 +277,6 @@ 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| {
+5 -74
View File
@@ -231,37 +231,14 @@ pub fn quad_to_rect(q: &[[f64; 2]; 4], w: f64, h: f64) -> Option<Homography> {
/// The destination rectangle [`warp_surface`] fills for `src_rect` under `h`: the warped corners
/// plus a pixel for filter support, each side clamped to ±2²⁰. `None` when a corner maps to
/// infinity (the result is then empty). Use [`warp_output_size`] to price the full result before
/// admitting a transform: clipping these bounds must not hide an excessive scale.
/// infinity (the result is then empty). Lets a caller price a warp before allocating it.
pub fn warp_output_bounds(src_rect: Rect, h: &Homography) -> Option<Rect> {
corner_bounds(&warp_corners(src_rect, h)?)
}
/// Unclipped output dimensions, including the same filter support as [`warp_output_bounds`].
/// Position does not affect the price, so translating a small layer far away stays cheap.
/// Unrepresentable or non-finite dimensions saturate at `u64::MAX`, allowing a caller to refuse
/// the warp with a saturating pixel-budget comparison. Empty and singular inputs have no output.
pub fn warp_output_size(src_rect: Rect, h: &Homography) -> Option<(u64, u64)> {
let corners = warp_corners(src_rect, h)?;
if !corners.iter().all(|(x, y)| x.is_finite() && y.is_finite()) {
return Some((u64::MAX, u64::MAX));
}
let x0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor();
let y0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor();
let x1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil();
let y1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil();
// Float-to-integer casts saturate, including when the finite endpoints' difference overflows.
Some((((x1 - x0) as u64).saturating_add(2), ((y1 - y0) as u64).saturating_add(2)))
}
fn warp_corners(src_rect: Rect, h: &Homography) -> Option<[(f64, f64); 4]> {
if src_rect.is_empty() || h.inverse().is_none() {
return None;
}
Some(
[(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)]
.map(|(x, y)| h.apply(x as f64, y as f64)),
)
let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)]
.map(|(x, y)| h.apply(x as f64, y as f64));
corner_bounds(&corners)
}
fn corner_bounds(corners: &[(f64, f64); 4]) -> Option<Rect> {
@@ -333,12 +310,7 @@ 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).
// 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 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]));
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.
@@ -538,31 +510,6 @@ fn warp(
mod tests {
use super::*;
#[test]
fn output_size_prices_the_unclipped_extent_independently_of_position() {
let src = Rect::new(0, 0, 24, 16);
for dx in [0.0, 900_000.0, 10_000_000.0] {
let h = Homography([1.0, 0.0, dx, 0.0, 1.0, -dx, 0.0, 0.0, 1.0]);
assert_eq!(warp_output_size(src, &h), Some((26, 18)));
}
for sx in [1e7, -1e7] {
let h = Homography([sx, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
assert_eq!(warp_output_size(src, &h), Some((240_000_002, 18)));
}
}
#[test]
fn output_size_saturates_overflows_and_preserves_filter_support() {
let src = Rect::new(0, 0, 24, 16);
let fractional = Homography([0.5, 0.0, 0.25, 0.0, 1.0, 0.75, 0.0, 0.0, 1.0]);
let bounds = warp_output_bounds(src, &fractional).unwrap();
assert_eq!(warp_output_size(src, &fractional), Some((u64::from(bounds.width()), u64::from(bounds.height()))));
let huge = Homography([1e308, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
assert_eq!(warp_output_size(src, &huge), Some((u64::MAX, u64::MAX)));
assert_eq!(warp_output_size(Rect::EMPTY, &Homography::IDENTITY), None);
assert_eq!(warp_output_size(src, &Homography([0.0; 9])), None);
}
#[test]
fn the_interruptible_warp_reports_progress_and_stops_when_cancelled() {
let mut s = Surface::new(PixelFormat::RGBA8);
@@ -718,22 +665,6 @@ 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();
+10 -50
View File
@@ -14,9 +14,8 @@
//! later (lower-right) cell wins. Work is split per destination tile (parallel on native).
use photocraft_color::PixelFormat;
use photocraft_geom::{Rect, TileCoord};
use photocraft_raster::{Surface, Tile};
use std::sync::Arc;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use crate::transform::{Interp, bicubic};
@@ -208,7 +207,7 @@ 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<(TileCoord, Arc<Tile>)> {
let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(Rect, Vec<f32>)> {
if tris.is_empty() {
return None;
}
@@ -297,11 +296,6 @@ 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];
@@ -319,17 +313,6 @@ 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) {
@@ -344,21 +327,6 @@ 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;
@@ -410,27 +378,19 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
}
outp[b + a] = al as f32;
}
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()
any.then_some((*t, outp))
};
#[cfg(not(target_arch = "wasm32"))]
let done: Vec<(TileCoord, Arc<Tile>)> = {
let done: Vec<(Rect, Vec<f32>)> = {
use rayon::prelude::*;
tiles.par_iter().zip(bins.par_iter()).filter_map(work).collect()
};
#[cfg(target_arch = "wasm32")]
let done: Vec<(TileCoord, Arc<Tile>)> = tiles.iter().zip(bins.iter()).filter_map(work).collect();
out.put_tiles(done);
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);
}
out.prune();
out
}
-4
View File
@@ -30,7 +30,3 @@ 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"] }
+2 -4
View File
@@ -19,8 +19,7 @@ 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)) {
let document = photocraft_format::load_from_bytes(bytes)?;
return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false });
return Ok(Opened { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), 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 })
@@ -30,8 +29,7 @@ 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() {
let document = photocraft_format::load_path(path)?;
return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false });
return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new(), 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();
+83 -89
View File
@@ -72,22 +72,6 @@ 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()))?;
@@ -129,25 +113,27 @@ 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> {
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 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()));
}
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))?)
})
};
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))?)
}
/// Export each exported layer of a document as its own file beneath the write root: the
@@ -157,23 +143,25 @@ 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> {
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)
})
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)
}
/// Export each data set of a document as its own flattened file beneath the write root: the
@@ -181,23 +169,25 @@ 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> {
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)
})
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)
}
/// Import a data set CSV from the read root: the capability-scoped
@@ -205,22 +195,24 @@ 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> {
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));
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}")))?
}
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)?)
})
_ => 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)?)
}
/// Save (`.pcraft` or any export format by extension). With no path,
@@ -274,12 +266,14 @@ 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.
self.on_doc(Some(i), |this| {
if let Some(st) = this.session.active_mut() {
st.saved_to(target.to_string_lossy().into_owned());
}
Ok(())
})?;
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);
}
}
Ok(json!({ "path": target.to_string_lossy(), "warnings": warnings }))
}
+4 -71
View File
@@ -114,20 +114,10 @@ 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()))?;
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;
}
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;
}
let path = str_of(&p, "path").map(PathBuf::from);
self.save(index_of(&p)?, path.as_deref(), str_of(&p, "format"), &opts)
@@ -586,26 +576,6 @@ 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();
@@ -786,43 +756,6 @@ 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();
+4 -54
View File
@@ -97,15 +97,11 @@ 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.to_ascii_lowercase() ^ b.to_ascii_lowercase())) == 0
expected.bytes().zip(supplied.bytes()).fold(0u8, |different, (a, b)| different | (a ^ b)) == 0
}
/// Validate the first TCP frame without exposing any control method before authentication.
@@ -372,45 +368,6 @@ 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();
@@ -480,23 +437,16 @@ 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 || {
rx.recv().unwrap();
std::thread::sleep((deadline + Duration::from_millis(300)).saturating_duration_since(Instant::now()));
client.write_all(b"one\n").unwrap();
std::thread::sleep(Duration::from_millis(500));
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");
+3 -25
View File
@@ -170,13 +170,6 @@ 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>,
}
@@ -1008,18 +1001,10 @@ 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), the Targa bits and the DDS options, so the other options are headless-only. Saying so beats
// does) and the Targa bits, 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"),
@@ -1041,22 +1026,15 @@ 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;
opts.encode.webp_quality = q;
opts.encode.jpeg_quality = q.clamp(1, 100);
opts.encode.webp_quality = q.clamp(1, 100);
opts.encode.webp_lossless = false;
}
let path = p.path.map(PathBuf::from);
+2 -45
View File
@@ -74,13 +74,6 @@ 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.
@@ -92,20 +85,10 @@ 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(|file| {
let mut file = file.into_std();
let written = root.dir.open_with(&tmp, &options).and_then(|mut file| {
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)
photocraft_format::atomic::sync_file(&file.into_std())
});
let renamed = written.and_then(|()| photocraft_format::atomic::retry_rename(RenameRetry::platform(), || root.dir.rename(&tmp, &root.dir, &relative)));
if let Err(e) = renamed {
@@ -589,32 +572,6 @@ 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() {
+3 -4
View File
@@ -4,15 +4,14 @@
use photocraft_automation::Headless;
use serde_json::json;
/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0). Krita writes a raw tile
/// (flag 0) as plain interleaved pixels; only LZF tiles are split into byte planes.
/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0).
fn tiles(bgra: [u8; 4]) -> Vec<u8> {
let n = 64 * 64;
let mut out = b"VERSION 2\nTILEWIDTH 64\nTILEHEIGHT 64\nPIXELSIZE 4\nDATA 1\n".to_vec();
out.extend(format!("0,0,LZF,{}\n", 1 + n * 4).into_bytes());
out.push(0);
for _ in 0..n {
out.extend(bgra);
for b in bgra {
out.extend(std::iter::repeat_n(b, n));
}
out
}
-43
View File
@@ -318,10 +318,6 @@ 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();
@@ -419,45 +415,6 @@ 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};
-7
View File
@@ -20,9 +20,6 @@ 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
@@ -39,10 +36,6 @@ 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
-131
View File
@@ -1,131 +0,0 @@
//! 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());
}
}
-3
View File
@@ -8,7 +8,6 @@
//! * [`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
@@ -20,7 +19,6 @@ 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;
@@ -34,7 +32,6 @@ 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};
+3 -28
View File
@@ -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, GpuMatrixShaper, Intent};
use crate::{CmsError, 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 = GpuMatrixShaper::INPUT_INTERVALS;
const TABLE_1D: usize = 4096;
/// Resolution of output shaper tables (indexed by `x^(1/4)`).
const OUT_TABLE: usize = GpuMatrixShaper::OUTPUT_ENTRIES;
const OUT_TABLE: usize = 4096;
/// 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,31 +226,6 @@ 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]) {
-197
View File
@@ -1,197 +0,0 @@
//! 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());
}
}
+2 -10
View File
@@ -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 | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | yes (BC1–BC3, when `EncodeOptions::dds` asks for one) | built in |
| DDS | yes | **no** | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | n/a | 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` |
@@ -73,15 +73,7 @@ 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 (`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.
(BC4–BC7, float) are refused with `CodecError::Unsupported`. Writing is not provided.
* **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`.
+35 -276
View File
@@ -1,24 +1,13 @@
//! 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.
//! 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.
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::{DdsCompression, EncodeOptions, Limits};
use crate::options::Limits;
/// `DDS ` plus the 124-byte DDS_HEADER.
const HEADER: usize = 128;
@@ -235,12 +224,27 @@ 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 palette = dds_bc::colour_palette(c0, c1, c0 > c1 || !one_bit_alpha);
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 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])
}
@@ -255,9 +259,21 @@ 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 palette = dds_bc::alpha_palette(byte(0), byte(1));
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 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])
std::array::from_fn(|t| palette[((bits >> (3 * t)) & 7) as usize] as u8)
}
fn with_alpha(mut texels: [[u8; 4]; 16], alpha: [u8; 16]) -> [[u8; 4]; 16] {
@@ -266,260 +282,3 @@ 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)
}
-368
View File
@@ -1,368 +0,0 @@
//! 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]);
}
}
+15 -4
View File
@@ -15,7 +15,7 @@ use half::f16;
use crate::Format;
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::image::{ChannelLayout, DecodeWarning, ExrChannelInfo, ExrPartInfo, Image, SampleType, base_channel_name, exr_color_channels};
use crate::image::{ChannelLayout, DecodeWarning, ExrChannelInfo, ExrPartInfo, Image, SampleType, base_channel_name};
use crate::options::{EncodeOptions, ExrCompression, Limits};
const F: Format = Format::OpenExr;
@@ -123,9 +123,20 @@ pub(super) fn read_layer(bytes: &[u8], index: usize) -> Result<Layer<AnyChannels
fn layer_to_image(layer: &Layer<AnyChannels<FlatSamples>>) -> Result<Image, CodecError> {
let (w, h) = (layer.size.0, layer.size.1);
let channels = &layer.channel_data.list;
let names: Vec<_> = channels.iter().map(|c| c.name.to_string()).collect();
let (layout, selected) = exr_color_channels(names.iter().map(String::as_str)).ok_or_else(|| CodecError::unsupported(F, "no R/G/B or Y channels"))?;
let color: Vec<_> = selected.iter().map(|i| channels.get(*i).ok_or_else(|| err("selected channel is missing"))).collect::<Result<_, _>>()?;
let find = |n: &str| channels.iter().find(|c| base_channel_name(&c.name.to_string()) == n);
let (color, layout): (Vec<_>, ChannelLayout) = if let (Some(r), Some(g), Some(b)) = (find("R"), find("G"), find("B")) {
match find("A") {
Some(a) => (vec![r, g, b, a], ChannelLayout::Rgba),
None => (vec![r, g, b], ChannelLayout::Rgb),
}
} else if let Some(y) = find("Y").or_else(|| (channels.len() == 1).then(|| &channels[0])) {
match find("A") {
Some(a) => (vec![y, a], ChannelLayout::GrayA),
None => (vec![y], ChannelLayout::Gray),
}
} else {
return Err(CodecError::unsupported(F, "no R/G/B or Y channels"));
};
let all_f16 = color.iter().all(|c| matches!(c.sample_data, FlatSamples::F16(_)));
let sample = if all_f16 { SampleType::F16 } else { SampleType::F32 };
let npx = w * h;
-126
View File
@@ -1,126 +0,0 @@
//! 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]);
}
}
}
+1 -4
View File
@@ -33,8 +33,6 @@ 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)>,
@@ -110,7 +108,6 @@ 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;
@@ -229,7 +226,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, photoshop_resources: meta.photoshop, ..Default::default() };
img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, ..Default::default() };
if end == DataEnd::Truncated {
img.warnings.push(DecodeWarning::Truncated { format: F });
}
-2
View File
@@ -1,12 +1,10 @@
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;
+24 -244
View File
@@ -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, PngStereo, alloc_zeroed};
use crate::options::{EncodeOptions, Limits, PngCompression, alloc_zeroed};
const F: Format = Format::Png;
pub(crate) const XMP_KEYWORD: &str = "XML:com.adobe.xmp";
@@ -127,38 +127,6 @@ 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()),
@@ -378,10 +346,6 @@ 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)?;
@@ -541,15 +505,8 @@ 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);
let start = out.len();
out.push(0);
out.extend_from_slice(row);
let mut best = (cost(row), 0u8);
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 {
@@ -558,17 +515,27 @@ 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;
}
}
if c < best {
best = c;
out[start] = kind;
out[start + 1..].copy_from_slice(&scratch[..n]);
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]);
}
const ADLER_MOD: u32 = 65_521;
@@ -607,7 +574,6 @@ 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()));
@@ -637,95 +603,12 @@ 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::*;
@@ -734,14 +617,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, None).unwrap();
let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), 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, None).is_err());
assert!(encode_indexed(3, 3, &idx, &pal, None, None, None).is_err());
assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None).is_err());
assert!(encode_indexed(3, 3, &idx, &pal, None, None).is_err());
}
fn png(color: png::ColorType, trns: Option<&[u8]>) -> Vec<u8> {
@@ -760,7 +643,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, None).unwrap();
let bytes = encode_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]], Some(1), 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 }));
@@ -776,109 +659,6 @@ 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();
+10 -36
View File
@@ -6,9 +6,7 @@
//! 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. 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`]).
//! Classic TIFF only on the reading side: an EXIF block has 4-byte offsets.
use tiff::encoder::{DirectoryEncoder, TiffEncoder, TiffKind};
use tiff::tags::{Tag, Type};
@@ -29,11 +27,6 @@ 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)]
@@ -60,22 +53,13 @@ impl ExifDirs {
}
}
/// 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 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()
}
fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option<Ifd> {
@@ -85,24 +69,14 @@ 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)?;
if f.big {
return None;
}
let ifd0 = f.ifd(f.first_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 })
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) })
}
/// Bytes per element whose order a byte-order change reverses: a RATIONAL is two LONGs.
+3 -6
View File
@@ -6,7 +6,7 @@ use std::fmt;
use crate::format::{Format, caps};
use crate::image::{ChannelLayout, Image, SampleType};
use crate::options::{DdsCompression, EncodeOptions};
use crate::options::EncodeOptions;
/// Something that will be lost or altered when writing an image to a format.
#[derive(Debug, Clone, PartialEq)]
@@ -165,8 +165,6 @@ 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) },
}
}
@@ -213,8 +211,7 @@ 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);
}
let binary_alpha = format == Format::Gif || (format == Format::Dds && opts.dds.compression == DdsCompression::Bc1);
if binary_alpha && p.layout.has_alpha() && image.has_translucency() {
if format == Format::Gif && p.layout.has_alpha() && image.has_translucency() {
w.push(W::AlphaBinarized);
}
if sl.is_cmyk() && !p.layout.is_cmyk() {
@@ -230,7 +227,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) || (format == Format::Dds && opts.dds.compression.is_block()) {
} else if c.lossy || (format == Format::WebP && !opts.webp_lossless) {
w.push(W::LossyCompression);
}
+6 -4
View File
@@ -19,8 +19,7 @@ pub enum Format {
Pcx,
/// X PixMap icons (XPM2 plain text and XPM3 C-string arrays).
Xpm,
/// DirectDraw Surface: a 2D texture, uncompressed or BC1–BC3. Reading takes the top mip
/// level; writing can add the full mip chain ([`crate::DdsOptions`]).
/// DirectDraw Surface: the top mip level of a 2D texture, uncompressed or BC1–BC3.
Dds,
Qoi,
OpenExr,
@@ -72,6 +71,10 @@ 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 \
@@ -252,8 +255,7 @@ 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 },
// Uncompressed by default; a block compression is lossy (see `fidelity_warnings_with`).
Format::Dds => FormatCaps { layouts: RGB_GRAY, ..base },
Format::Dds => FormatCaps { read: true, write: false, 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 },
+7 -62
View File
@@ -321,73 +321,18 @@ pub struct ExrPartInfo {
impl ExrPartInfo {
/// How much this part looks like a colour image the plain [`Image`] decode can show:
/// 4 RGBA, 3 RGB, 2 YA, 1 Y or any-single-channel, 0 anything else (e.g. `Z` beside `A`).
/// The colour and alpha channels must share a complete layer prefix. The highest-ranked
/// group wins; equal groups prefer unprefixed channels, then the first prefix alphabetically.
/// 4 RGBA, 3 RGB, 2 YA, 1 any-single-channel, 0 anything else (e.g. `Z` beside `A`).
/// The auto-pick takes the highest rank; ties go to the earlier part.
pub fn color_rank(&self) -> u8 {
exr_color_channels(self.channels.iter().map(|c| c.name.as_str())).map_or(0, |(layout, _)| layout.channels() as u8)
}
}
/// One selection policy for header ranking/probing and flat decoding: never synthesize
/// RGB(A) or YA from separate layers. OpenEXR layers use the full prefix before the last
/// dot, including nested layers; a leading or trailing dot alone does not name a layer.
pub(crate) fn exr_color_channels<'a>(names: impl IntoIterator<Item = &'a str>) -> Option<(ChannelLayout, Vec<usize>)> {
#[derive(Default)]
struct Group {
r: Option<usize>,
g: Option<usize>,
b: Option<usize>,
a: Option<usize>,
y: Option<usize>,
}
// Sorted prefixes make rank ties deterministic independently of channel order, with
// the empty (unprefixed) group first. Unsupported channels cannot complete a group.
let mut groups = std::collections::BTreeMap::<&str, Group>::new();
let mut count = 0usize;
for (index, name) in names.into_iter().enumerate() {
count = count.saturating_add(1);
let (prefix, base) = name.rsplit_once('.').filter(|(prefix, base)| !prefix.is_empty() && !base.is_empty()).unwrap_or(("", name));
if !matches!(base, "R" | "G" | "B" | "A" | "Y") {
continue;
let has = |n: &str| self.channels.iter().any(|c| base_channel_name(&c.name) == n);
if has("R") && has("G") && has("B") {
return if has("A") { 4 } else { 3 };
}
let group = groups.entry(prefix).or_default();
let component = match base {
"R" => &mut group.r,
"G" => &mut group.g,
"B" => &mut group.b,
"A" => &mut group.a,
"Y" => &mut group.y,
_ => continue,
};
component.get_or_insert(index);
}
let mut best = None;
let mut best_rank = 0;
for group in groups.values() {
let candidate = if let (Some(r), Some(g), Some(b)) = (group.r, group.g, group.b) {
match group.a {
Some(a) => (ChannelLayout::Rgba, vec![r, g, b, a]),
None => (ChannelLayout::Rgb, vec![r, g, b]),
}
} else if let Some(y) = group.y {
match group.a {
Some(a) => (ChannelLayout::GrayA, vec![y, a]),
None => (ChannelLayout::Gray, vec![y]),
}
} else {
continue;
};
let rank = candidate.0.channels();
if rank > best_rank {
best = Some(candidate);
best_rank = rank;
if has("Y") {
return if has("A") { 2 } else { 1 };
}
u8::from(self.channels.len() == 1)
}
best.or_else(|| (count == 1).then(|| (ChannelLayout::Gray, vec![0])))
}
/// Strip an optional `layer.` prefix from a channel name (`diffuse.R` → `R`).
+3 -11
View File
@@ -28,7 +28,7 @@ pub mod web;
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, stereo as png_stereo};
pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette};
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,9 +38,7 @@ 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::{
DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, PngStereo, TiffCompression,
};
pub use crate::options::{DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, 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};
@@ -128,12 +126,6 @@ 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)
@@ -260,7 +252,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 => dds::encode(image, plan, opts),
Format::Dds => Err(CodecError::unsupported(format, "DDS is import-only; save as PNG instead")),
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")),
-90
View File
@@ -100,34 +100,6 @@ pub enum PngCompression {
Best,
}
/// How a PNG's `sTER` chunk pairs its two views (PNG Extensions 1.4.0, §3.6).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PngStereo {
/// Left and right views side by side, left first.
Horizontal,
/// Left and right views stacked, left on top.
Vertical,
}
impl PngStereo {
/// The view for an `sTER` mode byte; reserved and unknown values are `None`.
pub fn from_mode(mode: u8) -> Option<Self> {
match mode {
0 => Some(Self::Horizontal),
1 => Some(Self::Vertical),
_ => None,
}
}
/// The `sTER` mode byte.
pub fn mode(self) -> u8 {
match self {
Self::Horizontal => 0,
Self::Vertical => 1,
}
}
}
/// TIFF strip compression. The default is none, as in Photoshop: uncompressed strips are the
/// only kind every TIFF reader opens (Deflate and the predictor are not baseline TIFF, #4484).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -182,61 +154,6 @@ impl DpxBits {
}
}
/// How a DDS texture stores its pixels.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DdsCompression {
/// Uncompressed, 8 bits per channel: BGRA (BGRX without alpha), or luminance with or
/// without alpha for grayscale. Lossless.
#[default]
None,
/// BC1 (DXT1): 4 bits per pixel. Alpha is on or off: texels under half opacity become
/// transparent black.
Bc1,
/// BC2 (DXT3): 8 bits per pixel, with 4 bits of explicit alpha per texel.
Bc2,
/// BC3 (DXT5): 8 bits per pixel, with smooth interpolated alpha.
Bc3,
}
impl DdsCompression {
pub const ALL: [DdsCompression; 4] = [DdsCompression::None, DdsCompression::Bc1, DdsCompression::Bc2, DdsCompression::Bc3];
/// The name scripts and the CLI use: `none`, `bc1`, `bc2` or `bc3`.
pub fn name(self) -> &'static str {
match self {
DdsCompression::None => "none",
DdsCompression::Bc1 => "bc1",
DdsCompression::Bc2 => "bc2",
DdsCompression::Bc3 => "bc3",
}
}
/// [`Self::name`] back to a compression, also taking the DXT names (`dxt1`, `dxt3`,
/// `dxt5`), in any case. Anything else is `None`.
pub fn from_name(name: &str) -> Option<Self> {
match name.to_ascii_lowercase().as_str() {
"none" => Some(DdsCompression::None),
"bc1" | "dxt1" => Some(DdsCompression::Bc1),
"bc2" | "dxt3" => Some(DdsCompression::Bc2),
"bc3" | "dxt5" => Some(DdsCompression::Bc3),
_ => None,
}
}
/// A block compression (lossy), not uncompressed pixels.
pub fn is_block(self) -> bool {
self != DdsCompression::None
}
}
/// What a DDS file is written with.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct DdsOptions {
pub compression: DdsCompression,
/// Write the full mip chain down to 1×1, each level a box filter of the one above.
pub mipmaps: bool,
}
/// Options for [`crate::encode`].
#[derive(Debug, Clone)]
pub struct EncodeOptions {
@@ -247,9 +164,6 @@ pub struct EncodeOptions {
pub png_compression: PngCompression,
/// Write Adam7-interlaced PNG.
pub png_interlaced: bool,
/// Write an `sTER` chunk marking the image as a stereo pair (PNG Extensions 1.4.0, §3.6),
/// so a stereo PNG keeps its mark when saved again (#6063).
pub png_stereo: Option<PngStereo>,
/// WebP: lossless (VP8L, via `image-webp`) or lossy (our VP8 encoder, see
/// `codecs::vp8`) with [`Self::webp_quality`].
pub webp_lossless: bool,
@@ -262,8 +176,6 @@ pub struct EncodeOptions {
pub exr_compression: ExrCompression,
/// DPX bits per sample. `None` follows the image: 8 for 8-bit, 16 otherwise.
pub dpx_bits: Option<DpxBits>,
/// DDS compression and mip chain.
pub dds: DdsOptions,
/// Embed the ICC profile when the format supports it.
pub embed_icc: bool,
/// Embed EXIF/XMP/DPI/text when the format supports it.
@@ -277,14 +189,12 @@ impl Default for EncodeOptions {
jpeg_chroma_subsampling: true,
png_compression: PngCompression::Default,
png_interlaced: false,
png_stereo: None,
webp_lossless: true,
webp_quality: 80,
tiff_compression: TiffCompression::None,
tiff_bigtiff: false,
exr_compression: ExrCompression::Zip16,
dpx_bits: None,
dds: DdsOptions::default(),
embed_icc: true,
embed_metadata: true,
}
+7 -19
View File
@@ -285,8 +285,7 @@ pub fn decode_wbmp(bytes: &[u8]) -> Result<(u32, u32, Vec<bool>), CodecError> {
}
/// JPEG from 8-bit RGB (`rgb.len() == 3·w·h`): quality 1..=100, optional progressive scans and
/// optimised Huffman tables (Save for Web's "Optimized"), optional ICC profile, density, EXIF and
/// XMP. EXIF that doesn't fit in one segment is left out (see [`jpeg_exif_fits`]).
/// optimised Huffman tables (Save for Web's "Optimized"), optional ICC profile, density and XMP.
#[allow(clippy::too_many_arguments)]
pub fn encode_jpeg_rgb8(
width: u32,
@@ -297,7 +296,6 @@ pub fn encode_jpeg_rgb8(
optimized: bool,
icc: Option<&[u8]>,
dpi: Option<f32>,
exif: Option<&[u8]>,
xmp: Option<&str>,
) -> Result<Vec<u8>, CodecError> {
let e = |e: jpeg_encoder::EncodingError| CodecError::encode(Format::Jpeg, e);
@@ -321,14 +319,9 @@ pub fn encode_jpeg_rgb8(
let v = d.round().min(65535.0) as u16;
enc.set_density(jpeg_encoder::Density::Inch { x: v, y: v });
}
let ppi = dpi.filter(|d| *d >= 1.0).map(|d| (d, d));
// Upright, without the source's thumbnail, at the JFIF resolution, as Save As writes it.
if let Some(seg) = exif.and_then(|x| crate::codecs::jpeg::exif_segment(x, ppi)) {
enc.add_app_segment(1, &seg).map_err(e)?;
}
if let Some(x) = xmp {
// Its `tiff:` resolution, if any, says what the JFIF density says (#1691).
let x = crate::resolution::export_xmp(x, ppi);
let x = crate::resolution::export_xmp(x, dpi.filter(|d| *d >= 1.0).map(|d| (d, d)));
let mut seg = b"http://ns.adobe.com/xap/1.0/\0".to_vec();
seg.extend_from_slice(x.as_bytes());
enc.add_app_segment(1, &seg).map_err(e)?;
@@ -340,11 +333,6 @@ pub fn encode_jpeg_rgb8(
Ok(out)
}
/// Whether [`encode_jpeg_rgb8`] can write `exif` at `dpi` (it must fit in one APP1 segment).
pub fn jpeg_exif_fits(exif: &[u8], dpi: Option<f32>) -> bool {
crate::codecs::jpeg::exif_segment(exif, dpi.filter(|d| *d >= 1.0).map(|d| (d, d))).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -371,7 +359,7 @@ mod tests {
let rgb: Vec<u8> = (0..16 * 16 * 3).map(|i| (i * 37 % 256) as u8).collect();
for (q, want) in [(1, (2, 2)), (50, (2, 2)), (51, (1, 1)), (60, (1, 1)), (89, (1, 1)), (100, (1, 1))] {
for progressive in [false, true] {
let bytes = encode_jpeg_rgb8(16, 16, &rgb, q, progressive, false, None, None, None, None).unwrap();
let bytes = encode_jpeg_rgb8(16, 16, &rgb, q, progressive, false, None, None, None).unwrap();
assert_eq!(luma_sampling(&bytes), Some(want), "quality {q} progressive {progressive}");
}
}
@@ -440,8 +428,8 @@ mod tests {
#[test]
fn progressive_jpeg_decodes() {
let rgb: Vec<u8> = (0..64 * 48 * 3).map(|i| (i % 251) as u8).collect();
let base = encode_jpeg_rgb8(64, 48, &rgb, 80, false, false, None, Some(72.0), None, Some("<x:xmpmeta/>")).unwrap();
let prog = encode_jpeg_rgb8(64, 48, &rgb, 80, true, true, None, None, None, None).unwrap();
let base = encode_jpeg_rgb8(64, 48, &rgb, 80, false, false, None, Some(72.0), Some("<x:xmpmeta/>")).unwrap();
let prog = encode_jpeg_rgb8(64, 48, &rgb, 80, true, true, None, None, None).unwrap();
assert_ne!(base, prog);
assert_eq!(crate::decode(&base).unwrap().meta.xmp.as_deref(), Some("<x:xmpmeta/>"));
// SOF2 marks a progressive frame.
@@ -454,7 +442,7 @@ mod tests {
fn web_jpeg_xmp_resolution_follows_the_density() {
let rgb = vec![100u8; 8 * 8 * 3];
let xmp = r#"<rdf:Description tiff:XResolution="300/1" tiff:YResolution="300/1"/>"#;
let out = encode_jpeg_rgb8(8, 8, &rgb, 80, false, false, None, Some(72.0), None, Some(xmp)).unwrap();
let out = encode_jpeg_rgb8(8, 8, &rgb, 80, false, false, None, Some(72.0), Some(xmp)).unwrap();
let img = crate::decode(&out).unwrap();
assert_eq!(img.meta.dpi, Some((72.0, 72.0)));
assert_eq!(img.meta.xmp.as_deref().and_then(crate::xmp_resolution), Some((72.0, 72.0)));
@@ -473,7 +461,7 @@ mod subsampling_tests {
let rgb: Vec<u8> = (0..w * h).flat_map(|i| [((i % w) * 4) as u8, 76, ((i / w) * 5) as u8]).collect();
for q in [30, 60, 95] {
for (prog, opt) in [(false, false), (false, true), (true, false), (true, true)] {
let bytes = encode_jpeg_rgb8(w, h, &rgb, q, prog, opt, None, None, None, None).unwrap();
let bytes = encode_jpeg_rgb8(w, h, &rgb, q, prog, opt, None, None, None).unwrap();
let ours = crate::decode(&bytes).unwrap().convert(crate::ChannelLayout::Rgb, crate::SampleType::U8);
let err = ours.data().iter().zip(&rgb).map(|(a, b)| i32::from(*a).abs_diff(i32::from(*b))).max().unwrap();
assert!(err < 24, "q{q} progressive={prog} optimized={opt}: max error {err}");
+4 -5
View File
@@ -59,12 +59,11 @@ const WHITE: u16 = 0xffff;
const INDICES_0123: [u8; 4] = [0x04, 0x0e, 0, 0];
#[test]
fn dds_is_readable_and_writable() {
fn dds_is_readable_but_not_writable() {
assert_eq!(from_extension("texture.DDS"), Some(Format::Dds));
assert!(caps(Format::Dds).read && caps(Format::Dds).write);
let img = photocraft_codecs::Image::from_u8(1, 1, ChannelLayout::Rgba, vec![1, 2, 3, 0]).unwrap();
let b = encode(&img, Format::Dds, &Default::default()).unwrap();
assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 0]);
assert!(caps(Format::Dds).read && !caps(Format::Dds).write);
let img = photocraft_codecs::Image::from_u8(1, 1, ChannelLayout::Rgba, vec![0, 0, 0, 0]).unwrap();
assert!(matches!(encode(&img, Format::Dds, &Default::default()), Err(CodecError::Unsupported { .. })));
}
#[test]
-372
View File
@@ -1,372 +0,0 @@
//! Writing DDS textures: every storage read back through the reader, the header fields, sizes
//! that aren't a multiple of four, the mip chain, and the block-compression error on ramps.
use photocraft_codecs::{ChannelLayout, DdsCompression, DdsOptions, EncodeOptions, FidelityWarning, Format, Image, decode, encode, fidelity_warnings_with};
const DDSD_PITCH: u32 = 0x8;
const DDSD_MIPMAPCOUNT: u32 = 0x2_0000;
const DDSD_LINEARSIZE: u32 = 0x8_0000;
const REQUIRED: u32 = 0x1007; // CAPS | HEIGHT | WIDTH | PIXELFORMAT
const DDSCAPS_COMPLEX: u32 = 0x8;
const DDSCAPS_TEXTURE: u32 = 0x1000;
const DDSCAPS_MIPMAP: u32 = 0x40_0000;
const DDPF_ALPHAPIXELS: u32 = 0x1;
const DDPF_FOURCC: u32 = 0x4;
const DDPF_RGB: u32 = 0x40;
const DDPF_LUMINANCE: u32 = 0x2_0000;
const BLOCKS: [DdsCompression; 3] = [DdsCompression::Bc1, DdsCompression::Bc2, DdsCompression::Bc3];
fn opts(compression: DdsCompression, mipmaps: bool) -> EncodeOptions {
EncodeOptions { dds: DdsOptions { compression, mipmaps }, ..Default::default() }
}
fn le32(b: &[u8], at: usize) -> u32 {
u32::from_le_bytes(b[at..at + 4].try_into().unwrap())
}
/// A ramp along one line through RGB (red up, green up half as fast, blue down) across the
/// texture, and alpha falling from opaque down it: every block's colours lie on a line, which
/// is what a colour block stores.
fn ramp(w: u32, h: u32, layout: ChannelLayout) -> Image {
let ch = layout.channels();
let at = |v: u32, n: u32| (v * 255 / (n - 1).max(1)) as u8;
let mut data = Vec::new();
for y in 0..h {
for x in 0..w {
let r = at(x, w);
let rgba = [r, 64 + r / 2, 255 - r, 255 - at(y, h)];
match ch {
1 => data.push(rgba[0]),
2 => data.extend([rgba[0], rgba[3]]),
_ => data.extend(&rgba[..ch]),
}
}
}
Image::from_u8(w, h, layout, data).unwrap()
}
/// The same RGBA image, fully opaque.
fn opaque(img: &Image) -> Image {
let data = img.data().chunks(4).flat_map(|p| [p[0], p[1], p[2], 255]).collect();
Image::from_u8(img.width(), img.height(), ChannelLayout::Rgba, data).unwrap()
}
/// Largest difference in channel `c` (of four) between RGBA images.
fn max_diff_channel(a: &Image, b: &Image, c: usize) -> u8 {
a.data().chunks(4).zip(b.data().chunks(4)).map(|(x, y)| x[c].abs_diff(y[c])).max().unwrap_or(0)
}
/// Largest colour difference between RGBA images where `b` is visible (BC1 turns texels under
/// half opacity into transparent black).
fn max_colour_diff(a: &Image, b: &Image) -> u8 {
a.data().chunks(4).zip(b.data().chunks(4)).filter(|(_, y)| y[3] != 0).flat_map(|(x, y)| (0..3).map(|c| x[c].abs_diff(y[c]))).max().unwrap_or(0)
}
/// A 2D gradient: red across, green down and blue along the diagonal, opaque.
fn gradient(w: u32, h: u32) -> Image {
let data = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x * 4).min(255) as u8, (y * 4).min(255) as u8, ((x + y) * 2).min(255) as u8, 255])).collect();
Image::from_u8(w, h, ChannelLayout::Rgba, data).unwrap()
}
/// PSNR of the colour (not the alpha) of two RGBA images.
fn psnr(a: &Image, b: &Image) -> f64 {
let rgb = |i: &Image| i.data().chunks(4).flat_map(|p| [p[0], p[1], p[2]]).map(f64::from).collect::<Vec<_>>();
let (a, b) = (rgb(a), rgb(b));
let mse = a.iter().zip(&b).map(|(x, y)| (x - y).powi(2)).sum::<f64>() / a.len() as f64;
if mse == 0.0 { f64::INFINITY } else { 10.0 * (255.0f64.powi(2) / mse).log10() }
}
/// Bytes of one level as a reader computes them from the header's pixel format.
fn level_bytes(file: &[u8], w: usize, h: usize) -> usize {
if le32(file, 80) & DDPF_FOURCC != 0 {
let block = if &file[84..88] == b"DXT1" { 8 } else { 16 };
w.div_ceil(4) * h.div_ceil(4) * block
} else {
w * h * le32(file, 88) as usize / 8
}
}
/// Mip level `i` of a written texture, cut out as a single-level texture and decoded.
fn level(file: &[u8], i: u32) -> Image {
let (w, h) = (le32(file, 16) as usize, le32(file, 12) as usize);
let size = |i: u32| ((w >> i).max(1), (h >> i).max(1));
let start = 128 + (0..i).map(|j| level_bytes(file, size(j).0, size(j).1)).sum::<usize>();
let (lw, lh) = size(i);
let mut b = file[..128].to_vec();
b[12..16].copy_from_slice(&(lh as u32).to_le_bytes());
b[16..20].copy_from_slice(&(lw as u32).to_le_bytes());
b[28..32].copy_from_slice(&1u32.to_le_bytes());
b.extend(&file[start..start + level_bytes(file, lw, lh)]);
decode(&b).unwrap()
}
#[test]
fn uncompressed_textures_round_trip_every_layout_exactly() {
for (layout, pf_flags, bits, masks) in [
(ChannelLayout::Gray, DDPF_LUMINANCE, 8, [0xff, 0, 0, 0]),
(ChannelLayout::GrayA, DDPF_LUMINANCE | DDPF_ALPHAPIXELS, 16, [0xff, 0, 0, 0xff00]),
(ChannelLayout::Rgb, DDPF_RGB, 32, [0xff_0000, 0xff00, 0xff, 0]),
(ChannelLayout::Rgba, DDPF_RGB | DDPF_ALPHAPIXELS, 32, [0xff_0000, 0xff00, 0xff, 0xff00_0000]),
] {
for (w, h) in [(1, 1), (5, 3), (17, 9)] {
let img = ramp(w, h, layout);
let b = encode(&img, Format::Dds, &EncodeOptions::default()).unwrap();
let back = decode(&b).unwrap();
assert_eq!(back.layout(), layout, "{layout:?} {w}x{h}");
assert_eq!(back.data(), img.data(), "{layout:?} {w}x{h}");
assert_eq!((&b[..4], le32(&b, 4), le32(&b, 76)), (&b"DDS "[..], 124, 32));
assert_eq!(le32(&b, 8), REQUIRED | DDSD_PITCH, "pitch, not linear size, and no mip count");
assert_eq!((le32(&b, 12), le32(&b, 16)), (h, w));
assert_eq!(le32(&b, 20), w * bits / 8, "pitch of {layout:?} {w}x{h}");
assert_eq!((le32(&b, 28), le32(&b, 108)), (1, DDSCAPS_TEXTURE));
assert_eq!((le32(&b, 80), le32(&b, 88)), (pf_flags, bits));
assert_eq!([le32(&b, 92), le32(&b, 96), le32(&b, 100), le32(&b, 104)], masks);
assert_eq!(b.len(), 128 + (w * h * bits / 8) as usize);
}
}
}
#[test]
fn block_compressed_textures_round_trip_through_the_reader() {
for (compression, fourcc, block) in [(DdsCompression::Bc1, b"DXT1", 8), (DdsCompression::Bc2, b"DXT3", 16), (DdsCompression::Bc3, b"DXT5", 16)] {
// Not multiples of four, and the 1×1 and 2×2 textures smaller than one block.
for (w, h) in [(1, 1), (2, 2), (4, 4), (5, 3), (7, 13), (33, 17)] {
let img = ramp(w, h, ChannelLayout::Rgba);
let b = encode(&img, Format::Dds, &opts(compression, false)).unwrap();
let linear = w.div_ceil(4) * h.div_ceil(4) * block;
assert_eq!(&b[84..88], fourcc);
assert_eq!((le32(&b, 80), le32(&b, 88)), (DDPF_FOURCC, 0));
assert_eq!(le32(&b, 8), REQUIRED | DDSD_LINEARSIZE, "linear size, not pitch");
assert_eq!(le32(&b, 20), linear, "{compression:?} {w}x{h}: the top level's size");
assert_eq!(b.len(), 128 + linear as usize);
let back = decode(&b).unwrap();
assert_eq!((back.dimensions(), back.layout()), ((w, h), ChannelLayout::Rgba));
// Steep ramps across tiny textures put three colours where a block interpolates at
// thirds, so this bound is loose; it still catches swapped channels or misplaced
// blocks. `block_compression_error_on_gradients_is_bounded` is the tight one. A BC1
// block with transparent texels has only three colours, so BC1's colours are
// checked on the opaque ramp.
let (src, back_colour) = if compression == DdsCompression::Bc1 {
let o = opaque(&img);
let back = decode(&encode(&o, Format::Dds, &opts(compression, false)).unwrap()).unwrap();
(o, back)
} else {
(img.clone(), back.clone())
};
let colour = max_colour_diff(&src, &back_colour);
assert!(colour <= 24, "{compression:?} {w}x{h}: colour off by {colour}");
let alpha = max_diff_channel(&img, &back, 3);
match compression {
DdsCompression::Bc1 => assert!(back.data().chunks(4).zip(img.data().chunks(4)).all(|(b, i)| b[3] == if i[3] < 128 { 0 } else { 255 })),
_ => assert!(alpha <= 8, "{compression:?} {w}x{h}: alpha off by {alpha}"),
}
}
}
}
#[test]
fn gray_and_rgb_sources_are_written_as_rgba_blocks() {
for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb] {
for compression in BLOCKS {
let img = ramp(8, 8, layout);
let back = decode(&encode(&img, Format::Dds, &opts(compression, false)).unwrap()).unwrap();
let want = img.convert(ChannelLayout::Rgba, img.sample_type());
assert_eq!(back.layout(), ChannelLayout::Rgba);
let colour = max_colour_diff(&want, &back);
assert!(colour <= 8, "{layout:?} {compression:?}: colour off by {colour}");
}
}
}
#[test]
fn block_compression_error_on_gradients_is_bounded() {
let (line, plane) = (ramp(64, 64, ChannelLayout::Rgba), gradient(64, 64));
for compression in BLOCKS {
let roundtrip = |img: &Image| decode(&encode(img, Format::Dds, &opts(compression, false)).unwrap()).unwrap();
let (line_back, plane_back) = (roundtrip(&line), roundtrip(&plane));
let (line_err, plane_err) = (max_colour_diff(&line, &line_back), max_colour_diff(&plane, &plane_back));
let p = psnr(&plane, &plane_back);
let alpha = max_diff_channel(&line, &line_back, 3);
// Colours on a line only lose the RGB565 rounding of the endpoints (up to 4 levels in
// red and blue). A gradient in two directions is not on a line: the spread off a
// block's best line alone limits this one to about 40 dB, and RGB565 costs a little
// more (the encoder measures 38.6 dB and 9 levels at most).
assert!(line_err <= 5, "{compression:?}: colour ramp off by {line_err}");
assert!(plane_err <= 10, "{compression:?}: 2D gradient off by {plane_err}");
assert!(p >= 38.0, "{compression:?}: 2D gradient PSNR {p:.1} dB");
match compression {
DdsCompression::Bc1 => assert!(line_back.data().chunks(4).zip(line.data().chunks(4)).all(|(b, i)| b[3] == if i[3] < 128 { 0 } else { 255 })),
// Four bits: within half a step of 17.
DdsCompression::Bc2 => assert!(alpha <= 8, "BC2 alpha off by {alpha}"),
// Eight values across a block's few levels of alpha.
_ => assert!(alpha <= 2, "BC3 alpha off by {alpha}"),
}
}
}
#[test]
fn solid_and_cutout_textures_keep_their_values() {
// RGB565-exact colours and BC2's 4-bit alpha steps come back exactly.
let solid = Image::from_u8(6, 5, ChannelLayout::Rgba, [255, 0, 255, 170].repeat(30)).unwrap();
for compression in BLOCKS {
let back = decode(&encode(&solid, Format::Dds, &opts(compression, false)).unwrap()).unwrap();
let want = if compression == DdsCompression::Bc1 { [255, 0, 255, 255] } else { [255, 0, 255, 170] };
assert!(back.data().chunks(4).all(|p| p == want), "{compression:?}: {:?}", &back.data()[..4]);
}
// A cutout: BC1's transparent texels decode as transparent black, the rest stay opaque.
let cutout: Vec<u8> = (0..36).flat_map(|i| if (i / 6 + i % 6) % 2 == 0 { [200, 40, 40, 255] } else { [10, 10, 10, 0] }).collect();
let cutout = Image::from_u8(6, 6, ChannelLayout::Rgba, cutout).unwrap();
for compression in BLOCKS {
let back = decode(&encode(&cutout, Format::Dds, &opts(compression, false)).unwrap()).unwrap();
assert_eq!(max_diff_channel(&cutout, &back, 3), 0, "{compression:?}");
if compression == DdsCompression::Bc1 {
assert!(back.data().chunks(4).filter(|p| p[3] == 0).all(|p| p == [0, 0, 0, 0]));
}
}
}
#[test]
fn mipmaps_write_the_full_chain_box_filtered() {
// 16×8 halves to 8×4, 4×2, 2×1 and 1×1: five levels.
let img = ramp(16, 8, ChannelLayout::Rgba);
let b = encode(&img, Format::Dds, &opts(DdsCompression::None, true)).unwrap();
assert_eq!(le32(&b, 8), REQUIRED | DDSD_PITCH | DDSD_MIPMAPCOUNT);
assert_eq!(le32(&b, 28), 5);
assert_eq!(le32(&b, 108), DDSCAPS_TEXTURE | DDSCAPS_COMPLEX | DDSCAPS_MIPMAP);
assert_eq!(b.len(), 128 + 4 * (16 * 8 + 8 * 4 + 4 * 2 + 2 + 1));
assert_eq!(decode(&b).unwrap().data(), img.data(), "the reader still reads the top level");
let mut above = img.clone();
for i in 1..5 {
let got = level(&b, i);
assert_eq!(got.dimensions(), ((16 >> i).max(1), (8 >> i).max(1)));
let want = box_filter(&above);
assert_eq!(got.data(), want.data(), "level {i}");
above = got;
}
// 1×1 has a single level.
let one = encode(&ramp(1, 1, ChannelLayout::Rgba), Format::Dds, &opts(DdsCompression::None, true)).unwrap();
assert_eq!((le32(&one, 28), le32(&one, 8) & DDSD_MIPMAPCOUNT), (1, 0));
}
/// The documented filter, written independently: each pixel averages the 2×2 pixels it covers,
/// and the last column and row of an odd-sized level take in the leftover pixel too.
fn box_filter(src: &Image) -> Image {
let (w, h) = (src.width() as usize, src.height() as usize);
let (nw, nh) = ((w / 2).max(1), (h / 2).max(1));
let ch = src.layout().channels();
let span = |i: usize, n: usize, size: usize| (2 * i, if i + 1 == n { size } else { 2 * i + 2 });
let mut out = Vec::new();
for y in 0..nh {
for x in 0..nw {
let ((x0, x1), (y0, y1)) = (span(x, nw, w), span(y, nh, h));
for c in 0..ch {
let values: Vec<u32> =
(y0..y1).flat_map(|sy| (x0..x1).map(move |sx| (sy, sx))).map(|(sy, sx)| u32::from(src.data()[(sy * w + sx) * ch + c])).collect();
let n = values.len() as u32;
out.push(((values.iter().sum::<u32>() + n / 2) / n) as u8);
}
}
}
Image::from_u8(nw as u32, nh as u32, src.layout(), out).unwrap()
}
#[test]
fn odd_sized_mip_chains_keep_every_pixel() {
// 5×3 → 2×1 → 1×1. The 2×1 level's right pixel averages columns 2–4 of all three rows.
let img = ramp(5, 3, ChannelLayout::Gray);
let b = encode(&img, Format::Dds, &opts(DdsCompression::None, true)).unwrap();
assert_eq!(le32(&b, 28), 3);
assert_eq!(b.len(), 128 + 5 * 3 + 2 + 1);
let second = level(&b, 1);
assert_eq!(second.data(), box_filter(&img).data());
let px = |x: usize, y: usize| u32::from(img.data()[y * 5 + x]);
let right: u32 = (0..3).flat_map(|y| (2..5).map(move |x| (x, y))).map(|(x, y)| px(x, y)).sum();
assert_eq!(u32::from(second.data()[1]), (right + 4) / 9);
}
#[test]
fn block_compressed_mip_chains_have_a_block_per_small_level() {
for compression in BLOCKS {
let block = if compression == DdsCompression::Bc1 { 8 } else { 16 };
let img = ramp(12, 6, ChannelLayout::Rgba);
let b = encode(&img, Format::Dds, &opts(compression, true)).unwrap();
// 12×6, 6×3, 3×1, 1×1: 3×2, 2×1, 1 and 1 blocks.
assert_eq!(le32(&b, 28), 4);
assert_eq!(le32(&b, 8), REQUIRED | DDSD_LINEARSIZE | DDSD_MIPMAPCOUNT);
assert_eq!(le32(&b, 20), 6 * block);
assert_eq!(b.len(), 128 + (6 + 2 + 1 + 1) * block as usize);
for i in 0..4 {
let got = level(&b, i);
assert_eq!(got.dimensions(), ((12 >> i).max(1), (6 >> i).max(1)), "{compression:?} level {i}");
}
}
}
#[test]
fn strips_and_tiny_textures_encode_with_every_option() {
for (w, h) in [(1, 1), (1, 9), (9, 1), (2, 3), (3, 2)] {
for compression in DdsCompression::ALL {
for mipmaps in [false, true] {
let img = ramp(w, h, ChannelLayout::Rgba);
let b = encode(&img, Format::Dds, &opts(compression, mipmaps)).unwrap_or_else(|e| panic!("{w}x{h} {compression:?} {mipmaps}: {e}"));
assert_eq!(decode(&b).unwrap().dimensions(), (w, h));
}
}
}
}
#[test]
fn fidelity_warnings_follow_the_compression() {
let translucent = ramp(8, 8, ChannelLayout::Rgba);
let warns = |c| fidelity_warnings_with(&translucent, Format::Dds, &opts(c, false));
assert!(warns(DdsCompression::None).is_empty(), "uncompressed RGBA loses nothing");
for c in BLOCKS {
assert!(warns(c).contains(&FidelityWarning::LossyCompression), "{c:?}");
}
assert!(warns(DdsCompression::Bc1).contains(&FidelityWarning::AlphaBinarized));
assert!(!warns(DdsCompression::Bc3).contains(&FidelityWarning::AlphaBinarized));
}
#[test]
fn compression_names_round_trip() {
for c in DdsCompression::ALL {
assert_eq!(DdsCompression::from_name(c.name()), Some(c));
}
assert_eq!(DdsCompression::from_name("DXT5"), Some(DdsCompression::Bc3));
assert_eq!(DdsCompression::from_name("dxt1"), Some(DdsCompression::Bc1));
for bad in ["", "bc7", "dxt2", "uncompressed "] {
assert_eq!(DdsCompression::from_name(bad), None, "{bad:?}");
}
}
/// Encode timings on a synthetic ~30 MP texture (6336×4752). Ignored by default; run in release:
///
/// ```sh
/// cargo test --release -p photocraft-codecs --test dds_write -- --ignored --nocapture
/// ```
#[test]
#[ignore]
fn dds_encode_timings_on_30_mp() {
let (w, h) = (6336u32, 4752u32);
let mut s = 0x1234_5678u32;
let mut data = Vec::with_capacity(w as usize * h as usize * 4);
for y in 0..h {
for x in 0..w {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
data.extend([(x / 25 + y / 19) as u8, (x / 31) as u8 ^ (s as u8 & 3), (y / 23) as u8, (255 - x / 25) as u8]);
}
}
let img = Image::from_u8(w, h, ChannelLayout::Rgba, data).unwrap();
for compression in DdsCompression::ALL {
for mipmaps in [false, true] {
let t = std::time::Instant::now();
let b = encode(&img, Format::Dds, &opts(compression, mipmaps)).unwrap();
eprintln!("{compression:?} mipmaps={mipmaps}: {:.0} ms, {} MB", t.elapsed().as_secs_f64() * 1e3, b.len() >> 20);
}
}
}
+1 -107
View File
@@ -8,9 +8,7 @@ use exr::prelude::{
WritableImage,
};
use photocraft_codecs::f16;
use photocraft_codecs::{
ChannelLayout, DecodeOptions, DecodeWarning, Format, Limits, SampleType, decode, decode_as_with, decode_exr_part, exr_info, probe_size,
};
use photocraft_codecs::{ChannelLayout, DecodeOptions, DecodeWarning, Format, Limits, SampleType, decode, decode_as_with, decode_exr_part, exr_info};
fn layer_f32(name: &str, comp: Compression, chans: &[(&str, Vec<f32>)]) -> Layer<AnyChannels<FlatSamples>> {
let n = chans.iter().find(|(_, v)| !v.is_empty()).map_or(0, |(_, v)| v.len());
@@ -228,107 +226,3 @@ fn decoding_one_part_does_not_decode_the_others() {
assert_eq!((img.width(), img.height()), (4, 4));
assert!(img.to_normalized().iter().all(|v| near(*v, 0.75)), "wrong pixels");
}
fn channel_group_file(channels: &[(&str, f32)]) -> Vec<u8> {
let channels: Vec<_> = channels.iter().map(|(name, value)| (*name, vec![*value; 4])).collect();
write_image(vec![layer_f32("groups", Compression::ZIP1, &channels)])
}
fn assert_channel_group(bytes: &[u8], layout: ChannelLayout, expected: &[f32]) {
assert_eq!(exr_info(bytes, &Limits::default()).unwrap()[0].color_rank(), expected.len() as u8);
assert_eq!(probe_size(bytes).unwrap(), (2, 2));
for img in [decode(bytes).unwrap(), decode_exr_part(bytes, 0, &DecodeOptions::default()).unwrap()] {
assert_eq!(img.layout(), layout);
assert_eq!(img.to_normalized(), expected.repeat(4));
}
}
#[test]
fn flat_decode_keeps_rgb_channels_in_one_group() {
// A partial group sorts before the nested beauty layer, but cannot donate its R.
let bytes = channel_group_file(&[("aaa.R", 9.0), ("beauty.diffuse.R", 0.25), ("beauty.diffuse.G", 0.5), ("beauty.diffuse.B", 2.0)]);
assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]);
}
#[test]
fn flat_decode_does_not_borrow_another_groups_alpha() {
for alpha in ["A", "aaa.A", "beauty.other.A"] {
let bytes = channel_group_file(&[(alpha, 0.125), ("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0)]);
assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]);
let bytes = channel_group_file(&[(alpha, 0.125), ("beauty.Y", 0.5)]);
assert_channel_group(&bytes, ChannelLayout::Gray, &[0.5]);
}
}
#[test]
fn flat_decode_rejects_rgb_split_between_groups() {
for channels in [
[("aaa.R", 0.25), ("bbb.G", 0.5), ("ccc.B", 2.0)],
[("beauty.R", 0.25), ("beauty.diffuse.G", 0.5), ("beauty.diffuse.B", 2.0)],
[(".R", 0.25), ("G", 0.5), ("B", 2.0)],
] {
let bytes = channel_group_file(&channels);
assert_eq!(exr_info(&bytes, &Limits::default()).unwrap()[0].color_rank(), 0);
assert!(probe_size(&bytes).is_err());
assert!(decode(&bytes).is_err());
assert!(decode_exr_part(&bytes, 0, &DecodeOptions::default()).is_err());
}
}
#[test]
fn auto_pick_and_probe_skip_incoherent_rgb_parts() {
let bytes = write_image(vec![
layer_f32("split", Compression::ZIP1, &[("aaa.R", vec![9.0; 4]), ("bbb.G", vec![8.0; 4]), ("ccc.B", vec![7.0; 4])]),
layer_f32("luminance", Compression::ZIP1, &[("Y", vec![0.5; 16]), ("A", vec![0.75; 16])]),
]);
let parts = exr_info(&bytes, &Limits::default()).unwrap();
assert_eq!(parts.iter().map(|part| part.color_rank()).collect::<Vec<_>>(), [0, 2]);
assert_eq!(probe_size(&bytes).unwrap(), (4, 4));
let img = decode(&bytes).unwrap();
assert_eq!((img.width(), img.height(), img.layout()), (4, 4, ChannelLayout::GrayA));
assert_eq!(img.to_f32_samples().unwrap(), [0.5, 0.75].repeat(16));
}
#[test]
fn flat_decode_prefers_the_highest_ranked_complete_group() {
let bytes =
channel_group_file(&[("aaa.R", 9.0), ("aaa.G", 8.0), ("aaa.B", 7.0), ("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0), ("beauty.A", 0.75)]);
assert_channel_group(&bytes, ChannelLayout::Rgba, &[0.25, 0.5, 2.0, 0.75]);
let bytes = channel_group_file(&[("aaa.Y", 9.0), ("beauty.Y", 0.5), ("beauty.A", 0.75)]);
assert_channel_group(&bytes, ChannelLayout::GrayA, &[0.5, 0.75]);
}
#[test]
fn flat_decode_breaks_group_ties_by_prefix() {
let bytes = channel_group_file(&[("z.R", 9.0), ("z.G", 8.0), ("z.B", 7.0), ("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0)]);
assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]);
// Even a prefix that sorts before the unprefixed channel names loses a rank tie.
let bytes = channel_group_file(&[("0.R", 9.0), ("0.G", 8.0), ("0.B", 7.0), ("R", 0.25), ("G", 0.5), ("B", 2.0)]);
assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]);
}
#[test]
fn flat_decode_preserves_luminance_and_lone_channel_fallbacks() {
for prefix in ["", "beauty."] {
let y = format!("{prefix}Y");
let a = format!("{prefix}A");
assert_channel_group(&channel_group_file(&[(&y, 2.0), (&a, 0.75)]), ChannelLayout::GrayA, &[2.0, 0.75]);
assert_channel_group(&channel_group_file(&[(&y, 2.0)]), ChannelLayout::Gray, &[2.0]);
}
for name in ["depth.Z", "A", "beauty.A"] {
// A lone alpha is one grayscale channel, not the same samples duplicated as YA.
assert_channel_group(&channel_group_file(&[(name, 9.0)]), ChannelLayout::Gray, &[9.0]);
}
}
#[test]
fn half_float_group_does_not_use_unrelated_float_channels() {
let mut layer = layer_f32("mixed", Compression::ZIP1, &[("aaa.R", vec![9.0; 4]), ("aaa.A", vec![0.125; 4])]);
for (name, value) in [("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0), ("beauty.A", 0.75)] {
layer.channel_data.list.push(AnyChannel::new(name, FlatSamples::F16(vec![f16::from_f32(value); 4])));
}
layer.channel_data = AnyChannels::sort(layer.channel_data.list);
let bytes = write_image(vec![layer]);
assert_channel_group(&bytes, ChannelLayout::Rgba, &[0.25, 0.5, 2.0, 0.75]);
assert_eq!(decode(&bytes).unwrap().sample_type(), SampleType::F16);
}
-3
View File
@@ -137,9 +137,6 @@ mod fits {
mod dpx {
cases!(Format::Dpx);
}
mod dds {
cases!(Format::Dds);
}
mod qoi {
cases!(Format::Qoi);
}
Loaded 100 of 554 files, more files were not shown because too many files have changed in this diff. Show more