2 Commits

Author SHA1 Message Date
Marcos Paulo bc42a000e6 feat(ui): Catppuccin Latte, Frappé, Macchiato and Mocha themes
Four new themes on the Studio layout (Window > Theme, Preferences, ui.set),
wired through the engine's Theme/DarkTheme/LightTheme preferences. Palette
is the MIT-licensed Catppuccin one (LICENSE-catppuccin.txt, ATTRIBUTION.md).
Menu and preference labels translated for every complete-menu language.
.debrand now exempts itself from its own check.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 19:59:03 -03:00
Marcos Paulo 06c6133ca4 chore: debrand ArtCraft and Discord references
Remove the Discord button, Help menu links and brand assets; rewrite docs,
packaging URLs and catalogs. Add .debrand rules and the craft-skills
submodule (debrand skill) to keep upstream syncs clean. Drop duplicate
Czech catalog rows that failed bundled_catalogs_are_consistent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 19:15:16 -03:00
587 changed files with 4726 additions and 65190 deletions
+21
View File
@@ -0,0 +1,21 @@
# Strip upstream (ArtCraft / Discord) branding. Run: ~/.agents/skills/debrand/debrand.pl (--check in CI)
delete docs/brand
# MIT/Apache texts stay verbatim. The copyright holder line stays everywhere (legal requirement).
keep ^(LICENSE-(MIT|APACHE)|\.debrand)$
dropline ^crates/ui-egui/src/i18n/.*\.tsv$ :: \t.*(?:ArtCraft|Discord)
dropline ^ATTRIBUTION\.md$ :: \|\s*`docs/brand/
sub (?s)\nThe ArtCraft name, wordmark and logos in docs/brand/.*?LICENSE-brand\.txt\.\n =>
sub (?s)\s*The ArtCraft name, wordmark, and logos in `docs/brand/`.*?source-code license\. =>
sub \s*The ArtCraft logos in `docs/brand/` are not open source \(`docs/brand/LICENSE-brand\.txt`\)\. =>
sub drawn in ArtCraft, vectorised => drawn by hand, vectorised
sub created\s+in ArtCraft, then => created by hand, then
sub made in ArtCraft \( => drawn by hand (
sub the owner's ArtCraft drawing => the owner's drawing
sub the ArtCraft team and PhotoCraft contributors => the PhotoCraft contributors
sub ArtCraft, ArtCraftX,\s =>
sub \bArtCraft, =>
sub https://getartcraft\.com/apps/photocraft => https://github.com/storytold/photocraft
sub https://discord\.gg/artcraft => https://github.com/storytold/photocraft/issues
forbid (?i)artcraft(?! Team and the PhotoCraft contributors)
forbid (?i)discord
sub Discord \(Help › Discord\), then the theme toggle => the theme toggle
-8
View File
@@ -257,14 +257,6 @@ jobs:
- { crate: psd, name: parse }
- { crate: psd, name: descriptor }
- { crate: psd, name: image_source_data }
- { crate: psd, name: grd }
- { crate: psd, name: ase }
- { crate: psd, name: aco }
- { crate: psd, name: patterns }
- { crate: psd, name: filter_effects }
- { crate: psd, name: metadata }
- { crate: psd, name: tagged }
- { crate: psd, name: resources }
- { crate: codecs, name: decode_any }
- { crate: codecs, name: decode_png }
- { crate: codecs, name: decode_jpeg }
-7
View File
@@ -21,10 +21,3 @@ jobs:
lint:
# 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/packaging-lint.yml@main
verified-appimage-inputs:
name: Verified AppImage inputs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: bash packaging/linux/verified-download-test.sh
-6
View File
@@ -35,9 +35,3 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+3
View File
@@ -0,0 +1,3 @@
[submodule "tools/craft-skills"]
path = tools/craft-skills
url = https://git.marcospaulo.dev.br/storytold/craft-skills.git
+2 -2
View File
@@ -1,6 +1,6 @@
# AGENTS.md: guide for AI agents and contributors
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, VectorCraft, EffectCraft, FilmCraft, LightCraft, PdfCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`; the repository is not public — outside contributors should ask a maintainer for the standards relevant to their change). Contribute reusable learnings there; shared Rust components live in [storytold/craft-libs](https://github.com/storytold/craft-libs) and are pinned by immutable git revision. UI libraries stay at L6. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings DesignCraft, VectorCraft, EffectCraft, FilmCraft, LightCraft, PdfCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`; the repository is not public — outside contributors should ask a maintainer for the standards relevant to their change). Contribute reusable learnings there; shared Rust components live in [storytold/craft-libs](https://github.com/storytold/craft-libs) and are pinned by immutable git revision. UI libraries stay at L6. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
## 1. Orientation (5 minutes)
@@ -61,7 +61,7 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
1. **Everything is a command.** New user-visible behaviour = a command in the engine (`crates/engine/src/*_cmds.rs`, registered in `commands.rs`) with id, label, menu path, shortcut, params doc, `enabled` and `run`, plus tests. The UI, CLI, control channel and MCP all dispatch commands by id. Use the **exact id from `crates/ui-egui/src/menu_catalog.rs`** and the menu item goes live automatically. Only pure view/window state (zoom, panels, screen mode) belongs to the shell (`menus.rs` `UI_COMMANDS`).
2. **No format or colour assumptions.** Bit depth (8/16/32f) and colour model (RGB/Gray/CMYK/Lab…) are runtime data. Never introduce a `u8`-only pixel path in public APIs. Never assume sRGB: colour conversions go through `photocraft-cms` (`Transform`, `transform::cached`). Test at several depths.
3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`). **Never commit font files:** fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: `../craftrules/standards/fonts.md`). PhotoCraft uses it through the optional build input `CRAFT_FONTS_DIR=<craft-fonts checkout>` (read by `crates/text/build.rs`, exposed as `photocraft_text::CRAFT_FONTS`; empty without it, and everything must still work). Build with it: `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo build`; details in `docs/development.md` › Fonts.
3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. **Never commit font files:** fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: `../craftrules/standards/fonts.md`). PhotoCraft uses it through the optional build input `CRAFT_FONTS_DIR=<craft-fonts checkout>` (read by `crates/text/build.rs`, exposed as `photocraft_text::CRAFT_FONTS`; empty without it, and everything must still work). Build with it: `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo build`; details in `docs/development.md` › Fonts.
4. **Tests are the gate.** Every change comes with tests. Format crates use round-trip, synthetic-generator, oracle and fuzz tests. Keep the PSD corpus results and the parity floor (`crates/ui-egui/src/parity.rs`) from regressing.
5. **The UI is thin and data-driven.** UI state lives in `ui-egui/src/state.rs` (serde), so the control channel can read and drive it. Colours and radii come from `theme::Tokens`, never hard-coded.
6. **Verify UI changes visually.** Render offscreen with `cargo run -p photocraft-ui-egui --example snapshot` (no window, no focus stealing), or launch with `--control` and take `ui.screenshot`. Look at the PNG. Demo images must be public-domain art, never personal photos. When fetching assets, never put a person's name, email or other personal details in requests (User-Agent, headers, URLs); use a generic `Photocraft-dev` User-Agent.
+2 -5
View File
@@ -19,9 +19,6 @@ next to them.
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/cs.tsv` | Czech UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `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`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.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) |
@@ -47,9 +44,10 @@ next to them.
| `assets/icons/lasso-magnetic.svg` | Magnetic Lasso tool glyph, adapted from "Magnetic Lasso Tool" (redrawn on the 24 px toolbar grid) | Lil' Seal (Noun Project); adapted by PhotoCraft contributors | <https://thenounproject.com/icon/magnetic-lasso-tool-177513/> | CC BY 3.0, [`assets/icons/LICENSE-noun-magnetic-lasso.txt`](assets/icons/LICENSE-noun-magnetic-lasso.txt) |
| `assets/dict/en_US-scowl-50.txt.gz` | English word list for Check Spelling (SCOWL 2020.12.07, size 50, en_US; 72,403 words) | Kevin Atkinson and the SCOWL contributors | <http://wordlist.aspell.net/> | SCOWL license (permissive, keep the notice), [`assets/dict/LICENSE-SCOWL.txt`](assets/dict/LICENSE-SCOWL.txt), which also has the build recipe |
| `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft Coated CMYK (synthetic) ICC profile | PhotoCraft contributors (generated by `photocraft-cms`, `src/synth.rs`) | Original work, see [`crates/cms/README.md`](crates/cms/README.md) | CC0-1.0 |
| `assets/app-icon/` (all files) | PhotoCraft app icon (nine-tailed kitsune) | The project owner (drawn in ArtCraft, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) |
| `assets/app-icon/` (all files) | PhotoCraft app icon (nine-tailed kitsune) | The project owner (drawn by hand, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) |
| `packaging/macos/dmg/` (all files) | PhotoCraft DMG window background and layout | @XusBadia, from the PhotoCraft app icon | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md) | MIT OR Apache-2.0, like the code |
| `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | <https://ethanschoonover.com/solarized/> | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) |
| `crates/ui-egui/src/theme_catppuccin.rs` (Catppuccin palettes) | Catppuccin Latte, Frappé, Macchiato and Mocha colours used by the Catppuccin themes | Catppuccin | <https://catppuccin.com/palette> | MIT, [`LICENSE-catppuccin.txt`](crates/ui-egui/src/LICENSE-catppuccin.txt) |
| `crates/ui-egui/src/theme.rs` (Breeze Light and Breeze Dark themes) | The Breeze Light and Breeze Dark themes are original colour values on PhotoCraft's Studio layout, chosen to look close to KDE Plasma's Breeze (cool blue-greys, blue accent, 5 px frame radius) so the app blends into a Plasma desktop. None of Breeze's colour values, files or code are copied; the values are a few levels away from its schemes and tuned for text contrast | PhotoCraft contributors | Inspired by the look of KDE Breeze, <https://invent.kde.org/plasma/breeze> (its colour schemes are LGPL-2.0-or-later) | Original work |
### Optional build input: craft-fonts (not files in this repo)
@@ -73,7 +71,6 @@ The other built-in ICC profiles and the generated LUT looks are produced by code
| Path | Title | Author | Source | License |
|---|---|---|---|---|
| `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
| `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below |
| `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 |
Generated
+31 -46
View File
@@ -1121,14 +1121,6 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5"
dependencies = [
"tempfile",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -1751,7 +1743,6 @@ dependencies = [
"miniz_oxide 0.8.9",
"num-complex",
"pulp",
"rayon-core",
"smallvec",
"zune-inflate",
]
@@ -2340,9 +2331,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "heic-rs"
version = "0.1.4"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66"
checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618"
dependencies = [
"rayon",
]
@@ -4191,7 +4182,7 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"arboard",
"async-io",
@@ -4208,7 +4199,6 @@ dependencies = [
"objc2-foundation 0.3.2",
"open",
"photocraft-automation",
"photocraft-cms",
"photocraft-codecs",
"photocraft-color",
"photocraft-compose",
@@ -4233,7 +4223,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4243,7 +4233,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4256,7 +4246,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"base64",
"cap-std",
@@ -4278,7 +4268,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
@@ -4292,7 +4282,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"moxcms",
"rayon",
@@ -4300,7 +4290,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"exr",
"flate2",
@@ -4321,7 +4311,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"photocraft-cms",
"serde",
@@ -4329,7 +4319,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"criterion",
"photocraft-color",
@@ -4344,7 +4334,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4355,10 +4345,9 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"blake3",
"craft-print",
"exr",
"flate2",
"photocraft-affinity",
@@ -4383,17 +4372,15 @@ dependencies = [
"roxmltree",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.21",
"wat",
]
[[package]]
name = "photocraft-format"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"blake3",
"crc32fast",
"flate2",
"photocraft-codecs",
"photocraft-color",
@@ -4410,14 +4397,14 @@ dependencies = [
[[package]]
name = "photocraft-geom"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"half",
"log",
@@ -4434,14 +4421,14 @@ dependencies = [
[[package]]
name = "photocraft-heif"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"blake3",
"crc32fast",
@@ -4470,14 +4457,14 @@ dependencies = [
[[package]]
name = "photocraft-ops"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4488,7 +4475,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4503,7 +4490,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4514,7 +4501,7 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4524,7 +4511,7 @@ dependencies = [
[[package]]
name = "photocraft-raw"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"flate2",
"image",
@@ -4539,7 +4526,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -4551,7 +4538,7 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"memory-stats",
"photocraft-color",
@@ -4562,7 +4549,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"flate2",
"parley",
@@ -4576,7 +4563,7 @@ dependencies = [
[[package]]
name = "photocraft-ui-egui"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"base64",
"craft-ui",
@@ -4584,7 +4571,6 @@ dependencies = [
"egui",
"egui_extras",
"egui_kittest",
"epaint_default_fonts",
"flate2",
"log",
"photocraft-algo",
@@ -4611,12 +4597,11 @@ dependencies = [
"serde_json",
"sys-locale",
"toml",
"winsafe",
]
[[package]]
name = "photocraft-vector"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -4627,7 +4612,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"eframe",
"egui",
@@ -7460,7 +7445,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.8.2"
version = "0.7.0"
dependencies = [
"serde",
"serde_json",
+10 -39
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.8.2"
version = "0.7.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -38,7 +38,6 @@ photocraft-testkit = { path = "crates/testkit" }
photocraft-tablet = { path = "crates/tablet" }
# external
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
@@ -51,9 +50,7 @@ zune-core = "0.5"
zune-jpeg = "0.5"
proptest = "1"
eframe = { version = "0.36", default-features = false }
# Without `default_fonts`, which embeds egui's fonts uncompressed: ui-egui ships them deflated
# (egui_fonts.rs; the web build's size gate).
egui = { version = "0.36", default-features = false }
egui = "0.36"
egui_kittest = "0.36"
rfd = "0.17"
log = "0.4"
@@ -84,14 +81,13 @@ codegen-units = 1
# The browser build (apps/photocraft-web, `data-cargo-profile` in its index.html). Hosts cap the
# size of a single file (Cloudflare Pages/Workers: 25 MiB), and the wasm is downloaded on every
# visit, so this profile optimizes for size: fat LTO, opt-level "z" for PhotoCraft's own crates,
# "s" for dependencies and for the crates that decode files, and 3 for pixel processing (filters
# and compositing). Dependencies stay at "s": at "z" the whole wasm grows (by 0.4 MB, measured).
# panic stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"`
# is for the native app shell). packaging/web/package.sh fails the build above 24 MiB.
# visit, so this profile optimizes for size: fat LTO, opt-level "z" for the UI shell, and "s" for
# other crates except pixel processing, which keeps opt-level 3 for filters and compositing. panic
# stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` is for
# the native app shell). packaging/web/package.sh fails the build above 24 MiB.
[profile.wasm-release]
inherits = "release"
opt-level = "z"
opt-level = "s"
lto = "fat"
codegen-units = 1
strip = "debuginfo"
@@ -99,35 +95,11 @@ strip = "debuginfo"
[profile.wasm-release.package."*"]
opt-level = "s"
# Decoding and rasterizing keep their speed.
[profile.wasm-release.package.photocraft-codecs]
opt-level = "s"
[profile.wasm-release.package.photocraft-raw]
opt-level = "s"
[profile.wasm-release.package.photocraft-heif]
opt-level = "s"
[profile.wasm-release.package.photocraft-vector]
opt-level = "s"
# Large crates off the per-frame and per-pixel paths: the document model, file parsers run once
# per open/save, JSON (settings, presets, control channel), SVG parsing, and shader translation
# (runs when a pipeline is created). "z" keeps the wasm well under the size gate.
[profile.wasm-release.package.photocraft-doc]
opt-level = "z"
[profile.wasm-release.package.photocraft-psd]
opt-level = "z"
[profile.wasm-release.package.photocraft-format]
opt-level = "z"
[profile.wasm-release.package.photocraft-affinity]
opt-level = "z"
[profile.wasm-release.package.serde_json]
opt-level = "z"
[profile.wasm-release.package.usvg]
opt-level = "z"
[profile.wasm-release.package.naga]
# Keep the browser shell compact as shared panel controls grow. Pixel-processing crates below
# retain their speed-oriented optimization level.
[profile.wasm-release.package.photocraft-ui-egui]
opt-level = "z"
# Pixel processing keeps its speed-oriented optimization level.
[profile.wasm-release.package.photocraft-raster]
opt-level = 3
[profile.wasm-release.package.photocraft-color]
@@ -140,4 +112,3 @@ opt-level = 3
opt-level = 3
[profile.wasm-release.package.photocraft-paint]
opt-level = 3
-4
View File
@@ -3,10 +3,6 @@ Copyright (c) 2026 ArtCraft Team and the PhotoCraft contributors.
Licensed under the MIT License or the Apache License, Version 2.0, at your option
(LICENSE-MIT, LICENSE-APACHE).
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
not open source and are not covered by the license above; their terms are in
docs/brand/LICENSE-brand.txt.
Third-party material
This product bundles third-party material under its own licenses, listed below. The per-file
+12 -70
View File
@@ -1,12 +1,3 @@
<p align="center">
<a href="https://getartcraft.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/artcraft-logo-white.svg">
<img alt="ArtCraft" src="docs/brand/artcraft-logo.svg" width="200">
</picture>
</a>
</p>
<h1 align="center">PhotoCraft</h1>
<p align="center">
@@ -22,16 +13,6 @@
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<p align="center">
<a href="https://getartcraft.com/apps/photocraft"><b>PhotoCraft on getartcraft.com</b></a> ·
<a href="https://getartcraft.com/">ArtCraft</a> ·
<a href="https://getartcraft.com/apps">All Crafting Apps</a>
</p>
<br>
<p align="center">
@@ -41,10 +22,6 @@
<i>The Great Wave off Kanagawa</i>, Katsushika Hokusai, c. 1831</sub>
</p>
> [!NOTE]
> **ArtCraft is a community of artists from all walks of life.** Digital, generative, music,
> games &mdash; if you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).**
<p align="center">
<a href="#features">Features</a> ·
<a href="#everything-in-the-box">Everything in the box</a> ·
@@ -53,8 +30,7 @@
<a href="#under-the-hood">Under the hood</a> ·
<a href="#get-started">Get started</a> ·
<a href="#downloads">Downloads</a> ·
<a href="#the-crafting-apps">Crafting Apps</a> ·
<a href="https://discord.gg/artcraft">Discord</a>
<a href="#the-crafting-apps">Crafting Apps</a>
</p>
<br>
@@ -298,7 +274,7 @@ photocraft
Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
> [!IMPORTANT]
> **Status:** PhotoCraft is in early alpha, and we want to be straight about where it stands: much of Photoshop's feature surface exists in some form, but **it is not yet a Photoshop replacement for daily professional work**. The biggest gaps are AI/generative features, 15 missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity-checklist.md`](docs/parity-checklist.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap](ROADMAP.md) and [parity assessment](docs/target-app-parity.md). Expect rough edges, and please file issues (include your OS, document size, layer count and a screenshot). You can also tell us what broke on [Discord](https://discord.gg/artcraft).
> **Status:** PhotoCraft is in early alpha, and we want to be straight about where it stands: much of Photoshop's feature surface exists in some form, but **it is not yet a Photoshop replacement for daily professional work**. The biggest gaps are AI/generative features, 15 missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity-checklist.md`](docs/parity-checklist.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap](ROADMAP.md) and [parity assessment](docs/target-app-parity.md). Expect rough edges, and please file issues (include your OS, document size, layer count and a screenshot). You can also tell us what broke on [GitHub](https://github.com/storytold/photocraft/issues).
## Documentation
@@ -319,8 +295,6 @@ sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests wi
## Downloads
**New to PhotoCraft?** Download it from the [PhotoCraft page on getartcraft.com](https://getartcraft.com/apps/photocraft). That's the easiest way to install it.
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
### Windows
@@ -367,45 +341,23 @@ RISC-V (riscv64): `photocraft-<ver>-linux-riscv64.tar.gz` only, built against Ub
## The Crafting Apps
PhotoCraft is one of the **Crafting Apps**: free, open-source creative tools from the
[ArtCraft](https://getartcraft.com/) team, each written from scratch in Rust and each able to
[storytold](https://github.com/storytold) team, each written from scratch in Rust and each able to
stand on its own.
| | App | What it's for | Code | Learn more |
|:-:|---|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | **Image editing: layers, masks, type and real PSD files · you are here** | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
| | App | What it's for | Code |
|:-:|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | **Image editing: layers, masks, type and real PSD files · you are here** | [GitHub](https://github.com/storytold/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) |
The Crafting Apps share the same conventions: clean-room and pure Rust, native on macOS, Windows and Linux, in the browser via WebAssembly, and fully drivable by agents.
<br>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<h3 align="center">Come make things with us</h3>
<p align="center">
Our Discord is where artists of every kind hang out: people who paint, shoot, draw, cut film,
set type, and people still figuring out what they like to make. Share what you're working on,
ask for help, tell us what's broken, or tell us what you wish these tools could do.
Whatever your medium and however long you've been at it, you're welcome here.
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
<a href="https://getartcraft.com/">getartcraft.com</a> ·
<a href="https://getartcraft.com/apps">The Crafting Apps</a> ·
<a href="https://getartcraft.com/apps/photocraft">PhotoCraft</a>
</p>
---
## License and credits
@@ -418,18 +370,8 @@ with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
Every artwork shown is in the public domain (Wikimedia Commons, NASA, U.S. National Archives); sources are listed in [`docs/images/SOURCES.md`](docs/images/SOURCES.md).
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
part of this repository and PhotoCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. PhotoCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/photocraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Fphotocraft&type=date&legend=top-left)
+13 -61
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>]
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
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.
photocraft-cli info <file> [--compact]
Print the document as JSON (size, mode, depth, layer tree).
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
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>]
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,
photocraft-cli batch --actions <actions.json> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>]
Apply an action list to every image in a directory. Steps are [id, params] pairs,
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
(a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An Actions set (the Actions
panel's actions.json, {\"actions\": [{\"name\", \"steps\"}…]}) plays the action --action names (needed
@@ -47,7 +45,7 @@ USAGE:
[--automation-read-root <dir>] [--automation-write-root <dir>]
Keep one headless session open and answer JSON lines ({\"id\",\"method\",\"params\"}) on stdio,
or on 127.0.0.1:<port>. Methods: engine.execute, jobs.list/cancel, engine.commands,
doc.open/new/place/save/exportLayers/exportDataSets/importDataSets/inspect/render/select/close, session.list, batch, methods
doc.open/new/save/inspect/render/select/close, session.list, batch, methods
(docs/control-protocol.md#headless-server).
photocraft-cli <subcommand> --help (or -h) prints this text. A flag the subcommand doesn't take is
@@ -69,17 +67,17 @@ struct Subcommand {
}
const SUBCOMMANDS: &[Subcommand] = &[
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert },
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits"], 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"],
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits"],
bare: &["--tiff-layers"],
run: run_cmds,
},
Subcommand {
name: "batch",
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits"],
bare: &["--in-place", "--tiff-layers"],
run: batch,
},
@@ -215,10 +213,6 @@ fn export_opts(a: &Args) -> Result<ExportOptions, String> {
if let Some(b) = a.get("--tga-bits") {
o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| format!("bad --tga-bits `{b}`: expected 24 or 32"))?);
}
if let Some(b) = a.get("--dpx-bits") {
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"))?);
}
Ok(o)
}
@@ -246,7 +240,7 @@ fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
let opts = export_opts(a)?;
let o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
warn_all(err, &o.warnings);
warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document));
warn_all(err, &missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&o.document)));
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?;
warn_all(err, &ws);
Ok(())
@@ -298,15 +292,11 @@ fn command_list(a: &Args) -> Result<Vec<(String, Value)>, String> {
fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let opts = export_opts(a)?;
let mut h = Headless::trusted_local();
// Each missing-font warning is printed once, wherever it is first detected (open, a command or
// the save).
let mut reported_fonts = std::collections::HashSet::<String>::new();
match (a.positional.as_slice(), a.get("--new")) {
([file], None) => {
let o = h.open(Path::new(file)).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(o["warnings"].clone()).unwrap_or_default();
warn_all(err, &ws);
reported_fonts.extend(ws);
}
([], Some(new)) => {
let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?;
@@ -318,11 +308,9 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
if cmds.is_empty() && a.get("--out").is_none() {
return Err("run needs at least one --cmd (or --out)".into());
}
let mut reported_fonts = std::collections::HashSet::<String>::new();
for (id, params) in cmds {
let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?;
if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
return Err(format!("`{id}`: {e}"));
}
let new_warnings: Vec<String> = h
.session
.active()
@@ -350,9 +338,7 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
warn_all(err, &remaining);
if let Some(o) = a.get("--out") {
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?;
// The save repeats the document's missing-font warnings; each was already reported above.
let ws: Vec<String> =
serde_json::from_value::<Vec<String>>(r["warnings"].clone()).unwrap_or_default().into_iter().filter(|w| !reported_fonts.contains(w)).collect();
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
warn_all(err, &ws);
}
Ok(())
@@ -398,38 +384,6 @@ pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec<Action
Ok((play, set))
}
/// [`load_actions`] for a file on disk: a Photoshop Actions file (`.atn`, by extension or by
/// its version word) is converted first; the played action's steps with no PhotoCraft
/// equivalent come back as warnings (they fail at playback, as in Photoshop when a step can't run).
pub fn load_actions_file(path: &str, name: Option<&str>) -> Result<(Steps, Vec<Action>, Vec<String>), String> {
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let is_atn = Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]);
if !is_atn {
let text = String::from_utf8(bytes).map_err(|e| format!("{path}: {e}"))?;
let (steps, set) = load_actions(&text, name)?;
return Ok((steps, set, Vec::new()));
}
let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|e| format!("{path}: {e}"))?;
let names = || imp.actions.iter().map(|a| format!("`{}`", a.name)).collect::<Vec<_>>().join(", ");
let action = match (name, imp.actions.as_slice()) {
(Some(n), _) => {
imp.actions.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set `{}` (it holds {})", imp.set, names()))?
}
(None, [only]) => only,
(None, []) => return Err(format!("{path}: the set holds no actions")),
(None, _) => return Err(format!("the set `{}` holds {} actions: pick one with --action <name> ({})", imp.set, imp.actions.len(), names())),
};
let warnings = imp
.unsupported
.iter()
.filter(|u| u.action == action.name)
.map(|u| format!("`{}` step {}: Photoshop's \"{}\" ({}) has no PhotoCraft equivalent yet", u.action, u.step + 1, u.name, u.event))
.chain(imp.warnings.iter().cloned())
.collect();
let play = vec![("actions.play".to_string(), json!({"action": action.name}))];
Ok((play, imp.actions, warnings))
}
/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a
/// trailing separator, through a symbolic link).
fn same_dir(a: &Path, b: &Path) -> bool {
@@ -448,10 +402,8 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let actions_path = a.get("--actions").ok_or("batch needs --actions <file>")?;
let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in <dir>")?);
let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out <dir>")?);
let (actions, set, warnings) = load_actions_file(actions_path, a.get("--action"))?;
for w in &warnings {
let _ = writeln!(err, "warning: {w}");
}
let text = std::fs::read_to_string(actions_path).map_err(|e| format!("{actions_path}: {e}"))?;
let (actions, set) = load_actions(&text, a.get("--action"))?;
let opts = export_opts(a)?;
// Results are saved as `<out>/<stem>.<ext>`, so an `--out` that is the `--in` folder would
// replace the originals (#492).
+1 -111
View File
@@ -189,11 +189,9 @@ fn run_commands_and_save() {
let lines: Vec<Value> = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[2]["command"], "document.inspect");
assert_eq!(lines[2]["result"]["name"], "Run");
let (info, _) = ok(bin().arg("info").arg(&out_pc));
let v: Value = serde_json::from_str(&info).unwrap();
// Saving a native document gives the reopened bundle its output filename.
assert_eq!(v["name"], "r.pcraft");
assert_eq!(v["name"], "Run");
let names: Vec<&str> = v["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect();
assert_eq!(names, ["Ink", "Background"]);
// Continue from the saved file and export PNG.
@@ -203,50 +201,6 @@ fn run_commands_and_save() {
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_stops_after_failed_action_without_exporting() {
let d = tmp("run-failed-action");
let out = d.join("result.png");
let result = bin()
.args(["run", "--new", r#"{"width":8,"height":8}"#])
.args(["--cmd", "layer.new.layer"])
.args(["--cmd", "actions.record", "--params", r#"{"name":"Delete"}"#])
.args(["--cmd", "layer.delete", "--cmd", "actions.stop"])
// The recorded deletion succeeded with two layers; replay cannot delete the last one.
.args(["--cmd", "actions.play", "--params", r#"{"action":"Delete"}"#])
.args(["--cmd", "document.inspect", "--out"])
.arg(&out)
.output()
.unwrap();
let error = String::from_utf8_lossy(&result.stderr);
assert_eq!(result.status.code(), Some(1), "stdout: {}\nstderr: {error}", String::from_utf8_lossy(&result.stdout));
assert!(error.contains("actions.play") && error.contains("Delete") && error.contains("step 1"), "{error}");
let rows: Vec<Value> = String::from_utf8_lossy(&result.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
assert!(rows.iter().all(|row| row["command"] != "document.inspect"), "later commands must not run");
assert!(!out.exists(), "a failed action must not export a partial result");
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_continues_after_successful_action_and_exports() {
let d = tmp("run-successful-action");
let out = d.join("result.png");
let (stdout, _) = ok(bin()
.args(["run", "--new", r#"{"width":8,"height":8}"#])
.args(["--cmd", "actions.record", "--params", r#"{"name":"Add"}"#])
.args(["--cmd", "layer.new.layer", "--cmd", "actions.stop"])
.args(["--cmd", "actions.play", "--params", r#"{"action":"Add"}"#])
.args(["--cmd", "document.inspect", "--out"])
.arg(&out));
let rows: Vec<Value> = stdout.lines().map(|line| serde_json::from_str(line).unwrap()).collect();
let playback = rows.iter().find(|row| row["command"] == "actions.play").unwrap();
assert_eq!(playback["result"]["ran"], 1);
assert!(playback["result"].get("failed").is_none());
assert!(rows.iter().any(|row| row["command"] == "document.inspect"));
assert!(out.is_file());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_errors() {
let o = bin().args(["run", "--new", "{}", "--cmd", "no.such"]).output().unwrap();
@@ -455,40 +409,6 @@ fn quality_outside_1_to_100_is_refused() {
assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}");
}
/// DPX converts both ways, `info` reports its size, mode and depth, and `--dpx-bits` picks the
/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is refused (#1049).
#[test]
fn dpx_converts_both_ways_with_the_bits_asked_for() {
let d = tmp("dpx");
let a = d.join("a.png");
write_png(&a, 8, 4, 3);
for (bits, want) in [(None, 8), (Some("10"), 10), (Some("12"), 12), (Some("16"), 16)] {
let out = d.join("a.dpx");
let mut convert = bin();
convert.arg("convert").arg(&a).arg(&out);
if let Some(b) = bits {
convert.args(["--dpx-bits", b]);
}
ok(&mut convert);
let bytes = std::fs::read(&out).unwrap();
assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], want), "--dpx-bits {bits:?}");
}
let (info, _) = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact"));
let v: Value = serde_json::from_str(&info).unwrap();
assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(8), Some(4)), "{v}");
assert!(info.contains("16") && info.to_lowercase().contains("rgb"), "{info}");
let back = d.join("back.png");
ok(bin().arg("convert").arg(d.join("a.dpx")).arg(&back));
assert!(back.exists());
for bad in ["24", "0", "abc"] {
let out = d.join("bad.dpx");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dpx-bits", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--dpx-bits {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dpx-bits `{bad}`: expected 8, 10, 12 or 16")), "--dpx-bits {bad}");
assert!(!out.exists(), "--dpx-bits {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]
@@ -718,33 +638,3 @@ fn a_non_unicode_argument_is_a_usage_error_not_a_panic() {
assert!(!err.contains("panicked"), "{err}");
}
}
/// `batch --actions set.atn`: a Photoshop Actions file plays like an Actions set, and its steps
/// PhotoCraft can't play are warned about up front.
#[test]
fn batch_plays_a_photoshop_actions_file() {
use photocraft_engine::actions_cmds::Action;
let d = tmp("atn");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
let invert = Action::new("Negative", vec![("image.adjustments.invert".into(), json!({})), ("select.all".into(), json!({}))]);
let odd = Action::new("Odd", vec![(photocraft_engine::atn_cmds::PHOTOSHOP_STEP.into(), json!({"event": "syntheticFill", "name": "Generative Fill"}))]);
let (bytes, skipped) = photocraft_engine::atn_cmds::export("Set", &[&invert, &odd], &[], false).unwrap();
assert!(skipped.is_empty());
let atn = d.join("Set.atn");
std::fs::write(&atn, bytes).unwrap();
let out_dir = d.join("out");
// Two actions: the set needs --action.
let o = bin().args(["batch", "--actions"]).arg(&atn).arg("--in").arg(&input).arg("--out").arg(&out_dir).output().unwrap();
assert!(!o.status.success());
assert!(String::from_utf8_lossy(&o.stderr).contains("--action"), "{}", String::from_utf8_lossy(&o.stderr));
let (_, err) = ok(bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Negative", "--in"]).arg(&input).arg("--out").arg(&out_dir));
assert!(out_dir.join("a.png").exists());
assert!(!err.contains("warning"), "{err}");
// The unsupported step is warned about, and fails the file as Photoshop would stop on it.
let o = bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Odd", "--in"]).arg(&input).arg("--out").arg(d.join("out2")).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert!(err.contains("Generative Fill"), "{err}");
assert!(!o.status.success());
}
+2 -7
View File
@@ -125,19 +125,14 @@ fn orientation_6_jpeg_converts_upright_and_rotates_back() {
let (a, b) = (d.join("a.pcraft"), d.join("b.pcraft"));
ok(bin().arg("convert").arg(&jpg).arg(&a));
ok(bin().arg("convert").arg(&a).arg(&b));
// Everything but the file's own path and saved document name must match.
// Everything but the file's own path must match.
let info = |p: &Path| {
let mut v = serde_json::from_str::<Value>(&ok(bin().arg("info").arg(p))).unwrap();
v.as_object_mut().unwrap().remove("file");
v.as_object_mut().unwrap().remove("path");
v
};
let (mut ia, mut ib) = (info(&a), info(&b));
let (ia, ib) = (info(&a), info(&b));
assert_eq!((ia["width"].as_u64(), ia["height"].as_u64()), (Some(16), Some(32)));
assert_eq!(ia["name"], "a.pcraft");
assert_eq!(ib["name"], "b.pcraft");
ia.as_object_mut().unwrap().remove("name");
ib.as_object_mut().unwrap().remove("name");
assert_eq!(ia, ib);
let (pa, pb) = (d.join("a.png"), d.join("b.png"));
ok(bin().arg("convert").arg(&a).arg(&pa));
+1 -1
View File
@@ -29,7 +29,7 @@ log = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["wgpu", "accesskit"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>PhotoCraft</title>
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-cargo-features="heif" data-wasm-opt="z" data-wasm-opt-params="--converge" data-weak-refs />
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-cargo-features="heif" data-wasm-opt="z" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#photocraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
+21 -19
View File
@@ -18,8 +18,8 @@ type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm",
"ppm", "pam", "pfm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr",
"grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
"ppm", "pam", "pfm", "fits", "fit", "fts", "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";
@@ -305,22 +305,27 @@ impl eframe::App for WebShell {
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
if let Some(error) = self.presets.error() {
let error = self.presets.error();
if error.is_some() || self.presets.unsaved() {
egui::Panel::top("browser_preset_storage").show(ui, |ui| {
let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme);
// Put retry first so floating brush windows cannot cover the control.
ui.horizontal_wrapped(|ui| {
if self.database.is_some() && ui.button(i18n::t("Retry saving presets")).clicked() {
self.presets.retry();
ui.ctx().request_repaint();
}
ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", &error)]));
});
ui.label(if self.database.is_some() {
i18n::t("Keep this tab open to retain your brushes.")
if let Some(error) = error {
let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme);
// Put retry first so floating brush windows cannot cover the control.
ui.horizontal_wrapped(|ui| {
if self.database.is_some() && ui.button(i18n::t("Retry saving presets")).clicked() {
self.presets.retry();
ui.ctx().request_repaint();
}
ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", &error)]));
});
ui.label(if self.database.is_some() {
i18n::t("Keep this tab open to retain your brushes.")
} else {
i18n::t("Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them.")
});
} else {
i18n::t("Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them.")
});
ui.label(i18n::t("Saving brush presets… Keep this tab open until saving finishes."));
}
});
}
self.app.ui(ui, frame);
@@ -347,7 +352,6 @@ fn services(inbox: Inbox) -> Services {
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
opts.tga_bits = settings.tga_bits;
opts.image_previews = settings.image_previews;
opts.psd_maximize_compatibility = settings.psd_maximize_compatibility;
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
})),
file_dialog: Some(Box::new(|request, _parent, reply| match request {
@@ -378,8 +382,6 @@ fn services(inbox: Inbox) -> Services {
};
reply.send(answer);
}),
// Browser folder handles cannot be represented as filesystem paths for engine commands.
FileDialogRequest::Folder { .. } => reply.send(None),
// No save dialog on the web: the suggested name becomes the download name.
FileDialogRequest::Save { suggested } => {
let name = std::path::Path::new(&suggested).file_name().map_or_else(|| suggested.clone(), |n| n.to_string_lossy().to_string());
+1 -2
View File
@@ -29,7 +29,7 @@ photocraft-automation = { workspace = true }
photocraft-ui-egui = { workspace = true }
# Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate).
photocraft-tablet = { workspace = true }
eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
@@ -64,7 +64,6 @@ dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
[target.'cfg(target_os = "linux")'.dependencies]
photocraft-cms = { workspace = true }
x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] }
# Wayland desktop color selection through the existing XDG portal transport.
zbus = "=5.19.0"
+1 -47
View File
@@ -9,7 +9,7 @@ use std::time::Instant;
use photocraft_automation::budgets::write_reply;
use photocraft_automation::security::{
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, repeat_auth_reply,
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line,
};
use photocraft_ui_egui::ControlRequest;
use photocraft_ui_egui::control::{REPLY_DEADLINE, timeout_error};
@@ -79,12 +79,6 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
}
continue;
}
if let Some(reply) = repeat_auth_reply(&line) {
if write_reply(&mut out, &reply).is_err() || out.flush().is_err() {
break;
}
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
@@ -208,44 +202,4 @@ mod tests {
handler.join().unwrap();
server.join().unwrap();
}
#[test]
fn repeat_auth_reports_already_authenticated_without_dispatching() {
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
let server = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
serve(stream, TOKEN, tx, egui::Context::default());
});
let handler = std::thread::spawn(move || {
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(req.method, "test.after", "a repeat auth must not reach the UI dispatcher");
req.reply.send(json!({"ok": true, "result": "still serving"})).unwrap();
});
let mut stream = TcpStream::connect(addr).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut line = String::new();
for id in ["auth", "again"] {
writeln!(stream, "{}", json!({"id": id, "method": "auth", "params": {"token": TOKEN}})).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
}
let again: Value = serde_json::from_str(&line).unwrap();
assert_eq!(again["id"], "again");
assert_eq!(again["ok"], false);
assert_eq!(again["error"], "already authenticated");
writeln!(stream, "{}", json!({"id": 9, "method": "test.after"})).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
let after: Value = serde_json::from_str(&line).unwrap();
assert_eq!(after["id"], 9);
assert_eq!(after["result"], "still serving");
drop(reader);
drop(stream);
handler.join().unwrap();
server.join().unwrap();
}
}
+2 -49
View File
@@ -90,21 +90,11 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
// eframe saves native window geometry and egui panel/window sizes on exit.
// Keep that state beside preferences, including config overrides and portable mode.
persistence_path: services::config_dir().map(|dir| dir.join("ui.ron")),
window_builder: Some(Box::new(restored_window)),
window_builder: Some(Box::new(at_least_min_size)),
..Default::default()
}
}
/// The window eframe restores from `ui.ron`, corrected before it is created: at least the
/// minimum size, and never full screen. PhotoCraft always starts in Standard Screen Mode, but
/// eframe also saves and restores full screen, and a window closed in a full-screen mode is
/// saved full screen and maximized at once: the overlap `view_cmds::screen_mode_commands` keeps
/// apart at run time. [OBSERVED 2026-10-12, Windows 11] Restored that way, View › Screen Mode ›
/// Full Screen did nothing until `F` had cycled through Standard.
fn restored_window(builder: egui::ViewportBuilder) -> egui::ViewportBuilder {
at_least_min_size(builder).with_fullscreen(false)
}
/// Grow a restored window that is smaller than the minimum size, keeping its centre. On
/// Windows, eframe saves a minimized window's size (160 pt wide, 64 pt tall once eframe has
/// clamped it), and the minimum size isn't applied to a restored one: PhotoCraft then reopened
@@ -240,8 +230,6 @@ fn main() -> eframe::Result {
// The display server to open the window on: Xwayland for a pen on Wayland, which gives this
// app no pen input (#639).
#[cfg(target_os = "linux")]
let native_session = linux_libs::session_from_env(|name| std::env::var(name).ok());
#[cfg(target_os = "linux")]
let session = tablet::display_session();
// Preferences › Performance › Linux display server = X11: Xwayland on a Wayland session too,
// where native file drops work (winit 0.30 has none on Wayland, #386). Only when Xwayland can
@@ -309,9 +297,6 @@ fn main() -> eframe::Result {
// Read the displays' ICC profiles while the window opens (colour-managed canvas; `None`
// where the platform has no reader).
#[cfg(target_os = "linux")]
let monitor = monitor_profile::detect_async_for_session(native_session);
#[cfg(not(target_os = "linux"))]
let monitor = monitor_profile::detect_async();
// Brush presets load in the background; the app attaches them when they arrive.
let presets = services::presets_dir().map(photocraft_engine::preset_store::open_dir_async);
@@ -394,14 +379,7 @@ fn main() -> eframe::Result {
// the right profile; a slower reading is applied when it arrives, and the shell reads
// again when the displays may have changed.
if let Some(rx) = monitor {
#[cfg(target_os = "linux")]
{
app.services.read_displays = Some(std::sync::Arc::new(move || monitor_profile::detect_async_for_session(native_session)));
}
#[cfg(not(target_os = "linux"))]
{
app.services.read_displays = Some(std::sync::Arc::new(monitor_profile::detect_async));
}
app.services.read_displays = Some(std::sync::Arc::new(monitor_profile::detect_async));
match rx.recv_timeout(std::time::Duration::from_secs(2)) {
Ok(r) => photocraft_ui_egui::monitor_status::apply(&mut app, r),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => photocraft_ui_egui::monitor_status::pending(&mut app, rx),
@@ -634,22 +612,6 @@ mod tests {
assert_eq!(super::at_least_min_size(builder).inner_size, None);
}
#[test]
fn a_window_saved_in_full_screen_reopens_maximized_not_full_screen() {
let hook = super::native_options(super::CUSTOM_TITLEBAR).window_builder.expect("a window builder hook");
// What eframe restores from a ui.ron saved in a full-screen mode on Windows.
let saved = super::native_options(super::CUSTOM_TITLEBAR)
.viewport
.with_inner_size([2560.0, 1392.0])
.with_position([0.0, 0.0])
.with_fullscreen(true)
.with_maximized(true);
let opened = hook(saved);
assert_eq!(opened.fullscreen, Some(false), "Standard Screen Mode is where PhotoCraft starts");
assert_eq!(opened.maximized, Some(true), "the rest of the saved geometry is kept");
assert_eq!(opened.inner_size, Some(egui::vec2(2560.0, 1392.0)));
}
#[test]
fn the_system_title_bar_preference_keeps_the_window_decorations() {
// #1271, #1316: Preferences › Interface › System Title Bar gives the window back its
@@ -670,13 +632,4 @@ mod tests {
}
let _ = std::fs::remove_dir_all(&dir);
}
/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
/// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
#[test]
fn the_desktop_entry_names_the_window_class() {
let entry = include_str!("../../../packaging/linux/ai.storyteller.photocraft.desktop");
let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass="));
assert_eq!(class, Some(super::APP_ID));
}
}
+6 -246
View File
@@ -6,12 +6,7 @@
//! AppleScriptObjC) so the app needs no `unsafe` FFI. A read takes about 0.4 s on a background
//! thread; the shell starts one at launch and again when the displays may have changed
//! (`photocraft_ui_egui::monitor_status`), and each window then uses the profile of the display
//! it is on. Linux X11: RandR output names and CRTC frames are matched to colord display devices
//! by the exact `XRANDR_name` metadata; cloned outputs get a profile only when every output has
//! the same ICC bytes. Native Wayland, including Xwayland windows opened from a Wayland session,
//! reports no display profiles: eframe/winit has no safe way to set the app surface's Wayland
//! image description, so applying the output ICC in PhotoCraft could double-transform it.
//! Other platforms report no displays (sRGB, or the profile chosen in Color Settings).
//! it is on. Other platforms report no displays (sRGB, or the profile chosen in Color Settings).
use std::sync::mpsc::Receiver;
@@ -21,33 +16,15 @@ use photocraft_engine::display_color::Display;
pub type Detection = Result<Vec<Display>, String>;
/// Starts reading the displays in the background; `None` when the platform has no reader.
#[cfg(not(target_os = "linux"))]
pub fn detect_async() -> Option<Receiver<Detection>> {
#[cfg(target_os = "macos")]
{
Some(spawn_reader(detect))
if !cfg!(target_os = "macos") {
return None;
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
/// Linux display profile reads are supported only for an X11 session. In particular, an app
/// window opened through Xwayland still belongs to the Wayland desktop and must not apply a
/// compositor-managed output profile a second time.
#[cfg(target_os = "linux")]
pub fn detect_async_for_session(session: crate::linux_libs::DisplaySession) -> Option<Receiver<Detection>> {
(session == crate::linux_libs::DisplaySession::X11).then(|| spawn_reader(detect))
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn spawn_reader(detect: fn() -> Detection) -> Receiver<Detection> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(detect());
});
rx
Some(rx)
}
/// One line per screen, primary (menu-bar) screen first: id, frame (x, y, width, height in
@@ -133,225 +110,9 @@ fn run_helper(cmd: &mut std::process::Command, timeout: std::time::Duration) ->
Ok(String::from_utf8_lossy(&out).into_owned())
}
#[cfg(target_os = "linux")]
#[cfg(not(target_os = "macos"))]
fn detect() -> Detection {
linux::detect()
}
#[cfg(target_os = "linux")]
mod linux {
use std::collections::{BTreeMap, HashMap};
use std::io::Read as _;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use photocraft_engine::display_color::Display;
use x11rb::connection::Connection as _;
use x11rb::protocol::randr;
use zbus::blocking::{connection::Builder, proxy::Proxy};
use zbus::zvariant::OwnedObjectPath;
use super::Detection;
const MAX_ICC_BYTES: usize = 64 * 1024 * 1024;
const COLORD: &str = "org.freedesktop.ColorManager";
const COLORD_ROOT: &str = "/org/freedesktop/ColorManager";
const COLORD_MANAGER: &str = "org.freedesktop.ColorManager";
const COLORD_DEVICE: &str = "org.freedesktop.ColorManager.Device";
const COLORD_PROFILE: &str = "org.freedesktop.ColorManager.Profile";
struct IccProfile {
name: String,
bytes: Arc<Vec<u8>>,
}
pub(super) fn detect() -> Detection {
let (connection, screen) = x11rb::connect(None).map_err(|e| format!("couldn't connect to the X11 display: {e}"))?;
let root = connection.setup().roots.get(screen).ok_or_else(|| "the X11 server didn't provide a screen".to_string())?.root;
let resources = randr::get_screen_resources_current(&connection, root)
.map_err(|e| format!("couldn't request XRandR outputs: {e}"))?
.reply()
.map_err(|e| format!("couldn't read XRandR outputs: {e}"))?;
let mut output_names = HashMap::new();
let mut active_crtcs = BTreeMap::new();
for output in resources.outputs {
let info = match randr::get_output_info(&connection, output, resources.config_timestamp) {
Ok(cookie) => match cookie.reply() {
Ok(info) => info,
Err(e) => {
log::debug!("couldn't read one XRandR output: {e}");
continue;
}
},
Err(e) => {
log::debug!("couldn't request one XRandR output: {e}");
continue;
}
};
if info.connection != randr::Connection::CONNECTED || info.crtc == 0 {
continue;
}
let Ok(name) = String::from_utf8(info.name) else {
log::debug!("skipping an XRandR output with a non-UTF-8 name");
continue;
};
if !name.is_empty() {
output_names.insert(output, name);
active_crtcs.insert(info.crtc, ());
}
}
let profiles = match colord_profiles() {
Ok(profiles) => profiles,
Err(e) => {
log::warn!("couldn't read X11 display profiles from colord: {e}");
HashMap::new()
}
};
let mut displays = Vec::with_capacity(active_crtcs.len());
for crtc in active_crtcs.into_keys() {
let crtc_info = match randr::get_crtc_info(&connection, crtc, resources.config_timestamp) {
Ok(cookie) => match cookie.reply() {
Ok(info) if info.status == randr::SetConfig::SUCCESS && info.width > 0 && info.height > 0 => info,
Ok(_) => continue,
Err(e) => {
log::debug!("couldn't read XRandR CRTC {crtc}: {e}");
continue;
}
},
Err(e) => {
log::debug!("couldn't request XRandR CRTC {crtc}: {e}");
continue;
}
};
let Some(output_names) = crtc_info.outputs.iter().map(|output| output_names.get(output).cloned()).collect::<Option<Vec<_>>>() else { continue };
let Some(primary_name) = output_names.first() else { continue };
let profile = unambiguous_clone_profile(&output_names, &profiles);
displays.push(Display {
id: crtc,
name: primary_name.clone(),
frame: [f64::from(crtc_info.x), f64::from(crtc_info.y), f64::from(crtc_info.width), f64::from(crtc_info.height)],
profile_name: profile.as_ref().map(|p| p.name.clone()),
icc: profile.map(|p| p.bytes),
});
}
if displays.is_empty() {
return Err("X11 reported no active RandR outputs".into());
}
Ok(displays)
}
fn colord_profiles() -> Result<HashMap<String, Vec<IccProfile>>, String> {
let connection = Builder::system()
.map_err(|e| format!("couldn't connect to the system bus: {e}"))?
.method_timeout(Duration::from_secs(2))
.build()
.map_err(|e| format!("couldn't connect to the system bus: {e}"))?;
let manager = Proxy::new(&connection, COLORD, COLORD_ROOT, COLORD_MANAGER).map_err(|e| format!("couldn't access colord: {e}"))?;
let devices: Vec<OwnedObjectPath> =
manager.call("GetDevicesByKind", &("display",)).map_err(|e| format!("colord couldn't list display devices: {e}"))?;
let mut profiles: HashMap<String, Vec<IccProfile>> = HashMap::new();
for device_path in devices {
let device = Proxy::new(&connection, COLORD, &device_path, COLORD_DEVICE).map_err(|e| format!("couldn't access a colord display device: {e}"))?;
let metadata: HashMap<String, String> = device.get_property("Metadata").map_err(|e| format!("couldn't read colord display metadata: {e}"))?;
let Some(output_name) = metadata.get("XRANDR_name").filter(|name| !name.is_empty()) else { continue };
// `*` is colord's default profile qualifier. A display without one is kept, with no
// ICC, so the engine reports the normal sRGB fallback.
let profile_path: OwnedObjectPath = match device.call("GetProfileForQualifiers", &(vec!["*"],)) {
Ok(path) => path,
Err(_) => continue,
};
let profile = Proxy::new(&connection, COLORD, &profile_path, COLORD_PROFILE).map_err(|e| format!("couldn't access a colord ICC profile: {e}"))?;
let filename: String = match profile.get_property::<String>("Filename") {
Ok(filename) if !filename.is_empty() => filename,
_ => continue,
};
let Some(profile) = read_icc(Path::new(&filename)) else {
log::warn!("couldn't read a valid ICC profile assigned to X11 output {output_name}");
continue;
};
profiles.entry(output_name.clone()).or_default().push(profile);
}
Ok(profiles)
}
fn read_icc(path: &Path) -> Option<IccProfile> {
let mut bytes = Vec::new();
std::fs::File::open(path).ok()?.take((MAX_ICC_BYTES + 1) as u64).read_to_end(&mut bytes).ok()?;
let bytes = checked_icc_bytes(bytes)?;
let name = path.file_name()?.to_str()?.to_owned();
Some(IccProfile { name, bytes: Arc::new(bytes) })
}
fn checked_icc_bytes(mut bytes: Vec<u8>) -> Option<Vec<u8>> {
if bytes.len() > MAX_ICC_BYTES || bytes.len() < 132 || bytes.get(36..40) != Some(b"acsp") {
return None;
}
let declared = u32::from_be_bytes(bytes.get(0..4)?.try_into().ok()?) as usize;
if declared < 132 || declared > bytes.len() {
return None;
}
bytes.truncate(declared);
let profile = photocraft_cms::Profile::parse(&bytes).ok()?;
(profile.color_space == photocraft_cms::ColorSpace::Rgb).then_some(bytes)
}
fn unambiguous_clone_profile(output_names: &[String], profiles: &HashMap<String, Vec<IccProfile>>) -> Option<IccProfile> {
let mut selected: Option<&IccProfile> = None;
for output in output_names {
let candidates = profiles.get(output)?;
let first = candidates.first()?;
if candidates.iter().any(|candidate| candidate.bytes != first.bytes) {
return None;
}
if selected.is_some_and(|profile| profile.bytes != first.bytes) {
return None;
}
selected = Some(first);
}
selected.map(|profile| IccProfile { name: profile.name.clone(), bytes: profile.bytes.clone() })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn checked_icc_bytes_require_a_bounded_parseable_rgb_profile() {
let rgb = photocraft_cms::Builtin::Srgb.profile().to_bytes();
let bytes = rgb.as_ref().clone();
assert_eq!(checked_icc_bytes(bytes.clone()), Some(bytes));
let cmyk = photocraft_cms::Builtin::CoatedCmyk.profile().to_bytes();
assert!(checked_icc_bytes(cmyk.as_ref().clone()).is_none());
assert!(checked_icc_bytes(vec![0; 132]).is_none());
assert!(checked_icc_bytes(vec![0; MAX_ICC_BYTES + 1]).is_none());
}
#[test]
fn cloned_outputs_use_only_one_unambiguous_profile() {
let srgb = photocraft_cms::Builtin::Srgb.profile().to_bytes();
let p3 = photocraft_cms::Builtin::DisplayP3.profile().to_bytes();
let profile = |name: &str, bytes: std::sync::Arc<Vec<u8>>| IccProfile { name: name.into(), bytes };
let outputs = ["DP-1".to_owned(), "HDMI-1".to_owned()];
let matching =
HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb.clone())]), ("HDMI-1".to_owned(), vec![profile("sRGB", srgb.clone())])]);
assert_eq!(unambiguous_clone_profile(&outputs, &matching).map(|p| p.name), Some("sRGB".into()));
let differing =
HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb.clone())]), ("HDMI-1".to_owned(), vec![profile("Display P3", p3.clone())])]);
assert!(unambiguous_clone_profile(&outputs, &differing).is_none());
let missing = HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb.clone())])]);
assert!(unambiguous_clone_profile(&outputs, &missing).is_none());
let conflicting = HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb), profile("Display P3", p3)])]);
assert!(unambiguous_clone_profile(&["DP-1".to_owned()], &conflicting).is_none());
}
}
Err("this platform doesn't report display profiles".into())
}
/// The helper's reply ([`SCRIPT`]) → displays, frames flipped to a top-left origin with y down
@@ -524,7 +285,6 @@ mod tests {
}
/// On a Mac, every display is read, and the profiles parse as RGB profiles.
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn detected_displays_parse() {
if let Ok(displays) = detect() {
+16 -88
View File
@@ -48,66 +48,6 @@ fn count(width: usize, height: usize) -> Result<usize, String> {
width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_PIXELS).ok_or_else(|| "Screen capture exceeds the 64 MP memory budget".into())
}
#[cfg(target_os = "linux")]
const MAX_ICC_PROFILE_BYTES: usize = 1024 * 1024;
#[cfg(target_os = "linux")]
const ICC_PROFILE_PROPERTY_LONG_LENGTH: u32 = 256 * 1024;
#[cfg(target_os = "linux")]
fn icc_profile(bytes: Vec<u8>) -> Option<Vec<u8>> {
if bytes.is_empty() || bytes.len() > MAX_ICC_PROFILE_BYTES {
return None;
}
let profile = match photocraft_cms::Profile::parse(&bytes) {
Ok(profile) => profile,
Err(_) => return None,
};
(profile.color_space == photocraft_cms::ColorSpace::Rgb).then_some(bytes)
}
#[cfg(target_os = "linux")]
fn icc_profile_atom<C: x11rb::connection::RequestConnection>(conn: &C, name: &str) -> Option<u32> {
use x11rb::protocol::xproto::ConnectionExt as _;
let cookie = match conn.intern_atom(true, name.as_bytes()) {
Ok(cookie) => cookie,
Err(_) => return None,
};
let atom = match cookie.reply() {
Ok(reply) => reply.atom,
Err(_) => return None,
};
(atom != 0).then_some(atom)
}
#[cfg(target_os = "linux")]
fn output_icc_profile<C: x11rb::connection::RequestConnection>(conn: &C, output: u32, atom: u32) -> Option<Vec<u8>> {
use x11rb::protocol::{randr::ConnectionExt as _, xproto::AtomEnum};
let cookie = match conn.randr_get_output_property(output, atom, AtomEnum::CARDINAL, 0, ICC_PROFILE_PROPERTY_LONG_LENGTH, false, false) {
Ok(cookie) => cookie,
Err(_) => return None,
};
let reply = match cookie.reply() {
Ok(reply) => reply,
Err(_) => return None,
};
if reply.type_ != u32::from(AtomEnum::CARDINAL) || reply.format != 8 || reply.bytes_after != 0 {
return None;
}
icc_profile(reply.data)
}
#[cfg(target_os = "linux")]
fn root_icc_profile<C: x11rb::connection::RequestConnection>(conn: &C, root: u32, atom: u32) -> Option<Vec<u8>> {
use x11rb::protocol::xproto::{AtomEnum, ConnectionExt as _};
let cookie = match conn.get_property(false, root, atom, AtomEnum::ANY, 0, ICC_PROFILE_PROPERTY_LONG_LENGTH) {
Ok(cookie) => cookie,
Err(_) => return None,
};
let reply = match cookie.reply() {
Ok(reply) => reply,
Err(_) => return None,
};
if reply.format != 8 || reply.bytes_after != 0 {
return None;
}
icc_profile(reply.value)
}
#[cfg(target_os = "linux")]
fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, placements: &[Placement]) -> Result<Capture, String> {
if wayland {
return portal(stop);
@@ -115,7 +55,10 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
use x11rb::{
connection::Connection,
image::{Image, PixelLayout},
protocol::{randr::ConnectionExt as _, xproto::VisualClass},
protocol::{
randr::ConnectionExt as _,
xproto::{AtomEnum, ConnectionExt, VisualClass},
},
};
let (conn, index) = x11rb::connect(None).map_err(|e| e.to_string())?;
let screen = conn.setup().roots.get(index).ok_or("X11 screen is missing")?;
@@ -128,16 +71,13 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
{
first.primary = true;
}
let regions: Vec<(i16, i16, u16, u16, bool, Vec<x11rb::protocol::randr::Output>)> = if monitors.is_empty() {
vec![(0, 0, w, h, true, Vec::new())]
} else {
monitors.iter().map(|m| (m.x, m.y, m.width, m.height, m.primary, m.outputs.clone())).collect()
};
let regions: Vec<(i16, i16, u16, u16, bool)> =
if monitors.is_empty() { vec![(0, 0, w, h, true)] } else { monitors.iter().map(|m| (m.x, m.y, m.width, m.height, m.primary)).collect() };
if regions.len() > 16 {
return Err("Too many screens for the color picker".into());
}
let mut total = 0usize;
for &(x, y, mw, mh, _, _) in &regions {
for &(x, y, mw, mh, _) in &regions {
if x < 0 || y < 0 || u32::from(x as u16) + u32::from(mw) > u32::from(w) || u32::from(y as u16) + u32::from(mh) > u32::from(h) {
return Err("Invalid X11 display geometry".into());
}
@@ -152,20 +92,20 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
return Err("Screen picking requires an X11 TrueColor visual".into());
}
let layout = PixelLayout::from_visual_type(*visual).map_err(|e| e.to_string())?;
let output_profile_atom = icc_profile_atom(&conn, "_ICC_PROFILE");
let atom = conn.intern_atom(true, b"_ICC_PROFILE").map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?.atom;
let primary_profile = if atom == 0 {
None
} else {
let reply = conn.get_property(false, screen.root, atom, AtomEnum::ANY, 0, 256 * 1024).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
(reply.bytes_after == 0 && reply.format == 8 && !reply.value.is_empty()).then_some(reply.value)
};
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
let byte = |v: u16| ((u32::from(v) + 128) / 257) as u8;
let mut images = Vec::new();
for (x, y, mw, mh, primary, outputs) in regions {
for (x, y, mw, mh, primary) in regions {
if stop.load(std::sync::atomic::Ordering::Relaxed) {
return Ok(Capture::Color(None));
}
let profile = output_profile_atom.and_then(|atom| outputs.iter().find_map(|output| output_icc_profile(&conn, *output, atom))).or_else(|| {
if !primary {
return None;
}
icc_profile_atom(&conn, "_ICC_PROFILE").and_then(|atom| root_icc_profile(&conn, screen.root, atom))
});
let mut rgba = Vec::with_capacity(count(usize::from(mw), usize::from(mh))?.saturating_mul(4));
for row in 0..mh {
for col in 0..mw {
@@ -180,7 +120,7 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
width: usize::from(mw),
height: usize::from(mh),
rgba,
profile,
profile: if primary { primary_profile.clone() } else { None },
});
}
Ok(Capture::Images(images))
@@ -419,18 +359,6 @@ mod tests {
}
#[cfg(target_os = "linux")]
#[test]
fn x11_screen_capture_accepts_only_bounded_rgb_icc_profiles() {
let rgb = photocraft_cms::Builtin::Srgb.profile().to_bytes();
let bytes = rgb.as_ref().clone();
assert_eq!(icc_profile(bytes.clone()), Some(bytes));
assert!(icc_profile(Vec::new()).is_none());
assert!(icc_profile(vec![0; MAX_ICC_PROFILE_BYTES + 1]).is_none());
let cmyk = photocraft_cms::Builtin::CoatedCmyk.profile().to_bytes();
assert!(icc_profile(cmyk.as_ref().clone()).is_none());
}
#[cfg(target_os = "linux")]
#[test]
#[ignore = "requires a live X11 display; draws only a tiny synthetic fixture"]
fn screen_capture_reads_a_known_x11_fixture_pixel() {
use x11rb::{
+10 -35
View File
@@ -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", "kra", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm",
"pgm", "ppm", "pam", "pfm", "pcx", "fits", "fit", "fts", "dpx", "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", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm",
"ppm", "pam", "pfm", "fits", "fit", "fts", "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"];
@@ -43,7 +43,6 @@ fn open_filters() -> Vec<(&'static str, Vec<String>)> {
("All Formats", open_filter_extensions(OPEN_EXTS)),
("PhotoCraft", open_filter_extensions(&["pcraft"])),
("OpenRaster", open_filter_extensions(&["ora"])),
("Krita", open_filter_extensions(&["kra"])),
("SVG", open_filter_extensions(SVG_EXTS)),
]
}
@@ -62,7 +61,6 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("Targa", &["tga"]),
("OpenEXR", &["exr"]),
("FITS", &["fits", "fit"]),
("DPX", &["dpx"]),
];
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
@@ -117,13 +115,6 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) })
}
}
FileDialogRequest::Folder { initial_dir } => {
if let Some(dir) = initial_dir {
dialog = dialog.set_directory(dir);
}
let picked = dialog.pick_folder();
Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) })
}
FileDialogRequest::Save { suggested } => {
for (name, exts) in save_filters(&suggested) {
dialog = dialog.add_filter(name, &exts);
@@ -200,11 +191,6 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
photocraft_format::atomic_write(path, bytes).map_err(|e| e.to_string())
}
/// [`write_atomic`] reporting the fraction written (a background save's progress, #3389).
fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> Result<(), String> {
photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string())
}
fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions {
let mut opts = photocraft_io::ExportOptions::default();
if let Some(q) = settings.jpeg_quality {
@@ -218,36 +204,25 @@ fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_i
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
opts.tga_bits = settings.tga_bits;
opts.image_previews = settings.image_previews;
opts.psd_maximize_compatibility = settings.psd_maximize_compatibility;
opts
}
/// The share of a save's progress bar that encoding fills; writing the file fills the rest. A
/// 24 MP, 5-layer PSD (572 MB) takes about 3 s to encode and 0.5 s or more to write and flush.
const ENCODE_SHARE: f32 = 0.85;
/// A save on a worker thread (`Services::save_file` and `Services::automation_save`, #2017):
/// encode, then write through `write`. Cancelling while encoding leaves the target untouched;
/// once writing starts the save can't be cancelled. The encoder's progress fills the first
/// [`ENCODE_SHARE`] of the job's progress, so the status bar and tab read "Saving NN%" (#3389).
/// once writing starts the save can't be cancelled.
fn save_through(
doc: &Document,
path: &str,
settings: &photocraft_ui_egui::ExportSettings,
ctl: &photocraft_ui_egui::jobs_ui::SaveCtl,
write: impl Fn(&str, &[u8], &dyn Fn(f32)) -> Result<(), String>,
write: impl Fn(&str, &[u8]) -> Result<(), String>,
) -> Result<Vec<String>, String> {
ctl.progress(0.0, "Encoding");
let opts = export_options(settings);
let cancelled = || ctl.check().is_err();
let progress = |f: f32| ctl.progress(f * ENCODE_SHARE, "");
let interrupt = photocraft_raster::Interrupt::new(&cancelled, &progress);
let r = crate::crash_guard::guard("Save", || photocraft_io::export_with(doc, path, &opts, &interrupt).map_err(|e| e.to_string()))?;
let r = crate::crash_guard::guard("Save", || photocraft_io::export(doc, path, &opts).map_err(|e| e.to_string()))?;
ctl.commit()?;
ctl.progress(ENCODE_SHARE, "Writing");
// The last bit is the flush and rename after the last chunk: 100% only once they're done.
write(path, &r.bytes, &|f| ctl.progress(ENCODE_SHARE + (0.99 - ENCODE_SHARE) * f, ""))?;
ctl.progress(1.0, "");
ctl.progress(0.0, "Writing");
write(path, &r.bytes)?;
Ok(r.warnings)
}
@@ -257,7 +232,7 @@ fn save_file(
settings: &photocraft_ui_egui::ExportSettings,
ctl: &photocraft_ui_egui::jobs_ui::SaveCtl,
) -> Result<Vec<String>, String> {
save_through(doc, path, settings, ctl, |p, b, progress| write_atomic_progress(Path::new(p), b, progress))
save_through(doc, path, settings, ctl, |p, b| write_atomic(Path::new(p), b))
}
type SharedRecovery = Rc<RefCell<Option<RecoveryStore>>>;
@@ -366,7 +341,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
});
let automation_save = automation.clone().map(|workspace| {
std::sync::Arc::new(move |doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings, ctl: &photocraft_ui_egui::jobs_ui::SaveCtl| {
save_through(doc, path, settings, ctl, |p, b, _| workspace.write(p, b).map_err(|e| e.to_string()))
save_through(doc, path, settings, ctl, |p, b| workspace.write(p, b).map_err(|e| e.to_string()))
}) as photocraft_ui_egui::SaveFileFn
});
let step: fn(&str, &serde_json::Value) -> photocraft_engine::Result<()> = photocraft_automation::workspace::authorize_desktop_engine_step;
+1 -2
View File
@@ -1,8 +1,7 @@
PhotoCraft app icon (photocraft.svg, photocraft-small.svg and every file derived from them in
this directory: photocraft-1024.png, photocraft.icns, photocraft.ico, hicolor/).
Original artwork made for this project by the project owner: an engraving-style drawing created
in ArtCraft, then vectorised. No third-party material. Licensed like the rest of PhotoCraft,
Original artwork made for this project by the project owner: an engraving-style drawing created by hand, then vectorised. No third-party material. Licensed like the rest of PhotoCraft,
under either of
* Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
+1 -1
View File
@@ -22,7 +22,7 @@ each side so the figure reads at 16–48 px.
## Provenance
The owner's original drawing, made in ArtCraft (2880 px, engraving style, keyed to the palette),
The owner's original drawing, drawn by hand (2880 px, engraving style, keyed to the palette),
vectorised with craftrules `assets/logo-options/_tools/vectorize_tile.py`. The source PNG stays in
craftrules at `assets/app-icons/photocraft/source.png`. License: see `LICENSE.txt`.
+1 -1
View File
@@ -1,6 +1,6 @@
# Credits
PhotoCraft is created and maintained by the ArtCraft team and PhotoCraft contributors. Project ownership, copyright, and licensing remain as stated in the repository's `README.md`, `NOTICE`, and license files. Every merged contribution is credited in the repository history and release notes.
PhotoCraft is created and maintained by the PhotoCraft contributors. Project ownership, copyright, and licensing remain as stated in the repository's `README.md`, `NOTICE`, and license files. Every merged contribution is credited in the repository history and release notes.
## Documentation
+1 -1
View File
@@ -7,6 +7,6 @@ PhotoCraft source is dual-licensed under MIT or Apache-2.0 at the user's option.
- [`NOTICE`](https://github.com/storytold/photocraft/blob/main/NOTICE)
- [`ATTRIBUTION.md`](https://github.com/storytold/photocraft/blob/main/ATTRIBUTION.md)
Bundled third-party assets retain their own licenses and attribution requirements. The ArtCraft name, wordmark, and logos in `docs/brand/` are subject to [`docs/brand/LICENSE-brand.txt`](https://github.com/storytold/photocraft/blob/main/docs/brand/LICENSE-brand.txt) and are not covered by the source-code license.
Bundled third-party assets retain their own licenses and attribution requirements.
This page summarizes repository locations and does not alter any ownership or license term.
+6 -12
View File
@@ -7,15 +7,10 @@ Automation is a privilege boundary because requests can cause filesystem access,
- Desktop control binds to `127.0.0.1` and uses one JSON request/reply per line.
- Headless TCP refuses a successfully bound non-loopback address.
- Every desktop-control and headless-TCP connection must authenticate with a 256-bit bearer token before method dispatch.
- Encoded request lines are limited to 1 MiB on desktop and headless TCP, the headless
JSON-lines stdio server (`photocraft-cli serve`), and MCP stdio. The MCP line limit is
enforced before rmcp's line buffer; an oversized MCP line closes that stdio session because
its request ID cannot safely be parsed. Active TCP connections are limited to 16, and
headless/MCP batches to 256 steps.
- Capability-scoped automation file opens read in chunks without reserving from the file's
metadata length. A read rejects data beyond the length observed on the opened file, so growth
after the metadata check cannot expand the read. Large PSB files have no fixed automation file
size ceiling; their materialized bytes and decoded document still require available memory.
- Encoded request lines are limited to 1 MiB on desktop and headless TCP and on the headless
JSON-lines stdio server (`photocraft-cli serve`). MCP over stdio has no request-byte cap: the
MCP SDK reads its own lines, and only MCP tool results are size-checked. Active TCP connections
are limited to 16, and headless/MCP batches to 256 steps.
- JSON-lines replies and encoded MCP tool results are limited to 8 MiB; retained batch replies
have an aggregate budget and stop later steps when exhausted. The headless stdio JSON-lines
transport enforces the same request/reply ceilings as TCP.
@@ -54,9 +49,8 @@ Automation is a privilege boundary because requests can cause filesystem access,
- no general per-client or per-tool capability model beyond filesystem read/write authority;
- no automated bulk apply of the Preferences dialog; agents can change individual safe keys with
`prefs.set`;
- no application-specific JSON-depth policy (serde_json's parser has its default finite recursion
limit), aggregate document/session-memory accounting, compositor scratch-space accounting,
or command-duration/cancellation budget;
- no explicit JSON-depth policy, aggregate document/session-memory accounting, compositor
scratch-space accounting, or command-duration/cancellation budget;
- one thread per accepted TCP connection;
- no structured security audit event stream;
- desktop screenshot capture/encoding and document import/export have no automation-specific
+1 -1
View File
@@ -14,7 +14,7 @@ Parser safety is broader than “does not crash.” A parser must also bound mem
### Native format
`photocraft-format` bounds manifest, blob, and total decompressed bytes; admits the declared canvas (by default at most 300000 pixels per side, the largest canvas `file.new`, Canvas Size and PSB import create, so whatever saves reopens; `LoadOptions::max_canvas_side` / `max_canvas_pixels` let a caller lower it) before decoding anything for it; validates ZIP structure and decompressed size; verifies CRCs and content hashes; rejects unsupported versions; and property-tests random bundles/manifests.
`photocraft-format` bounds manifest, blob, and total decompressed bytes; validates ZIP structure and decompressed size; verifies CRCs and content hashes; rejects unsupported versions; and property-tests random bundles/manifests.
## Review checklist
-24
View File
@@ -17,10 +17,6 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.jonathanwxh-cell]
display_name = "jonathnwxh"
sync_github_name = false
[people.Skyfuller1303]
display_name = "Skyfuller"
sync_github_name = true
@@ -108,28 +104,8 @@ sync_github_name = true
[people.timothydaly1]
display_name = "SyntaxError"
[people.atoslins]
real_name = "Atos Lins"
display_name = "Atos Lins"
sync_github_name = false
[people.axlecoffee]
sync_github_name = true
[people.Losminek]
sync_github_name = true
[people.FredApps]
sync_github_name = true
[people.arthurdeka]
sync_github_name = true
[people.aweussom]
real_name = "Tommy Leonhardsen"
[people.Furash]
sync_github_name = true
[people.lucAmbr0]
sync_github_name = true
-16
View File
@@ -59,22 +59,6 @@ fn main() {
prev = p;
}
println!("forward-warp segment (600 px brush, 25 px moves) + proxy update: mean {:.2} ms, worst {:.2} ms", total / n as f64, worst);
// One 3000 px drag with a 200 px brush (a straight leg, then a diagonal one), as a slow
// (every 2 px) and a fast (every 60 px) pointer reports it: the field cost of the whole
// stroke, and how far the two results differ.
let mut drag = LiquifyStroke::new(LiquifyTool::ForwardWarp, 200.0);
let mut fields = Vec::new();
let along = |d: f64| if d <= 1500.0 { vec![1500.0 + d, 2000.0] } else { vec![3000.0 + (d - 1500.0) * 0.6, 2000.0 + (d - 1500.0) * 0.8] };
for step in [2.0, 60.0] {
drag.points = (0..=(3000.0 / step) as usize).map(|k| along(k as f64 * step)).collect();
let t = Instant::now();
let mut f = LiquifyField::new(b, cell);
f.apply_stroke(&drag);
println!("forward-warp 3000 px drag (200 px brush), an event every {step} px: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
fields.push(f);
}
let diff = fields[0].d.iter().zip(&fields[1].d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
println!(" slow vs fast pointer: fields differ by at most {diff:.3} px (max displacement {:.1} px)", fields[0].max_displacement());
st.tool = LiquifyTool::Bloat;
let t = Instant::now();
let mut d = Rect::EMPTY;
+2 -3
View File
@@ -992,9 +992,8 @@ mod tests {
let mixed = crate::apply_in(&s, &p, canvas, canvas, Some(&half), canvas);
for (x, y) in [(0, 0), (18, 11), (36, 22)] {
let (o, f, m) = (s.pixel(x, y), full.pixel(x, y), mixed.pixel(x, y));
assert!((m[3] - (o[3] + f[3]) / 2.0).abs() < 1e-5, "({x},{y}) alpha");
for c in 0..3 {
assert!((m[c] * m[3] - (o[c] * o[3] + f[c] * f[3]) / 2.0).abs() < 1e-5, "({x},{y}) premultiplied channel {c}");
for c in 0..4 {
assert!((m[c] - (o[c] + f[c]) / 2.0).abs() < 1e-5, "({x},{y}) channel {c}");
}
}
let cancel = || true;
+1 -1
View File
@@ -169,7 +169,7 @@ fn apply_inner(
return None;
}
if let Some(selection) = selection {
crate::mix_selection(&mut data, output, selection, &src, fmt.alpha);
crate::mix_selection(&mut data, output, selection, &src);
}
if ctl.cancelled() {
return None;
+2 -2
View File
@@ -280,7 +280,7 @@ fn motion_fft_pipeline_supports_depths_models_edges_selections_and_tile_seams()
for mask in [None, Some(&selection)] {
let mut expected_data = filtered.clone();
if let Some(mask) = mask {
crate::mix_selection(&mut expected_data, rect, mask, &src, alpha);
crate::mix_selection(&mut expected_data, rect, mask, &src);
}
let mut expected = source.clone();
expected.write_region(rect, &expected_data);
@@ -362,7 +362,7 @@ fn motion_fft_production_crosses_real_tile_seams_and_leaves_other_pixels_untouch
let mut expected = source.clone();
let mut data = filtered.clone();
if let Some(mask) = mask {
crate::mix_selection(&mut data, area, mask, &src, true);
crate::mix_selection(&mut data, area, mask, &src);
}
expected.write_region(area, &data);
let expected = Image::read(&expected, check);
+1 -1
View File
@@ -69,7 +69,7 @@ fn row_surface(surface: &Surface, params: &FilterParams, area: Rect, bounds: Rec
let ctx = Ctx { bounds, mode: format.mode, alpha: format.alpha };
let mut data = motion_rows(&src, area, &ctx, *angle, *distance);
if let Some(selection) = selection {
crate::mix_selection(&mut data, area, selection, &src, format.alpha);
crate::mix_selection(&mut data, area, selection, &src);
}
let mut result = surface.clone();
result.write_region(area, &data);
+24 -87
View File
@@ -9,10 +9,9 @@
//! with the guided filter); negative amounts add haze toward the atmospheric light.
//! 3. **Tone** on luminance with colour ratios kept: Highlights / Shadows as exposure changes
//! weighted by an edge-preserving base layer (guided filter: K. He, J. Sun, X. Tang, *Guided
//! Image Filtering*, ECCV 2010), so local contrast survives; Contrast is an S-curve about
//! middle grey; Clarity (large radius, midtone weighted) and Texture (small radius) add
//! band-pass local contrast. Whites / Blacks follow as Photoshop's Camera Raw applies them:
//! image-adaptive global curves measured on Photoshop 25.4 (see `whites_blacks`).
//! Image Filtering*, ECCV 2010), so local contrast survives; Whites / Blacks move the ends of
//! the curve; Contrast is an S-curve about middle grey; Clarity (large radius, midtone
//! weighted) and Texture (small radius) add band-pass local contrast.
//! 4. **Presence**: Vibrance (weighted toward less saturated colours) and Saturation.
//! 5. **Tone Curve**: parametric regions (Highlights, Lights, Darks, Shadows with movable splits)
//! then point curves (master and per channel), monotone cubic interpolation (F. Fritsch,
@@ -55,38 +54,6 @@ pub fn validate_curve(points: &[[f32; 2]]) -> Result<(), String> {
use crate::photo_util::{hash01, linear_to_srgb, par_rows, srgb_to_linear};
mod local_tone;
mod whites_blacks;
/// How the pixels encode light: the sRGB curve (gamma 0) or a pure power law (ProPhoto's 1.8,
/// Adobe RGB's 2.2). The tone controls work on light, so the document's own curve decodes it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Encoding(f32);
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 })
}
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) }
}
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)
}
/// One colour grading wheel.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
@@ -169,9 +136,6 @@ pub struct CameraRaw {
pub seed: u32,
/// Pixel-size multiplier for previews on downsampled proxies (radii in px scale by it).
pub pixel_scale: f32,
/// The document's transfer curve: 0 for sRGB's, else the gamma of a power-law space (1.8
/// for ProPhoto). Set from the document's profile; Whites and Blacks act on light.
pub encoding_gamma: f32,
}
impl Default for CameraRaw {
@@ -227,7 +191,6 @@ impl Default for CameraRaw {
vignette_style: "highlightPriority".into(),
seed: 0,
pixel_scale: 1.0,
encoding_gamma: 0.0,
}
}
}
@@ -528,11 +491,7 @@ impl CameraRaw {
self.temperature == 0.0 && self.tint == 0.0 && self.exposure == 0.0
}
fn tone_neutral(&self) -> bool {
self.local_tone_neutral() && self.whites == 0.0 && self.blacks == 0.0
}
/// Contrast, Highlights, Shadows, Texture and Clarity (the luminance stage).
fn local_tone_neutral(&self) -> bool {
[self.contrast, self.highlights, self.shadows, self.texture, self.clarity].iter().all(|v| *v == 0.0)
[self.contrast, self.highlights, self.shadows, self.whites, self.blacks, self.texture, self.clarity].iter().all(|v| *v == 0.0)
}
fn hsl_neutral(&self) -> bool {
self.hsl_hue.iter().chain(&self.hsl_sat).chain(&self.hsl_lum).all(|v| *v == 0.0)
@@ -602,13 +561,16 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
if p.dehaze != 0.0 {
dehaze(px, w, h, p.dehaze / 100.0);
}
// 3. Tone. Highlights and Shadows: a local Laplacian filter (see `local_tone`).
if p.highlights != 0.0 || p.shadows != 0.0 {
local_tone::apply(px, w, h, p.shadows, p.highlights, Encoding::of(p.encoding_gamma));
}
if !p.local_tone_neutral() && [p.contrast, p.texture, p.clarity].iter().any(|v| *v != 0.0) {
// 3. Tone on luminance.
if !p.tone_neutral() {
let l: Vec<f32> = px.iter().map(|q| luma([q[0], q[1], q[2]]).max(0.0)).collect();
let lc: Vec<f32> = l.iter().map(|v| v.min(1.0)).collect();
let base = if p.highlights != 0.0 || p.shadows != 0.0 {
let r = ((long * 0.015) as usize).max(2);
Some(guided_fast(&lc, &lc, w, h, r, 0.02))
} else {
None
};
let clar = (p.clarity != 0.0).then(|| guided_fast(&lc, &lc, w, h, ((long * 0.02) as usize).max(3), 0.005));
let tex = (p.texture != 0.0).then(|| gauss(&lc, w, h, (long * 0.002).max(1.5 * ps)));
let k_con = p.contrast / 100.0 * 0.6;
@@ -618,6 +580,12 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
let i = y * w + x;
let l0 = l[i];
let mut lv = lc[i];
if let Some(b) = &base {
let bv = b[i].clamp(0.0, 1.0);
let hm = smoothstep(0.45, 0.95, bv);
let sm = 1.0 - smoothstep(0.05, 0.5, bv);
lv *= 2f32.powf(p.highlights / 100.0 * 1.2 * hm + p.shadows / 100.0 * 1.4 * sm);
}
if let Some(c) = &clar {
let mw = 1.0 - (2.0 * lv.clamp(0.0, 1.0) - 1.0).powi(2);
lv += p.clarity / 100.0 * 0.8 * (lc[i] - c[i]) * mw;
@@ -625,6 +593,12 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
if let Some(t) = &tex {
lv += p.texture / 100.0 * 0.9 * (lc[i] - t[i]).clamp(-0.1, 0.1);
}
if p.whites != 0.0 {
lv += p.whites / 100.0 * 0.25 * smoothstep(0.4, 1.0, lv);
}
if p.blacks != 0.0 {
lv += p.blacks / 100.0 * 0.15 * (1.0 - smoothstep(0.0, 0.45, lv));
}
if k_con != 0.0 {
let xx = lv.clamp(0.0, 1.0);
lv += -k_con * (tau * xx).sin() / tau;
@@ -637,13 +611,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
}
});
}
// 3b. Whites and Blacks: global curves adapted to the image's black and white points.
if p.whites != 0.0 || p.blacks != 0.0 {
let enc = Encoding::of(p.encoding_gamma);
let (bp, wp) = luminance_points(px, w, h, enc);
let (xw, xb) = whites_blacks::amounts(p.whites.clamp(-100.0, 100.0), p.blacks.clamp(-100.0, 100.0), bp, wp);
whites_blacks::apply(px, w, xw, xb, enc);
}
// 4–7: per-pixel colour stages.
let need_color = p.vibrance != 0.0 || p.saturation != 0.0 || !p.curves_neutral() || !p.hsl_neutral() || !p.grade_neutral();
if need_color {
@@ -765,36 +732,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
}
}
/// The image's black and white points as sRGB-encoded grey levels: the 3rd percentile of the
/// luminance and the 99.5th of the brightest channel, on a copy at most 256 pixels across (so a
/// preview proxy and the full image agree).
fn luminance_points(px: &[[f32; 4]], w: usize, h: usize, enc: Encoding) -> (f32, f32) {
let f = w.max(h).div_ceil(256).max(1);
let (sw, sh) = (w.div_ceil(f), h.div_ceil(f));
let mut sum = vec![[0.0f64; 4]; sw * sh];
for y in 0..h {
for x in 0..w {
let q = px[y * w + x];
let s = &mut sum[(y / f) * sw + x / f];
for c in 0..3 {
s[c] += f64::from(enc.decode(q[c]).clamp(0.0, 1.0));
}
s[3] += 1.0;
}
}
let cells: Vec<[f32; 3]> = sum.iter().filter(|s| s[3] > 0.0).map(|s| [0, 1, 2].map(|c| (s[c] / s[3]) as f32)).collect();
if cells.is_empty() {
return (0.0, 1.0);
}
let pct = |mut v: Vec<f32>, q: f32| {
v.sort_by(f32::total_cmp);
v[((v.len() - 1) as f32 * q).round() as usize]
};
let bp = pct(cells.iter().map(|c| luma(*c)).collect(), 0.03);
let wp = pct(cells.iter().map(|c| c[0].max(c[1]).max(c[2])).collect(), 0.995);
(linear_to_srgb(bp), linear_to_srgb(wp))
}
fn value_noise(x: f32, y: f32, seed: u64) -> f32 {
let (xi, yi) = (x.floor(), y.floor());
let (tx, ty) = (x - xi, y - yi);
-428
View File
@@ -1,428 +0,0 @@
//! Shadows and Highlights as a local Laplacian filter.
//!
//! Adobe has said Camera Raw's process version 2012 rests on local Laplacian filters (S. Paris,
//! S. W. Hasinoff, J. Kautz, *Local Laplacian Filters: Edge-aware Image Processing with a
//! Laplacian Pyramid*, SIGGRAPH 2011). This follows that paper, in its fast form (M. Aubry et
//! al., *Fast Local Laplacian Filters*, ACM TOG 2014: the remapping sampled at a few reference
//! values, each pyramid coefficient interpolated between them), on the log2 luminance:
//!
//! - **Shadows** raises pixels darker than their surroundings, by an amount set by their own and
//! their surroundings' place in the image's range (1st to 99th percentile); **Highlights**
//! pulls pixels brighter than their surroundings toward them. Differences under a threshold
//! are left alone, so fine detail stays.
//! - Shadows then keeps the image's 99th percentile, Highlights its 1st (Photoshop keeps the
//! brightest content under Shadows and the darkest under Highlights).
//! - A uniform image is unchanged, edges keep no halo, and the result does not depend on the
//! image size.
//!
//! The strengths were fitted to Photoshop 25.4's Camera Raw Filter on ten public-domain photos
//! (black-box measurements, no Adobe code or data). It is an approximation: on those photos the
//! mean error against Photoshop is 2.8–9.4 levels (of 255) where the previous heuristic's was
//! 3.7–14.3.
use super::{Encoding, luma};
use crate::photo_util::par_rows;
/// Spacing of the fast filter's reference values in stops. Its remapping changes within about
/// half a stop, and coefficients interpolated across wider gaps show as contour lines on smooth
/// gradients (12 references over a whole raw's range left visible steps; at an eighth of a stop a
/// ramp's slope varies by under a third, as the remapping itself makes it).
const REF_STEP: f32 = 0.125;
/// The filter runs on a copy at most this many pixels across; the change it makes is brought back
/// to full size by a guided upsampling, so a preview and the full image agree.
const WORK_SIDE: usize = 1600;
/// Knots of the strength curves over the surroundings' place in the image range (0 … 1).
const KNOTS: usize = 5;
/// Shadows: how far a pixel darker than its surroundings rises, in stops per unit slider, as
/// a function of its own place in the image range (`own`) times a factor of its surroundings'
/// place (`around`); it rises fully once it is `sigma` stops below them. The lift depends on
/// the pixel's tone, not on the size of the difference, so it is the same at every pyramid
/// level and a big edge keeps no halo. Lowering (negative values) is `down` times as strong.
const SHADOWS: Lift = Lift { own: [-0.017, 0.466, 0.886, 1.221, -0.081], around: [4.566, 4.067, 1.088, 0.149, 0.878], sigma: 0.571, down: 0.808 };
const HIGHLIGHTS: Side = Side { pull: [0.179, 0.141, 0.196, 0.629, 2.07], push: [0.156, 0.08, 0.175, 0.049, 1.261], sigma: 0.05 };
/// See [`SHADOWS`].
struct Lift {
own: [f32; KNOTS],
around: [f32; KNOTS],
sigma: f32,
down: f32,
}
/// Highlights: strengths per unit slider over the surroundings' place in the image range, for
/// lowering (`pull`, toward the surroundings) and raising (`push`), and the detail threshold in
/// stops.
struct Side {
pull: [f32; KNOTS],
push: [f32; KNOTS],
sigma: f32,
}
/// One channel, `w × h`.
struct Plane {
w: usize,
h: usize,
v: Vec<f32>,
}
/// Mirrors `i` into `0..n` (the edge sample is not repeated).
fn mirror(i: isize, n: usize) -> usize {
let n = n as isize;
if n == 1 {
return 0;
}
let p = 2 * (n - 1);
let m = i.rem_euclid(p);
(if m >= n { p - m } else { m }) as usize
}
const TAPS: [f32; 5] = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0];
/// The binomial 5-tap blur, sampled every `step` pixels from 0 (1 = full resolution).
fn blur_step(p: &Plane, step: usize) -> Plane {
let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step));
// Columns first at the kept rows, then rows at the kept columns.
let mut tmp = vec![0.0f32; p.w * h2];
par_rows(&mut tmp, p.w, 1, |y2, row| {
let y = (y2 * step) as isize;
for (x, o) in row.iter_mut().enumerate() {
*o = (0..5).map(|k| TAPS[k] * p.v[mirror(y + k as isize - 2, p.h) * p.w + x]).sum();
}
});
let mut out = vec![0.0f32; w2 * h2];
par_rows(&mut out, w2, 1, |y2, row| {
let src = &tmp[y2 * p.w..(y2 + 1) * p.w];
for (x2, o) in row.iter_mut().enumerate() {
let x = (x2 * step) as isize;
*o = (0..5).map(|k| TAPS[k] * src[mirror(x + k as isize - 2, p.w)]).sum();
}
});
Plane { w: w2, h: h2, v: out }
}
/// The next coarser level.
fn down(p: &Plane) -> Plane {
blur_step(p, 2)
}
/// `p` brought up to `w × h`: zeros between samples, then the blur times four.
fn up(p: &Plane, w: usize, h: usize) -> Plane {
let mut z = Plane { w, h, v: vec![0.0; w * h] };
for y in 0..p.h.min(h.div_ceil(2)) {
for x in 0..p.w.min(w.div_ceil(2)) {
z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0;
}
}
blur_step(&z, 1)
}
/// Levels down to about 2 pixels on the short side.
fn depth(w: usize, h: usize) -> usize {
let mut s = w.min(h);
let mut n = 1;
while s > 2 {
s = s.div_ceil(2);
n += 1;
}
n
}
fn gaussian(p: Plane, n: usize) -> Vec<Plane> {
let mut g = vec![p];
for _ in 1..n {
let next = down(&g[g.len() - 1]);
g.push(next);
}
g
}
/// The local Laplacian filter of `u` with `remap(value, reference)`; the coarsest level is kept.
fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
let n = depth(u.w, u.h);
let g = gaussian(Plane { w: u.w, h: u.h, v: u.v.clone() }, n);
// References span the image's tones; the deepest 0.1 % (noise down to the floor) share the
// lowest reference instead of stretching the spacing.
let hi = u.v.iter().fold(f32::NEG_INFINITY, |a, &v| a.max(v));
let lo = quantile(&u.v, 0.001).min(hi);
if hi - lo <= 1e-6 || (hi - lo).is_nan() {
return Plane { w: u.w, h: u.h, v: u.v.clone() };
}
let refs = (((hi - lo) / REF_STEP).ceil() as usize + 1).clamp(2, 96);
let step = (hi - lo) / (refs - 1) as f32;
let mut lap: Vec<Vec<f32>> = g[..n - 1].iter().map(|p| vec![0.0; p.v.len()]).collect();
for k in 0..refs {
let r = lo + step * k as f32;
let mut m = Plane { w: u.w, h: u.h, v: u.v.clone() };
par_rows(&mut m.v, u.w, 1, |_, row| {
for v in row.iter_mut() {
*v = remap(*v, r);
}
});
let gr = gaussian(m, n);
for l in 0..n - 1 {
let coarse = up(&gr[l + 1], gr[l].w, gr[l].h);
let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w);
par_rows(&mut lap[l], lw, 1, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let i = y * lw + x;
let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0);
if wgt > 0.0 {
*o += wgt * (fine[i] - coarse.v[i]);
}
}
});
}
}
let mut o = Plane { w: g[n - 1].w, h: g[n - 1].h, v: g[n - 1].v.clone() };
for l in (0..n - 1).rev() {
let mut next = up(&o, g[l].w, g[l].h);
for (a, b) in next.v.iter_mut().zip(&lap[l]) {
*a += b;
}
o = next;
}
o
}
/// The `q` quantile (0 … 1) of `v`.
fn quantile(v: &[f32], q: f32) -> f32 {
let mut s = v.to_vec();
s.sort_by(f32::total_cmp);
s[((s.len() - 1) as f32 * q).round() as usize]
}
/// 1st and 99th percentiles of the log luminance on a copy at most 256 pixels across.
fn range(u: &Plane) -> (f32, f32) {
let f = u.w.max(u.h).div_ceil(256).max(1);
let (sw, sh) = (u.w.div_ceil(f), u.h.div_ceil(f));
let mut sum = vec![(0.0f64, 0u32); sw * sh];
for y in 0..u.h {
for x in 0..u.w {
let c = &mut sum[(y / f) * sw + x / f];
c.0 += f64::from(u.v[y * u.w + x]);
c.1 += 1;
}
}
let cells: Vec<f32> = sum.iter().filter(|c| c.1 > 0).map(|c| (c.0 / f64::from(c.1)) as f32).collect();
(quantile(&cells, 0.01), quantile(&cells, 0.99))
}
/// `d` past the detail threshold: about 0 below `sigma`, `d − sigma` well above it.
fn excess(d: f32, sigma: f32) -> f32 {
d - sigma * (d / sigma).tanh()
}
fn knot(t: f32, k: &[f32; KNOTS]) -> f32 {
let x = t.clamp(0.0, 1.0) * (KNOTS - 1) as f32;
let i = (x as usize).min(KNOTS - 2);
k[i] + (k[i + 1] - k[i]) * (x - i as f32)
}
/// The Highlights pass on the log luminance `u` (`amount` −1 … 1): pixels brighter than their
/// surroundings move toward them (or away, for positive values) by the part of the difference
/// past `sigma`, then the darkest content is put back where it was.
fn highlights_pass(u: &Plane, amount: f32, side: &Side) -> Plane {
let (lo, hi) = range(u);
let span = (hi - lo).max(1.0);
let strengths = if amount < 0.0 { &side.pull } else { &side.push };
let o = llf(u, |x, g| x + knot((g - lo) / span, strengths) * amount * excess((x - g).max(0.0), side.sigma));
let shift = quantile(&u.v, 0.01) - quantile(&o.v, 0.01);
Plane { w: o.w, h: o.h, v: o.v.iter().map(|v| v + shift).collect() }
}
/// The Shadows pass on the log luminance `u` (`amount` −1 … 1).
fn shadows_pass(u: &Plane, amount: f32) -> Plane {
let (lo, hi) = range(u);
let span = (hi - lo).max(1.0);
let k = if amount > 0.0 { amount } else { amount * SHADOWS.down };
let o = llf(u, |x, g| {
let below = ((g - x) / SHADOWS.sigma).clamp(0.0, 1.0);
if below == 0.0 {
return x;
}
let w = below * below * (3.0 - 2.0 * below);
x + k * knot((x - lo) / span, &SHADOWS.own) * knot((g - lo) / span, &SHADOWS.around) * w
});
// Keep the brightest content.
let shift = quantile(&u.v, 0.99) - quantile(&o.v, 0.99);
Plane { w: o.w, h: o.h, v: o.v.iter().map(|v| v + shift).collect() }
}
/// Applies Shadows and Highlights (−100 … 100) to straight RGBA pixels encoded with `enc`.
pub(crate) fn apply(px: &mut [[f32; 4]], w: usize, h: usize, shadows: f32, highlights: f32, enc: Encoding) {
if (shadows == 0.0 && highlights == 0.0) || w < 2 || h < 2 {
return;
}
const FLOOR: f32 = 1.0 / 65536.0;
let u = Plane { w, h, v: px.iter().map(|q| (luma([0, 1, 2].map(|c| enc.decode(q[c]))).max(0.0) + FLOOR).log2()).collect() };
// The filter on a copy at most WORK_SIDE across (box-averaged in log).
let f = w.max(h).div_ceil(WORK_SIDE).max(1);
let small = if f == 1 { Plane { w, h, v: u.v.clone() } } else { shrink(&u, f) };
let mut o = Plane { w: small.w, h: small.h, v: small.v.clone() };
if highlights != 0.0 {
o = highlights_pass(&o, highlights.clamp(-100.0, 100.0) / 100.0, &HIGHLIGHTS);
}
if shadows != 0.0 {
o = shadows_pass(&o, shadows.clamp(-100.0, 100.0) / 100.0);
}
// The change in log luminance, at full size.
let change: Vec<f32> = if f == 1 {
o.v.iter().zip(&u.v).map(|(a, b)| a - b).collect()
} else {
let d: Vec<f32> = o.v.iter().zip(&small.v).map(|(a, b)| a - b).collect();
guided_upsample(&small, &d, &u)
};
par_rows(px, w, 1, |y, row| {
for (x, q) in row.iter_mut().enumerate() {
let gain = change[y * w + x].exp2();
for c in 0..3 {
q[c] = enc.encode(enc.decode(q[c]) * gain);
}
}
});
}
/// `u` averaged over `f × f` blocks.
fn shrink(u: &Plane, f: usize) -> Plane {
let (sw, sh) = (u.w.div_ceil(f), u.h.div_ceil(f));
let mut v = vec![0.0f32; sw * sh];
par_rows(&mut v, sw, 1, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let (x0, y0) = (x * f, y * f);
let (x1, y1) = ((x0 + f).min(u.w), (y0 + f).min(u.h));
let mut s = 0.0f64;
for yy in y0..y1 {
for xx in x0..x1 {
s += f64::from(u.v[yy * u.w + xx]);
}
}
*o = (s / ((x1 - x0) * (y1 - y0)) as f64) as f32;
}
});
Plane { w: sw, h: sh, v }
}
/// The change `d` computed on `small` brought to the size of `full`, following its edges: a local
/// linear model of the change against the log luminance (the guided filter of K. He, J. Sun,
/// *Fast Guided Filter*, 2015), fitted on the small copy and evaluated on the full one.
fn guided_upsample(small: &Plane, d: &[f32], full: &Plane) -> Vec<f32> {
let (sw, sh) = (small.w, small.h);
let r = 2;
let eps = 0.01;
let mean = |v: &[f32]| super::box_mean(v, sw, sh, r);
let ud: Vec<f32> = small.v.iter().zip(d).map(|(a, b)| a * b).collect();
let uu: Vec<f32> = small.v.iter().map(|a| a * a).collect();
let (mu, md, mud, muu) = (mean(&small.v), mean(d), mean(&ud), mean(&uu));
let a: Vec<f32> = (0..sw * sh).map(|i| (mud[i] - mu[i] * md[i]) / (muu[i] - mu[i] * mu[i] + eps)).collect();
let b: Vec<f32> = (0..sw * sh).map(|i| md[i] - a[i] * mu[i]).collect();
let (ma, mb) = (mean(&a), mean(&b));
let (fx, fy) = (sw as f32 / full.w as f32, sh as f32 / full.h as f32);
let mut out = vec![0.0f32; full.w * full.h];
par_rows(&mut out, full.w, 1, |y, row| {
let sy = (y as f32 + 0.5) * fy - 0.5;
for (x, o) in row.iter_mut().enumerate() {
let sx = (x as f32 + 0.5) * fx - 0.5;
let av = crate::photo_util::bilinear(&ma, sw, sh, 1, 0, sx, sy);
let bv = crate::photo_util::bilinear(&mb, sw, sh, 1, 0, sx, sy);
*o = av * full.v[y * full.w + x] + bv;
}
});
out
}
#[cfg(test)]
mod tests {
use super::*;
const SRGB: Encoding = Encoding(0.0);
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()
}
#[test]
fn a_flat_image_is_unchanged() {
let mut px = grey(64, 48, |_, _| 0.4);
apply(&mut px, 64, 48, 100.0, -100.0, SRGB);
assert!(px.iter().all(|q| (q[0] - 0.4).abs() < 1e-5));
}
#[test]
fn shadows_raise_a_dark_area_without_a_halo() {
// A 0.1 | 0.6 step (the dark half is the image's black point, which Photoshop barely
// moves either): the bright half stays, and no halo forms on either side of the edge.
let (w, h) = (256, 128);
let mut px = grey(w, h, |x, _| if x < w / 2 { 0.1 } else { 0.6 });
apply(&mut px, w, h, 100.0, 0.0, SRGB);
let row = &px[64 * w..65 * w];
assert!((row[w / 2 + 1][0] - 0.6).abs() < 0.002 && (row[w - 5][0] - 0.6).abs() < 0.002);
let (far, near) = (row[10][0], row[w / 2 - 1][0]);
assert!((far - 0.1).abs() < 0.004 && (far - near).abs() < 0.004, "{far} {near}");
}
#[test]
fn grey_moves_as_in_photoshop() {
// Mid-grey with 1 % black (0.015) and white (0.97) corners, Photoshop 25.4: Shadows +100
// takes the grey from 127.5 to 165.3, Highlights −100 to 89.6 (of 255).
let n = 256;
let image = |c: f32| {
grey(n, n, |x, y| {
if x >= n - 26 && y >= n - 26 {
0.97
} else if x >= n - 26 && y < 26 {
0.015
} else {
c
}
})
};
for (s, hl, want) in [(100.0, 0.0, 165.3), (0.0, -100.0, 89.6)] {
let mut px = image(0.5);
apply(&mut px, n, n, s, hl, SRGB);
let got = px[175 * n + 100][0] * 255.0;
assert!((got - want).abs() < 5.0, "{s} {hl}: {got} vs {want}");
}
}
#[test]
fn highlights_lower_a_bright_area_and_keep_the_darkest() {
let (w, h) = (192, 192);
let mut px = grey(w, h, |x, y| if (64..128).contains(&x) && (64..128).contains(&y) { 0.8 } else { 0.2 });
apply(&mut px, w, h, 0.0, -100.0, SRGB);
assert!(px[96 * w + 96][0] < 0.75, "{}", px[96 * w + 96][0]);
assert!((px[5 * w + 5][0] - 0.2).abs() < 0.01, "{}", px[5 * w + 5][0]);
}
#[test]
fn smooth_gradients_stay_smooth() {
// A soft ramp over eight stops, as in an out-of-focus background. Coefficients interpolated
// between references too far apart drew contour lines across such ramps (Shadows +100 on
// a D4 raw): the output must rise steadily, without ripples.
let (w, h) = (640, 48);
let mut px = grey(w, h, |x, _| crate::photo_util::linear_to_srgb((x as f32 / (w - 1) as f32 * 8.0 - 9.0).exp2()));
for (s, hl) in [(100.0, 0.0), (0.0, -100.0), (-100.0, 100.0)] {
let mut p = px.clone();
apply(&mut p, w, h, s, hl, SRGB);
let row: Vec<f32> = (0..w).map(|x| crate::photo_util::srgb_to_linear(p[24 * w + x][0]).max(1e-6).log2()).collect();
let steps: Vec<f32> = row.windows(2).map(|d| d[1] - d[0]).collect();
assert!(steps.iter().all(|d| *d > 0.0), "{s} {hl}: the ramp reverses");
let jumps: Vec<f32> = steps.windows(2).map(|d| (d[1] - d[0]).abs()).collect();
let ripple = jumps[16..jumps.len() - 16].iter().fold(0.0f32, |a, b| a.max(*b));
// The ramp rises 0.0125 stops per pixel; its slope may change by a third at most.
assert!(ripple < 0.004, "{s} {hl}: slope jumps by {ripple} stops per pixel");
}
px.clear();
}
#[test]
fn the_result_does_not_depend_on_the_image_size() {
let at = |n: usize| {
let mut px = grey(n, n, |x, y| if x < n / 3 || y < n / 4 { 0.08 } else { 0.7 });
apply(&mut px, n, n, 70.0, -60.0, SRGB);
[px[(n / 8) * n + n / 8][0], px[(3 * n / 4) * n + 3 * n / 4][0]]
};
let (a, b) = (at(96), at(384));
assert!((a[0] - b[0]).abs() < 0.02 && (a[1] - b[1]).abs() < 0.02, "{a:?} {b:?}");
}
}
-418
View File
@@ -1,418 +0,0 @@
//! Whites and Blacks as Adobe Camera Raw (process version 2012) applies them.
//!
//! Measured as a black box on the Camera Raw Filter of Photoshop 25.4: grey blocks with chosen
//! histograms went through the filter by script and came back exactly; no Adobe code or data
//! was used.
//!
//! - Both are global curves of the grey level, applied after Shadows and Highlights, Whites
//! first. On an image whose darkest and lightest percent reach black and white the curve of a
//! slider value `x` is the measured row at `x` of [`WHITES`] or [`BLACKS`].
//! - The direction that only compresses (Whites down, Blacks up) is that fixed curve on every
//! image. The direction that clips (Whites up, Blacks down) adapts: the row used is
//! `x · A(BP, WP)`. On the block images BP and WP are their darkest and lightest levels; on
//! photographs Photoshop's amounts follow best with BP the 3rd percentile of the luminance and
//! WP the 99.5th of the brightest channel, and Blacks at least 1.2 times. A low
//! white point makes Whites +100 stronger (up to 2.7 times), a raised black point Blacks
//! (up to 1.45 times at −40). Whites rows past +100 come from images where `A > 1`.
//! - Not reproduced: past −40 Photoshop's Blacks grows faster on images with a raised black
//! point (on one spanning 0.6–1 its −100 blacks out everything below 0.74, here below about
//! 0.15); its curves there are not rows of the full-range family.
//! - Colour: the clipping directions act on each channel, the compressing ones scale the pixel
//! by the curve's change of its luminance (closest to Photoshop on saturated colours).
use super::Encoding;
use crate::photo_util::par_rows;
/// Grey levels the curves are sampled at: 0, 1/32, …, 1 (sRGB-encoded).
const STEPS: usize = 33;
/// The first row's internal amount; rows are 10 apart.
const WHITES_FROM: f32 = -100.0;
const BLACKS_FROM: f32 = -100.0;
// Measured levels; some happen to lie near π/8 and π/4.
/// Whites curves at internal amounts -100, -90, …, 270 (rows) over grey levels 0, 1/32, …, 1.
#[allow(clippy::approx_constant)]
static WHITES: [[f32; 33]; 38] = [
[
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1523, 0.1817, 0.2107, 0.2391, 0.2671, 0.2946, 0.3215, 0.3480, 0.3738, 0.3995, 0.4256, 0.4517, 0.4774, 0.5028,
0.5278, 0.5524, 0.5768, 0.6008, 0.6245, 0.6482, 0.6717, 0.6957, 0.7207, 0.7483, 0.7814, 0.8263, 0.8949, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1523, 0.1817, 0.2107, 0.2392, 0.2672, 0.2947, 0.3217, 0.3482, 0.3742, 0.4000, 0.4263, 0.4526, 0.4785, 0.5042,
0.5296, 0.5548, 0.5797, 0.6044, 0.6291, 0.6539, 0.6789, 0.7046, 0.7317, 0.7617, 0.7969, 0.8425, 0.9074, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1524, 0.1818, 0.2108, 0.2393, 0.2673, 0.2950, 0.3221, 0.3487, 0.3748, 0.4008, 0.4274, 0.4540, 0.4803, 0.5064,
0.5323, 0.5580, 0.5837, 0.6093, 0.6350, 0.6610, 0.6875, 0.7149, 0.7438, 0.7756, 0.8122, 0.8576, 0.9184, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1524, 0.1818, 0.2109, 0.2395, 0.2677, 0.2954, 0.3227, 0.3495, 0.3759, 0.4022, 0.4291, 0.4561, 0.4828, 0.5094,
0.5360, 0.5624, 0.5888, 0.6154, 0.6423, 0.6695, 0.6973, 0.7261, 0.7566, 0.7897, 0.8271, 0.8716, 0.9280, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1525, 0.1820, 0.2111, 0.2398, 0.2682, 0.2961, 0.3237, 0.3508, 0.3775, 0.4042, 0.4317, 0.4591, 0.4864, 0.5136,
0.5408, 0.5680, 0.5953, 0.6229, 0.6508, 0.6791, 0.7082, 0.7382, 0.7698, 0.8037, 0.8412, 0.8843, 0.9365, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0927, 0.1227, 0.1527, 0.1822, 0.2116, 0.2404, 0.2690, 0.2972, 0.3251, 0.3527, 0.3799, 0.4071, 0.4351, 0.4630, 0.4910, 0.5189,
0.5469, 0.5749, 0.6031, 0.6316, 0.6605, 0.6899, 0.7199, 0.7509, 0.7832, 0.8174, 0.8544, 0.8958, 0.9439, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0927, 0.1229, 0.1529, 0.1827, 0.2122, 0.2414, 0.2702, 0.2989, 0.3272, 0.3553, 0.3831, 0.4110, 0.4396, 0.4682, 0.4968, 0.5254,
0.5542, 0.5830, 0.6121, 0.6414, 0.6712, 0.7014, 0.7322, 0.7637, 0.7964, 0.8305, 0.8668, 0.9062, 0.9503, 0.9999,
],
[
0.0000, 0.0298, 0.0622, 0.0928, 0.1231, 0.1534, 0.1834, 0.2132, 0.2427, 0.2720, 0.3011, 0.3300, 0.3587, 0.3872, 0.4160, 0.4452, 0.4745, 0.5038, 0.5332,
0.5627, 0.5923, 0.6221, 0.6522, 0.6826, 0.7134, 0.7447, 0.7765, 0.8092, 0.8429, 0.8781, 0.9155, 0.9559, 1.0000,
],
[
0.0000, 0.0298, 0.0623, 0.0930, 0.1236, 0.1541, 0.1844, 0.2145, 0.2445, 0.2744, 0.3041, 0.3337, 0.3632, 0.3925, 0.4221, 0.4520, 0.4820, 0.5120, 0.5421,
0.5723, 0.6026, 0.6330, 0.6636, 0.6946, 0.7257, 0.7572, 0.7890, 0.8214, 0.8545, 0.8885, 0.9237, 0.9608, 1.0000,
],
[
0.0000, 0.0298, 0.0624, 0.0934, 0.1242, 0.1551, 0.1858, 0.2164, 0.2470, 0.2775, 0.3080, 0.3383, 0.3687, 0.3989, 0.4293, 0.4600, 0.4906, 0.5213, 0.5520,
0.5827, 0.6136, 0.6445, 0.6755, 0.7067, 0.7380, 0.7694, 0.8011, 0.8330, 0.8652, 0.8978, 0.9310, 0.9651, 1.0000,
],
[
0.0000, 0.0312, 0.0625, 0.0938, 0.1250, 0.1562, 0.1875, 0.2188, 0.2500, 0.2812, 0.3125, 0.3438, 0.3750, 0.4062, 0.4375, 0.4688, 0.5000, 0.5312, 0.5625,
0.5938, 0.6250, 0.6562, 0.6875, 0.7188, 0.7500, 0.7812, 0.8125, 0.8438, 0.8750, 0.9062, 0.9375, 0.9688, 1.0000,
],
[
0.0000, 0.0299, 0.0626, 0.0940, 0.1253, 0.1567, 0.1881, 0.2195, 0.2510, 0.2825, 0.3140, 0.3456, 0.3772, 0.4089, 0.4405, 0.4720, 0.5036, 0.5351, 0.5667,
0.5983, 0.6298, 0.6613, 0.6929, 0.7244, 0.7559, 0.7874, 0.8189, 0.8503, 0.8816, 0.9128, 0.9438, 0.9742, 1.0000,
],
[
0.0000, 0.0299, 0.0627, 0.0941, 0.1257, 0.1573, 0.1890, 0.2208, 0.2526, 0.2846, 0.3167, 0.3488, 0.3811, 0.4134, 0.4456, 0.4777, 0.5098, 0.5419, 0.5741,
0.6062, 0.6383, 0.6704, 0.7025, 0.7345, 0.7664, 0.7983, 0.8301, 0.8617, 0.8931, 0.9241, 0.9543, 0.9829, 1.0000,
],
[
0.0000, 0.0299, 0.0628, 0.0944, 0.1262, 0.1582, 0.1903, 0.2225, 0.2550, 0.2876, 0.3204, 0.3534, 0.3867, 0.4200, 0.4529, 0.4858, 0.5186, 0.5516, 0.5845,
0.6174, 0.6502, 0.6830, 0.7157, 0.7482, 0.7807, 0.8131, 0.8451, 0.8769, 0.9082, 0.9385, 0.9673, 0.9928, 1.0000,
],
[
0.0000, 0.0299, 0.0629, 0.0948, 0.1269, 0.1594, 0.1920, 0.2249, 0.2582, 0.2917, 0.3256, 0.3597, 0.3942, 0.4289, 0.4628, 0.4966, 0.5305, 0.5644, 0.5983,
0.6321, 0.6658, 0.6994, 0.7327, 0.7660, 0.7989, 0.8316, 0.8639, 0.8956, 0.9263, 0.9553, 0.9815, 1.0000, 1.0000,
],
[
0.0000, 0.0299, 0.0631, 0.0953, 0.1280, 0.1610, 0.1944, 0.2282, 0.2625, 0.2973, 0.3325, 0.3683, 0.4045, 0.4407, 0.4758, 0.5109, 0.5460, 0.5811, 0.6161,
0.6510, 0.6857, 0.7201, 0.7541, 0.7879, 0.8213, 0.8543, 0.8864, 0.9175, 0.9468, 0.9733, 0.9950, 1.0000, 1.0000,
],
[
0.0000, 0.0299, 0.0633, 0.0961, 0.1293, 0.1632, 0.1976, 0.2327, 0.2684, 0.3048, 0.3419, 0.3798, 0.4183, 0.4562, 0.4928, 0.5294, 0.5660, 0.6024, 0.6387,
0.6748, 0.7104, 0.7456, 0.7803, 0.8145, 0.8480, 0.8807, 0.9121, 0.9417, 0.9684, 0.9907, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0300, 0.0637, 0.0971, 0.1313, 0.1663, 0.2020, 0.2387, 0.2763, 0.3149, 0.3546, 0.3952, 0.4365, 0.4763, 0.5149, 0.5532, 0.5914, 0.6294, 0.6670,
0.7041, 0.7406, 0.7763, 0.8113, 0.8456, 0.8788, 0.9104, 0.9401, 0.9667, 0.9888, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0300, 0.0641, 0.0984, 0.1338, 0.1703, 0.2079, 0.2468, 0.2870, 0.3286, 0.3717, 0.4161, 0.4602, 0.5024, 0.5430, 0.5833, 0.6232, 0.6627, 0.7015,
0.7394, 0.7764, 0.8123, 0.8472, 0.8808, 0.9126, 0.9420, 0.9681, 0.9896, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0302, 0.0647, 0.1003, 0.1374, 0.1760, 0.2160, 0.2579, 0.3016, 0.3472, 0.3949, 0.4436, 0.4908, 0.5355, 0.5784, 0.6207, 0.6622, 0.7029, 0.7424,
0.7806, 0.8176, 0.8530, 0.8868, 0.9185, 0.9474, 0.9726, 0.9926, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0304, 0.0656, 0.1029, 0.1423, 0.1837, 0.2271, 0.2731, 0.3215, 0.3726, 0.4262, 0.4791, 0.5294, 0.5769, 0.6220, 0.6660, 0.7086, 0.7498, 0.7893,
0.8272, 0.8631, 0.8969, 0.9281, 0.9560, 0.9795, 0.9967, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0305, 0.0679, 0.1094, 0.1509, 0.1968, 0.2459, 0.2956, 0.3510, 0.4094, 0.4680, 0.5248, 0.5781, 0.6278, 0.6742, 0.7195, 0.7624, 0.8031, 0.8417,
0.8779, 0.9113, 0.9414, 0.9675, 0.9886, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0306, 0.0702, 0.1160, 0.1623, 0.2144, 0.2709, 0.3243, 0.3885, 0.4556, 0.5199, 0.5807, 0.6369, 0.6884, 0.7355, 0.7799, 0.8217, 0.8606, 0.8964,
0.9288, 0.9570, 0.9803, 0.9958, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0308, 0.0725, 0.1225, 0.1737, 0.2320, 0.2960, 0.3641, 0.4393, 0.5133, 0.5825, 0.6460, 0.7032, 0.7544, 0.8003, 0.8426, 0.8817, 0.9168, 0.9475,
0.9733, 0.9924, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0322, 0.0751, 0.1303, 0.1918, 0.2594, 0.3348, 0.4189, 0.5044, 0.5841, 0.6565, 0.7208, 0.7771, 0.8262, 0.8692, 0.9074, 0.9408, 0.9686, 0.9900,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0331, 0.0818, 0.1482, 0.2171, 0.2977, 0.3892, 0.4865, 0.5779, 0.6602, 0.7322, 0.7943, 0.8470, 0.8915, 0.9286, 0.9600, 0.9844, 0.9996, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0340, 0.0884, 0.1661, 0.2544, 0.3540, 0.4658, 0.5729, 0.6681, 0.7496, 0.8183, 0.8748, 0.9208, 0.9568, 0.9832, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.3356, 0.5799, 0.6166, 0.6166, 0.6166, 0.6166, 0.6624, 0.7556, 0.8321, 0.8935, 0.9411, 0.9755, 0.9970, 0.9998, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.5007, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.1655, 0.1655, 0.2972, 0.4728, 0.6327, 0.7616, 0.8598, 0.9317, 0.9790, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0457, 0.1830, 0.3836, 0.5790, 0.7379, 0.8549, 0.9360, 0.9869, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0520, 0.2329, 0.4864, 0.6989, 0.8449, 0.9403, 0.9884, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0773, 0.3027, 0.6040, 0.8058, 0.9302, 0.9930, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.1454, 0.4738, 0.7615, 0.9074, 0.9908, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0964, 0.5416, 0.8589, 0.9897, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.1538, 0.1538, 0.7080, 0.9596, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.8439, 0.8439, 0.8439, 0.9993, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.9228, 0.9228, 0.9228, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
];
/// Blacks curves at internal amounts -100, …, 100 in steps of 10.
#[allow(clippy::approx_constant)]
static BLACKS: [[f32; 33]; 21] = [
[
0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0290, 0.0572, 0.0879, 0.1222, 0.1580, 0.1935, 0.2287, 0.2636, 0.2988, 0.3355, 0.3741, 0.4151, 0.4571,
0.4990, 0.5406, 0.5818, 0.6226, 0.6631, 0.7031, 0.7424, 0.7811, 0.8192, 0.8568, 0.8937, 0.9300, 0.9655, 1.0000,
],
[
0.0000, 0.0000, 0.0000, 0.0000, 0.0015, 0.0363, 0.0640, 0.0956, 0.1300, 0.1651, 0.2000, 0.2345, 0.2686, 0.3026, 0.3369, 0.3727, 0.4105, 0.4495, 0.4885,
0.5272, 0.5657, 0.6039, 0.6419, 0.6796, 0.7169, 0.7538, 0.7903, 0.8264, 0.8621, 0.8974, 0.9322, 0.9664, 1.0000,
],
[
0.0000, 0.0000, 0.0000, 0.0000, 0.0346, 0.0625, 0.0934, 0.1271, 0.1616, 0.1959, 0.2298, 0.2635, 0.2969, 0.3301, 0.3638, 0.3990, 0.4357, 0.4727, 0.5094,
0.5460, 0.5824, 0.6187, 0.6547, 0.6906, 0.7262, 0.7615, 0.7965, 0.8313, 0.8657, 0.8998, 0.9336, 0.9671, 1.0000,
],
[
0.0000, 0.0000, 0.0000, 0.0235, 0.0554, 0.0850, 0.1175, 0.1514, 0.1852, 0.2187, 0.2519, 0.2849, 0.3178, 0.3505, 0.3837, 0.4183, 0.4537, 0.4891, 0.5243,
0.5594, 0.5944, 0.6292, 0.6639, 0.6985, 0.7329, 0.7671, 0.8010, 0.8347, 0.8683, 0.9016, 0.9347, 0.9676, 1.0000,
],
[
0.0000, 0.0000, 0.0020, 0.0438, 0.0724, 0.1035, 0.1365, 0.1699, 0.2030, 0.2359, 0.2686, 0.3011, 0.3336, 0.3659, 0.3986, 0.4327, 0.4669, 0.5011, 0.5352,
0.5692, 0.6031, 0.6369, 0.6706, 0.7043, 0.7378, 0.7711, 0.8043, 0.8373, 0.8702, 0.9029, 0.9355, 0.9679, 1.0000,
],
[
0.0000, 0.0000, 0.0230, 0.0567, 0.0865, 0.1182, 0.1510, 0.1839, 0.2164, 0.2489, 0.2812, 0.3133, 0.3455, 0.3775, 0.4100, 0.4433, 0.4767, 0.5100, 0.5433,
0.5765, 0.6096, 0.6426, 0.6756, 0.7086, 0.7414, 0.7741, 0.8067, 0.8392, 0.8716, 0.9039, 0.9361, 0.9682, 1.0000,
],
[
0.0000, 0.0000, 0.0368, 0.0670, 0.0976, 0.1294, 0.1619, 0.1943, 0.2265, 0.2586, 0.2906, 0.3225, 0.3544, 0.3862, 0.4184, 0.4512, 0.4839, 0.5166, 0.5492,
0.5818, 0.6144, 0.6469, 0.6793, 0.7117, 0.7441, 0.7763, 0.8085, 0.8406, 0.8727, 0.9046, 0.9365, 0.9683, 1.0000,
],
[
0.0000, 0.0000, 0.0444, 0.0749, 0.1058, 0.1377, 0.1699, 0.2020, 0.2339, 0.2657, 0.2975, 0.3292, 0.3609, 0.3926, 0.4245, 0.4568, 0.4891, 0.5213, 0.5535,
0.5857, 0.6179, 0.6499, 0.6820, 0.7140, 0.7460, 0.7779, 0.8098, 0.8416, 0.8734, 0.9052, 0.9368, 0.9685, 1.0000,
],
[
0.0000, 0.0086, 0.0511, 0.0817, 0.1128, 0.1445, 0.1763, 0.2080, 0.2397, 0.2713, 0.3029, 0.3344, 0.3660, 0.3975, 0.4292, 0.4611, 0.4930, 0.5249, 0.5567,
0.5886, 0.6204, 0.6522, 0.6839, 0.7157, 0.7475, 0.7791, 0.8108, 0.8424, 0.8740, 0.9056, 0.9371, 0.9686, 1.0000,
],
[
0.0000, 0.0198, 0.0564, 0.0874, 0.1186, 0.1501, 0.1817, 0.2133, 0.2447, 0.2762, 0.3076, 0.3390, 0.3705, 0.4018, 0.4333, 0.4649, 0.4965, 0.5281, 0.5597,
0.5912, 0.6227, 0.6543, 0.6858, 0.7173, 0.7488, 0.7802, 0.8117, 0.8431, 0.8745, 0.9059, 0.9373, 0.9686, 1.0000,
],
[
0.0000, 0.0312, 0.0625, 0.0938, 0.1250, 0.1562, 0.1875, 0.2188, 0.2500, 0.2812, 0.3125, 0.3438, 0.3750, 0.4062, 0.4375, 0.4688, 0.5000, 0.5312, 0.5625,
0.5938, 0.6250, 0.6562, 0.6875, 0.7188, 0.7500, 0.7812, 0.8125, 0.8438, 0.8750, 0.9062, 0.9375, 0.9688, 1.0000,
],
[
0.0000, 0.0313, 0.0650, 0.0979, 0.1303, 0.1624, 0.1942, 0.2260, 0.2576, 0.2891, 0.3206, 0.3520, 0.3833, 0.4146, 0.4455, 0.4763, 0.5070, 0.5378, 0.5684,
0.5991, 0.6298, 0.6605, 0.6912, 0.7219, 0.7526, 0.7833, 0.8141, 0.8450, 0.8758, 0.9067, 0.9377, 0.9688, 1.0000,
],
[
0.0000, 0.0326, 0.0677, 0.1022, 0.1357, 0.1686, 0.2010, 0.2332, 0.2652, 0.2969, 0.3286, 0.3601, 0.3915, 0.4227, 0.4532, 0.4835, 0.5138, 0.5440, 0.5741,
0.6042, 0.6344, 0.6645, 0.6946, 0.7248, 0.7550, 0.7853, 0.8156, 0.8461, 0.8766, 0.9072, 0.9380, 0.9689, 1.0000,
],
[
0.0000, 0.0341, 0.0706, 0.1066, 0.1413, 0.1749, 0.2079, 0.2405, 0.2728, 0.3048, 0.3366, 0.3682, 0.3996, 0.4307, 0.4608, 0.4906, 0.5203, 0.5500, 0.5796,
0.6092, 0.6387, 0.6683, 0.6979, 0.7276, 0.7573, 0.7871, 0.8170, 0.8472, 0.8773, 0.9077, 0.9382, 0.9690, 1.0000,
],
[
0.0000, 0.0354, 0.0736, 0.1113, 0.1472, 0.1814, 0.2149, 0.2479, 0.2805, 0.3127, 0.3446, 0.3763, 0.4077, 0.4385, 0.4681, 0.4974, 0.5267, 0.5558, 0.5849,
0.6139, 0.6429, 0.6720, 0.7011, 0.7303, 0.7595, 0.7889, 0.8184, 0.8481, 0.8780, 0.9081, 0.9384, 0.9690, 1.0000,
],
[
0.0000, 0.0368, 0.0768, 0.1162, 0.1531, 0.1881, 0.2221, 0.2554, 0.2882, 0.3206, 0.3527, 0.3843, 0.4158, 0.4461, 0.4753, 0.5041, 0.5328, 0.5615, 0.5900,
0.6185, 0.6470, 0.6755, 0.7041, 0.7328, 0.7616, 0.7906, 0.8197, 0.8491, 0.8787, 0.9085, 0.9386, 0.9691, 1.0000,
],
[
0.0000, 0.0381, 0.0802, 0.1214, 0.1593, 0.1949, 0.2293, 0.2630, 0.2961, 0.3286, 0.3607, 0.3924, 0.4236, 0.4536, 0.4823, 0.5106, 0.5388, 0.5669, 0.5949,
0.6229, 0.6509, 0.6789, 0.7070, 0.7352, 0.7636, 0.7922, 0.8209, 0.8500, 0.8793, 0.9089, 0.9388, 0.9691, 1.0000,
],
[
0.0000, 0.0394, 0.0837, 0.1267, 0.1656, 0.2018, 0.2367, 0.2707, 0.3039, 0.3365, 0.3686, 0.4003, 0.4314, 0.4608, 0.4890, 0.5168, 0.5445, 0.5721, 0.5996,
0.6271, 0.6546, 0.6822, 0.7098, 0.7375, 0.7655, 0.7937, 0.8221, 0.8509, 0.8799, 0.9092, 0.9390, 0.9692, 1.0000,
],
[
0.0000, 0.0407, 0.0875, 0.1322, 0.1720, 0.2088, 0.2441, 0.2784, 0.3118, 0.3444, 0.3765, 0.4082, 0.4389, 0.4679, 0.4955, 0.5229, 0.5501, 0.5771, 0.6042,
0.6312, 0.6582, 0.6853, 0.7124, 0.7398, 0.7673, 0.7952, 0.8233, 0.8517, 0.8804, 0.9096, 0.9391, 0.9692, 1.0000,
],
[
0.0000, 0.0421, 0.0915, 0.1379, 0.1786, 0.2160, 0.2516, 0.2861, 0.3196, 0.3523, 0.3844, 0.4159, 0.4462, 0.4747, 0.5019, 0.5287, 0.5554, 0.5820, 0.6085,
0.6351, 0.6616, 0.6882, 0.7149, 0.7419, 0.7690, 0.7965, 0.8243, 0.8524, 0.8810, 0.9099, 0.9393, 0.9693, 1.0000,
],
[
0.0000, 0.0436, 0.0957, 0.1438, 0.1853, 0.2232, 0.2592, 0.2938, 0.3274, 0.3601, 0.3922, 0.4235, 0.4533, 0.4813, 0.5080, 0.5343, 0.5605, 0.5866, 0.6127,
0.6387, 0.6648, 0.6910, 0.7173, 0.7439, 0.7707, 0.7978, 0.8253, 0.8532, 0.8815, 0.9102, 0.9394, 0.9693, 1.0000,
],
];
const BPS: [f32; 9] = [0.0000, 0.0200, 0.0500, 0.1000, 0.2000, 0.3000, 0.4500, 0.6000, 0.8000];
const WPS: [f32; 8] = [0.1000, 0.2000, 0.3000, 0.4500, 0.6000, 0.8000, 0.9000, 1.0000];
static AW: [[f32; 8]; 9] = [
[1.7750, 2.3875, 2.4000, 2.1625, 1.9438, 1.6125, 1.3875, 1.0000],
[1.8125, 2.5250, 2.3937, 2.1688, 1.9375, 1.6062, 1.3938, 0.9875],
[2.7000, 2.4688, 2.2750, 2.0688, 1.8875, 1.6000, 1.3875, 0.9938],
[2.3250, 2.3250, 2.1875, 1.9750, 1.7937, 1.5188, 1.3250, 1.0437],
[2.0187, 2.0187, 2.0187, 1.8625, 1.7063, 1.4062, 1.2250, 0.9250],
[1.7500, 1.7500, 1.7500, 1.7500, 1.6438, 1.3562, 1.1687, 0.8625],
[1.4937, 1.4937, 1.4937, 1.4937, 1.4937, 1.2875, 1.1000, 0.7875],
[1.1687, 1.1687, 1.1687, 1.1687, 1.1687, 1.1687, 1.0375, 0.7562],
[0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.7562],
];
static AB: [[f32; 8]; 9] = [
[0.9938, 0.9812, 1.0000, 1.0125, 0.9563, 0.9688, 1.0437, 1.0000],
[1.0437, 1.0250, 1.0125, 1.0000, 1.0000, 1.0375, 1.0000, 0.9938],
[1.2312, 1.2063, 1.1875, 1.1625, 1.1438, 1.1438, 1.1375, 1.1250],
[1.2500, 1.2500, 1.2312, 1.2125, 1.2125, 1.2375, 1.2375, 1.2375],
[1.1750, 1.1750, 1.1750, 1.1750, 1.2063, 1.2688, 1.3125, 1.3375],
[1.1812, 1.1812, 1.1812, 1.1812, 1.2125, 1.2812, 1.3313, 1.4125],
[1.2375, 1.2375, 1.2375, 1.2375, 1.2375, 1.3062, 1.3562, 1.4375],
[1.3250, 1.3250, 1.3250, 1.3250, 1.3250, 1.3250, 1.3687, 1.4500],
[1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.4312],
];
/// A curve row at a fractional internal amount `x`, at grey level `v` (both interpolated).
fn curve(rows: &[[f32; STEPS]], from: f32, x: f32, v: f32) -> f32 {
if v >= 1.0 {
return 1.0;
}
let r = ((x - from) / 10.0).clamp(0.0, (rows.len() - 1) as f32);
let i = (r as usize).min(rows.len() - 2);
let t = r - i as f32;
let at = |row: &[f32; STEPS]| {
let p = v.max(0.0) * (STEPS - 1) as f32;
let j = (p as usize).min(STEPS - 2);
let s = p - j as f32;
row[j] + (row[j + 1] - row[j]) * s
};
at(&rows[i]) * (1.0 - t) + at(&rows[i + 1]) * t
}
/// Whites at internal amount `x` (−100 … 270) of grey level `v`.
pub(crate) fn whites(v: f32, x: f32) -> f32 {
curve(&WHITES, WHITES_FROM, x, v)
}
/// Blacks at internal amount `x` (−100 … 100) of grey level `v`.
pub(crate) fn blacks(v: f32, x: f32) -> f32 {
curve(&BLACKS, BLACKS_FROM, x, v)
}
/// The least Blacks adaptation on photographs.
const PHOTO_BLACKS: f32 = 1.2;
/// Bilinear lookup of an adaptation table at black point `bp` and white point `wp`.
fn adapt(table: &[[f32; 8]; 9], bp: f32, wp: f32) -> f32 {
let idx = |grid: &[f32], v: f32| {
let v = v.clamp(grid[0], grid[grid.len() - 1]);
let i = grid.windows(2).position(|w| v <= w[1]).unwrap_or(grid.len() - 2);
(i, (v - grid[i]) / (grid[i + 1] - grid[i]))
};
let (i, s) = idx(&BPS, bp);
let (j, t) = idx(&WPS, wp);
let row = |i: usize| table[i][j] * (1.0 - t) + table[i][j + 1] * t;
row(i) * (1.0 - s) + row(i + 1) * s
}
/// The internal amounts of the Whites and Blacks sliders (−100 … 100) for an image whose 1st
/// and 99th luminance percentiles are the grey levels `bp` and `wp` (sRGB-encoded).
pub(crate) fn amounts(whites: f32, blacks: f32, bp: f32, wp: f32) -> (f32, f32) {
let w = if whites > 0.0 { whites * adapt(&AW, bp, wp) } else { whites };
// On photographs Photoshop's Blacks is about 1.2 times the block images' amount even where
// their black point is 0 (ten public-domain photos: 1.08–1.36).
let b = if blacks < 0.0 { blacks * adapt(&AB, bp, wp).max(PHOTO_BLACKS) } else { blacks };
(w.clamp(-100.0, 270.0), b.clamp(-100.0, 100.0))
}
/// Applies Whites and Blacks (internal amounts `w`, `b`) to straight RGBA pixels (`width` per
/// row) encoded with `enc`. Values above 1 (float documents) keep their excess.
pub(crate) fn apply(px: &mut [[f32; 4]], width: usize, w: f32, b: f32, enc: Encoding) {
if w == 0.0 && b == 0.0 {
return;
}
// A grey level's new level, both sRGB-encoded.
let grey = |v: f32| {
let v = if w != 0.0 { whites(v, w) } else { v };
if b != 0.0 { blacks(v, b) } else { v }
};
let per_channel = w > 0.0 || b < 0.0;
par_rows(px, width, 1, |_, row| {
for q in row.iter_mut() {
let lin = [0, 1, 2].map(|c| enc.decode(q[c]));
if per_channel {
for c in 0..3 {
let over = (lin[c] - 1.0).max(0.0);
q[c] = enc.encode(super::srgb_decode(grey(super::srgb_encode(lin[c].min(1.0)))) + over);
}
} else {
let y = super::luma_linear(lin).max(0.0);
if y <= 1e-7 {
continue;
}
let y2 = super::srgb_decode(grey(super::srgb_encode(y.min(1.0)))) + (y - 1.0).max(0.0);
let k = y2 / y;
for c in 0..3 {
q[c] = enc.encode(lin[c] * k);
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn full_range_rows_match_photoshop() {
// Photoshop 25.4 on a 64-level block image spanning black to white (sRGB levels).
assert!((whites(97.1 / 255.0, 100.0) * 255.0 - 137.4).abs() < 0.6);
assert!((whites(194.3 / 255.0, -100.0) * 255.0 - 167.6).abs() < 0.6);
assert!((blacks(97.1 / 255.0, -100.0) * 255.0 - 60.0).abs() < 0.6);
assert!((blacks(48.6 / 255.0, 100.0) * 255.0 - 67.0).abs() < 0.6);
// The compressing directions keep black and white.
assert_eq!(whites(1.0, -100.0), 1.0);
assert_eq!(blacks(0.0, 100.0), 0.0);
}
#[test]
fn the_clipping_directions_adapt_to_the_image() {
// A dark, low-contrast image needs more: Whites +50 acts like +94.5 on [0.05, 0.6].
let (w, _) = amounts(50.0, 0.0, 0.05, 0.6);
assert!((w - 94.5).abs() < 2.0, "{w}");
// A raised black point strengthens Blacks −40 to −58 on [0.6, 1].
let (_, b) = amounts(0.0, -40.0, 0.6, 1.0);
assert!((b + 58.0).abs() < 1.0, "{b}");
// Never below the photographs' 1.2.
assert!((amounts(0.0, -50.0, 0.0, 1.0).1 + 60.0).abs() < 1e-3);
// The compressing directions never adapt.
assert_eq!(amounts(-50.0, 50.0, 0.3, 0.6), (-50.0, 50.0));
}
}
+90 -202
View File
@@ -30,12 +30,7 @@
use photocraft_geom::Rect;
use std::borrow::Cow;
use photocraft_raster::{Cancelled, Interrupt};
use crate::inpaint::{Adapt, CompleteParams, Px, complete_masked, complete_masked_owned};
use crate::par;
use crate::inpaint::{Adapt, CompleteParams, Px, complete_masked};
use crate::poisson::membrane_fill_with;
/// Options for [`fill`].
@@ -148,8 +143,7 @@ fn sample(src: Src, x: f32, y: f32, out: &mut [f32]) -> bool {
}
/// Transformed copy of the source: rotation (degrees) and scale about the centre, optional mirror.
/// `Err` when cancelled.
fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Result<Variant, Cancelled> {
fn variant(src: Src, deg: f32, scale: f32, mirror: bool) -> Variant {
let Src { w, h, ch, .. } = src;
let (s, c) = (-deg.to_radians()).sin_cos();
// Output size: the rotated, scaled bounding box.
@@ -159,12 +153,8 @@ fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Res
let (icx, icy) = ((w as f32 - 1.0) / 2.0, (h as f32 - 1.0) / 2.0);
let (ocx, ocy) = ((ow as f32 - 1.0) / 2.0, (oh as f32 - 1.0) / 2.0);
let mut out = Variant { w: ow, h: oh, img: vec![0.0; ow * oh * ch], ok: vec![false; ow * oh] };
// Every pixel is sampled on its own, so the rows can be done in any order.
let row = |y: usize, irow: &mut [f32], orow: &mut [bool]| {
if ctl.cancelled() {
return;
}
let mut px = vec![0.0f32; ch];
let mut px = vec![0.0f32; ch];
for y in 0..oh {
for x in 0..ow {
let (dx, dy) = ((x as f32 - ocx) / scale, (y as f32 - ocy) / scale);
let mut sx = icx + dx * c - dy * s;
@@ -173,14 +163,13 @@ fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Res
sx = (w as f32 - 1.0) - sx;
}
if sample(src, sx, sy, &mut px) {
orow[x] = true;
irow[x * ch..(x + 1) * ch].copy_from_slice(&px);
let i = y * ow + x;
out.ok[i] = true;
out.img[i * ch..(i + 1) * ch].copy_from_slice(&px);
}
}
};
par::chunks2_mut(&mut out.img, ow * ch, &mut out.ok, ow, true, row);
ctl.check()?;
Ok(out)
}
out
}
/// Fill `hole` in `img`, copying only from pixels where `source` is true (and not in the hole).
@@ -202,136 +191,81 @@ pub fn fill_with(
source: &[bool],
opts: &FillOptions,
ctl: &photocraft_raster::Interrupt,
) -> Result<Vec<f32>, photocraft_raster::Cancelled> {
fill_cow(w, h, ch, Cow::Borrowed(img), hole, source, opts, ctl)
}
/// [`fill_with`] that fills `img` itself rather than a copy of it (a big window need not be held
/// twice). The result differs from `img` only in the hole.
#[allow(clippy::too_many_arguments)]
pub fn fill_owned(
w: usize,
h: usize,
ch: usize,
img: Vec<f32>,
hole: &[bool],
source: &[bool],
opts: &FillOptions,
ctl: &photocraft_raster::Interrupt,
) -> Result<Vec<f32>, photocraft_raster::Cancelled> {
fill_cow(w, h, ch, Cow::Owned(img), hole, source, opts, ctl)
}
#[allow(clippy::too_many_arguments)]
fn fill_cow(
w: usize,
h: usize,
ch: usize,
img: Cow<[f32]>,
hole: &[bool],
source: &[bool],
opts: &FillOptions,
ctl: &photocraft_raster::Interrupt,
) -> Result<Vec<f32>, photocraft_raster::Cancelled> {
assert_eq!(img.len(), w * h * ch);
assert_eq!(hole.len(), w * h);
assert_eq!(source.len(), w * h);
if !hole.iter().any(|h| *h) {
return Ok(img.into_owned());
return Ok(img.to_vec());
}
let params = CompleteParams { seed: opts.seed, adapt: opts.color_adaptation, ..Default::default() };
let gap = 2 * params.patch_radius + 2;
let ok: Vec<bool> = par::map(w * h, true, |i| !hole[i] && source[i]);
let src = Src { w, h, ch, img: &img, ok: &ok };
let ok: Vec<bool> = hole.iter().zip(source).map(|(h, s)| !h && *s).collect();
let src = Src { w, h, ch, img, ok: &ok };
// Variants: the identity (which holds the hole) first, then the adaptations.
let mut extra: Vec<Variant> = Vec::new();
if opts.mirror {
extra.push(variant(src, 0.0, 1.0, true, ctl)?);
extra.push(variant(src, 0.0, 1.0, true));
}
for &deg in &opts.rotations {
extra.push(variant(src, deg, 1.0, false, ctl)?);
extra.push(variant(src, deg, 1.0, false));
}
if opts.scale {
extra.push(variant(src, 0.0, 0.8, false, ctl)?);
extra.push(variant(src, 0.0, 1.25, false, ctl)?);
extra.push(variant(src, 0.0, 0.8, false));
extra.push(variant(src, 0.0, 1.25, false));
}
let role = |s: usize| {
if hole[s] {
Px::Hole
} else if ok[s] {
Px::Source
} else {
Px::Known
}
};
// Mosaic: identity at the left, variants in a column to its right, gaps that are no image.
// Without variants it is the image itself.
let (mw, mh, mosaic, roles): (usize, usize, Option<Vec<f32>>, Vec<Px>) = if extra.is_empty() {
(w, h, None, par::map(w * h, true, role))
} else {
let col_w = extra.iter().map(|v| v.w).max().unwrap_or(0);
let col_h: usize = extra.iter().map(|v| v.h + gap).sum();
let mw = w + gap + col_w;
let mh = h.max(col_h);
let mut mimg = vec![0.0f32; mw * mh * ch];
let mut roles = vec![Px::Void; mw * mh];
for y in 0..h {
for x in 0..w {
let (s, d) = (y * w + x, y * mw + x);
mimg[d * ch..(d + 1) * ch].copy_from_slice(&img[s * ch..(s + 1) * ch]);
roles[d] = role(s);
}
let col_w = extra.iter().map(|v| v.w).max().unwrap_or(0);
let col_h: usize = extra.iter().map(|v| v.h + gap).sum();
let mw = if extra.is_empty() { w } else { w + gap + col_w };
let mh = h.max(col_h);
let mut mimg = vec![0.0f32; mw * mh * ch];
let mut roles = vec![Px::Void; mw * mh];
for y in 0..h {
for x in 0..w {
let (s, d) = (y * w + x, y * mw + x);
mimg[d * ch..(d + 1) * ch].copy_from_slice(&img[s * ch..(s + 1) * ch]);
roles[d] = if hole[s] {
Px::Hole
} else if ok[s] {
Px::Source
} else {
Px::Known
};
}
let mut oy = 0;
for v in &extra {
ctl.check()?;
for y in 0..v.h {
for x in 0..v.w {
let (s, d) = (y * v.w + x, (oy + y) * mw + w + gap + x);
mimg[d * ch..(d + 1) * ch].copy_from_slice(&v.img[s * ch..(s + 1) * ch]);
if v.ok[s] {
roles[d] = Px::Source;
}
}
let mut oy = 0;
for v in &extra {
for y in 0..v.h {
for x in 0..v.w {
let (s, d) = (y * v.w + x, (oy + y) * mw + w + gap + x);
mimg[d * ch..(d + 1) * ch].copy_from_slice(&v.img[s * ch..(s + 1) * ch]);
if v.ok[s] {
roles[d] = Px::Source;
}
}
oy += v.h + gap;
}
(mw, mh, Some(mimg), roles)
};
drop((extra, ok));
oy += v.h + gap;
}
ctl.check()?;
// The completion is nearly all the work.
let cancel = || ctl.cancelled();
let progress = |f: f32| ctl.progress(f * 0.99);
let inner = photocraft_raster::Interrupt::new(&cancel, &progress);
let out = match mosaic {
// Without variants the image itself is completed (it changes only in the hole).
None => {
let filled: Result<Vec<f32>, Cow<[f32]>> = match img {
Cow::Owned(v) => complete_masked_owned(w, h, ch, v, &roles, &params, &inner)?.map_err(Cow::Owned),
Cow::Borrowed(b) => complete_masked(w, h, ch, b, &roles, &params, &inner)?.ok_or(Cow::Borrowed(b)),
};
match filled {
Ok(f) => f,
Err(img) => membrane_fill_with(w, h, ch, &img, hole, ctl)?,
}
}
Some(mimg) => match complete_masked(mw, mh, ch, &mimg, &roles, &params, &inner)? {
Some(f) => {
let mut out = img.into_owned();
for y in 0..h {
for x in 0..w {
if hole[y * w + x] {
let (s, d) = ((y * mw + x) * ch, (y * w + x) * ch);
out[d..d + ch].copy_from_slice(&f[s..s + ch]);
}
let filled = complete_masked(mw, mh, ch, &mimg, &roles, &params, &photocraft_raster::Interrupt::new(&cancel, &progress))?;
let mut out = img.to_vec();
match filled {
Some(f) => {
for y in 0..h {
for x in 0..w {
if hole[y * w + x] {
let (s, d) = ((y * mw + x) * ch, (y * w + x) * ch);
out[d..d + ch].copy_from_slice(&f[s..s + ch]);
}
}
out
}
None => membrane_fill_with(w, h, ch, &img, hole, ctl)?,
},
};
}
None => out = membrane_fill_with(w, h, ch, img, hole, ctl)?,
}
ctl.check()?;
ctl.progress(1.0);
Ok(out)
@@ -371,50 +305,36 @@ const AUTO_SIDE: usize = 384;
/// pixels that may be copied from (never the hole). Falls back to everything outside the hole
/// when the area would be too small to copy from.
pub fn auto_sampling(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool]) -> Vec<bool> {
// Never cancelled; the fallback (everything outside the hole) is unreachable.
auto_sampling_with(w, h, ch, img, hole, &Interrupt::NONE).unwrap_or_else(|_| hole.iter().map(|h| !h).collect())
}
/// [`auto_sampling`] that can be cancelled. `Err(Cancelled)` when `ctl` was cancelled.
pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], ctl: &Interrupt) -> Result<Vec<bool>, Cancelled> {
use crate::segment::{FREE, HARD_FG, RgbImage, grid_cut_cancellable};
use crate::segment::{FREE, HARD_FG, RgbImage, grid_cut_with};
assert_eq!(img.len(), w * h * ch);
assert_eq!(hole.len(), w * h);
let everything = || -> Vec<bool> { par::map(hole.len(), true, |i| !hole[i]) };
let everything: Vec<bool> = hole.iter().map(|h| !h).collect();
if w == 0 || h == 0 || ch == 0 || !hole.iter().any(|h| *h) {
return Ok(everything());
return everything;
}
// Work at a reduced scale for big windows (the area is smooth at this scale).
let k = w.max(h).div_ceil(AUTO_SIDE).max(1);
let (sw, sh) = (w.div_ceil(k), h.div_ceil(k));
let nc = ch.min(3);
// Each cell sums its k × k pixels, a row of cells at a time (in the same order as one pass
// over the image would).
// Per cell: the sums of its colour planes, its pixel count and whether it holds the hole.
let mut sums = vec![0.0f32; sw * sh * (nc + 2)];
par::chunks_mut(&mut sums, sw * (nc + 2), true, |sy, row| {
if ctl.cancelled() {
return;
}
for y in sy * k..((sy + 1) * k).min(h) {
for x in 0..w {
let cell = &mut row[(x / k) * (nc + 2)..(x / k + 1) * (nc + 2)];
let p = y * w + x;
if hole[p] {
cell[nc + 1] = 1.0;
}
for c in 0..nc {
cell[c] += img[p * ch + c];
}
cell[nc] += 1.0;
let mut planes = vec![vec![0.0f32; sw * sh]; nc];
let mut shole = vec![false; sw * sh];
let mut cnt = vec![0.0f32; sw * sh];
for y in 0..h {
for x in 0..w {
let i = (y / k) * sw + x / k;
let p = y * w + x;
if hole[p] {
shole[i] = true;
}
for (c, pl) in planes.iter_mut().enumerate() {
pl[i] += img[p * ch + c];
}
cnt[i] += 1.0;
}
});
ctl.check()?;
let cell = |i: usize| &sums[i * (nc + 2)..(i + 1) * (nc + 2)];
let planes: Vec<Vec<f32>> = (0..nc).map(|c| (0..sw * sh).map(|i| cell(i)[c] / cell(i)[nc].max(1.0)).collect()).collect();
let shole: Vec<bool> = (0..sw * sh).map(|i| cell(i)[nc + 1] > 0.0).collect();
drop(sums);
}
for pl in &mut planes {
pl.iter_mut().zip(&cnt).for_each(|(v, n)| *v /= n.max(1.0));
}
// Features: local means and local contrast (of the luma-ish average of the colour planes).
let r = 3;
let means: Vec<Vec<f32>> = planes.iter().map(|p| box_mean(sw, sh, p, r)).collect();
@@ -434,9 +354,8 @@ pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bo
let ring_w = 6.0f32 / k as f32 + 1.0;
let ring: Vec<usize> = (0..sw * sh).filter(|&i| !shole[i] && dist[i] <= ring_w).collect();
if ring.is_empty() {
return Ok(everything());
return everything;
}
ctl.check()?;
// A few cluster centres of the ring's features (k-means, deterministic start).
let kc = 6.min(ring.len());
let mut f = vec![0.0f32; nf];
@@ -491,21 +410,20 @@ pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bo
// Contrast-sensitive smoothness (the weight of a squared colour difference). The scale of
// "similar" and this weight were fitted to Photoshop's Auto overlays (mean IoU 0.92 on ten
// synthetic and public-domain cases).
ctl.check()?;
let area =
grid_cut_cancellable(&rgb, &cost_fg, &cost_bg, &fixed, |d2| 8.0 * (-d2 / (2.0 * 0.05 * 0.05)).exp() + 0.05, &|| ctl.cancelled()).ok_or(Cancelled)?;
let area = grid_cut_with(&rgb, &cost_fg, &cost_bg, &fixed, |d2| 8.0 * (-d2 / (2.0 * 0.05 * 0.05)).exp() + 0.05);
let mut out = vec![false; w * h];
par::chunks_mut(&mut out, w, true, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
*o = !hole[y * w + x] && area[(y / k) * sw + x / k];
for y in 0..h {
for x in 0..w {
let i = y * w + x;
out[i] = !hole[i] && area[(y / k) * sw + x / k];
}
});
// Too little to copy from (fewer source pixels than twice the hole): sample everything.
let n_hole = par::count(hole.len(), |i| hole[i]);
if par::count(out.len(), |i| out[i]) < 2 * n_hole.max(64) {
return Ok(everything());
}
Ok(out)
// Too little to copy from (fewer source pixels than twice the hole): sample everything.
let n_hole = hole.iter().filter(|h| **h).count();
if out.iter().filter(|o| **o).count() < 2 * n_hole.max(64) {
return everything;
}
out
}
/// The Patch Tool's and the Content-Aware Move Tool's Color option (0–10) as the colour adaptation
@@ -579,36 +497,6 @@ mod tests {
assert_eq!(out, fill(w, h, 1, &img, &hole, &source, &FillOptions::default()), "same result as the plain fill");
}
#[test]
fn filling_in_place_is_filling_a_copy() {
let (w, h) = (90, 70);
let rgb: Vec<f32> = noise(w, h, 4).iter().enumerate().map(|(i, v)| v * 0.5 + 0.3 * ((i / 3 % w) % 11 < 5) as u8 as f32).collect();
let hole: Vec<bool> = (0..w * h).map(|i| (30..52).contains(&(i % w)) && (25..44).contains(&(i / w))).collect();
let source: Vec<bool> = (0..w * h).map(|i| i % w < 75).collect();
let none = Interrupt::NONE;
for opts in [FillOptions::default(), FillOptions { color_adaptation: Adapt::NONE, mirror: true, ..Default::default() }] {
let copy = fill_with(w, h, 3, &rgb, &hole, &source, &opts, &none).unwrap();
let owned = fill_owned(w, h, 3, rgb.clone(), &hole, &source, &opts, &none).unwrap();
assert_eq!(copy.iter().map(|v| v.to_bits()).collect::<Vec<_>>(), owned.iter().map(|v| v.to_bits()).collect::<Vec<_>>());
// Only the hole changes.
assert!((0..w * h).all(|i| hole[i] || (0..3).all(|c| owned[i * 3 + c].to_bits() == rgb[i * 3 + c].to_bits())));
}
// Nothing to copy from: the membrane, either way.
let nowhere = vec![false; w * h];
let copy = fill_with(w, h, 3, &rgb, &hole, &nowhere, &FillOptions::default(), &none).unwrap();
assert_eq!(copy, fill_owned(w, h, 3, rgb.clone(), &hole, &nowhere, &FillOptions::default(), &none).unwrap());
}
#[test]
fn auto_sampling_can_be_cancelled() {
let (w, h) = (200, 160);
let img: Vec<f32> = noise(w, h, 5).iter().enumerate().map(|(i, v)| v * 0.1 + if (i / 3) % w < 120 { 0.3 } else { 0.6 }).collect();
let hole: Vec<bool> = (0..w * h).map(|i| (80..110).contains(&(i % w)) && (60..90).contains(&(i / w))).collect();
let yes = || true;
assert!(auto_sampling_with(w, h, 3, &img, &hole, &Interrupt::cancel_only(&yes)).is_err());
assert_eq!(auto_sampling_with(w, h, 3, &img, &hole, &Interrupt::NONE).unwrap(), auto_sampling(w, h, 3, &img, &hole));
}
#[test]
fn fills_only_the_hole_with_known_texture() {
let (w, h) = (48, 32);
@@ -783,9 +671,9 @@ mod tests {
let img = vec![0.5f32; 10 * 6];
let ok = vec![true; 60];
let src = Src { w: 10, h: 6, ch: 1, img: &img, ok: &ok };
let v = variant(src, 90.0, 1.0, false, &Interrupt::NONE).unwrap();
let v = variant(src, 90.0, 1.0, false);
assert_eq!((v.w, v.h), (6, 10));
let v = variant(src, 0.0, 0.8, true, &Interrupt::NONE).unwrap();
let v = variant(src, 0.0, 0.8, true);
assert_eq!((v.w, v.h), (8, 5));
assert!(v.ok.iter().filter(|o| **o).count() > 20);
}
+401 -1424
View File
File diff suppressed because it is too large Load Diff
+4 -30
View File
@@ -49,7 +49,6 @@ mod other;
mod other2;
pub mod paint;
pub mod panorama;
mod par;
pub mod perspective;
mod photo_util;
mod pixelate;
@@ -1095,7 +1094,7 @@ fn apply_tiled_with_impl(
return (*t, Vec::new());
};
if let Some(sel) = selection {
mix_selection(&mut data, *t, sel, src, fmt.alpha);
mix_selection(&mut data, *t, sel, src);
}
tick();
(*t, data)
@@ -1179,7 +1178,7 @@ fn apply_box_blur(
let write = |(s, v): &mut (Surface, Vec<blur::Strip>)| {
for (t, data) in v.iter_mut() {
if let Some(sel) = selection {
mix_selection(data, *t, sel, &Image::read(surface, *t), fmt.alpha);
mix_selection(data, *t, sel, &Image::read(surface, *t));
}
s.write_region(*t, data);
}
@@ -1202,9 +1201,8 @@ fn apply_box_blur(
}
/// Mixes filtered `data` for `t` back toward the original pixels in `orig` by the selection's
/// coverage (channel 0 of `sel`). Changing alpha requires premultiplied colour interpolation;
/// otherwise hidden colours leak into feathered edges and alpha-removing filters darken them.
fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image, alpha: bool) {
/// coverage (channel 0 of `sel`).
fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image) {
let w = t.width() as usize;
for (i, px) in data.chunks_exact_mut(orig.ch.max(1)).enumerate() {
let (x, y) = (t.x0 + (i % w) as i32, t.y0 + (i / w) as i32);
@@ -1212,27 +1210,6 @@ fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image, alpha:
if k >= 1.0 {
continue;
}
if k <= 0.0 {
for (c, v) in px.iter_mut().enumerate() {
*v = orig.get(x, y, c);
}
continue;
}
if alpha {
let a = px.len() - 1;
let original_alpha = orig.get(x, y, a);
let filtered_alpha = px[a];
if original_alpha != filtered_alpha {
let mixed_alpha = original_alpha + (filtered_alpha - original_alpha) * k;
let original_weight = original_alpha * (1.0 - k);
let filtered_weight = filtered_alpha * k;
for (c, v) in px[..a].iter_mut().enumerate() {
*v = if mixed_alpha > 0.0 { (orig.get(x, y, c) * original_weight + *v * filtered_weight) / mixed_alpha } else { 0.0 };
}
px[a] = mixed_alpha;
continue;
}
}
for (c, v) in px.iter_mut().enumerate() {
let o = orig.get(x, y, c);
*v = o + (*v - o) * k;
@@ -1251,9 +1228,6 @@ mod tests_ext;
#[cfg(test)]
mod tests_mosaic;
#[cfg(test)]
mod tests_selection_mix;
/// The current upstream sampler through the identical source/selection/tile pipeline.
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
+58 -266
View File
@@ -116,8 +116,7 @@ pub struct LiquifyStroke {
/// Brush diameter (px).
#[serde(default = "d_size")]
pub size: f64,
/// How far the full effect reaches toward the brush edge, 0 (only the centre; feathered)
/// ..100 (most of the brush). It always fades to nothing at the edge ([`falloff`]).
/// Edge feathering, 0 (hard) ..100 (soft).
#[serde(default = "d_density")]
pub density: f64,
/// Overall strength, 0..100.
@@ -154,7 +153,7 @@ impl LiquifyStroke {
}
/// The displacement field plus the freeze mask, over `bounds` (document pixels).
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
pub struct LiquifyField {
pub bounds: Rect,
/// Document pixels per field node.
@@ -165,38 +164,6 @@ pub struct LiquifyField {
pub d: Vec<[f32; 2]>,
/// Freeze mask per node (0 = free, 1 = frozen).
pub freeze: Vec<f32>,
/// Where the brush stroke in progress stands, so dab spacing runs on across its segments.
cursor: Option<StrokeCursor>,
}
/// Two fields are equal when their displacement and freeze mask are: the stroke cursor is
/// transient brush state, not part of the result.
impl PartialEq for LiquifyField {
fn eq(&self, o: &Self) -> bool {
self.bounds == o.bounds && self.cell == o.cell && self.w == o.w && self.h == o.h && self.d == o.d && self.freeze == o.freeze
}
}
/// The brush stroke in progress: the point its last segment ended at, its last dab and how far
/// the brush has travelled along the path since that dab.
#[derive(Clone, Copy, Debug, PartialEq)]
struct StrokeCursor {
end: [f64; 2],
last_dab: [f64; 2],
carry: f64,
}
/// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a
/// drag is many small pushes and its edge stays smooth.
fn dab_spacing(r: f64) -> f64 {
(r * 0.05).max(0.5)
}
/// Per-dab strength of a stationary tool (Twirl, Pucker, Bloat, Reconstruct, Smooth, Freeze,
/// Thaw) while it moves: scaled to the spacing so its effect per distance travelled stays what it
/// was with dabs a fifth of the radius apart.
fn moving_weight(r: f64) -> f64 {
dab_spacing(r) / (r * 0.2).max(0.5)
}
/// The default field resolution for `bounds`: 2 px per node up to 4 MP, 4 px beyond.
@@ -209,7 +176,7 @@ impl LiquifyField {
let cell = cell.clamp(1.0, 64.0);
let w = (f64::from(bounds.width()) / cell).ceil() as usize + 2;
let h = (f64::from(bounds.height()) / cell).ceil() as usize + 2;
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None }
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h] }
}
/// Document position of node (i, j) (node 0 sits on the first pixel centre).
@@ -252,7 +219,6 @@ impl LiquifyField {
/// Applies one stroke. Returns the document rectangle whose output changed.
pub fn apply_stroke(&mut self, s: &LiquifyStroke) -> Rect {
self.cursor = None;
match s.tool {
LiquifyTool::LassoMask => return self.apply_lasso(s),
LiquifyTool::ReconstructAll => {
@@ -339,120 +305,77 @@ impl LiquifyField {
/// callers use this plus [`Self::stroke_segment`] per new point, which is exactly what
/// [`Self::apply_stroke`] does with the whole point list.
pub fn stroke_begin(&mut self, s: &LiquifyStroke, p: [f64; 3]) -> Rect {
self.cursor = Some(StrokeCursor { end: [p[0], p[1]], last_dab: [p[0], p[1]], carry: 0.0 });
if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0), 1.0) } else { Rect::EMPTY }
if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0)) } else { Rect::EMPTY }
}
/// Continues a stroke from `a` to `b`. Dabs fall every [`dab_spacing`] of distance travelled
/// along the whole stroke, carried from one segment to the next, so the result is the same
/// however finely the pointer path was sampled (a fast and a slow mouse give one warp). A
/// stationary tool held still dabs once at `b`. When `a` is not where the previous segment
/// ended, the stroke starts afresh at `a`.
/// Continues a stroke from `a` to `b`: dabs every fifth of the brush radius (a stationary
/// tool held still dabs once at `b`).
pub fn stroke_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
self.walk_segment(s, a, b, true)
}
/// [`Self::stroke_segment`]; `skip` leaves out the dabs that cannot reach a field node (they
/// would change nothing), which tests compare with the full walk.
fn walk_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3], skip: bool) -> Rect {
let mut dirty = Rect::EMPTY;
let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0));
let stationary = s.tool.is_stationary();
let cur = match self.cursor {
Some(c) if c.end == [a[0], a[1]] => c,
_ => StrokeCursor { end: [a[0], a[1]], last_dab: [a[0], a[1]], carry: 0.0 },
};
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len = dx.hypot(dy);
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
if len == 0.0 {
self.cursor = Some(cur);
return if stationary { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb, 1.0) } else { dirty };
}
// NaN or a length that overflowed: nothing sensible to walk; the next segment restarts.
if !len.is_finite() {
self.cursor = None;
return dirty;
return if s.tool.is_stationary() { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb) } else { dirty };
}
let r = (s.size / 2.0).max(0.5);
let spacing = dab_spacing(r);
let weight = if stationary { moving_weight(r) } else { 1.0 };
// Distance along this segment of its first dab, then one every `spacing`.
let first = (spacing - cur.carry).clamp(0.0, spacing);
// Distances are compared with a little slack: a dab that rounding puts a hair past the
// segment's end still falls on it, so cutting one segment in two never adds or drops a dab.
let slack = spacing * 1e-6;
if first > len + slack {
self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: cur.last_dab, carry: cur.carry + len });
return dirty;
let spacing = (r * 0.2).max(0.5);
let steps = (len / spacing).ceil().max(1.0) as usize;
// Walk only the steps whose dab can reach a field node: the rest return from `dab` before
// touching anything, and a segment to a far-off point has trillions of them (#937).
let Some((k0, k1)) = self.reachable_steps(a, b, steps, r, s.tool.is_stationary()) else { return dirty };
for k in k0..=k1 {
let t0 = (k - 1) as f64 / steps as f64;
let t1 = k as f64 / steps as f64;
let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1];
let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)];
let pr = pa + (pb - pa) * t1;
// Forward Warp centres the dab where the brush was (it drags what is under it).
let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] };
dirty = dirty.union(&self.dab(s, centre, delta, pr));
}
let last_k = ((len - first + slack) / spacing).floor().max(0.0);
let at = |dist: f64| [a[0] + dx * dist / len, a[1] + dy * dist / len];
let (mut k0, mut k1) = (0.0f64, last_k);
if skip {
match self.reachable_span(a, [dx, dy], len, r) {
// A dragging dab is centred one spacing behind its position: pad by that and a step.
Some((d0, d1)) => {
k0 = k0.max(((d0 - spacing - first) / spacing).floor() - 1.0);
k1 = k1.min(((d1 + spacing - first) / spacing).ceil() + 1.0);
}
None => k1 = -1.0,
}
}
if k0 <= k1 {
let mut prev = if k0 > 0.0 { at(first + (k0 - 1.0) * spacing) } else { cur.last_dab };
for k in (k0 as u64)..=(k1 as u64) {
let dist = (first + k as f64 * spacing).min(len);
let c = at(dist);
let delta = [c[0] - prev[0], c[1] - prev[1]];
// Forward Warp centres the dab where the brush was (it drags what is under it).
let centre = if stationary { c } else { prev };
dirty = dirty.union(&self.dab(s, centre, delta, pa + (pb - pa) * dist / len, weight));
prev = c;
}
}
let last = (first + last_k * spacing).min(len);
self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: at(last), carry: (len - last).clamp(0.0, spacing) });
dirty
}
/// The distances along a segment from `a` (direction `dir`, length `len`) whose points lie
/// within a dab radius `r` plus two nodes of the node grid, or `None` when none do. Only dabs
/// near that span can touch a node; a segment to a far-off point has trillions of others
/// (#937).
fn reachable_span(&self, a: [f64; 3], dir: [f64; 2], len: f64, r: f64) -> Option<(f64, f64)> {
/// The steps `k0..=k1` of a segment walked in `steps` whose dab can touch a field node, or
/// `None` when none can. Step `k` centres its dab at `a + (b - a)·(k - o)/steps`, with `o` = 1
/// for the dragging tools and 0 for the stationary ones (see `stroke_segment`). A dab reaches a
/// node only when its centre is within `r` plus a node of the node grid, so the window below
/// pads that by another node and a step: the dabs it leaves out would each change nothing.
fn reachable_steps(&self, a: [f64; 3], b: [f64; 3], steps: usize, r: f64, stationary: bool) -> Option<(usize, usize)> {
if self.w == 0 || self.h == 0 {
return None;
}
let pad = r + 2.0 * self.cell;
let lo = [f64::from(self.bounds.x0) + 0.5 - pad, f64::from(self.bounds.y0) + 0.5 - pad];
let hi = [lo[0] + (self.w - 1) as f64 * self.cell + 2.0 * pad, lo[1] + (self.h - 1) as f64 * self.cell + 2.0 * pad];
let (mut u0, mut u1) = (0.0f64, 1.0f64);
let (n, o) = (steps as f64, if stationary { 0.0 } else { 1.0 });
let (mut k0, mut k1) = (1.0f64, n);
for ax in 0..2 {
let d = dir[ax];
let d = b[ax] - a[ax];
if d == 0.0 {
if a[ax].is_nan() || a[ax] < lo[ax] || a[ax] > hi[ax] {
return None;
}
continue;
}
let (v0, v1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d);
u0 = u0.max(v0.min(v1));
u1 = u1.min(v0.max(v1));
let (u0, u1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d);
k0 = k0.max((u0.min(u1) * n + o).floor() - 1.0);
k1 = k1.min((u0.max(u1) * n + o).ceil() + 1.0);
}
if u0.is_nan() || u1.is_nan() || u0 > u1 {
// NaN: a segment whose length overflowed.
if k0.is_nan() || k1.is_nan() || k0 > k1 {
return None;
}
Some((u0 * len, u1 * len))
Some((k0 as usize, k1 as usize))
}
/// One dab at `c` with brush motion `delta`, its strength scaled by `weight`. Returns the
/// document rect it affected.
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64, weight: f64) -> Rect {
/// One dab at `c` with brush motion `delta`. Returns the document rect it affected.
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64) -> Rect {
if s.tool == LiquifyTool::LassoMask {
return Rect::EMPTY; // the lasso is polygon-only; never a brush dab
}
let r = (s.size / 2.0).max(0.5);
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure * weight;
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure;
let rate = (s.rate / 100.0).clamp(0.0, 1.0);
let density = (s.density / 100.0).clamp(0.0, 1.0);
if strength <= 0.0 {
@@ -497,7 +420,8 @@ impl LiquifyField {
if t2 >= 1.0 {
continue;
}
let fall = falloff(t2, density);
let soft = (1.0 - t2) * (1.0 - t2);
let fall = (1.0 - density) + density * soft;
let idx = j * self.w + i;
let k_raw = strength * fall;
if s.tool == LiquifyTool::Freeze || s.tool == LiquifyTool::Thaw {
@@ -602,23 +526,6 @@ impl LiquifyField {
}
}
/// A dab's strength at squared distance `t2` (in brush radii, 0..1) for Density `density` (0..1):
/// 1 at the centre, and down to 0 at the rim with a flat slope at every density, as in
/// Photoshop, where Density sets how far the full effect reaches toward the edge (0: only the
/// centre, the rest feathered; 1: most of the brush) but the effect always fades out at it.
/// A brush that stopped short of 0 at its rim moved the pixels inside it and left those just
/// outside, tearing the image along every dab's circle (#2606).
pub fn falloff(t2: f64, density: f64) -> f64 {
let t2 = t2.clamp(0.0, 1.0);
let density = if density.is_nan() { 0.5 } else { density.clamp(0.0, 1.0) };
// Feathered: (1 - t²)³. Dense: (1 - t⁴)², flat in the middle. Both are 0 with zero slope at
// t = 1, and their slopes stay small enough that a dab every fifth of the radius never folds
// the image over itself.
let feathered = (1.0 - t2).powi(3);
let dense = (1.0 - t2 * t2).powi(2);
feathered + (dense - feathered) * density
}
fn bilinear(d: &[[f32; 2]], w: usize, h: usize, gx: f64, gy: f64) -> [f64; 2] {
if !(gx > -1.0 && gy > -1.0 && gx < w as f64 && gy < h as f64) {
return [0.0, 0.0];
@@ -997,9 +904,21 @@ mod tests {
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
/// The segment walk before #937: every dab, whether or not it can reach the field.
/// The segment walk before #937: every step, whether or not its dab can reach the field.
fn full_walk(f: &mut LiquifyField, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
f.walk_segment(s, a, b, false)
let mut dirty = Rect::EMPTY;
let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0));
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
let r = (s.size / 2.0).max(0.5);
let steps = (len / (r * 0.2).max(0.5)).ceil().max(1.0) as usize;
for k in 1..=steps {
let (t0, t1) = ((k - 1) as f64 / steps as f64, k as f64 / steps as f64);
let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1];
let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)];
let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] };
dirty = dirty.union(&f.dab(s, centre, delta, pa + (pb - pa) * t1));
}
dirty
}
#[test]
@@ -1009,7 +928,7 @@ mod tests {
// every result bit-identical to the full walk.
use LiquifyTool::*;
let bounds = Rect::new(-7, 3, 89, 67);
let segments: [([f64; 3], [f64; 3]); 10] = [
let segments: [([f64; 3], [f64; 3]); 9] = [
([10.0, 20.0, 1.0], [70.0, 50.0, 0.5]), // inside
([-900.0, 30.0, 1.0], [40.0, 31.0, 1.0]), // in from far left
([40.0, 30.0, 1.0], [40.0, 4000.0, 0.2]), // out downwards
@@ -1019,7 +938,6 @@ mod tests {
([88.5, -50.0, 1.0], [88.5, 120.0, 1.0]), // vertical, along the right edge
([200.0, 200.0, 1.0], [201.0, 900.0, 1.0]), // entirely outside
([0.0, 0.0, 1.0], [0.25, 0.1, 1.0]), // shorter than one step
([60.0, 40.0, 1.0], [-2000.0, 45.0, 1.0]), // continues the stroke before it, out left
];
for tool in [ForwardWarp, PushLeft, Reconstruct, Smooth, TwirlCw, TwirlCcw, Pucker, Bloat, Freeze, Thaw] {
for size in [6.0, 40.0] {
@@ -1038,81 +956,6 @@ mod tests {
}
}
/// The same path sampled by `parts` pointer events per corner-to-corner leg, unevenly.
fn sampled(path: &[[f64; 3]], parts: usize) -> Vec<Vec<f64>> {
let mut out = vec![path[0].to_vec()];
for w in path.windows(2) {
let (a, b) = (w[0], w[1]);
// Uneven steps, as a real pointer gives: t grows quadratically.
for k in 1..=parts {
let t = (k * k) as f64 / (parts * parts) as f64;
out.push((0..3).map(|i| a[i] + (b[i] - a[i]) * t).collect());
}
}
out
}
/// #2606: dabs restarted at every pointer event, so a fast mouse (few far-apart events) and a
/// slow one (many close ones) warped the same path differently. Spacing now runs on along the
/// whole stroke: any sampling of a path gives the same field.
#[test]
fn the_result_does_not_depend_on_how_finely_the_path_was_sampled() {
use LiquifyTool::*;
let path = [[20.0, 20.0, 1.0], [62.0, 26.0, 0.6], [70.0, 50.0, 0.9], [28.0, 55.0, 1.0], [30.0, 30.0, 1.0]];
for tool in [ForwardWarp, PushLeft, TwirlCw, Bloat, Reconstruct, Smooth, Freeze] {
for size in [12.0, 40.0] {
let field = |parts: usize| {
let mut warp = LiquifyStroke::new(ForwardWarp, 30.0);
warp.points = vec![vec![30.0, 30.0], vec![50.0, 40.0]];
let mut s = LiquifyStroke::new(tool, size);
s.points = sampled(&path, parts);
// Some displacement first, so Reconstruct and Smooth have something to change.
LiquifyField::from_strokes(bounds(), 2.0, &[warp, s])
};
let coarse = field(1);
for parts in [3, 17, 60] {
let fine = field(parts);
let dd = coarse.d.iter().zip(&fine.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
let df = coarse.freeze.iter().zip(&fine.freeze).map(|(p, q)| (p - q).abs()).fold(0.0f32, f32::max);
assert!(dd < 1e-3 && df < 1e-4, "{tool:?} {size}px, {parts} events per leg: field differs by {dd} px, mask by {df}");
}
}
}
}
/// A long drag whose events land exactly on dab positions, along a diagonal: rounding must not
/// add or drop the dab at a segment's end, or the deep displacement a long Forward Warp builds
/// up differs by a whole dab between a slow and a fast pointer.
#[test]
fn a_long_diagonal_drag_gives_one_field_at_any_event_spacing() {
let field = |step: f64| {
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 40.0);
s.points = (0..=(300.0 / step) as usize).map(|k| vec![40.0 + k as f64 * step * 0.6, 20.0 + k as f64 * step * 0.8]).collect();
LiquifyField::from_strokes(Rect::new(0, 0, 320, 300), 2.0, &[s])
};
let (slow, fast) = (field(2.0), field(60.0));
let dd = slow.d.iter().zip(&fast.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
assert!(slow.max_displacement() > 50.0, "the drag builds up a deep displacement");
assert!(dd < 1e-3, "slow and fast pointer fields differ by {dd} px");
}
#[test]
fn a_stroke_interrupted_by_a_jump_restarts_its_spacing() {
// A segment that does not start where the last one ended begins a new run of dabs there.
let s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 20.0);
let mut f = LiquifyField::new(bounds(), 2.0);
f.stroke_begin(&s, [10.0, 10.0, 1.0]);
f.stroke_segment(&s, [10.0, 10.0, 1.0], [10.3, 10.0, 1.0]);
let mut g = LiquifyField::new(bounds(), 2.0);
f.stroke_segment(&s, [40.0, 30.0, 1.0], [50.0, 30.0, 1.0]);
g.stroke_begin(&s, [40.0, 30.0, 1.0]);
g.stroke_segment(&s, [40.0, 30.0, 1.0], [50.0, 30.0, 1.0]);
assert_eq!(f, g);
// Hostile points: nothing panics and the next segment starts afresh.
f.stroke_segment(&s, [f64::NAN, 3.0, 1.0], [4.0, f64::INFINITY, 1.0]);
f.stroke_segment(&s, [4.0, 3.0, f64::NAN], [9.0, 3.0, 1.0]);
}
#[test]
fn a_segment_to_a_far_off_point_returns() {
// The issue's request: about 3.3e14 steps before the fix. Also a segment whose length
@@ -1223,57 +1066,6 @@ mod tests {
assert_eq!(f.freeze_at(30.0, 30.0), 1.0);
}
#[test]
fn the_brush_fades_to_nothing_at_its_rim_at_every_density() {
for density in [0.0, 0.25, 0.5, 0.75, 1.0, f64::NAN, -3.0, 7.0] {
assert_eq!(falloff(0.0, density), 1.0, "full strength at the centre ({density})");
assert_eq!(falloff(1.0, density), 0.0, "nothing at the rim ({density})");
// Flat at the rim: the last 2 % of the radius moves at most a couple of percent.
assert!(falloff(0.98f64.powi(2), density) < 0.02, "{density}: {}", falloff(0.98f64.powi(2), density));
let mut prev = 1.0;
for k in 1..=100 {
let v = falloff(k as f64 / 100.0, density);
assert!(v <= prev && v >= 0.0, "{density}: not falling at t² = {k}%");
prev = v;
}
}
// Density widens the strong core: more strength at every radius, as in Photoshop.
for k in 1..100 {
let t2 = k as f64 / 100.0;
assert!(falloff(t2, 1.0) >= falloff(t2, 0.5) && falloff(t2, 0.5) >= falloff(t2, 0.0), "t² = {t2}");
}
assert!(falloff(0.5, 1.0) > 0.5 && falloff(0.5, 0.0) < 0.2);
}
/// #2606: the dab's strength used to stop at 1 - Density at its rim, so a Forward Warp moved
/// everything just inside the brush and nothing just outside, tearing the image along every
/// dab's circle (a hard-edged block at Density 0, half strength at the default 50).
#[test]
fn forward_warp_leaves_the_brush_rim_in_place() {
// A drag of one dab spacing (a twentieth of the radius): one dab, centred where it started.
let (r, drag) = (20.0, 1.0);
for density in [0.0, 50.0, 100.0] {
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 2.0 * r);
s.density = density;
s.points = vec![vec![48.0, 32.0], vec![48.0 + drag, 32.0]];
let f = LiquifyField::from_strokes(bounds(), 1.0, &[s]);
let mut rim = 0;
for j in 0..f.h {
for i in 0..f.w {
let p = f.node_pos(i, j);
let t = (p[0] - 48.0).hypot(p[1] - 32.0) / r;
if (0.97..1.0).contains(&t) {
let d = f.d[j * f.w + i];
assert!(f64::from(d[0].hypot(d[1])) < 0.03 * drag, "density {density}: a node at {t:.3} r moved {d:?}");
rim += 1;
}
}
}
assert!(rim > 20, "{rim} rim nodes");
assert!(f.sample(48.0, 32.0)[0] < -0.9 * drag, "density {density}: the centre follows the brush");
}
}
#[test]
fn no_strokes_is_identity_at_every_depth() {
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
@@ -1352,7 +1144,7 @@ mod tests {
assert!(f.max_displacement() < before * 0.2, "{} vs {before}", f.max_displacement());
// Frozen area doesn't move.
let mut fr = LiquifyStroke::new(LiquifyTool::Freeze, 60.0);
fr.density = 100.0;
fr.density = 0.0;
fr.points = vec![vec![40.0, 32.0]];
let mut g = LiquifyField::new(bounds(), 2.0);
g.apply_stroke(&fr);
+5 -51
View File
@@ -35,8 +35,7 @@ pub struct RefineParams {
pub feather: f32,
/// 0–100.
pub contrast: f32,
/// −100–100. 100% moves the edge by `max(radius, 4)` pixels. Values between whole
/// pixels blend the two neighbouring shifts, so the slider does not jump.
/// −100–100 (% of `max(radius, 4 px)`).
pub shift_edge: f32,
}
@@ -60,18 +59,8 @@ impl RefineParams {
fn feather_sigma(&self) -> f32 {
self.feather.max(0.0) / 2.0
}
/// Signed shift in pixels, fractional. 100% is `max(radius, 4)` px, clamped to ±64.
fn shift_amount(&self) -> f32 {
let px = self.shift_edge.clamp(-100.0, 100.0) / 100.0 * self.radius.max(4.0);
if px.is_finite() { px.clamp(-64.0, 64.0) } else { 0.0 }
}
/// How many pixels the mask can move: the farther of the two integer radii a fractional
/// shift blends. Whole-pixel amounts stay exact (`ceil(4) == 4`).
fn shift_px(&self) -> i32 {
let px = self.shift_amount();
let reach = px.abs().ceil();
let signed = if px.is_sign_negative() { -reach } else { reach };
if signed.is_finite() { signed as i32 } else { 0 }
(self.shift_edge.clamp(-100.0, 100.0) / 100.0 * self.radius.max(4.0)).round().clamp(-64.0, 64.0) as i32
}
fn uses_image(&self) -> bool {
self.radius > 0.0
@@ -381,38 +370,13 @@ where
*v = ((*v - 0.5) * gain + 0.5).clamp(0.0, 1.0);
}
}
let amount = p.shift_amount();
if amount.abs() >= 1.0e-4 {
a = shift_mask(&a, w, h, amount, morphology);
let d = p.shift_px();
if d != 0 {
a = morphology(&a, w, h, d.unsigned_abs() as usize, d > 0);
}
a
}
/// Grow or shrink `src` by a fractional pixel distance. Whole pixels use one morphology;
/// the remainder blends that result with the next pixel, so 0.3 px is not the same step as 1 px.
fn shift_mask<F>(src: &[f32], w: usize, h: usize, amount: f32, morphology: F) -> Vec<f32>
where
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy,
{
let mag = amount.abs();
if !mag.is_finite() || mag < 1.0e-4 {
return src.to_vec();
}
let grow = amount > 0.0;
let whole = mag.floor() as usize;
let frac = mag - whole as f32;
if frac < 1.0e-4 {
return morphology(src, w, h, whole, grow);
}
let lo = if whole == 0 { src.to_vec() } else { morphology(src, w, h, whole, grow) };
let hi = morphology(src, w, h, whole + 1, grow);
let n = w.saturating_mul(h);
if lo.len() < n || hi.len() < n || lo.len() != hi.len() {
return hi;
}
lo.into_iter().zip(hi).map(|(a, b)| a + (b - a) * frac).collect()
}
/// Refines a selection mask. `mask(r)` returns coverage over `r` (row-major); `content` bounds
/// its non-zero pixels; the result is clipped to `canvas`. `sampler` supplies the guide image
/// (only read when `radius > 0`). Returns `None` when nothing remains selected.
@@ -756,16 +720,6 @@ mod tests {
let shrunk = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -50.0, ..Default::default() });
assert_eq!(shrunk[5 * w + 27], 1.0);
assert_eq!(shrunk[5 * w + 28], 0.0);
// Radius 0 scales 100% to 4 px, so 12.5% is half a pixel and 25% is one pixel.
// The pixel just outside a hard edge fades in instead of snapping on.
let half = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 12.5, ..Default::default() });
let one = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 25.0, ..Default::default() });
assert_eq!(m[5 * w + 30], 0.0);
assert!((half[5 * w + 30] - 0.5).abs() < 0.02, "half pixel {}", half[5 * w + 30]);
assert_eq!(one[5 * w + 30], 1.0);
assert_eq!(half[5 * w + 31], 0.0, "the blend must not jump a second pixel");
let pulled = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -12.5, ..Default::default() });
assert!((pulled[5 * w + 29] - 0.5).abs() < 0.02, "half-pixel contract {}", pulled[5 * w + 29]);
let sm = refine_buffer(None, &m, w, h, &RefineParams { smooth: 50.0, ..Default::default() });
assert!(sm[5 * w + 10] == 1.0 && sm[5 * w + 50] == 0.0);
}
-77
View File
@@ -1,77 +0,0 @@
//! Data-parallel helpers: rayon on native targets, sequential on wasm (where this crate has no
//! threads). Each gives the same result either way, and whatever `par` says.
/// `f(index, chunk)` for each `len`-item chunk of `buf`, in parallel when `par`.
pub(crate) fn chunks_mut<T: Send>(buf: &mut [T], len: usize, par: bool, f: impl Fn(usize, &mut [T]) + Sync + Send) {
if len == 0 {
return;
}
#[cfg(not(target_arch = "wasm32"))]
if par {
use rayon::prelude::*;
buf.par_chunks_mut(len).enumerate().for_each(|(i, c)| f(i, c));
return;
}
let _ = par;
buf.chunks_mut(len).enumerate().for_each(|(i, c)| f(i, c));
}
/// [`chunks_mut`] over two buffers in step: the `i`-th `la`-item chunk of `a` with the `i`-th
/// `lb`-item chunk of `b`.
pub(crate) fn chunks2_mut<A: Send, B: Send>(a: &mut [A], la: usize, b: &mut [B], lb: usize, par: bool, f: impl Fn(usize, &mut [A], &mut [B]) + Sync + Send) {
if la == 0 || lb == 0 {
return;
}
#[cfg(not(target_arch = "wasm32"))]
if par {
use rayon::prelude::*;
a.par_chunks_mut(la).zip(b.par_chunks_mut(lb)).enumerate().for_each(|(i, (ca, cb))| f(i, ca, cb));
return;
}
let _ = par;
a.chunks_mut(la).zip(b.chunks_mut(lb)).enumerate().for_each(|(i, (ca, cb))| f(i, ca, cb));
}
/// `[f(0), f(1), …, f(n - 1)]`, in parallel when `par`.
pub(crate) fn map<T: Send>(n: usize, par: bool, f: impl Fn(usize) -> T + Sync + Send) -> Vec<T> {
#[cfg(not(target_arch = "wasm32"))]
if par {
use rayon::prelude::*;
return (0..n).into_par_iter().map(f).collect();
}
let _ = par;
(0..n).map(f).collect()
}
/// How many of `0..n` satisfy `f`.
pub(crate) fn count(n: usize, f: impl Fn(usize) -> bool + Sync + Send) -> usize {
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
(0..n).into_par_iter().filter(|&i| f(i)).count()
}
#[cfg(target_arch = "wasm32")]
(0..n).filter(|&i| f(i)).count()
}
/// `f(item)` for each of `items`, in parallel when `par`.
pub(crate) fn each<T: Send>(items: Vec<T>, par: bool, f: impl Fn(T) + Sync + Send) {
#[cfg(not(target_arch = "wasm32"))]
if par {
use rayon::prelude::*;
items.into_par_iter().for_each(f);
return;
}
let _ = par;
items.into_iter().for_each(f);
}
/// Threads the parallel helpers can use (1 on wasm).
pub(crate) fn threads() -> usize {
#[cfg(not(target_arch = "wasm32"))]
{
rayon::current_num_threads()
}
#[cfg(target_arch = "wasm32")]
1
}
+1 -1
View File
@@ -153,7 +153,7 @@ fn tiled_filters_match_scalar_at_all_depths_with_soft_selection() {
FilterParams::ColorHalftone { max_radius, angles } => reference::halftone(&image, bounds, &ctx, max_radius, angles),
_ => Vec::new(),
};
crate::mix_selection(&mut expected_data, bounds, &selection, &image, ctx.alpha);
crate::mix_selection(&mut expected_data, bounds, &selection, &image);
let mut expected = surface.clone();
expected.write_region(bounds, &expected_data);
for tile in [1, 7, 17, 256] {
+105 -645
View File
@@ -1,12 +1,10 @@
//! Edit › Puppet Warp: pins on a triangle mesh over the layer's opaque region, deformed
//! as-rigidly-as-possible.
//!
//! * **Mesh.** The layer's coverage (alpha above a threshold) becomes a signed distance field,
//! offset by `expansion` to grow or shrink the region. A square grid at the density's spacing,
//! split into triangles, is fitted to that outline (2D isosurface stuffing): vertices near the
//! outline snap onto it and the triangles it crosses are cut along it, so the mesh edge follows
//! the shape, and separate shapes get separate meshes. Inside, the mesh stays a uniform grid,
//! which is all ARAP needs.
//! * **Mesh.** The layer's coverage (alpha above a threshold, grown or shrunk by `expansion`) is
//! rasterized onto a coarse block grid; a square grid at the density's spacing keeps every
//! cell that touches covered blocks, split into two triangles (alternating diagonals). A
//! grid-clipped mesh is simple, robust and uniform, which is all ARAP needs.
//! * **Deformation.** The per-triangle ARAP energy of Liu et al. 2008 (the triangle form of
//! Sorkine & Alexa 2007), `E = Σ_t Σ_(i,j)∈t w_ij |(v_i − v_j) − R_t (x_i − x_j)|²` with
//! cotangent weights, minimized by alternating a local step (best rotation per triangle from
@@ -20,8 +18,6 @@
//! * **Rendering.** Triangles are rasterized with [`crate::warp::warp_triangles`] (tile-parallel,
//! premultiplied, bilinear/bicubic), back to front by the depth of their nearest pin.
use std::collections::{HashMap, HashSet};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde::{Deserialize, Serialize};
@@ -129,619 +125,120 @@ pub struct PuppetMesh {
/// Coverage threshold: alpha above this counts as part of the puppet.
const ALPHA_MIN: f32 = 0.05;
/// The outline field's longest side, in blocks: how finely the mesh edge follows the outline.
const FIELD_BLOCKS: f64 = 1024.0;
/// The puppet's region as a signed distance field over a block grid: negative inside the
/// opaque area grown (or shrunk) by the expansion, positive outside, in px.
struct Field {
x0: f64,
y0: f64,
/// Block size (px).
g: f64,
bw: usize,
bh: usize,
/// Signed distance from each block centre to the opaque outline (px, negative inside).
sd: Vec<f32>,
expansion: f64,
}
impl Field {
fn new(src: &Surface, bounds: Rect, expansion: f64) -> Field {
let long = f64::from(bounds.width().max(bounds.height()));
let grow = expansion.max(0.0).ceil();
let g = ((long + 2.0 * grow + 8.0) / FIELD_BLOCKS).ceil().max(1.0) as i32;
let area = bounds.inflate(grow as i32 + 2 * g);
let bw = ((area.width() as i32 + g - 1) / g).max(1) as usize;
let bh = ((area.height() as i32 + g - 1) / g).max(1) as usize;
let mut cov = vec![false; bw * bh];
let fmt = src.format();
let n = fmt.channels();
let block = |bx: usize, by: usize| {
let (x, y) = (area.x0 + bx as i32 * g, area.y0 + by as i32 * g);
Rect::new(x, y, x + g, y + g)
};
if !fmt.alpha {
for (i, c) in cov.iter_mut().enumerate() {
*c = !block(i % bw, i / bw).intersect(&bounds).is_empty();
}
} else {
// One block row per task (rows are independent).
let scan = |by: usize, out: &mut [bool]| {
let r = Rect::new(area.x0, area.y0 + by as i32 * g, area.x0 + bw as i32 * g, area.y0 + (by as i32 + 1) * g).intersect(&bounds);
if r.is_empty() || !src.has_tiles_in(r) {
return;
}
let row = src.read_region(r);
let rw = r.width() as usize;
for (i, p) in row.chunks_exact(n).enumerate() {
if p.last().is_some_and(|&a| a > ALPHA_MIN) {
let x = r.x0 + (i % rw) as i32;
if let Some(o) = out.get_mut(((x - area.x0) / g) as usize) {
*o = true;
}
}
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
cov.par_chunks_mut(bw).enumerate().for_each(|(by, out)| scan(by, out));
}
#[cfg(target_arch = "wasm32")]
cov.chunks_mut(bw).enumerate().for_each(|(by, out)| scan(by, out));
}
let outside = edt(&cov, true, bw, bh);
let inside = edt(&cov, false, bw, bh);
let gf = f64::from(g);
let sd = cov
.iter()
.zip(outside.iter().zip(&inside))
.map(|(&c, (&o, &i))| if c { (-(i.sqrt() - 0.5) * gf) as f32 } else { ((o.sqrt() - 0.5) * gf) as f32 })
.collect();
Field { x0: f64::from(area.x0), y0: f64::from(area.y0), g: gf, bw, bh, sd, expansion }
}
/// The field at a block centre (exact, no interpolation).
fn at(&self, bx: usize, by: usize) -> f64 {
self.sd.get(by * self.bw + bx).map_or(f64::MAX, |&d| f64::from(d) - self.expansion)
}
/// The field at `p` (bilinear between block centres; clamped to the grid, which ends
/// outside the region).
fn value(&self, p: [f64; 2]) -> f64 {
let fx = ((p[0] - self.x0) / self.g - 0.5).clamp(0.0, (self.bw - 1) as f64);
let fy = ((p[1] - self.y0) / self.g - 0.5).clamp(0.0, (self.bh - 1) as f64);
let (ix, iy) = (fx.floor() as usize, fy.floor() as usize);
let (ix1, iy1) = ((ix + 1).min(self.bw - 1), (iy + 1).min(self.bh - 1));
let (tx, ty) = (fx - ix as f64, fy - iy as f64);
let top = self.at(ix, iy) * (1.0 - tx) + self.at(ix1, iy) * tx;
let bottom = self.at(ix, iy1) * (1.0 - tx) + self.at(ix1, iy1) * tx;
top * (1.0 - ty) + bottom * ty
}
/// The outline (where the field crosses zero) as closed loops, traced by marching squares
/// over the block centres. The field's border blocks lie outside, so every loop closes.
fn outline(&self) -> Vec<Vec<[f64; 2]>> {
let (bw, bh) = (self.bw, self.bh);
if bw < 2 || bh < 2 {
return Vec::new();
}
let inside = |x: usize, y: usize| self.at(x, y) < 0.0;
// A crossing is named by its block edge: 2·block for the edge to the right neighbour,
// 2·block + 1 for the edge to the one below.
let id = |x: usize, y: usize, down: bool| 2 * (y * bw + x) + usize::from(down);
let point = |id: usize| {
let b = id / 2;
let (x, y) = (b % bw, b / bw);
let (x2, y2) = if id.is_multiple_of(2) { (x + 1, y) } else { (x, y + 1) };
let (fa, fb) = (self.at(x, y), self.at(x2, y2));
let t = if (fa - fb).abs() > 1e-12 { (fa / (fa - fb)).clamp(0.0, 1.0) } else { 0.5 };
let (dx, dy) = ((x2 - x) as f64, (y2 - y) as f64);
[self.x0 + (x as f64 + 0.5 + dx * t) * self.g, self.y0 + (y as f64 + 0.5 + dy * t) * self.g]
};
let mut links: HashMap<usize, [usize; 2]> = HashMap::new();
let mut link = |a: usize, b: usize| {
for (p, q) in [(a, b), (b, a)] {
let e = links.entry(p).or_insert([usize::MAX; 2]);
if e[0] == usize::MAX {
e[0] = q;
} else {
e[1] = q;
}
}
};
for y in 0..bh - 1 {
for x in 0..bw - 1 {
let c = [inside(x, y), inside(x + 1, y), inside(x + 1, y + 1), inside(x, y + 1)];
if c.iter().all(|&v| v == c[0]) {
continue;
}
// Top, right, bottom, left.
let edges = [id(x, y, false), id(x + 1, y, true), id(x, y + 1, false), id(x, y, true)];
let cross = [c[0] != c[1], c[1] != c[2], c[3] != c[2], c[0] != c[3]];
let ks: Vec<usize> = (0..4).filter(|&k| cross[k]).collect();
if let [a, b] = ks[..] {
link(edges[a], edges[b]);
} else if ks.len() == 4 {
// A saddle: the centre decides which diagonal pair is joined.
let centre = self.at(x, y) + self.at(x + 1, y) + self.at(x + 1, y + 1) + self.at(x, y + 1) < 0.0;
if c[0] == centre {
link(edges[0], edges[1]);
link(edges[2], edges[3]);
} else {
link(edges[3], edges[0]);
link(edges[1], edges[2]);
}
}
}
}
let mut keys: Vec<usize> = links.keys().copied().collect();
keys.sort_unstable();
let mut seen: HashSet<usize> = HashSet::with_capacity(keys.len());
let mut loops = Vec::new();
for &start in &keys {
if seen.contains(&start) {
continue;
}
let mut lp = Vec::new();
let (mut prev, mut cur) = (usize::MAX, start);
loop {
seen.insert(cur);
lp.push(point(cur));
let Some(&[a, b]) = links.get(&cur) else { break };
let next = if a != prev { a } else { b };
if next == usize::MAX || seen.contains(&next) {
break;
}
(prev, cur) = (cur, next);
}
if lp.len() >= 3 {
loops.push(lp);
}
}
loops
}
}
/// Squared Euclidean distance (in blocks) from every block to the nearest block whose `mask`
/// equals `site` (Felzenszwalb & Huttenlocher's separable lower-envelope transform). About
/// 1e20 when there is none.
fn edt(mask: &[bool], site: bool, w: usize, h: usize) -> Vec<f64> {
const FAR: f64 = 1e20;
let mut d: Vec<f64> = mask.iter().map(|&m| if m == site { 0.0 } else { FAR }).collect();
let mut f = vec![0.0; w.max(h)];
let mut out = vec![0.0; w.max(h)];
let mut v = vec![0usize; w.max(h)];
let mut z = vec![0.0; w.max(h) + 1];
for x in 0..w {
for (y, fy) in f[..h].iter_mut().enumerate() {
*fy = d[y * w + x];
}
edt_1d(&f[..h], &mut out[..h], &mut v, &mut z);
for (y, o) in out[..h].iter().enumerate() {
d[y * w + x] = *o;
}
}
for row in d.chunks_mut(w) {
f[..w].copy_from_slice(row);
edt_1d(&f[..w], row, &mut v, &mut z);
}
d
}
/// One-dimensional squared distance transform of the sampled function `f`.
fn edt_1d(f: &[f64], out: &mut [f64], v: &mut [usize], z: &mut [f64]) {
let n = f.len();
if n == 0 {
return;
}
let mut k = 0usize;
v[0] = 0;
z[0] = f64::NEG_INFINITY;
z[1] = f64::INFINITY;
let meet = |q: usize, p: usize| {
let (qf, pf) = (q as f64, p as f64);
((f[q] + qf * qf) - (f[p] + pf * pf)) / (2.0 * (qf - pf))
};
for q in 1..n {
let mut s = meet(q, v[k]);
while k > 0 && s <= z[k] {
k -= 1;
s = meet(q, v[k]);
}
k += 1;
v[k] = q;
z[k] = s;
z[k + 1] = f64::INFINITY;
}
k = 0;
for (q, o) in out.iter_mut().enumerate() {
let qf = q as f64;
while z[k + 1] < qf {
k += 1;
}
let p = v[k];
*o = (qf - p as f64).powi(2) + f[p];
}
}
/// Distance from `p` to the segment `a`–`b`.
fn seg_dist(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 };
dist2(p, [a[0] + dx * t, a[1] + dy * t]).sqrt()
}
/// Simplifies a closed loop (Douglas–Peucker, within `tol` px), which keeps its corners.
fn simplify(lp: &[[f64; 2]], tol: f64) -> Vec<[f64; 2]> {
let n = lp.len();
if n < 4 {
return lp.to_vec();
}
let far = (1..n).max_by(|&a, &b| dist2(lp[0], lp[a]).total_cmp(&dist2(lp[0], lp[b]))).unwrap_or(n / 2);
let mut keep = vec![false; n];
keep[0] = true;
keep[far] = true;
// Spans (a, b) of the loop; b == n stands for point 0.
let mut stack = vec![(0, far), (far, n)];
while let Some((a, b)) = stack.pop() {
let (pa, pb) = (lp[a], lp[b % n]);
let mut best = (0.0, 0);
for (i, &p) in lp.iter().enumerate().take(b).skip(a + 1) {
let d = seg_dist(p, pa, pb);
if d > best.0 {
best = (d, i);
}
}
if best.0 > tol {
keep[best.1] = true;
stack.push((a, best.1));
stack.push((best.1, b));
}
}
let out: Vec<[f64; 2]> = (0..n).filter(|&i| keep[i]).map(|i| lp[i]).collect();
if out.len() >= 3 {
return out;
}
// A speck thinner than the tolerance: keep a few of its points.
(0..n).step_by((n / 4).max(1)).map(|i| lp[i]).collect()
}
/// Splits the sides of a closed loop longer than `step` into equal parts.
fn densify(lp: &[[f64; 2]], step: f64) -> Vec<[f64; 2]> {
let mut out = Vec::with_capacity(lp.len());
for (k, &a) in lp.iter().enumerate() {
let b = lp[(k + 1) % lp.len()];
let parts = (dist2(a, b).sqrt() / step).ceil().clamp(1.0, 1e4) as usize;
for i in 0..parts {
out.push(lerp(a, b, i as f64 / parts as f64));
}
}
out
}
fn lerp(a: [f64; 2], b: [f64; 2], t: f64) -> [f64; 2] {
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
}
fn orient(a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> f64 {
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
}
/// Positive when `d` lies inside the circumcircle of the positively oriented `a`, `b`, `c`.
fn in_circle(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> f64 {
let (ax, ay) = (a[0] - d[0], a[1] - d[1]);
let (bx, by) = (b[0] - d[0], b[1] - d[1]);
let (cx, cy) = (c[0] - d[0], c[1] - d[1]);
(ax * ax + ay * ay) * (bx * cy - cx * by) - (bx * bx + by * by) * (ax * cy - cx * ay) + (cx * cx + cy * cy) * (ax * by - bx * ay)
}
const NONE: usize = usize::MAX;
/// A triangle of the Delaunay triangulation: vertices (positively oriented) and the neighbour
/// across the edge opposite each vertex.
#[derive(Clone, Copy)]
struct DTri {
v: [usize; 3],
n: [usize; 3],
alive: bool,
}
/// Incremental Delaunay triangulation (Bowyer–Watson), started from a triangle around the area.
/// Vertices 0..3 are that triangle's.
struct Delaunay {
pts: Vec<[f64; 2]>,
tris: Vec<DTri>,
stamp: Vec<u32>,
pass: u32,
last: usize,
}
impl Delaunay {
fn new(lo: [f64; 2], hi: [f64; 2]) -> Delaunay {
let (cx, cy) = (0.5 * (lo[0] + hi[0]), 0.5 * (lo[1] + hi[1]));
let d = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(1.0) * 4.0;
let mut pts = vec![[cx - 2.0 * d, cy - d], [cx + 2.0 * d, cy - d], [cx, cy + 2.0 * d]];
if orient(pts[0], pts[1], pts[2]) < 0.0 {
pts.swap(1, 2);
}
Delaunay { pts, tris: vec![DTri { v: [0, 1, 2], n: [NONE; 3], alive: true }], stamp: vec![0], pass: 0, last: 0 }
}
/// The triangle holding `p`: a visibility walk from the last one made.
fn locate(&self, p: [f64; 2]) -> Option<usize> {
let mut t = if self.tris.get(self.last).is_some_and(|t| t.alive) { self.last } else { self.tris.iter().position(|t| t.alive)? };
for _ in 0..self.tris.len() + 3 {
let tri = self.tris.get(t)?;
let step = (0..3).find(|&k| orient(self.pts[tri.v[(k + 1) % 3]], self.pts[tri.v[(k + 2) % 3]], p) < 0.0);
match step {
None => return Some(t),
Some(k) if tri.n[k] != NONE => t = tri.n[k],
Some(_) => return None,
}
}
self.tris.iter().position(|tri| tri.alive && (0..3).all(|k| orient(self.pts[tri.v[(k + 1) % 3]], self.pts[tri.v[(k + 2) % 3]], p) >= 0.0))
}
/// Inserts `p`; returns its vertex index (an existing vertex's when `p` coincides with it).
fn insert(&mut self, p: [f64; 2]) -> Option<usize> {
let t0 = self.locate(p)?;
if let Some(&v) = self.tris[t0].v.iter().find(|&&v| dist2(self.pts[v], p) < 1e-12) {
return Some(v);
}
self.pass = self.pass.wrapping_add(1);
let pass = self.pass;
// The cavity: every triangle whose circumcircle holds `p`, grown from `t0`.
let mut bad = vec![t0];
self.stamp[t0] = pass;
let mut i = 0;
while let Some(&t) = bad.get(i) {
i += 1;
for k in 0..3 {
let nb = self.tris[t].n[k];
if nb == NONE || self.stamp[nb] == pass {
continue;
}
let [a, b, c] = self.tris[nb].v.map(|v| self.pts[v]);
if in_circle(a, b, c, p) > 0.0 {
self.stamp[nb] = pass;
bad.push(nb);
}
}
}
let mut rim: Vec<(usize, usize, usize)> = Vec::new();
for &t in &bad {
let tri = self.tris[t];
for k in 0..3 {
let nb = tri.n[k];
if nb == NONE || self.stamp[nb] != pass {
rim.push((tri.v[(k + 1) % 3], tri.v[(k + 2) % 3], nb));
}
}
self.tris[t].alive = false;
}
let pi = self.pts.len();
self.pts.push(p);
let base = self.tris.len();
for (i, &(a, b, o)) in rim.iter().enumerate() {
self.tris.push(DTri { v: [a, b, pi], n: [NONE, NONE, o], alive: true });
self.stamp.push(0);
if let Some(ot) = self.tris.get_mut(o) {
for k in 0..3 {
let (x, y) = (ot.v[(k + 1) % 3], ot.v[(k + 2) % 3]);
if (x == b && y == a) || (x == a && y == b) {
ot.n[k] = base + i;
}
}
}
}
for (i, &(a, b, _)) in rim.iter().enumerate() {
let across_b = rim.iter().position(|r| r.0 == b).map_or(NONE, |j| base + j);
let across_a = rim.iter().position(|r| r.1 == a).map_or(NONE, |j| base + j);
self.tris[base + i].n[0] = across_b;
self.tris[base + i].n[1] = across_a;
}
self.last = base;
Some(pi)
}
fn edges(&self) -> HashSet<(usize, usize)> {
let mut e = HashSet::new();
for t in self.tris.iter().filter(|t| t.alive) {
for k in 0..3 {
let (a, b) = (t.v[k], t.v[(k + 1) % 3]);
e.insert((a.min(b), a.max(b)));
}
}
e
}
}
/// Even-odd point-in-polygon test against closed outline segments, bucketed by row.
struct Inside<'a> {
pts: &'a [[f64; 2]],
segs: &'a [(usize, usize)],
y0: f64,
row: f64,
rows: Vec<Vec<usize>>,
}
impl<'a> Inside<'a> {
fn new(pts: &'a [[f64; 2]], segs: &'a [(usize, usize)], row: f64) -> Self {
let y0 = segs.iter().flat_map(|&(a, b)| [pts[a][1], pts[b][1]]).fold(f64::MAX, f64::min);
let y1 = segs.iter().flat_map(|&(a, b)| [pts[a][1], pts[b][1]]).fold(f64::MIN, f64::max);
let n = if y1 >= y0 { ((y1 - y0) / row).floor().clamp(0.0, 1e5) as usize + 1 } else { 0 };
let mut rows = vec![Vec::new(); n];
for (i, &(a, b)) in segs.iter().enumerate() {
let (lo, hi) = (pts[a][1].min(pts[b][1]), pts[a][1].max(pts[b][1]));
let (r0, r1) = (((lo - y0) / row).floor() as usize, ((hi - y0) / row).floor() as usize);
for r in rows.iter_mut().take(r1 + 1).skip(r0) {
r.push(i);
}
}
Inside { pts, segs, y0, row, rows }
}
fn contains(&self, p: [f64; 2]) -> bool {
if p[1] < self.y0 {
return false;
}
let Some(row) = self.rows.get(((p[1] - self.y0) / self.row).floor() as usize) else { return false };
let mut odd = false;
for &i in row {
let (a, b) = (self.pts[self.segs[i].0], self.pts[self.segs[i].1]);
if (a[1] > p[1]) != (b[1] > p[1]) && p[0] < a[0] + (p[1] - a[1]) / (b[1] - a[1]) * (b[0] - a[0]) {
odd = !odd;
}
}
odd
}
}
/// Most outline points the mesh takes; a busier outline is simplified more coarsely.
const MAX_OUTLINE_POINTS: usize = 20_000;
/// Builds the mesh over the covered part of `src` inside `bounds`.
///
/// The outline of the opaque region grown by `expansion` is traced, simplified (corners kept)
/// and split into sides no longer than the density's spacing; a triangular lattice at that
/// spacing fills the inside, at least half a step from the outline. The mesh is the
/// Delaunay triangulation of those points with the outline's sides recovered (split until
/// each is an edge), minus the triangles outside the outline.
pub fn build_mesh(src: &Surface, bounds: Rect, density: PuppetDensity, expansion: f64) -> PuppetMesh {
if bounds.is_empty() {
return PuppetMesh::default();
}
let e = if expansion.is_finite() { expansion.clamp(-200.0, 200.0) } else { 0.0 };
// The density counts steps across the region the mesh covers (grown by the expansion).
let long = f64::from(bounds.width().max(bounds.height())) + 2.0 * e.max(0.0);
let long = f64::from(bounds.width().max(bounds.height()));
let spacing = (long / density.cells()).max(2.0);
let field = Field::new(src, bounds, e);
let loops = field.outline();
let empty = PuppetMesh { spacing, ..Default::default() };
if loops.is_empty() {
return empty;
}
let mut tol = (0.5 * field.g).max(0.5);
let rings = loop {
let rings: Vec<Vec<[f64; 2]>> = loops.iter().map(|l| densify(&simplify(l, tol), spacing)).collect();
if rings.iter().map(Vec::len).sum::<usize>() <= MAX_OUTLINE_POINTS || tol > spacing {
break rings;
let e = expansion.clamp(-200.0, 200.0);
// Coverage on a block grid (block = a quarter cell or less).
let g = ((spacing / 4.0).floor() as i32).max(1);
let pad = e.max(0.0).ceil() as i32 + g;
let area = bounds.inflate(pad);
let bw = (area.width() as i32 + g - 1) / g;
let bh = (area.height() as i32 + g - 1) / g;
let (bw, bh) = (bw as usize, bh as usize);
let mut cov = vec![false; bw * bh];
let fmt = src.format();
let n = fmt.channels();
if !fmt.alpha {
for by in 0..bh {
for bx in 0..bw {
let r = Rect::new(area.x0 + bx as i32 * g, area.y0 + by as i32 * g, area.x0 + (bx as i32 + 1) * g, area.y0 + (by as i32 + 1) * g);
cov[by * bw + bx] = !r.intersect(&bounds).is_empty();
}
}
tol *= 2.0;
};
let (lo, hi) = ([field.x0, field.y0], [field.x0 + field.bw as f64 * field.g, field.y0 + field.bh as f64 * field.g]);
let mut dl = Delaunay::new(lo, hi);
let mut segs: Vec<(usize, usize)> = Vec::new();
for ring in &rings {
let idx: Vec<usize> = ring.iter().filter_map(|&p| dl.insert(p)).collect();
for (k, &a) in idx.iter().enumerate() {
let b = idx[(k + 1) % idx.len()];
if a != b {
segs.push((a, b));
} else {
// One block row per task (rows are independent).
let scan = |by: usize, out: &mut [bool]| {
let r = Rect::new(area.x0, area.y0 + by as i32 * g, area.x0 + bw as i32 * g, area.y0 + (by as i32 + 1) * g).intersect(&bounds);
if r.is_empty() || !src.has_tiles_in(r) {
return;
}
let row = src.read_region(r);
let rw = r.width() as usize;
for (i, p) in row.chunks_exact(n).enumerate() {
if p[n - 1] > ALPHA_MIN {
let x = r.x0 + (i % rw) as i32;
out[((x - area.x0) / g) as usize] = true;
}
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
cov.par_chunks_mut(bw).enumerate().for_each(|(by, out)| scan(by, out));
}
#[cfg(target_arch = "wasm32")]
cov.chunks_mut(bw).enumerate().for_each(|(by, out)| scan(by, out));
}
// Grow / shrink by the expansion (round stamp in block units).
let eb = (e.abs() / f64::from(g)).round() as i32;
if eb > 0 {
let src_cov = cov.clone();
let grow = e > 0.0;
for by in 0..bh as i32 {
for bx in 0..bw as i32 {
let mut hit = !grow;
'stamp: for dy in -eb..=eb {
for dx in -eb..=eb {
if dx * dx + dy * dy > eb * eb {
continue;
}
let (x, y) = (bx + dx, by + dy);
let v = x >= 0 && y >= 0 && x < bw as i32 && y < bh as i32 && src_cov[y as usize * bw + x as usize];
if grow && v {
hit = true;
break 'stamp;
}
if !grow && !v {
hit = false;
break 'stamp;
}
}
}
cov[by as usize * bw + bx as usize] = hit;
}
}
}
// The inside: a triangular lattice, kept half a step clear of the outline.
let rise = spacing * 3f64.sqrt() / 2.0;
let rows = ((hi[1] - lo[1]) / rise).ceil() as usize;
let cols = ((hi[0] - lo[0]) / spacing).ceil() as usize;
for j in 0..=rows {
for i in 0..=cols {
let p = [lo[0] + (i as f64 + 0.5 * (j % 2) as f64) * spacing, lo[1] + j as f64 * rise];
if field.value(p) < -0.5 * spacing {
dl.insert(p);
}
}
}
// Recover the outline's sides: split a side the triangulation lacks at its midpoint.
let mut budget = segs.len() * 2;
for _ in 0..8 {
let edges = dl.edges();
let mut next = Vec::with_capacity(segs.len());
let mut split = false;
for &(a, b) in &segs {
if budget == 0 || edges.contains(&(a.min(b), a.max(b))) {
next.push((a, b));
// Grid cells touching covered blocks.
let ox = f64::from(area.x0);
let oy = f64::from(area.y0);
let cw = (f64::from(bw as i32 * g) / spacing).ceil() as usize;
let ch = (f64::from(bh as i32 * g) / spacing).ceil() as usize;
let mut index = vec![usize::MAX; (cw + 1) * (ch + 1)];
let mut mesh = PuppetMesh { spacing, ..Default::default() };
for cj in 0..ch {
for ci in 0..cw {
let bx0 = ((ci as f64 * spacing) / f64::from(g)).floor() as usize;
let by0 = ((cj as f64 * spacing) / f64::from(g)).floor() as usize;
let bx1 = (((ci + 1) as f64 * spacing) / f64::from(g)).ceil().min(bw as f64) as usize;
let by1 = (((cj + 1) as f64 * spacing) / f64::from(g)).ceil().min(bh as f64) as usize;
let any = (by0..by1).any(|y| (bx0..bx1).any(|x| cov[y * bw + x]));
if !any {
continue;
}
match dl.insert(lerp(dl.pts[a], dl.pts[b], 0.5)) {
Some(c) if c != a && c != b => {
next.push((a, c));
next.push((c, b));
budget -= 1;
split = true;
let mut v = |i: usize, j: usize| {
let k = j * (cw + 1) + i;
if index[k] == usize::MAX {
index[k] = mesh.verts.len();
mesh.verts.push([ox + i as f64 * spacing, oy + j as f64 * spacing]);
}
_ => next.push((a, b)),
}
}
segs = next;
if !split {
break;
}
}
let inside = Inside::new(&dl.pts, &segs, spacing);
let mut tris: Vec<[usize; 3]> = Vec::new();
for t in dl.tris.iter().filter(|t| t.alive && t.v.iter().all(|&v| v >= 3)) {
let [a, b, c] = t.v.map(|v| dl.pts[v]);
if orient(a, b, c) > 1e-9 && inside.contains([(a[0] + b[0] + c[0]) / 3.0, (a[1] + b[1] + c[1]) / 3.0]) {
tris.push(t.v);
}
}
if tris.is_empty() {
return empty;
}
// Keep the vertices in use, numbered for a narrow band in the solver's matrix.
let pos = &dl.pts;
let order = band_order(pos, &tris);
let mut remap = vec![usize::MAX; pos.len()];
for (new, &old) in order.iter().enumerate() {
remap[old] = new;
}
PuppetMesh { verts: order.iter().map(|&i| pos[i]).collect(), tris: tris.iter().map(|t| t.map(|i| remap[i])).collect(), spacing }
}
/// The vertices `tris` use, in reverse Cuthill–McKee order (breadth first from the topmost
/// vertex of each piece, lower degrees first), which keeps the solver's banded factor small.
fn band_order(pos: &[[f64; 2]], tris: &[[usize; 3]]) -> Vec<usize> {
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); pos.len()];
for t in tris {
for k in 0..3 {
let (a, b) = (t[k], t[(k + 1) % 3]);
adj[a].push(b);
adj[b].push(a);
}
}
for a in &mut adj {
a.sort_unstable();
a.dedup();
}
let mut starts: Vec<usize> = (0..pos.len()).filter(|&i| !adj[i].is_empty()).collect();
starts.sort_by(|&a, &b| pos[a][1].total_cmp(&pos[b][1]).then(pos[a][0].total_cmp(&pos[b][0])));
let mut seen = vec![false; pos.len()];
let mut order = Vec::with_capacity(starts.len());
for s in starts {
if seen[s] {
continue;
}
seen[s] = true;
let first = order.len();
order.push(s);
let mut i = first;
while let Some(&v) = order.get(i) {
i += 1;
let mut next: Vec<usize> = adj[v].iter().copied().filter(|&w| !seen[w]).collect();
next.sort_by_key(|&w| adj[w].len());
for w in next {
seen[w] = true;
order.push(w);
index[k]
};
let (a, b, c, d) = (v(ci, cj), v(ci + 1, cj), v(ci + 1, cj + 1), v(ci, cj + 1));
if (ci + cj) % 2 == 0 {
mesh.tris.push([a, b, c]);
mesh.tris.push([a, c, d]);
} else {
mesh.tris.push([a, b, d]);
mesh.tris.push([b, c, d]);
}
}
}
order.reverse();
order
mesh
}
/// A compressed sparse row matrix (symmetric, used by CG).
@@ -833,9 +330,9 @@ impl Csr {
}
}
/// Banded Cholesky factor `A = L Lᵀ` (the mesh numbers its vertices in reverse Cuthill–McKee
/// order, so the system's bandwidth is about one breadth-first front): factored once per pin
/// set, then each global step is two triangular solves.
/// Banded Cholesky factor `A = L Lᵀ` (grid meshes number their vertices row by row, so the
/// system's bandwidth is about two mesh rows): factored once per pin set, then each global step
/// is two triangular solves.
struct BandChol {
n: usize,
p: usize,
@@ -1336,43 +833,6 @@ mod tests {
assert!(more.tris.len() > m.tris.len() && m.tris.len() > fewer.tris.len());
}
fn in_mesh(m: &PuppetMesh, p: [f64; 2]) -> bool {
m.tris.iter().any(|t| {
let [a, b, c] = t.map(|i| m.verts[i]);
let (d0, d1, d2) = (orient(a, b, p), orient(b, c, p), orient(c, a, p));
(d0 >= 0.0 && d1 >= 0.0 && d2 >= 0.0) || (d0 <= 0.0 && d1 <= 0.0 && d2 <= 0.0)
})
}
#[test]
fn mesh_follows_the_outline() {
// #3105: the mesh was every grid cell touching the shape, a staircase up to a cell
// outside it. It now follows the outline grown by the expansion.
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true));
for y in 40..160 {
for x in 40..160 {
if (f64::from(x) + 0.5 - 100.0).hypot(f64::from(y) + 0.5 - 100.0) <= 60.0 {
s.write_pixel(x, y, &[0.3, 0.6, 0.9, 1.0]);
}
}
}
let m = build_mesh(&s, s.content_bounds(), PuppetDensity::Normal, 2.0);
let r = |v: &[f64; 2]| (v[0] - 100.0).hypot(v[1] - 100.0);
assert!(m.verts.iter().all(|v| r(v) <= 63.0), "a vertex outside the grown disc");
assert!(m.verts.iter().filter(|v| r(v) > 61.0).count() >= 40, "the outline carries vertices");
let area: f64 = m.tris.iter().map(|t| orient(m.verts[t[0]], m.verts[t[1]], m.verts[t[2]]).abs() / 2.0).sum();
let disc = std::f64::consts::PI * 62.0 * 62.0;
assert!((area / disc - 1.0).abs() < 0.03, "{area} vs {disc}");
// Sharp corners and a concave corner: every opaque pixel is still inside the mesh, even
// without an expansion, and the empty corner of the L is not.
let l = blob(SampleType::U8);
let m = build_mesh(&l, l.content_bounds(), PuppetDensity::Fewer, 0.0);
for (x, y) in (20..36).flat_map(|x| (10..70).map(move |y| (x, y))).chain((36..70).flat_map(|x| (56..70).map(move |y| (x, y)))) {
assert!(in_mesh(&m, [f64::from(x) + 0.5, f64::from(y) + 0.5]), "({x}, {y}) is outside the mesh");
}
assert!(!in_mesh(&m, [40.0, 50.0]) && !in_mesh(&m, [60.0, 20.0]));
}
#[test]
fn no_pins_or_unmoved_pins_are_identity() {
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
+4 -26
View File
@@ -23,12 +23,6 @@ const INF_DIST: u32 = u32::MAX;
/// Residual capacities at or below this count as saturated (guards against float round-off
/// leaving tiny positive residuals that would cause long chains of negligible augmentations).
const EPS: f32 = 1e-5;
/// How many steps the max-flow loops take between asks whether to stop.
const CANCEL_EVERY: u32 = 1024;
fn never() -> bool {
false
}
/// A directed graph with source/sink terminal capacities.
#[derive(Clone, Debug, Default)]
@@ -116,35 +110,24 @@ impl Graph {
/// Computes the maximum flow (= minimum cut capacity).
pub fn maxflow(&mut self) -> f64 {
self.maxflow_with(&never).unwrap_or(self.flow)
}
/// [`Graph::maxflow`] that stops when `cancel` returns true (asked every few thousand steps):
/// `None` then, and the cut is meaningless. Not cancelled, it does exactly what
/// [`Graph::maxflow`] does.
pub fn maxflow_with(&mut self, cancel: &dyn Fn() -> bool) -> Option<f64> {
if self.node_count() >= 4096 {
let mut source = false;
let mut sink = false;
for &capacity in &self.tr_cap {
if !capacity.is_finite() {
return self.bk(cancel);
return self.maxflow_bk();
}
source |= capacity > EPS;
sink |= capacity < -EPS;
}
if source && sink && self.r_cap.iter().all(|v| v.is_finite() && *v >= 0.0) {
return self.push_relabel(cancel);
return self.maxflow_push_relabel();
}
}
self.bk(cancel)
self.maxflow_bk()
}
pub(super) fn maxflow_bk(&mut self) -> f64 {
self.bk(&never).unwrap_or(self.flow)
}
fn bk(&mut self, cancel: &dyn Fn() -> bool) -> Option<f64> {
let n = self.node_count();
let mut queue: VecDeque<u32> = VecDeque::new();
let mut orphans: VecDeque<u32> = VecDeque::new();
@@ -162,12 +145,7 @@ impl Graph {
}
}
let mut time = 0u32;
let mut steps = 0u32;
while let Some(i) = queue.pop_front() {
steps = steps.wrapping_add(1);
if steps.is_multiple_of(CANCEL_EVERY) && cancel() {
return None;
}
let i = i as usize;
self.active[i] = false;
if self.parent[i] == NONE {
@@ -219,7 +197,7 @@ impl Graph {
self.adopt(o as usize, time, &mut queue, &mut orphans);
}
}
Some(self.flow)
self.flow
}
fn augment(&mut self, m: usize, orphans: &mut VecDeque<u32>) {
@@ -5,20 +5,14 @@
//! The existing SoA residual graph is reused. Terminal source arcs are saturated
//! into a preflow; `back` keeps their reverse capacity, `excess` uses f64 so local
//! f32 capacity updates do not accumulate excess-rounding drift.
use super::{CANCEL_EVERY, EPS, Graph, NONE, TERMINAL};
use super::{EPS, Graph, NONE, TERMINAL};
use std::collections::VecDeque;
impl Graph {
#[cfg(test)]
pub(super) fn maxflow_push_relabel(&mut self) -> f64 {
self.push_relabel(&super::never).unwrap_or(self.flow)
}
/// Push/relabel; `None` when `cancel` stopped it.
pub(super) fn push_relabel(&mut self, cancel: &dyn Fn() -> bool) -> Option<f64> {
let n = self.node_count();
let Some(source_height) = u32::try_from(n).ok().filter(|&n| n < u32::MAX / 2).map(|n| n + 2) else {
return self.bk(cancel);
return self.maxflow_bk();
};
let mut back = vec![0.0f32; n];
let mut excess = vec![0.0f64; n];
@@ -40,12 +34,7 @@ impl Graph {
}
let mut work = 0usize;
let frequency = self.r_cap.len().saturating_mul(2).saturating_add(n);
let mut steps = 0u32;
while let Some(i) = queue.pop_front() {
steps = steps.wrapping_add(1);
if steps.is_multiple_of(CANCEL_EVERY) && cancel() {
return None;
}
self.active[i] = false;
while excess[i] > f64::from(EPS) {
if self.dist[i] == 1 && self.tr_cap[i] < -EPS {
@@ -138,7 +127,7 @@ impl Graph {
a = self.next[ai];
}
}
Some(self.flow)
self.flow
}
fn global_relabel(&mut self, source_height: u32, back: &[f32]) {
+5 -29
View File
@@ -242,38 +242,14 @@ pub fn grid_cut_with(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[
grid_cut_weighted::<true>(img, cost_fg, cost_bg, fixed, weight)
}
/// [`grid_cut_with`] that stops when `cancel` returns true (asked every few thousand max-flow
/// steps): `None` then. Not cancelled, the same cut.
pub fn grid_cut_cancellable(
img: &RgbImage,
cost_fg: &[f32],
cost_bg: &[f32],
fixed: &[u8],
weight: impl Fn(f32) -> f32,
cancel: &dyn Fn() -> bool,
) -> Option<Vec<bool>> {
grid_cut_impl::<true>(img, cost_fg, cost_bg, fixed, weight, cancel)
}
mod edge_starts;
// The const parameter also runs the previous full scan in before/after workflow tests.
fn grid_cut_weighted<const OPTIMIZED: bool>(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[u8], weight: impl Fn(f32) -> f32) -> Vec<bool> {
grid_cut_impl::<OPTIMIZED>(img, cost_fg, cost_bg, fixed, weight, &|| false).unwrap_or_default()
}
fn grid_cut_impl<const OPTIMIZED: bool>(
img: &RgbImage,
cost_fg: &[f32],
cost_bg: &[f32],
fixed: &[u8],
weight: impl Fn(f32) -> f32,
cancel: &dyn Fn() -> bool,
) -> Option<Vec<bool>> {
let (w, h) = (img.w as i32, img.h as i32);
let Some(n) = img.w.checked_mul(img.h).filter(|&n| n < u32::MAX as usize - 2) else { return Some(Vec::new()) };
let Some(n) = img.w.checked_mul(img.h).filter(|&n| n < u32::MAX as usize - 2) else { return Vec::new() };
if img.w > i32::MAX as usize || img.h > i32::MAX as usize || [img.px.len(), cost_fg.len(), cost_bg.len(), fixed.len()].iter().any(|&len| len < n) {
return Some(Vec::new());
return Vec::new();
}
let mut free = Vec::new();
let mut idx = vec![u32::MAX; n];
@@ -286,7 +262,7 @@ fn grid_cut_impl<const OPTIMIZED: bool>(
}
let mut out: Vec<bool> = fixed.iter().map(|f| *f == HARD_FG).collect();
if count == 0 {
return Some(out);
return out;
}
let mut g = Graph::with_capacity(count as usize, count as usize * 4);
for i in 0..n {
@@ -331,7 +307,7 @@ fn grid_cut_impl<const OPTIMIZED: bool>(
}
}
if OPTIMIZED {
g.maxflow_with(cancel)?;
g.maxflow();
} else {
g.maxflow_bk();
}
@@ -340,7 +316,7 @@ fn grid_cut_impl<const OPTIMIZED: bool>(
out[i] = g.in_source(idx[i] as usize);
}
}
Some(out)
out
}
fn add_fixed_link(g: &mut Graph, node: usize, fixed: u8, wt: f32) {
-29
View File
@@ -35,35 +35,6 @@ fn maxflow_clrs_network() {
assert_eq!(g.residual_out_of_source_side(), 0.0);
}
#[test]
fn maxflow_can_be_cancelled_and_otherwise_cuts_the_same() {
// A 120 × 100 grid with terminal links both ways (push/relabel) and a small one (BK).
for (w, h) in [(120usize, 100usize), (20, 15)] {
let mut g = Graph::with_capacity(w * h, w * h * 2);
let mut z = 7u64;
let mut next = || {
z = z.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(z >> 40) as f32 / (1u64 << 24) as f32
};
for i in 0..w * h {
g.add_tweights(i, next() * 2.0, next() * 2.0);
if i % w + 1 < w {
g.add_edge(i, i + 1, next(), next());
}
if i + w < w * h {
g.add_edge(i, i + w, next(), next());
}
}
let (mut plain, mut asked) = (g.clone(), g.clone());
let flow = plain.maxflow();
assert_eq!(asked.maxflow_with(&|| false), Some(flow));
assert!((0..w * h).all(|i| plain.in_source(i) == asked.in_source(i)));
if w * h > 4096 {
assert_eq!(g.maxflow_with(&|| true), None);
}
}
}
/// Reference max flow (Edmonds–Karp on a dense matrix; node 0 = s, n−1 = t).
fn edmonds_karp(cap: &mut [Vec<f64>]) -> f64 {
let n = cap.len();
+29 -167
View File
@@ -766,84 +766,47 @@ pub fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
}
/// Anti-aliased polygon (even-odd) coverage over `area`: exact horizontal
/// coverage, 4 sub-rows per pixel row (or 1 when not anti-aliased). Bands of
/// rows are filled in parallel, and each sub-row tests only the edges that
/// cross it (an active edge list over the edges sorted by their top).
/// coverage, 4 sub-rows per pixel row (or 1 when not anti-aliased).
pub fn polygon(points: &[(f32, f32)], area: Rect, anti_alias: bool) -> Vec<f32> {
let (w, h) = (area.width() as usize, area.height() as usize);
let mut m = vec![0.0f32; w * h];
if points.len() < 3 || w == 0 {
if points.len() < 3 {
return m;
}
let sub = if anti_alias { 4 } else { 1 };
let sample_y = |py: usize, s: usize| area.y0 as f32 + py as f32 + (s as f32 + 0.5) / sub as f32;
// A sub-row at y crosses the edge a→b iff top <= y < bottom; horizontal and NaN edges
// never cross one.
struct Edge {
top: f32,
bottom: f32,
a: (f32, f32),
b: (f32, f32),
}
let mut edges: Vec<Edge> = points
.iter()
.zip(points.iter().cycle().skip(1))
.filter_map(|(&a, &b)| {
if a.1 < b.1 {
Some(Edge { top: a.1, bottom: b.1, a, b })
} else if b.1 < a.1 {
Some(Edge { top: b.1, bottom: a.1, a, b })
} else {
None
}
})
.collect();
edges.sort_by(|p, q| p.top.total_cmp(&q.top));
const BAND: usize = 64;
par_rows(&mut m, w, BAND, |bi, rows| {
let py0 = bi * BAND;
let (first, last) = (sample_y(py0, 0), sample_y(py0 + rows.len() / w - 1, sub - 1));
let reach = edges.partition_point(|e| e.top <= last);
let band_edges = edges.get(..reach).unwrap_or_default();
let mut pending = band_edges.iter().filter(|e| e.bottom > first).peekable();
let mut active: Vec<&Edge> = Vec::new();
let mut xs: Vec<f32> = Vec::new();
for (r, row) in rows.chunks_exact_mut(w).enumerate() {
for s in 0..sub {
// Sub-row ys never decrease, so an edge whose bottom is passed stays out.
let y = sample_y(py0 + r, s);
while let Some(e) = pending.next_if(|e| e.top <= y) {
active.push(e);
let mut xs: Vec<f32> = Vec::new();
for py in 0..h {
for s in 0..sub {
let y = area.y0 as f32 + py as f32 + (s as f32 + 0.5) / sub as f32;
xs.clear();
for i in 0..points.len() {
let (a, b) = (points[i], points[(i + 1) % points.len()]);
if (a.1 <= y && b.1 > y) || (b.1 <= y && a.1 > y) {
xs.push(a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0));
}
active.retain(|e| e.bottom > y);
if active.is_empty() {
}
xs.sort_by(|a, b| a.total_cmp(b));
for span in xs.as_chunks::<2>().0 {
let (x0, x1) = ((span[0] - area.x0 as f32).max(0.0), (span[1] - area.x0 as f32).min(w as f32));
if x1 <= x0 {
continue;
}
xs.clear();
xs.extend(active.iter().map(|&&Edge { a, b, .. }| a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0)));
xs.sort_by(|a, b| a.total_cmp(b));
for span in xs.as_chunks::<2>().0 {
let (x0, x1) = ((span[0] - area.x0 as f32).max(0.0), (span[1] - area.x0 as f32).min(w as f32));
if x1 <= x0 {
continue;
}
let (c0, c1) = (x0.floor() as usize, (x1.ceil() as usize).min(w));
for (cx, v) in row.iter_mut().enumerate().take(c1).skip(c0) {
let cov = if anti_alias {
((cx + 1) as f32).min(x1) - (cx as f32).max(x0)
} else {
let c = cx as f32 + 0.5;
if c >= x0 && c < x1 { 1.0 } else { 0.0 }
};
*v += cov.max(0.0) / sub as f32;
}
let (c0, c1) = (x0.floor() as usize, (x1.ceil() as usize).min(w));
for cx in c0..c1 {
let cov = if anti_alias {
((cx + 1) as f32).min(x1) - (cx as f32).max(x0)
} else {
let c = cx as f32 + 0.5;
if c >= x0 && c < x1 { 1.0 } else { 0.0 }
};
m[py * w + cx] += cov.max(0.0) / sub as f32;
}
}
}
for v in rows {
*v = v.min(1.0);
}
});
}
for v in &mut m {
*v = v.min(1.0);
}
m
}
@@ -1198,107 +1161,6 @@ mod tests {
assert!(polygon(&[(0.0, 0.0), (1.0, 1.0)], a, true).iter().all(|v| *v == 0.0));
}
/// The previous polygon fill (every edge tested on every sub-row), kept as the oracle.
fn old_polygon(points: &[(f32, f32)], area: Rect, anti_alias: bool) -> Vec<f32> {
let (w, h) = (area.width() as usize, area.height() as usize);
let mut m = vec![0.0f32; w * h];
if points.len() < 3 {
return m;
}
let sub = if anti_alias { 4 } else { 1 };
let mut xs: Vec<f32> = Vec::new();
for py in 0..h {
for s in 0..sub {
let y = area.y0 as f32 + py as f32 + (s as f32 + 0.5) / sub as f32;
xs.clear();
for i in 0..points.len() {
let (a, b) = (points[i], points[(i + 1) % points.len()]);
if (a.1 <= y && b.1 > y) || (b.1 <= y && a.1 > y) {
xs.push(a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0));
}
}
xs.sort_by(|a, b| a.total_cmp(b));
for span in xs.as_chunks::<2>().0 {
let (x0, x1) = ((span[0] - area.x0 as f32).max(0.0), (span[1] - area.x0 as f32).min(w as f32));
if x1 <= x0 {
continue;
}
let (c0, c1) = (x0.floor() as usize, (x1.ceil() as usize).min(w));
for cx in c0..c1 {
let cov = if anti_alias {
((cx + 1) as f32).min(x1) - (cx as f32).max(x0)
} else {
let c = cx as f32 + 0.5;
if c >= x0 && c < x1 { 1.0 } else { 0.0 }
};
m[py * w + cx] += cov.max(0.0) / sub as f32;
}
}
}
}
for v in &mut m {
*v = v.min(1.0);
}
m
}
#[test]
fn polygon_matches_the_every_edge_fill_bit_for_bit() {
let mut seed = 0x2545_f491_u32;
let mut rnd = move || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed as f32 / u32::MAX as f32
};
// Taller than one 64-row band, offset, and a 1-pixel-wide area.
let areas = [Rect::new(0, 0, 90, 150), Rect::new(-13, 7, 60, 211), Rect::new(5, -40, 6, 30)];
let mut cases: Vec<Vec<(f32, f32)>> = vec![
// Self-intersecting star, horizontal edges, repeated points, collinear runs.
(0..11)
.map(|i| {
let t = i as f32 * 4.0 * std::f32::consts::PI / 11.0;
(40.0 + 35.0 * t.cos(), 80.0 + 70.0 * t.sin())
})
.collect(),
vec![(10.0, 10.0), (50.0, 10.0), (50.0, 10.0), (50.0, 60.0), (30.0, 60.0), (30.0, 30.0), (10.0, 30.0)],
vec![(0.0, 0.0), (20.0, 0.0), (40.0, 0.0), (40.0, 0.0), (0.0, 0.0)],
// Vertices on the sub-row centres and the pixel edges.
vec![(3.125, 2.375), (70.5, 2.375), (70.875, 140.625), (3.0, 140.0), (35.0, 64.0), (35.0, 64.125)],
// Entirely outside, and straddling every side.
vec![(-500.0, -500.0), (-400.0, -500.0), (-450.0, -300.0)],
vec![(-80.0, -60.0), (300.0, 20.0), (60.0, 400.0)],
// Non-finite coordinates and a negative zero.
vec![(10.0, 10.0), (f32::NAN, 40.0), (60.0, 70.0), (20.0, f32::INFINITY), (-0.0, -0.0), (0.0, 0.0)],
];
for n in [3, 7, 40, 400] {
for spread in [1.0, 2.0] {
cases.push((0..n).map(|_| (rnd() * 100.0 * spread - 50.0 * (spread - 1.0), rnd() * 220.0 * spread - 60.0 * spread)).collect());
// Lasso-like: a random walk snapped to quarter pixels.
let (mut x, mut y) = (45.0f32, 75.0f32);
cases.push(
(0..n)
.map(|_| {
x = ((x + (rnd() - 0.5) * 12.0 * spread) * 4.0).round() / 4.0;
y = ((y + (rnd() - 0.5) * 12.0 * spread) * 4.0).round() / 4.0;
(x, y)
})
.collect(),
);
}
}
for pts in &cases {
for &area in &areas {
for aa in [true, false] {
let (new, old) = (polygon(pts, area, aa), old_polygon(pts, area, aa));
assert_eq!(new.len(), old.len());
let diff = new.iter().zip(&old).position(|(a, b)| a.to_bits() != b.to_bits());
assert!(diff.is_none(), "{pts:?} {area:?} aa={aa}: first difference at {diff:?}");
}
}
}
}
#[test]
fn combine_modes() {
let a = Rect::new(0, 0, 4, 1);
+1 -1
View File
@@ -129,7 +129,7 @@ fn surfaces_exact_depth_models_alpha_selection_and_tile_boundaries() {
};
let spans = Spans::new(radius, shape_inside(shape));
let mut expected = conv_spans(&src, area, &ctx, &spans);
crate::mix_selection(&mut expected, area, &selection, &src, alpha);
crate::mix_selection(&mut expected, area, &selection, &src);
let mut reference = surface.clone();
reference.write_region(area, &expected);
for tile in [17, 256] {
+11 -1
View File
@@ -167,7 +167,17 @@ fn storage_selection_and_tile_sizes_match_original_kernels() {
FilterParams::Wind { method, from_right, seed } => wind_direct(&src, area, &ctx, method, from_right, seed),
_ => continue,
};
crate::mix_selection(&mut raw, area, &selection, &src, fmt.alpha);
let original = s.read_region(area);
for (i, px) in raw.chunks_exact_mut(fmt.channels()).enumerate() {
let (x, y) = (area.x0 + (i % area.width() as usize) as i32, area.y0 + (i / area.width() as usize) as i32);
let k = selection.sample_channel(x, y, 0);
if k >= 1.0 {
continue;
}
for (c, v) in px.iter_mut().enumerate() {
*v = original[i * fmt.channels() + c] + (*v - original[i * fmt.channels() + c]) * k;
}
}
let mut want = s.clone();
want.write_region(area, &raw);
for tile in [1, 7, 17, 256] {
+1 -1
View File
@@ -170,7 +170,7 @@ fn iris_depths_models_selection_tiles_and_cancellation() {
let t = Rect::new(x, y, (x + tile).min(area.x1), (y + tile).min(area.y1));
let src = Image::read_clamped(&surface, t.inflate(radius), bounds);
let mut data = reference::iris(&src, t, &ctx, &pins);
crate::mix_selection(&mut data, t, &selection, &src, alpha);
crate::mix_selection(&mut data, t, &selection, &src);
want.write_region(t, &data);
}
}
-98
View File
@@ -1,98 +0,0 @@
use super::*;
use photocraft_color::{PixelFormat, SampleType};
fn selection(coverage: f32) -> Surface {
Surface::with_default(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false), &[coverage])
}
#[test]
fn feathered_color_to_alpha_preserves_appearance_over_the_key() {
let r = Rect::new(0, 0, 1, 1);
let filter = FilterParams::ColorToAlpha { color: [1.0; 4], transparency_threshold: 0.0, opacity_threshold: 1.0 };
for sample in SampleType::ALL {
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
for alpha in [0.5, 1.0] {
let format = PixelFormat::new(mode, sample, true);
let mut s = Surface::new(format);
let mut p = vec![0.5; format.channels()];
*p.last_mut().unwrap() = alpha;
s.write_pixel(0, 0, &p);
let original = s.pixel(0, 0);
let a = format.channels() - 1;
for coverage in [0.0, 0.25, 0.5, 0.75, 1.0] {
let out = apply(&s, &filter, r, r, Some(&selection(coverage)));
let actual = out.pixel(0, 0);
let tolerance = if sample == SampleType::U8 { 2.0 / 255.0 } else { 1e-4 };
for c in 0..a {
let before = original[c] * original[a] + 1.0 - original[a];
let after = actual[c] * actual[a] + 1.0 - actual[a];
assert!(
(before - after).abs() < tolerance,
"{sample:?}/{mode:?}, alpha={alpha}, coverage={coverage}: {original:?} -> {actual:?}, over white {before} -> {after}"
);
}
}
}
}
}
}
#[test]
fn feathered_offset_ignores_hidden_colour_of_the_original_pixel() {
let r = Rect::new(0, 0, 2, 1);
let filter = FilterParams::Offset { horizontal: 1, vertical: 0, undefined: UndefinedAreas::Wrap };
for sample in SampleType::ALL {
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
s.write_pixel(0, 0, &[1.0, 0.0, 0.0, 0.0]);
s.write_pixel(1, 0, &[0.0, 0.0, 1.0, 1.0]);
let out = apply(&s, &filter, r, r, Some(&selection(0.5)));
let p = out.pixel(0, 0);
assert_eq!(&p[..3], &[0.0, 0.0, 1.0], "{sample:?}: hidden red contaminated the selected blue: {p:?}");
assert!((p[3] - 0.5).abs() < 1.0 / 255.0);
}
}
#[test]
fn feathered_blurs_do_not_reintroduce_hidden_colour() {
let r = Rect::new(0, 0, 3, 3);
let mut s = Surface::new(PixelFormat::RGBA32F);
s.fill_rect(r, &[1.0, 0.0, 0.0, 0.0]);
s.write_pixel(1, 1, &[0.0, 0.0, 1.0, 1.0]);
for filter in [FilterParams::BoxBlur { radius: 1.0 }, FilterParams::MotionBlur { angle: 0.0, distance: 3.0 }] {
let full = apply(&s, &filter, r, r, None);
let out = apply(&s, &filter, r, r, Some(&selection(0.5)));
let p = out.pixel(0, 1);
assert!(p[3] > 0.0, "{filter:?}: expected blur to spread coverage");
assert!((p[3] - full.pixel(0, 1)[3] * 0.5).abs() < 1e-6, "{filter:?}: selection should halve the new coverage");
assert!(p[0].abs() < 1e-6 && (p[2] - 1.0).abs() < 1e-6, "{filter:?}: hidden red reappeared: {p:?}");
}
}
#[test]
fn selection_mixing_keeps_hdr_range_and_exact_endpoints() {
let r = Rect::new(0, 0, 1, 1);
let original = Image { rect: r, ch: 4, data: vec![-2.0, 4.0, 0.5, 0.25] };
let filtered = vec![8.0, -4.0, 0.75, 1.0];
let mut data = filtered.clone();
mix_selection(&mut data, r, &selection(0.5), &original, true);
for (&actual, expected) in data.iter().zip([6.0, -2.4, 0.7, 0.625]) {
assert!((actual - expected).abs() < 1e-6, "{data:?}");
}
for coverage in [0.0, 1.0] {
let mut data = filtered.clone();
mix_selection(&mut data, r, &selection(coverage), &original, true);
assert_eq!(data, if coverage == 0.0 { &original.data } else { &filtered }.clone());
}
}
#[test]
fn selection_mixing_without_alpha_keeps_every_channel_independent() {
let r = Rect::new(0, 0, 1, 1);
// One channel is a mask; three/four channels can be RGB or CMYK without alpha.
for ch in [1, 3, 4] {
let original = Image { rect: r, ch, data: vec![0.25; ch] };
let mut data = vec![0.75; ch];
mix_selection(&mut data, r, &selection(0.25), &original, false);
assert_eq!(data, vec![0.375; ch]);
}
}
+1 -1
View File
@@ -177,7 +177,7 @@ fn tilt_shift_depths_models_selection_and_tile_edges_match_reference() {
None => Image::read(&surface, t.inflate(radius)),
};
let mut data = reference::tilt_shift(&src, t, &ctx, 17.0, (0.3, 0.7), 29.0, 0.07, 0.3);
crate::mix_selection(&mut data, t, &selection, &src, alpha);
crate::mix_selection(&mut data, t, &selection, &src);
want.write_region(t, &data);
}
}
+127 -4
View File
@@ -4,8 +4,7 @@
//! All work on straight (non-premultiplied) RGBA buffers in 0..=1, row-major `w × h`, so callers
//! convert any colour model / bit depth to RGBA first (as `Image › Adjustments` does for the
//! per-pixel adjustments). Formulas are documented approximations of Photoshop's behaviour,
//! derived from its manual and observation, not from its implementation; Shadows/Highlights is
//! measured on Photoshop (see [`shadows_highlights`]).
//! derived from its manual and observation, not from its implementation.
use photocraft_color::convert::{lab_to_srgb, srgb_to_lab};
use serde::{Deserialize, Serialize};
@@ -18,6 +17,18 @@ fn luma(c: [f32; 3]) -> f32 {
0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
}
fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// Blurred luminance (Gaussian, `radius` px ≈ 2σ like Photoshop's radius fields).
fn blurred_luma(px: &[[f32; 4]], w: usize, h: usize, radius: f32) -> Vec<f32> {
let mut l: Vec<f32> = px.iter().map(|p| luma([p[0], p[1], p[2]])).collect();
gauss_blur_n(&mut l, w, h, 1, radius.max(0.0) / 2.0);
l
}
/// Image › Adjustments › Shadows/Highlights parameters (Photoshop's dialog, percentages as 0..=100).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ShadowsHighlights {
@@ -53,8 +64,95 @@ impl Default for ShadowsHighlights {
}
}
mod shadows_highlights;
pub use shadows_highlights::shadows_highlights;
/// Local tone mapping: a blurred-luminance mask selects shadows (dark neighbourhoods, up to the
/// tonal width) and highlights; shadows are lifted and highlights pulled down by a gain that
/// preserves hue, then saturation is corrected where the gain moved tones, midtone contrast is
/// applied, and the result is stretched by the clip percentages.
pub fn shadows_highlights(px: &mut [[f32; 4]], w: usize, h: usize, p: &ShadowsHighlights) {
if px.is_empty() {
return;
}
let ls = blurred_luma(px, w, h, p.shadow_radius);
let lh = if (p.highlight_radius - p.shadow_radius).abs() < 1e-3 { ls.clone() } else { blurred_luma(px, w, h, p.highlight_radius) };
let (sa, ha) = (p.shadow_amount / 100.0, p.highlight_amount / 100.0);
let (st, ht) = ((p.shadow_tone / 100.0).max(0.01), (p.highlight_tone / 100.0).max(0.01));
let color = p.color / 100.0;
let mid = p.midtone / 100.0;
par_rows(px, w, 1, |y, pxrow| {
for (x, q) in pxrow.iter_mut().enumerate() {
let i = y * w + x;
if q[3] <= 0.0 {
continue;
}
let c = [q[0], q[1], q[2]];
// Mask weights: 1 in deep shadows (resp. bright highlights), fading out at the tonal width.
let ms = 1.0 - smoothstep(0.0, st, ls[i]);
let mh = smoothstep(1.0 - ht, 1.0, lh[i]);
let mut out = c;
if sa > 0.0 && ms > 0.0 {
// Lift: a gamma-like curve on the pixel, stronger for darker neighbourhoods.
let g = 1.0 / (1.0 + 2.0 * sa * ms);
out = out.map(|v| v.max(0.0).powf(g));
}
if ha > 0.0 && mh > 0.0 {
let g = 1.0 / (1.0 + 2.0 * ha * mh);
out = out.map(|v| 1.0 - (1.0 - v.min(1.0)).powf(g));
}
if mid != 0.0 {
let k = 1.0 + mid * (1.0 - ms - mh).clamp(0.0, 1.0);
out = out.map(|v| (v - 0.5) * k + 0.5);
}
let changed = (ms * sa + mh * ha).min(1.0);
if color != 0.0 && changed > 0.0 {
let l1 = luma(out);
let s = 1.0 + color * changed;
out = out.map(|v| l1 + (v - l1) * s);
}
q[0] = out[0].clamp(0.0, 1.0);
q[1] = out[1].clamp(0.0, 1.0);
q[2] = out[2].clamp(0.0, 1.0);
}
});
clip_stretch(px, p.black_clip, p.white_clip);
}
/// Stretches levels so `black`% of pixels clip to 0 and `white`% to 1 (luminance histogram).
fn clip_stretch(px: &mut [[f32; 4]], black: f32, white: f32) {
if black <= 0.0 && white <= 0.0 {
return;
}
let mut hist = [0u32; 1024];
let mut n = 0u32;
for q in px.iter().filter(|q| q[3] > 0.0) {
hist[((luma([q[0], q[1], q[2]]).clamp(0.0, 1.0)) * 1023.0).round() as usize] += 1;
n += 1;
}
if n == 0 {
return;
}
let find = |pct: f32, rev: bool| -> f32 {
let target = (pct.clamp(0.0, 50.0) / 100.0 * n as f32) as u32;
let mut acc = 0;
for k in 0..1024 {
let i = if rev { 1023 - k } else { k };
acc += hist[i];
if acc > target {
return i as f32 / 1023.0;
}
}
if rev { 0.0 } else { 1.0 }
};
let lo = find(black, false);
let hi = find(white, true);
if hi - lo < 1e-3 {
return;
}
for q in px.iter_mut().filter(|q| q[3] > 0.0) {
for v in &mut q[..3] {
*v = ((*v - lo) / (hi - lo)).clamp(0.0, 1.0);
}
}
}
/// Image › Adjustments › Replace Color: Color Range-style selection (fuzziness in 0..=200 levels)
/// around `color`, then a hue (±180°), saturation and lightness (±100) shift weighted by it.
@@ -280,6 +378,31 @@ mod tests {
.collect()
}
#[test]
fn shadows_lift_dark_pixels_more_than_bright() {
let mut px = ramp(32, 8);
let orig = px.clone();
shadows_highlights(&mut px, 32, 8, &ShadowsHighlights { black_clip: 0.0, white_clip: 0.0, ..Default::default() });
let gain = |i: usize| luma([px[i][0], px[i][1], px[i][2]]) - luma([orig[i][0], orig[i][1], orig[i][2]]);
assert!(gain(3) > 0.02, "{}", gain(3));
assert!(gain(3) > gain(30), "{} {}", gain(3), gain(30));
// Zero amounts and no clipping: identity.
let mut px2 = orig.clone();
let id = ShadowsHighlights { shadow_amount: 0.0, highlight_amount: 0.0, color: 0.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() };
shadows_highlights(&mut px2, 32, 8, &id);
assert_eq!(px2, orig);
}
#[test]
fn highlights_darken_bright_pixels() {
let mut px = ramp(32, 8);
let orig = px.clone();
let p = ShadowsHighlights { shadow_amount: 0.0, highlight_amount: 80.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() };
shadows_highlights(&mut px, 32, 8, &p);
assert!(px[30][0] < orig[30][0]);
assert!((px[1][0] - orig[1][0]).abs() < 1e-6);
}
#[test]
fn replace_color_only_touches_matching_pixels() {
let mut px = vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]];
-423
View File
@@ -1,423 +0,0 @@
//! Image › Adjustments › Shadows/Highlights, as Photoshop 25.4 does it.
//!
//! Measured on Photoshop as a black box (synthetic 16-bit images through the adjustment, results
//! read back exactly); no Photoshop code was read. The pieces:
//!
//! - **Neighbourhood brightness.** Per pixel, the mean of its brightest channel and its Rec. 601
//! luma. Its neighbourhood value is an edge-aware average: a Gaussian of σ = 1.14 · Radius whose
//! weights fall linearly with the difference from the pixel's own (lightly blurred) brightness,
//! so pixels across a strong edge count less and halos stay small. It is quantised to 8 bits.
//! - **Tonal mask.** Tone sets which neighbourhoods are treated: shadows take the complement of a
//! Beta distribution function of the neighbourhood brightness, from Beta(1.95, 23) at Tone 0 to
//! 1 − L at Tone 95 and over; highlights take the same of 1 − L.
//! - **Curve.** Amount sets a curve that screens the image with itself: 1 − (1 − v)^q, blended
//! between q = 1, 2, 4 and 8 by a strength s = 3 at 100, 2 at 50 and 1 near 15. Shadows lift each
//! channel by mask × (curve − v); highlights darken with the mirrored curve.
//! - **Colour.** Only the luma of that result is kept: the pixel's own colour differences are
//! added back, scaled by the Color slider (by exp(h · ΔY / max Y) for positive values, by
//! 1 + h · ΔY / max Y for negative ones) and shortened where they would leave the gamut.
//! - **Midtone** is a global S-curve on the luma, k · (Y − ½) · (4Y(1 − Y))^p.
use crate::fxutil::gauss_blur_n;
use crate::photo_util::par_rows;
use super::ShadowsHighlights;
const W: [f32; 3] = [0.299, 0.587, 0.114];
fn luma(c: [f32; 3]) -> f32 {
W[0] * c[0] + W[1] * c[1] + W[2] * c[2]
}
/// Neighbourhood σ per pixel of Radius.
const SPATIAL: f32 = 1.1424;
/// σ of the blur of the pixel's own brightness the edge weights compare with.
const GUIDE: f32 = 1.3559;
/// Brightness difference at which a neighbour's weight reaches zero.
const RANGE: f32 = 1.0152;
/// Brightness levels of the piecewise-linear edge-aware average.
const LEVELS: usize = 17;
/// Edge-aware neighbourhood brightness of `lum` (`w × h`) for Photoshop's Radius.
fn neighbourhood(lum: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
let mut guide = lum.to_vec();
gauss_blur_n(&mut guide, w, h, 1, GUIDE);
let sigma = SPATIAL * radius;
if sigma < 0.3 {
return guide;
}
// The average at each of a few brightness levels, interpolated at the pixel's own level
// (Durand and Dorsey, "Fast bilateral filtering", 2002). The averages are smooth, so they are
// taken on a grid `d` times coarser, with σ ≥ 4 cells there.
let d = ((sigma / 4.0).floor() as usize).max(1);
let (rw, rh) = (w.div_ceil(d), h.div_ceil(d));
let cells = rw * rh;
let step = 1.0 / (LEVELS - 1) as f32;
let mut avg = vec![0.0f32; LEVELS * cells];
let mut buf = vec![0.0f32; 2 * cells];
for (k, out) in avg.chunks_exact_mut(cells).enumerate() {
let g = k as f32 * step;
buf.fill(0.0);
for (i, &l) in lum.iter().enumerate() {
let wgt = (1.0 - (l - g).abs() / RANGE).max(0.0);
let c = 2 * ((i / w) / d * rw + (i % w) / d);
buf[c] += wgt * l;
buf[c + 1] += wgt;
}
gauss_blur_n(&mut buf, rw, rh, 2, sigma / d as f32);
for (o, b) in out.iter_mut().zip(buf.as_chunks::<2>().0) {
*o = if b[1] > 1e-6 { b[0] / b[1] } else { g };
}
}
let mut out = vec![0.0f32; lum.len()];
par_rows(&mut out, w, 1, |y, row| {
let fy = ((y as f32 + 0.5) / d as f32 - 0.5).clamp(0.0, (rh - 1) as f32);
let (y0, ty) = (fy.floor() as usize, fy.fract());
let y1 = (y0 + 1).min(rh - 1);
for (x, o) in row.iter_mut().enumerate() {
let fx = ((x as f32 + 0.5) / d as f32 - 0.5).clamp(0.0, (rw - 1) as f32);
let (x0, tx) = (fx.floor() as usize, fx.fract());
let x1 = (x0 + 1).min(rw - 1);
let at = |k: usize| {
let a = &avg[k * cells..(k + 1) * cells];
let top = a[y0 * rw + x0] + (a[y0 * rw + x1] - a[y0 * rw + x0]) * tx;
let bot = a[y1 * rw + x0] + (a[y1 * rw + x1] - a[y1 * rw + x0]) * tx;
top + (bot - top) * ty
};
let t = (guide[y * w + x].clamp(0.0, 1.0) / step).min((LEVELS - 1) as f32 - 1e-4);
let k = t.floor() as usize;
let f = t - k as f32;
*o = at(k) * (1.0 - f) + at(k + 1) * f;
}
});
out
}
/// Beta(α, β) parameters of the tonal mask for Tone 0, 5, …, 95 (fitted to Photoshop's masks
/// within 2/255).
const MASK_BETA: [(f32, f32); 20] = [
(1.9468, 22.9366),
(1.7770, 18.3471),
(1.6403, 14.7559),
(1.5270, 11.9346),
(1.4269, 9.6467),
(1.3470, 7.8430),
(1.2807, 6.4038),
(1.2240, 5.2384),
(1.1772, 4.2967),
(1.1373, 3.5350),
(1.1021, 2.9109),
(1.0755, 2.4186),
(1.0508, 2.0189),
(1.0307, 1.7034),
(1.0140, 1.4583),
(1.0015, 1.2758),
(1.0, 1.1403),
(1.0, 1.0510),
(1.0, 1.0002),
(1.0, 1.0),
];
/// The tonal mask for `tone` (0..=100) at the 256 neighbourhood levels: 1 − I_L(α, β).
fn tonal_mask(tone: f32) -> [f32; 256] {
let t = (tone.clamp(0.0, 95.0)) / 5.0;
let i = (t.floor() as usize).min(MASK_BETA.len() - 2);
let f = t - i as f32;
let (a0, b0) = MASK_BETA[i];
let (a1, b1) = MASK_BETA[i + 1];
let (a, b) = ((a0 + (a1 - a0) * f) as f64, (b0 + (b1 - b0) * f) as f64);
// Midpoint sums of the Beta density, 16 per level.
const SUB: usize = 16;
let n = 255 * SUB;
let dens = |x: f64| ((a - 1.0) * x.ln() + (b - 1.0) * (1.0 - x).ln()).exp();
let mut cum = vec![0.0f64; 256];
let mut acc = 0.0;
for j in 0..n {
acc += dens((j as f64 + 0.5) / n as f64);
if (j + 1) % SUB == 0 {
cum[(j + 1) / SUB] = acc;
}
}
let total = acc.max(f64::MIN_POSITIVE);
std::array::from_fn(|k| (1.0 - cum[k] / total) as f32)
}
/// Curve strength for Amount `a` (0..=100): 0 at 0, 1 near 15, 2 at 50, 3 at 100.
fn strength(a: f32) -> f32 {
let u = (a.clamp(0.0, 100.0) / 100.0).powf(0.6216);
if u <= 0.3 { u / 0.3 } else { (1.0 + 20.0 * u) / 7.0 }
}
/// The full-strength curve of strength `s`: 1 − (1 − v)^q between q = 2^⌊s⌋ and 2^(⌊s⌋+1).
fn lift(s: f32, v: f32) -> f32 {
let n = (s.floor() as i32).clamp(0, 2);
let t = (s - n as f32).clamp(0.0, 1.0);
let c = (1.0 - v).clamp(0.0, 1.0);
let lo = c.powi(1 << n);
let hi = lo * lo;
1.0 - ((1.0 - t) * lo + t * hi)
}
/// Linear interpolation in a table of `(x, y)` sorted by `x`.
fn interp(table: &[(f32, f32)], x: f32) -> f32 {
let Some(&(x0, y0)) = table.first() else { return 0.0 };
if x <= x0 {
return y0;
}
for p in table.windows(2) {
let ((xa, ya), (xb, yb)) = (p[0], p[1]);
if x <= xb {
return ya + (yb - ya) * (x - xa) / (xb - xa);
}
}
table.last().map_or(0.0, |p| p.1)
}
/// Color: the h of the colour-difference gain for positive values (exp(h · F))…
const COLOR_UP: [(f32, f32); 12] = [
(0.0, 0.0),
(1.0, 0.1566),
(2.0, 0.2271),
(5.0, 0.3776),
(10.0, 0.5653),
(20.0, 0.8669),
(30.0, 1.1299),
(40.0, 1.3748),
(50.0, 1.6093),
(60.0, 1.8375),
(75.0, 2.1721),
(100.0, 2.7193),
];
/// … and for negative values (1 + h · F), by −Color.
const COLOR_DOWN: [(f32, f32); 9] =
[(0.0, 0.0), (1.0, -0.0145), (5.0, -0.0709), (10.0, -0.1373), (20.0, -0.2570), (30.0, -0.3612), (50.0, -0.5312), (75.0, -0.6866), (100.0, -0.7968)];
/// Midtone: (value, k, p) of k · (Y − ½) · (4Y(1 − Y))^p.
const MIDTONE: [(f32, f32, f32); 11] = [
(-100.0, -0.6955, 2.267),
(-75.0, -0.5873, 2.362),
(-50.0, -0.4429, 2.492),
(-25.0, -0.2514, 2.667),
(-10.0, -0.1088, 2.803),
(0.0, 0.0, 2.9),
(10.0, 0.1206, 3.016),
(25.0, 0.3262, 3.216),
(50.0, 0.7423, 3.625),
(75.0, 1.2635, 4.160),
(100.0, 1.9032, 4.850),
];
fn midtone(m: f32, y: f32) -> f32 {
if m == 0.0 {
return y;
}
let ks: Vec<(f32, f32)> = MIDTONE.iter().map(|r| (r.0, r.1)).collect();
let ps: Vec<(f32, f32)> = MIDTONE.iter().map(|r| (r.0, r.2)).collect();
let (k, p) = (interp(&ks, m), interp(&ps, m));
(y + k * (y - 0.5) * (4.0 * y * (1.0 - y)).max(0.0).powf(p)).clamp(0.0, 1.0)
}
fn level(v: f32) -> usize {
(v.clamp(0.0, 1.0) * 255.0).round() as usize
}
/// Image › Adjustments › Shadows/Highlights on straight RGBA `w × h`.
pub fn shadows_highlights(px: &mut [[f32; 4]], w: usize, h: usize, p: &ShadowsHighlights) {
if px.is_empty() || px.len() != w * h {
return;
}
let (sa, ha) = (p.shadow_amount, p.highlight_amount);
let lum: Vec<f32> = px.iter().map(|q| (q[0].max(q[1]).max(q[2]) + luma([q[0], q[1], q[2]])) / 2.0).collect();
let ls = (sa > 0.0).then(|| neighbourhood(&lum, w, h, p.shadow_radius));
let lh = (ha > 0.0).then(|| match &ls {
Some(l) if (p.highlight_radius - p.shadow_radius).abs() < 1e-3 => l.clone(),
_ => neighbourhood(&lum, w, h, p.highlight_radius),
});
let (ms, mh) = (tonal_mask(p.shadow_tone), tonal_mask(p.highlight_tone));
let (ss, hs) = (strength(sa), strength(ha));
// The new luma of every pixel.
let mut y2 = vec![0.0f32; px.len()];
par_rows(&mut y2, w, 1, |y, row| {
for (x, out) in row.iter_mut().enumerate() {
let i = y * w + x;
let q = px[i];
let mut adj = [q[0], q[1], q[2]];
if let Some(l) = &ls {
let m = ms[level(l[i])];
for v in &mut adj {
*v += m * (lift(ss, *v) - *v);
}
}
if let Some(l) = &lh {
let m = mh[255 - level(l[i])];
for (v, &o) in adj.iter_mut().zip(&q[..3]) {
*v -= m * (lift(hs, 1.0 - o) - (1.0 - o));
}
}
*out = midtone(p.midtone, luma(adj).clamp(0.0, 1.0));
}
});
clip_levels(&mut y2, px, p.black_clip, p.white_clip);
let gain = |f: f32| {
if p.color >= 0.0 { (interp(&COLOR_UP, p.color) * f).exp() } else { 1.0 + interp(&COLOR_DOWN, -p.color) * f }
};
par_rows(px, w, 1, |y, row| {
for (x, q) in row.iter_mut().enumerate() {
if q[3] <= 0.0 {
continue;
}
let y1 = luma([q[0], q[1], q[2]]);
let yn = y2[y * w + x];
let f = (yn - y1).abs() / yn.max(y1).max(1e-6);
let g = gain(f);
let d = [(q[0] - y1) * g, (q[1] - y1) * g, (q[2] - y1) * g];
// Shorten the colour difference where it would leave 0..=1.
let mut k = 1.0f32;
for v in d {
if v > 1e-9 {
k = k.min((1.0 - yn) / v);
} else if v < -1e-9 {
k = k.min(yn / -v);
}
}
let k = k.clamp(0.0, 1.0);
for c in 0..3 {
q[c] = (yn + k * d[c]).clamp(0.0, 1.0);
}
}
});
}
/// Black and White Clip: stretches the new luma so `black`% of pixels reach 0 and `white`% reach 1.
fn clip_levels(y2: &mut [f32], px: &[[f32; 4]], black: f32, white: f32) {
if black <= 0.0 && white <= 0.0 {
return;
}
let mut hist = vec![0u32; 4096];
let mut n = 0u32;
for (&y, q) in y2.iter().zip(px) {
if q[3] > 0.0 {
hist[(y.clamp(0.0, 1.0) * 4095.0).round() as usize] += 1;
n += 1;
}
}
if n == 0 {
return;
}
let find = |pct: f32, rev: bool| -> f32 {
let target = (pct.clamp(0.0, 50.0) / 100.0 * n as f32) as u32;
let mut acc = 0;
for k in 0..4096 {
let i = if rev { 4095 - k } else { k };
acc += hist[i];
if acc > target {
return i as f32 / 4095.0;
}
}
if rev { 0.0 } else { 1.0 }
};
let (lo, hi) = (if black > 0.0 { find(black, false) } else { 0.0 }, if white > 0.0 { find(white, true) } else { 1.0 });
if hi - lo < 1e-3 {
return;
}
for y in y2.iter_mut() {
*y = ((*y - lo) / (hi - lo)).clamp(0.0, 1.0);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A flat `w × h` grey or colour image with one different pixel in the middle; returns that
/// pixel after Shadows/Highlights.
fn probe(bg: [f32; 3], px: [f32; 3], p: &ShadowsHighlights) -> [f32; 3] {
let (w, h) = (121, 121);
let mut img = vec![[bg[0], bg[1], bg[2], 1.0]; w * h];
img[60 * w + 60] = [px[0], px[1], px[2], 1.0];
shadows_highlights(&mut img, w, h, p);
let q = img[60 * w + 60];
[q[0], q[1], q[2]]
}
fn levels(v: [i32; 3]) -> [f32; 3] {
v.map(|c| c as f32 / 32768.0)
}
fn off(a: [f32; 3], b: [f32; 3]) -> f32 {
a.iter().zip(b).map(|(x, y)| (x - y).abs() * 255.0).fold(0.0, f32::max)
}
/// Photoshop 25.4's results (16-bit levels of 32768) for grey pixels lifted by the default
/// Shadows (35 %, Tone 50 %, Radius 30 px; Color 0), in black surroundings and in grey 0.5.
#[test]
fn grey_matches_photoshop() {
let p = ShadowsHighlights { color: 0.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() };
for (bg, want) in [(0.0, [11389, 19422, 28451, 31931]), (0.5, [5211, 9909, 18229, 25700])] {
for (v, w) in [0.125f32, 0.25, 0.5, 0.749969].into_iter().zip(want) {
let got = probe([bg; 3], [v; 3], &p);
assert!(off(got, levels([w; 3])) < 2.0, "bg {bg} v {v}: {} vs {}", got[0] * 32768.0, w);
}
}
}
/// Colour pixels keep their colour differences, scaled by Color (+20 here; Amount 35 %,
/// Radius 10 px), in black, 0.15 and 0.3 grey surroundings: Photoshop's results.
#[test]
fn colour_matches_photoshop() {
let p = ShadowsHighlights { shadow_radius: 10.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() };
for (bg, px, want) in [
(0.0, [3277, 1638, 0], [8235, 5202, 2170]),
(0.0, [8192, 6554, 4915], [19350, 16514, 13676]),
(4915.0 / 32768.0, [4915, 3277, 1638], [10124, 7424, 4723]),
(9830.0 / 32768.0, [11469, 9830, 8192], [16540, 14376, 12214]),
] {
let got = probe([bg; 3], levels(px), &p);
assert!(off(got, levels(want)) < 2.0, "{px:?} on {bg}: {:?} vs {want:?}", got.map(|c| (c * 32768.0).round()));
}
}
/// Across a strong edge the neighbourhood barely leaks (Amount 100 %, Tone 100 %, Radius
/// 10 px; 0.1 | 0.9): Photoshop's results 8, 2 and 1 pixels either side.
#[test]
fn edge_matches_photoshop() {
let (w, h) = (200, 24);
let (a, b) = (3277.0 / 32768.0, 29491.0 / 32768.0);
let mut img: Vec<[f32; 4]> = (0..w * h).map(|i| if i % w < 100 { [a, a, a, 1.0] } else { [b, b, b, 1.0] }).collect();
let p = ShadowsHighlights {
shadow_amount: 100.0,
shadow_tone: 100.0,
shadow_radius: 10.0,
color: 0.0,
black_clip: 0.0,
white_clip: 0.0,
..Default::default()
};
shadows_highlights(&mut img, w, h, &p);
let row = &img[12 * w..13 * w];
for (x, want) in [(92, 16370), (98, 14439), (99, 12146), (100, 30891), (101, 30403), (102, 30146), (108, 29979)] {
let got = row[x][0] * 32768.0;
assert!((got - want as f32).abs() / 32768.0 * 255.0 < 6.0, "x {x}: {got} vs {want}");
}
}
#[test]
fn strength_and_curve_follow_photoshop() {
assert!((strength(50.0) - 2.0).abs() < 2e-3 && (strength(100.0) - 3.0).abs() < 1e-4);
assert!((strength(15.0) - 1.02).abs() < 0.01 && (strength(1.0) - 0.19).abs() < 0.01);
// Amount 50 is the image screened with itself twice: 1 − (1 − v)^4.
for v in [0.1f32, 0.3, 0.6] {
assert!((lift(2.0, v) - (1.0 - (1.0 - v).powi(4))).abs() < 1e-6);
}
}
#[test]
fn tonal_mask_spans_photoshop_tones() {
let m95 = tonal_mask(95.0);
assert!((m95[128] - (1.0 - 128.0 / 255.0)).abs() < 1e-3, "{}", m95[128]);
assert_eq!(tonal_mask(100.0), m95);
// Tone 0 still reaches about 7 % into the levels (Photoshop: half at level 18).
let m0 = tonal_mask(0.0);
assert!(m0[0] > 0.999 && (m0[18] - 0.5).abs() < 0.03 && m0[60] < 0.02, "{} {}", m0[18], m0[60]);
}
}
+2 -41
View File
@@ -186,7 +186,7 @@ pub fn quad_size(q: &[[f64; 2]; 4]) -> (f64, f64) {
/// Mapping and Image Warping", 1989, §2.2.3) scaled to the rectangle. `None` when `q` isn't a
/// [`convex_quad`] or the size isn't positive.
pub fn quad_to_rect(q: &[[f64; 2]; 4], w: f64, h: f64) -> Option<Homography> {
if !(convex_quad(q) && w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
if !convex_quad(q) || !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
return None;
}
Homography::rect_to_quad([0.0, 0.0, w, h], *q)?.inverse()
@@ -368,8 +368,7 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
any = true;
let o = ((y - t.y0) as usize * w + (x - t.x0) as usize) * n;
for c in 0..a {
// Integer storage clips on write; float colours retain their signed/HDR range.
outp[o + c] = (acc[c] / al) as f32;
outp[o + c] = (acc[c] / al).clamp(0.0, 1.0) as f32;
}
outp[o + a] = al as f32;
}
@@ -483,44 +482,6 @@ mod tests {
}
}
#[test]
fn translation_preserves_colour_range_at_every_depth() {
use photocraft_color::{ColorMode, SampleType};
let h = Homography([1.0, 0.0, 7.0, 0.0, 1.0, -3.0, 0.0, 0.0, 1.0]);
for sample in SampleType::ALL {
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
for alpha in [false, true] {
let mut s = Surface::new(PixelFormat::new(mode, sample, alpha));
let mut pixel = if mode == ColorMode::Rgb { vec![2.0, -0.25, 0.5] } else { vec![2.0] };
if alpha {
pixel.push(0.5);
}
s.fill_rect(Rect::new(4, 4, 8, 8), &pixel);
let mut expected = s.pixel(5, 5);
if !alpha {
expected.push(1.0);
}
for interp in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] {
let out = warp_surface(&s, s.content_bounds(), &h, interp).unwrap();
assert_eq!(out.pixel(12, 2), expected, "{sample:?}/{mode:?}/alpha={alpha}/{interp:?}");
assert_eq!(out.pixel(0, 0).last(), Some(&0.0));
}
}
}
}
}
#[test]
fn bilinear_translation_interpolates_signed_hdr_colours() {
let mut s = Surface::new(PixelFormat::RGBA32F);
s.write_pixel(0, 0, &[2.0, -0.5, 0.25, 0.5]);
s.write_pixel(1, 0, &[4.0, -0.25, 0.75, 0.5]);
let h = Homography([1.0, 0.0, 0.5, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
let out = warp_surface(&s, s.content_bounds(), &h, Interp::Bilinear).unwrap();
assert_eq!(out.pixel(1, 0), vec![3.0, -0.375, 0.5, 0.5]);
}
#[test]
fn scale_doubles_size_and_rotation_keeps_area() {
let mut s = rgba();
-86
View File
@@ -114,73 +114,6 @@ impl VpPlane {
});
Lifted { focal: f, center: c, pts }
}
/// Turn of each corner (`sin` of the angle between consecutive edges, positive when the
/// corners run clockwise on screen); `None` when an edge is shorter than a pixel or a corner
/// is not finite.
fn turns(&self) -> Option<[f64; 4]> {
let q = &self.corners;
if q.iter().flatten().any(|v| !v.is_finite() || v.abs() > 1e9) {
return None;
}
let e = |i: usize| [q[(i + 1) % 4][0] - q[i][0], q[(i + 1) % 4][1] - q[i][1]];
let mut out = [0.0; 4];
for (i, t) in out.iter_mut().enumerate() {
let (a, b) = (e(i), e((i + 1) % 4));
let (la, lb) = (a[0].hypot(a[1]), b[0].hypot(b[1]));
if la < 1.0 || lb < 1.0 {
return None;
}
*t = (a[0] * b[1] - a[1] * b[0]) / (la * lb);
}
Some(out)
}
/// How usable the plane is, the way Photoshop colours its grid: [`PlaneCheck::Invalid`] for
/// a quad that isn't convex (self-intersecting, a corner pushed inside, a near-straight or
/// near-zero corner), [`PlaneCheck::Questionable`] for a convex quad whose perspective can't
/// come from a camera centred at `center` (or with a corner sharper than 10°), else
/// [`PlaneCheck::Valid`].
pub fn check(&self, center: [f64; 2]) -> PlaneCheck {
let Some(t) = self.turns() else { return PlaneCheck::Invalid };
let same_sign = t.iter().all(|v| *v > 0.0) || t.iter().all(|v| *v < 0.0);
let min = t.iter().fold(f64::MAX, |m, v| m.min(v.abs()));
if !same_sign || min < 1f64.to_radians().sin() || self.homography().is_none() {
return PlaneCheck::Invalid;
}
if min < 10f64.to_radians().sin() {
return PlaneCheck::Questionable;
}
// Both vanishing points finite but no real focal length fits them.
let q = &self.corners;
let (v1, v2) = (meet(q[0], q[1], q[3], q[2]), meet(q[0], q[3], q[1], q[2]));
let finite = |v: V3| v[2].abs() > 1e-9 * (v[0].abs() + v[1].abs()).max(1.0);
if finite(v1) && finite(v2) && self.focal(center).is_none() {
return PlaneCheck::Questionable;
}
PlaneCheck::Valid
}
/// The same quad with its corners clockwise on screen, starting at the top-left one
/// (the order [`paste`] and [`Scene::tear_off`] expect), however they were clicked.
pub fn normalized(&self) -> VpPlane {
let mut q = self.corners;
if self.turns().is_some_and(|t| t.iter().sum::<f64>() < 0.0) {
q = [q[0], q[3], q[2], q[1]];
}
let first = (0..4).min_by(|&a, &b| (q[a][0] + q[a][1]).total_cmp(&(q[b][0] + q[b][1]))).unwrap_or(0);
VpPlane { corners: [q[first], q[(first + 1) % 4], q[(first + 2) % 4], q[(first + 3) % 4]] }
}
}
/// Plane validity ([`VpPlane::check`]): Photoshop draws a valid plane's grid blue, a doubtful
/// one yellow and an invalid one red.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PlaneCheck {
Valid,
Questionable,
Invalid,
}
impl Lifted {
@@ -430,25 +363,6 @@ mod tests {
assert!((lu / lv - 2.0).abs() < 0.01, "aspect {}", lu / lv);
}
#[test]
fn plane_check_and_corner_order() {
let c = [400.0, 300.0];
assert_eq!(floor_quad(500.0, c).check(c), PlaneCheck::Valid);
let square = VpPlane { corners: [[10.0, 10.0], [110.0, 10.0], [110.0, 110.0], [10.0, 110.0]] };
assert_eq!(square.check(c), PlaneCheck::Valid, "fronto-parallel");
let bowtie = VpPlane { corners: [[10.0, 10.0], [110.0, 110.0], [110.0, 10.0], [10.0, 110.0]] };
assert_eq!(bowtie.check(c), PlaneCheck::Invalid);
let dent = VpPlane { corners: [[10.0, 10.0], [110.0, 10.0], [40.0, 40.0], [10.0, 110.0]] };
assert_eq!(dent.check(c), PlaneCheck::Invalid, "concave");
let sliver = VpPlane { corners: [[10.0, 10.0], [300.0, 10.0], [300.0, 40.0], [200.0, 40.0]] };
assert_eq!(sliver.check(c), PlaneCheck::Questionable, "a corner sharper than 10°");
let nan = VpPlane { corners: [[f64::NAN, 10.0], [110.0, 10.0], [110.0, 110.0], [10.0, 110.0]] };
assert_eq!(nan.check(c), PlaneCheck::Invalid);
// Clicked counter-clockwise from the bottom-right: clockwise from the top-left.
let ccw = VpPlane { corners: [[110.0, 110.0], [110.0, 10.0], [10.0, 10.0], [10.0, 110.0]] };
assert_eq!(ccw.normalized(), square);
}
#[test]
fn tear_off_is_perpendicular_and_shares_the_edge() {
let c = [400.0, 300.0];
+19 -98
View File
@@ -6,9 +6,7 @@
//! interpolated barycentrically, then the source is sampled there (premultiplied, nearest /
//! bilinear / Catmull-Rom bicubic, like Free Transform). Cells are a few source pixels across, so
//! the piecewise-affine inverse is within a small fraction of a pixel of the true one; affine
//! maps (the identity included) are reproduced exactly. Where the warp shrinks the source, each
//! pixel instead averages a grid of bilinear subsamples over its source footprint (about one per
//! source pixel), so thin lines don't alias. Where the warp folds over itself the
//! maps (the identity included) are reproduced exactly. Where the warp folds over itself the
//! later (lower-right) cell wins. Work is split per destination tile (parallel on native).
use photocraft_color::PixelFormat;
@@ -27,9 +25,6 @@ fn catmull_rom(t: f64) -> [f64; 4] {
const CELL: f64 = 4.0;
/// Most cells along one axis (bounds memory and time on huge layers).
const MAX_CELLS: usize = 512;
/// Most subsamples per axis when a warp shrinks the source; also the longest source step per
/// destination pixel the filter follows (bounds the footprint each tile reads).
const MAX_SS: f64 = 8.0;
/// Places a smart object's source image (`src`, its pixels in `src_rect`) in the document:
/// through `warp` (source space) and then the affine `t` (source → document) in one resampling
@@ -177,37 +172,14 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
}
let w = t.width() as usize;
let h = t.height() as usize;
// Subsamples along one destination axis, from the source step `j` it makes: one where
// the warp keeps or enlarges the source, else about one per source pixel the output pixel
// spans (an area average, as the transform path gets from its pre-reduction; a single
// bicubic sample aliases thin lines when shrinking). The subsamples are bilinear: the
// box does the filtering, and it is ~4x cheaper than bicubic at no loss.
let count = |j: [f64; 2]| if interp == Interp::Nearest { 1 } else { j[0].hypot(j[1]).round().clamp(1.0, MAX_SS) as usize };
// Source position per destination pixel (NaN = not covered) and the triangle it came
// from. Per triangle: its source step per destination pixel along x and y (`[dx_u, dx_v,
// dy_u, dy_v]`, the inverse map's Jacobian, constant over a triangle; zero when
// point-sampled) and the subsamples along each.
// Source position per destination pixel (NaN = not covered).
let mut uv = vec![[f64::NAN; 2]; w * h];
let mut owner = vec![0u32; w * h];
let mut jac: Vec<([f64; 4], usize, usize)> = Vec::with_capacity(tris.len());
for (k, tri) in tris.iter().enumerate() {
for tri in tris {
let [(p0, s0), (p1, s1), (p2, s2)] = tri.map(|v| verts[v]);
let det = (p1[0] - p0[0]) * (p2[1] - p0[1]) - (p2[0] - p0[0]) * (p1[1] - p0[1]);
if det.abs() < 1e-12 {
jac.push(([0.0; 4], 1, 1));
continue;
}
let (d1, d2) = ([s1[0] - s0[0], s1[1] - s0[1]], [s2[0] - s0[0], s2[1] - s0[1]]);
let (l1x, l1y, l2x, l2y) = ((p2[1] - p0[1]) / det, (p0[0] - p2[0]) / det, (p0[1] - p1[1]) / det, (p1[0] - p0[0]) / det);
// Capped, so a sliver triangle can't ask for an unbounded footprint.
let cap = |j: [f64; 2]| {
let l = j[0].hypot(j[1]);
if l > MAX_SS { [j[0] * MAX_SS / l, j[1] * MAX_SS / l] } else { j }
};
let jx = cap([l1x * d1[0] + l2x * d2[0], l1x * d1[1] + l2x * d2[1]]);
let jy = cap([l1y * d1[0] + l2y * d2[0], l1y * d1[1] + l2y * d2[1]]);
let (nx, ny) = (count(jx), count(jy));
jac.push(if nx * ny == 1 { ([0.0; 4], 1, 1) } else { ([jx[0], jx[1], jy[0], jy[1]], nx, ny) });
let bx0 = (p0[0].min(p1[0]).min(p2[0]) - 0.5).floor().max(f64::from(t.x0)) as i32;
let by0 = (p0[1].min(p1[1]).min(p2[1]) - 0.5).floor().max(f64::from(t.y0)) as i32;
let bx1 = (p0[0].max(p1[0]).max(p2[0]) + 0.5).ceil().min(f64::from(t.x1)) as i32;
@@ -225,18 +197,16 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
}
let o = (y - t.y0) as usize * w + (x - t.x0) as usize;
uv[o] = [l0 * s0[0] + l1 * s1[0] + l2 * s2[0], l0 * s0[1] + l1 * s1[1] + l2 * s2[1]];
owner[o] = k as u32;
}
}
}
// Source footprint of the covered pixels, including the spread of their subsamples.
// Source footprint of the covered pixels.
let (mut fx0, mut fy0, mut fx1, mut fy1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for (q, j) in uv.iter().zip(&owner).filter(|(q, _)| !q[0].is_nan()).map(|(q, &k)| (q, jac[k as usize].0)) {
let (ex, ey) = (0.5 * (j[0].abs() + j[2].abs()), 0.5 * (j[1].abs() + j[3].abs()));
fx0 = fx0.min(q[0] - ex);
fy0 = fy0.min(q[1] - ey);
fx1 = fx1.max(q[0] + ex);
fy1 = fy1.max(q[1] + ey);
for q in uv.iter().filter(|q| !q[0].is_nan()) {
fx0 = fx0.min(q[0]);
fy0 = fy0.min(q[1]);
fx1 = fx1.max(q[0]);
fy1 = fy1.max(q[1]);
}
if fx0 > fx1 {
return None;
@@ -263,9 +233,11 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
let mut outp = vec![0.0f32; w * h * n];
let mut any = false;
let mut acc = [0.0f64; 8];
let mut one = [0.0f64; 8];
// One premultiplied sample at source position (u, v) (pixel centres at integers).
let point = |interp: Interp, u: f64, v: f64, acc: &mut [f64; 8]| {
for (o, q) in uv.iter().enumerate() {
if q[0].is_nan() {
continue;
}
let (u, v) = (q[0] - 0.5, q[1] - 0.5);
acc[..n].fill(0.0);
match interp {
Interp::Nearest => {
@@ -304,31 +276,6 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
}
}
}
};
for (o, (q, &k)) in uv.iter().zip(&owner).enumerate() {
if q[0].is_nan() {
continue;
}
let (j, nx, ny) = jac[k as usize];
if nx * ny == 1 {
point(interp, q[0] - 0.5, q[1] - 0.5, &mut acc);
} else {
acc[..n].fill(0.0);
for sy in 0..ny {
let t = (sy as f64 + 0.5) / ny as f64 - 0.5;
for sx in 0..nx {
let s = (sx as f64 + 0.5) / nx as f64 - 0.5;
point(Interp::Bilinear, q[0] + s * j[0] + t * j[2] - 0.5, q[1] + s * j[1] + t * j[3] - 0.5, &mut one);
for (c, v) in acc.iter_mut().zip(&one).take(n) {
*c += v;
}
}
}
let k = 1.0 / (nx * ny) as f64;
for c in acc.iter_mut().take(n) {
*c *= k;
}
}
let al = acc[a].clamp(0.0, 1.0);
// Float noise (a vertex a hair off the pixel grid) must not leave invisible
// colour behind in transparent pixels.
@@ -447,43 +394,17 @@ mod tests {
let out = warp_mesh_surface(&src, r, &|x, y| (x + 10.0, y - 2.0), Interp::Bilinear);
assert_eq!(out.content_bounds(), r.translate(10, -2));
assert_eq!(out.pixel(20, 10), src.pixel(10, 12));
// Arc 50 % (Photoshop): the bottom corners keep their pixels, the top rises and spreads.
// Arc 50 %: the centre column keeps its pixels, the ends drop.
let b = [f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)];
let w = Warp::preset(WarpStyle::Arc, 50.0, b);
let arc = warp_mesh_surface(&src, r, &|x, y| w.map(x, y), Interp::Bilinear);
let corner = w.map(f64::from(r.x0), f64::from(r.y1));
assert!((corner.0 - f64::from(r.x0)).abs() < 1e-9 && (corner.1 - f64::from(r.y1)).abs() < 1e-9, "{corner:?}");
let cx = (r.x0 + r.x1) / 2;
let (p, q) = (arc.pixel(cx, 10), src.pixel(cx, 10));
assert!(p.iter().zip(&q).all(|(a, b)| (a - b).abs() < 0.05), "{p:?} vs {q:?}");
let (ex, ey) = w.map(f64::from(r.x0) + 2.5, f64::from(r.y0) + 2.5);
let e = arc.pixel(ex.floor() as i32, ey.floor() as i32);
assert!(e[3] > 0.2, "a point near the top-left corner lands at its mapped position: {e:?}");
let c = arc.content_bounds();
assert!(c.y0 < r.y0 - 2 && c.x0 < r.x0 && c.x1 > r.x1, "the top rises and spreads past the box: {c:?}");
}
#[test]
fn a_shrinking_mesh_warp_is_prefiltered() {
// A warped smart object placed at ~1/5 size: a grid of 1 px lines every 4 px must come
// out its area average (9/16 white) everywhere, not point-sampled moiré (crinkled,
// doubled or missing lines), like the affine path's `large_downscale_is_prefiltered`.
for s in [SampleType::U8, SampleType::U16, SampleType::F32] {
let (w, h) = (400, 320);
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, s, true));
let px: Vec<f32> =
(0..h).flat_map(|y| (0..w).map(move |x| if x % 4 == 0 || y % 4 == 0 { 0.0 } else { 1.0 })).flat_map(|v| [v, v, v, 1.0]).collect();
let r = Rect::new(0, 0, w, h);
src.write_region(r, &px);
let warp = Warp::preset(WarpStyle::Arc, 15.0, [0.0, 0.0, f64::from(w), f64::from(h)]);
let t = photocraft_geom::Affine { m: [0.2, 0.0, 0.0, 0.2, 7.0, 5.0] };
let out = place_source(&src, r, &t, Some(&warp)).unwrap();
let b = out.content_bounds();
let o = out.read_region(b);
let opaque: Vec<f32> = o.as_chunks::<4>().0.iter().filter(|p| p[3] >= 0.999).map(|p| p[0]).collect();
assert!(opaque.len() > 2000, "{s:?}: {} opaque pixels", opaque.len());
let worst = opaque.iter().map(|v| (v - 0.5625).abs()).fold(0.0f32, f32::max);
// An area average over the ~5 px footprint still covers one or two lines per axis
// (up to 0.2 off); point sampling lands wholly on or off a line (up to 0.56 off).
assert!(worst < 0.25, "{s:?}: worst deviation from the area average {worst}");
}
assert!(arc.content_bounds().y1 > r.y1 + 2, "ends drop below the original box");
}
#[test]
+2 -15
View File
@@ -41,17 +41,6 @@ pub fn previews(doc: &Document) -> SaveOptions {
SaveOptions { thumbnail: Some(photocraft_compose::thumbnail(doc, 256)), composite: Some(photocraft_compose::thumbnail(doc, 1024)) }
}
/// Encode the name the saved native bundle will report on reopening. Save As updates
/// the session identity only *after* a successful write; without a separate snapshot
/// the manifest serializes the previous name instead.
fn named_for_output(doc: &Document, path: &str) -> Document {
let mut named = doc.clone();
if let Some(name) = path.rsplit(['/', '\\']).next().filter(|name| !name.is_empty()) {
named.name = name.to_owned();
}
named
}
/// Save or export `doc` to `path`, choosing the format from the extension
/// (or `format_override`, an extension such as `"png"`). `writer` makes
/// repeated `.pcraft` saves incremental. Returns warnings about lost data.
@@ -67,10 +56,9 @@ pub fn save(
.or_else(|| path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()))
.ok_or_else(|| AutomationError::BadRequest(format!("cannot tell the format of `{}`; pass a format", path.display())))?;
if ext == photocraft_format::EXTENSION {
let named = named_for_output(doc, &path.to_string_lossy());
let mut local = PcraftWriter::new();
let w = writer.unwrap_or(&mut local);
w.save_path(&named, path, &previews(&named))?;
w.save_path(doc, path, &previews(doc))?;
return Ok(Vec::new());
}
let r = photocraft_io::export(doc, &ext, opts)?;
@@ -87,8 +75,7 @@ pub fn save_bytes(doc: &Document, name: &str, format_override: Option<&str>, opt
.or_else(|| Path::new(name).extension().map(|extension| extension.to_string_lossy().to_ascii_lowercase()))
.ok_or_else(|| AutomationError::BadRequest(format!("cannot tell the format of `{name}`; pass a format")))?;
if ext == photocraft_format::EXTENSION {
let named = named_for_output(doc, name);
return Ok((photocraft_format::save_to_bytes(&named, &previews(&named))?, Vec::new()));
return Ok((photocraft_format::save_to_bytes(doc, &previews(doc))?, Vec::new()));
}
let result = photocraft_io::export(doc, &ext, opts)?;
Ok((result.bytes, result.warnings))
+18 -131
View File
@@ -9,7 +9,7 @@ use photocraft_format::PcraftWriter;
use photocraft_io::ExportOptions;
use serde_json::{Value, json};
use crate::workspace::{ExportSequence, authorize_engine_command, authorize_engine_step};
use crate::workspace::{authorize_engine_command, authorize_engine_step};
use crate::{AuthorizedWorkspace, AutomationError, files};
fn untrusted(filesystem: Filesystem) -> Headless {
@@ -96,125 +96,16 @@ impl Headless {
st.source_read_only = o.source_read_only;
}
let d = &self.session.documents()[index];
let mut warnings = o.warnings;
warnings.extend(photocraft_engine::type_extra_cmds::missing_font_warnings(&d.doc));
Ok(json!({
"index": index,
"name": d.doc.name,
"width": d.doc.size.width,
"height": d.doc.size.height,
"layers": d.doc.layer_count(),
"warnings": warnings,
"warnings": o.warnings,
}))
}
/// Place a file into a document as a Smart Object layer: the capability-scoped counterpart
/// of `file.placeEmbedded`, which reads an ambient path and is therefore refused in
/// automation (#2810). `path` resolves beneath the read root the way `open` resolves one,
/// and `params` carries the command's own options (`scale`, `fit`, `center`).
pub fn place(&mut self, index: Option<usize>, path: &str, params: &Value) -> Result<Value, AutomationError> {
let i = self.doc_index(index)?;
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
}
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("path".into(), json!(path));
// Where ambient paths are allowed the command reads the file itself, as everywhere else.
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
return self.command_run("file.placeEmbedded", Value::Object(opts));
}
let bytes = match &self.filesystem {
Filesystem::Workspace(workspace) => workspace.read(path)?,
_ => {
return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into()));
}
};
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
/// capability-scoped `file.export.layersToFiles`, which takes an ambient `dir` and is
/// therefore refused in automation (#3216). The bytes come from the command's own core, so
/// both paths write the same files. The write capability refuses to overwrite a file that
/// already exists, where the ambient command would, and it creates the directory before the
/// export runs, so a refused export can leave an empty directory behind — never a file.
pub fn export_layers(&mut self, index: Option<usize>, dir: &str, params: &Value) -> Result<Value, AutomationError> {
let i = self.doc_index(index)?;
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
}
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("dir".into(), json!(dir));
return self.command_run("file.export.layersToFiles", Value::Object(opts));
}
self.sync_jobs();
let workspace = match &self.filesystem {
Filesystem::Workspace(workspace) => workspace,
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
};
let mut outputs = workspace.export_sequence(dir)?;
let written = file_cmds::export_layers_with(&self.session, params, |name, bytes| {
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
});
exported(dir, written, &mut outputs)
}
/// Export each data set of a document as its own flattened file beneath the write root: the
/// capability-scoped `file.export.dataSetsAsFiles` (#3216). As in the command, the whole
/// batch is planned first and colliding names are refused before any file is written; the
/// directory itself is created before that, so a refused batch can leave it empty behind.
pub fn export_data_sets(&mut self, index: Option<usize>, dir: &str, params: &Value) -> Result<Value, AutomationError> {
let i = self.doc_index(index)?;
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
}
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("dir".into(), json!(dir));
return self.command_run("file.export.dataSetsAsFiles", Value::Object(opts));
}
self.sync_jobs();
let workspace = match &self.filesystem {
Filesystem::Workspace(workspace) => workspace,
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
};
let mut outputs = workspace.export_sequence(dir)?;
let written = photocraft_engine::export_data_sets_with(&self.session, params, |name, bytes| {
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
});
exported(dir, written, &mut outputs)
}
/// Import a data set CSV from the read root: the capability-scoped
/// `file.import.variableDataSets`, which takes an ambient `path` and is therefore refused
/// in automation (#3216). The file is read beneath the root the way `open` reads one and
/// handed to the command's own parser over the text it already has.
pub fn import_data_sets(&mut self, index: Option<usize>, path: &str, params: &Value) -> Result<Value, AutomationError> {
let i = self.doc_index(index)?;
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
}
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("path".into(), json!(path));
return self.command_run("file.import.variableDataSets", Value::Object(opts));
}
let text = match &self.filesystem {
Filesystem::Workspace(workspace) => {
let bytes = workspace.read(path)?;
String::from_utf8(bytes).map_err(|e| AutomationError::BadRequest(format!("automation read `{path}` is not valid UTF-8: {e}")))?
}
_ => 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,
/// saves to the document's own path.
pub fn save(&mut self, index: Option<usize>, path: Option<&Path>, format: Option<&str>, opts: &ExportOptions) -> Result<Value, AutomationError> {
@@ -242,7 +133,7 @@ impl Headless {
}
};
let target_text = target.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?;
let mut warnings = match &self.filesystem {
let warnings = match &self.filesystem {
Filesystem::Denied => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
Filesystem::TrustedLocal => {
let writer = self.writers.entry(doc.id.0).or_default();
@@ -254,7 +145,6 @@ impl Headless {
warnings
}
};
warnings.extend(photocraft_engine::type_extra_cmds::missing_font_warnings(&doc));
// A layered document save (PSD, PSB, OpenRaster or .pcraft) is a full write, not a flattened copy:
// record the current revision as saved so the session stops reporting `dirty`, and make
// the file the document's path. A flat export (PNG, JPEG, …) is a copy and leaves both
@@ -320,7 +210,21 @@ impl Headless {
}
pub fn command_list(&self) -> Value {
Value::Array(command_specs().iter().map(|c| photocraft_engine::commands::list_entry(&self.session, c, self.session.disabled_reason(c.id))).collect())
Value::Array(
command_specs()
.iter()
.map(|c| {
json!({
"id": c.id,
"label": c.label,
"menu": c.menu,
"shortcut": c.shortcut,
"params": c.params,
"enabled": self.session.is_enabled(c.id),
})
})
.collect(),
)
}
pub fn command_run(&mut self, id: &str, params: Value) -> Result<Value, AutomationError> {
@@ -378,20 +282,3 @@ impl Headless {
}
}
}
/// Common tail of the two capability-scoped exports: the sequence keeps what it wrote, or drops
/// every file it created, so a half-finished export never looks like a finished one.
fn exported(dir: &str, written: photocraft_engine::Result<Vec<String>>, outputs: &mut ExportSequence) -> Result<Value, AutomationError> {
match written {
Ok(names) => {
outputs.commit();
let files: Vec<String> = names.iter().map(|name| format!("{dir}/{name}")).collect();
let count = files.len();
Ok(json!({"files": files, "count": count}))
}
Err(error) => {
outputs.cleanup()?;
Err(error.into())
}
}
}
+3 -179
View File
@@ -8,12 +8,7 @@
//! - `engine.execute {command, params?, wait?}` / `engine.commands {filter?}`
//! - `jobs.list` / `jobs.cancel {job?}`: background jobs (#210)
//! - `session.list`, `doc.open {path}`, `doc.new {…file.new params}`,
//! `doc.place {path, scale?, fit?, center?, index?}` (#2810: the read root is the scope,
//! the counterpart of the refused ambient `file.placeEmbedded`),
//! `doc.exportLayers {dir, format?, prefix?, visibleOnly?, quality?, index?}`,
//! `doc.exportDataSets {dir, format?, dataSets?, naming?, index?}`,
//! `doc.importDataSets {path, delimiter?, index?}` (#3216: the same treatment for the export
//! and CSV half of #2810), `doc.save {path?, format?, quality?, index?}`, `doc.inspect {index?}`,
//! `doc.save {path?, format?, quality?, index?}`, `doc.inspect {index?}`,
//! `doc.render {index?, maxSide?, path?}` (writes a PNG to `path`, else
//! returns it base64-encoded), `doc.select {index}`, `doc.close {index?}`
//! - `batch {steps: [{command, params?, wait?} | {method, params?}], stopOnError?}`
@@ -30,7 +25,7 @@ use serde_json::{Value, json};
use crate::budgets::{BatchReplyBudget, write_reply};
use crate::security::{
ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, discard_rest_of_line,
read_bounded_line, repeat_auth_reply,
read_bounded_line,
};
use crate::{AutomationError, Headless};
@@ -42,12 +37,8 @@ pub const METHODS: &[&str] = &[
"engine.commands",
"session.list",
"doc.open",
"doc.place",
"doc.new",
"doc.save",
"doc.exportLayers",
"doc.exportDataSets",
"doc.importDataSets",
"doc.inspect",
"doc.render",
"doc.select",
@@ -104,19 +95,11 @@ impl Headless {
let path = str_of(&p, "path").ok_or_else(|| bad("doc.open needs `path`"))?;
self.open(&PathBuf::from(path))
}
"doc.place" => {
let path = str_of(&p, "path").ok_or_else(|| bad("doc.place needs `path`"))?;
let mut opts = p.as_object().cloned().unwrap_or_default();
opts.remove("path");
opts.remove("index");
self.place(index_of(&p), path, &Value::Object(opts))
}
"doc.new" => self.command_run("file.new", p),
"doc.save" => {
let tga_bits = photocraft_engine::file_cmds::tga_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
let mut opts =
photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() };
opts.encode.dpx_bits = photocraft_engine::file_cmds::dpx_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
if let Some(q) = p.get("quality").and_then(Value::as_u64) {
opts.encode.jpeg_quality = q.clamp(1, 100) as u8;
opts.encode.webp_quality = q.clamp(1, 100) as u8;
@@ -125,21 +108,6 @@ impl Headless {
let path = str_of(&p, "path").map(PathBuf::from);
self.save(index_of(&p), path.as_deref(), str_of(&p, "format"), &opts)
}
"doc.exportLayers" | "doc.exportDataSets" => {
let dir = str_of(&p, "dir").ok_or_else(|| bad(format!("{method} needs `dir`")))?;
let mut opts = p.as_object().cloned().unwrap_or_default();
opts.remove("dir");
opts.remove("index");
let opts = Value::Object(opts);
if method == "doc.exportLayers" { self.export_layers(index_of(&p), dir, &opts) } else { self.export_data_sets(index_of(&p), dir, &opts) }
}
"doc.importDataSets" => {
let path = str_of(&p, "path").ok_or_else(|| bad("doc.importDataSets needs `path`"))?;
let mut opts = p.as_object().cloned().unwrap_or_default();
opts.remove("path");
opts.remove("index");
self.import_data_sets(index_of(&p), path, &Value::Object(opts))
}
"doc.inspect" => self.inspect(index_of(&p)),
"doc.render" => {
let max = match p.get("maxSide") {
@@ -289,7 +257,7 @@ fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token:
LineRead::Line => {}
}
let reply = if authenticated {
repeat_auth_reply(&line).unwrap_or_else(|| respond(h, &line))
respond(h, &line)
} else {
let (reply, ok) = authentication_reply(&line, token);
authenticated = ok;
@@ -604,120 +572,6 @@ mod tests {
std::fs::remove_dir(root).unwrap();
}
#[test]
fn doc_place_reads_through_the_read_root_and_never_outside_it() {
let base = std::env::temp_dir().join(format!("pc-doc-place-{}", std::process::id()));
let root = base.join("workspace");
let outside = base.join("outside");
for dir in [&root, &outside] {
std::fs::create_dir_all(dir).unwrap();
}
let img = photocraft_codecs::Image::from_u8(8, 6, photocraft_codecs::ChannelLayout::Rgb, vec![42; 8 * 6 * 3]).unwrap();
let png = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
std::fs::write(root.join("logo.png"), &png).unwrap();
std::fs::write(outside.join("logo.png"), &png).unwrap();
let absolute = outside.join("logo.png").to_string_lossy().into_owned();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.new", json!({"width": 40, "height": 30})).unwrap();
let placed = h.handle("doc.place", json!({"path": "logo.png", "scale": 50})).unwrap();
assert!(placed["layer"].as_u64().is_some(), "{placed}");
assert_eq!(placed["scale"].as_f64(), Some(50.0), "the options reach the command: {placed}");
let inspected = h.handle("doc.inspect", json!({})).unwrap();
assert_eq!(inspected["layers"].as_array().map(Vec::len), Some(2), "the placed layer is in the document: {inspected}");
// A path outside the read root is refused the way the ambient command is (#2810).
for (path, reason) in [("../outside/logo.png", "parent traversal"), (absolute.as_str(), "must be inside the automation root")] {
let refused = h.handle("doc.place", json!({"path": path})).unwrap_err().to_string();
assert!(refused.contains(reason), "{refused}");
let inspected = h.handle("doc.inspect", json!({})).unwrap();
assert_eq!(inspected["layers"].as_array().map(Vec::len), Some(2), "nothing was placed: {refused}");
}
// Trusted local keeps the ambient command: that caller may read the path itself.
let mut t = Headless::trusted_local();
t.handle("doc.new", json!({"width": 40, "height": 30})).unwrap();
let trusted = t.handle("doc.place", json!({"path": root.join("logo.png").to_string_lossy()})).unwrap();
assert!(trusted["layer"].as_u64().is_some(), "{trusted}");
// Release directory capabilities before cleanup on Windows.
drop(t);
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn doc_export_layers_writes_only_beneath_the_write_root() {
let base = std::env::temp_dir().join(format!("pc-doc-export-layers-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.new", json!({"width": 12, "height": 8, "background": "white", "name": "card"})).unwrap();
h.handle("engine.execute", json!({"command": "layer.new.layer", "params": {"name": "Badge"}})).unwrap();
let out = root.join("out");
let exported = h.handle("doc.exportLayers", json!({"dir": out.to_string_lossy(), "format": "png", "prefix": "card"})).unwrap();
assert_eq!(exported["count"].as_u64(), Some(2), "{exported}");
let mut names: Vec<String> = std::fs::read_dir(&out).unwrap().map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()).collect();
names.sort();
assert_eq!(names.len(), 2, "{exported}");
for name in names {
assert!(name.starts_with("card_") && name.ends_with(".png"), "{name}");
let bytes = std::fs::read(out.join(&name)).unwrap();
assert_eq!(photocraft_codecs::decode(&bytes).unwrap().dimensions(), (12, 8), "{name}");
}
// Outside the write root nothing is created, not even the directory.
let escaped = base.join("escaped");
let refused = h.handle("doc.exportLayers", json!({"dir": escaped.to_string_lossy()})).unwrap_err().to_string();
assert!(refused.contains("must be inside the automation root"), "{refused}");
assert!(!escaped.exists());
// The ambient command keeps its refusal for the very same directory.
let ambient =
h.handle("engine.execute", json!({"command": "file.export.layersToFiles", "params": {"dir": out.to_string_lossy()}})).unwrap_err().to_string();
assert!(ambient.contains("ambient filesystem paths"), "{ambient}");
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn doc_export_data_sets_refuse_collisions_and_import_reads_the_root() {
let base = std::env::temp_dir().join(format!("pc-doc-data-sets-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white", "name": "card"})).unwrap();
let inspected = h.handle("doc.inspect", json!({})).unwrap();
let layer = inspected["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(
"engine.execute",
json!({"command": "image.variables.dataSets", "params": {"dataSets": [
{"name": "one", "values": [{"variable": "show", "kind": "visibility", "value": true}]},
{"name": "two", "values": [{"variable": "show", "kind": "visibility", "value": false}]},
]}}),
)
.unwrap();
let sets = root.join("sets");
let exported = h.handle("doc.exportDataSets", json!({"dir": sets.to_string_lossy(), "format": "png"})).unwrap();
assert_eq!(exported["count"].as_u64(), Some(2), "{exported}");
assert_eq!(std::fs::read_dir(&sets).unwrap().count(), 2, "{exported}");
// Colliding names are refused as in the command, before any file is written.
let clash = root.join("clash");
let refused = h.handle("doc.exportDataSets", json!({"dir": clash.to_string_lossy(), "naming": "{document}"})).unwrap_err().to_string();
assert!(refused.contains("{index}"), "{refused}");
assert_eq!(std::fs::read_dir(&clash).unwrap().count(), 0, "no file of the refused batch");
// A CSV from the read root, and one outside it.
std::fs::write(root.join("sets.csv"), "Data Set,show\none,true\ntwo,false\n").unwrap();
let imported = h.handle("doc.importDataSets", json!({"path": "sets.csv"})).unwrap();
assert_eq!(imported["imported"].as_u64(), Some(2), "{imported}");
std::fs::write(base.join("outside.csv"), "Data Set,show\none,true\n").unwrap();
let outside = h.handle("doc.importDataSets", json!({"path": base.join("outside.csv").to_string_lossy()})).unwrap_err().to_string();
assert!(outside.contains("must be inside the automation root"), "{outside}");
// 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();
@@ -781,36 +635,6 @@ mod tests {
assert!(v["result"].as_array().unwrap().iter().any(|m| m == "batch"));
}
#[test]
fn tcp_repeat_auth_reports_already_authenticated_and_keeps_serving() {
use std::io::{BufReader, Write as _};
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let h = Arc::new(Mutex::new(Headless::new()));
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = serve_tcp("127.0.0.1:0", h, TOKEN.into(), move |a| tx.send(a).unwrap());
});
let addr = rx.recv().unwrap();
let mut s = std::net::TcpStream::connect(addr).unwrap();
let mut reader = BufReader::new(s.try_clone().unwrap());
let mut line = String::new();
for id in ["auth", "again"] {
writeln!(s, r#"{{"id":"{id}","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
}
let again: Value = serde_json::from_str(&line).unwrap();
assert_eq!(again["id"], "again");
assert_eq!(again["ok"], false);
assert_eq!(again["error"], "already authenticated");
writeln!(s, r#"{{"id":"m","method":"methods"}}"#).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["id"], "m");
assert_eq!(v["ok"], true);
}
#[test]
fn tcp_rejects_requests_before_authentication() {
use std::io::{BufReader, Write as _};
-23
View File
@@ -118,18 +118,6 @@ pub fn authentication_reply(line: &str, expected_token: &str) -> (Value, bool) {
}
}
/// Answer a repeat `auth` frame on an already authenticated connection. Returns `None` for any
/// other frame, which the caller dispatches as usual.
/// Without this, a second `auth` fell through to the method dispatcher as an unknown method.
pub fn repeat_auth_reply(line: &str) -> Option<Value> {
let req: Value = serde_json::from_str(line).ok()?;
if req.get("method").and_then(Value::as_str) != Some(AUTH_METHOD) {
return None;
}
let id = req.get("id").cloned().unwrap_or(Value::Null);
Some(json!({"id": id, "ok": false, "error": "already authenticated"}))
}
fn read_token_file(path: &Path) -> Result<String, AutomationError> {
let token = std::fs::read_to_string(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
let token = token.trim().to_owned();
@@ -306,17 +294,6 @@ mod tests {
assert_eq!(reply["result"]["authenticated"], true);
}
#[test]
fn repeat_auth_on_an_authenticated_connection_says_so() {
let line = json!({"id": 3, "method": AUTH_METHOD, "params": {"token": "anything"}}).to_string();
let reply = repeat_auth_reply(&line).unwrap();
assert_eq!(reply["id"], 3);
assert_eq!(reply["ok"], false);
assert_eq!(reply["error"], "already authenticated");
assert!(repeat_auth_reply(r#"{"id":4,"method":"methods"}"#).is_none());
assert!(repeat_auth_reply("{not json").is_none());
}
#[test]
fn token_file_round_trips_between_server_and_client() {
let path = std::env::temp_dir().join(format!("photocraft-control-token-{}-{}.txt", std::process::id(), generate_token().unwrap()));
+5 -193
View File
@@ -52,79 +52,6 @@ pub struct OpenParams {
pub path: String,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PlaceParams {
/// Path beneath the configured automation read root: relative (forward slashes) or absolute.
pub path: String,
/// Document index from `session_list` (default: the active document).
#[serde(default)]
pub index: Option<usize>,
/// Scale as a percentage; defaults to fitting a file larger than the canvas.
#[serde(default)]
pub scale: Option<f64>,
/// Place at a centred size instead of a scaled one.
#[serde(default)]
pub fit: Option<bool>,
/// Centre of the placed layer, in document pixels.
#[serde(default)]
pub center: Option<[f64; 2]>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ExportLayersParams {
/// Directory beneath the configured automation write root: relative (forward slashes) or absolute.
pub dir: String,
/// Document index from `session_list` (default: the active document).
#[serde(default)]
pub index: Option<usize>,
/// Export format (default png).
#[serde(default)]
pub format: Option<String>,
/// Filename prefix (default: the document name).
#[serde(default)]
pub prefix: Option<String>,
/// Export only the layers that are visible (default true).
#[serde(default, rename = "visible_only")]
pub visible_only: Option<bool>,
/// Encoder quality, 0..12, for the formats that take one.
#[serde(default)]
pub quality: Option<f64>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ExportDataSetsParams {
/// Directory beneath the configured automation write root: relative (forward slashes) or absolute.
pub dir: String,
/// Document index from `session_list` (default: the active document).
#[serde(default)]
pub index: Option<usize>,
/// Export format (default png).
#[serde(default)]
pub format: Option<String>,
/// Only these data sets, by name; default every one of them.
#[serde(default, rename = "data_sets")]
pub data_sets: Option<Vec<String>>,
/// Filename template with `{name}`, `{index}` and `{document}` (default `{name}`).
#[serde(default)]
pub naming: Option<String>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ImportDataSetsParams {
/// Path beneath the configured automation read root: relative (forward slashes) or absolute.
pub path: String,
/// Document index from `session_list` (default: the active document).
#[serde(default)]
pub index: Option<usize>,
/// Field delimiter (default `,`).
#[serde(default)]
pub delimiter: Option<String>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NewParams {
@@ -167,9 +94,6 @@ pub struct SaveParams {
/// alpha, 24 none. Unset: 32 with an alpha channel or transparency, else 24.
#[serde(default, rename = "tgaBits")]
pub tga_bits: Option<u8>,
/// 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>,
#[serde(default)]
pub index: Option<usize>,
}
@@ -309,7 +233,7 @@ pub struct UiSetParams {
/// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, colorPanel
/// ({background: bool} picks which swatch the Color panel edits), maskTarget,
/// vectorMaskTarget, selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio,
/// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark), brushSection, brushTab,
/// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark, catppuccinLatte, catppuccinFrappe, catppuccinMacchiato, catppuccinMocha), brushSection, brushTab,
/// brushesView, brushPicker ([x, y] opens the Brush Preset picker there, null closes it),
/// brushPickerName, brushPickerStroke, brushPickerTip (the picker card's parts, at least one
/// on), brushPickerScale (the picker footer's card-scale slider, 0.15 to 2, 1 the standard),
@@ -322,8 +246,7 @@ pub struct UiSetParams {
/// color ("matchCanvas" or "custom"), custom_color ([r, g, b]), opacity (0..100),
/// auto_adjust, classic_mode and auto_center_preview), shapeStroke (an object patching width, align, cap, join, miterLimit,
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
/// the Content-Aware Move tool's Transform On Drop). Other fields are an error.
/// `shape.edit` names and units; colour comes from `tools.setColors`. Other fields are an error.
pub fields: Value,
}
@@ -596,107 +519,6 @@ impl PhotocraftMcp {
to_result(b.call("app.open", json!({"path": p.path})).await)
}
#[tool(
description = "Place a file into the document as a Smart Object layer (Place Embedded), reading `path` beneath the read root. `scale` (percent), `fit` and `center` are the command's own."
)]
async fn doc_place(&self, Parameters(p): Parameters<PlaceParams>) -> Result<CallToolResult, McpError> {
let mut opts = serde_json::Map::new();
if let Some(v) = p.scale {
opts.insert("scale".into(), json!(v));
}
if let Some(v) = p.fit {
opts.insert("fit".into(), json!(v));
}
if let Some(v) = p.center {
opts.insert("center".into(), json!(v));
}
let opts = Value::Object(opts);
// Bridge mode runs the app on this machine, which reads the path itself like any command.
let mut bridge_params = opts.clone();
if let Some(o) = bridge_params.as_object_mut() {
o.insert("path".into(), Value::String(p.path.clone()));
}
let (path, index) = (p.path, p.index);
if let Some(r) = self.headless_op(move |h| h.place(index, &path, &opts)).await {
return to_result(r);
}
self.run_command("file.placeEmbedded".into(), bridge_params, true).await
}
#[tool(
description = "Export each exported layer of the document as its own file beneath the write root (the capability-scoped Layers to Files). A file that already exists is an error, not an overwrite."
)]
async fn doc_export_layers(&self, Parameters(p): Parameters<ExportLayersParams>) -> Result<CallToolResult, McpError> {
let mut opts = serde_json::Map::new();
if let Some(v) = p.format {
opts.insert("format".into(), json!(v));
}
if let Some(v) = p.prefix {
opts.insert("prefix".into(), json!(v));
}
if let Some(v) = p.visible_only {
opts.insert("visibleOnly".into(), json!(v));
}
if let Some(v) = p.quality {
opts.insert("quality".into(), json!(v));
}
let opts = Value::Object(opts);
// Bridge mode runs the app on this machine, which writes its own paths like any command.
let mut bridge_params = opts.clone();
if let Some(o) = bridge_params.as_object_mut() {
o.insert("dir".into(), Value::String(p.dir.clone()));
}
let (dir, index) = (p.dir, p.index);
if let Some(r) = self.headless_op(move |h| h.export_layers(index, &dir, &opts)).await {
return to_result(r);
}
self.run_command("file.export.layersToFiles".into(), bridge_params, true).await
}
#[tool(
description = "Export every data set of the document as its own flattened file beneath the write root (the capability-scoped Export Data Sets). The batch is planned first, so colliding filenames are refused before any file is written."
)]
async fn doc_export_data_sets(&self, Parameters(p): Parameters<ExportDataSetsParams>) -> Result<CallToolResult, McpError> {
let mut opts = serde_json::Map::new();
if let Some(v) = p.format {
opts.insert("format".into(), json!(v));
}
if let Some(v) = p.data_sets {
opts.insert("dataSets".into(), json!(v));
}
if let Some(v) = p.naming {
opts.insert("naming".into(), json!(v));
}
let opts = Value::Object(opts);
let mut bridge_params = opts.clone();
if let Some(o) = bridge_params.as_object_mut() {
o.insert("dir".into(), Value::String(p.dir.clone()));
}
let (dir, index) = (p.dir, p.index);
if let Some(r) = self.headless_op(move |h| h.export_data_sets(index, &dir, &opts)).await {
return to_result(r);
}
self.run_command("file.export.dataSetsAsFiles".into(), bridge_params, true).await
}
#[tool(description = "Import a data set CSV from the read root into the document (the capability-scoped Import Variable Data Sets).")]
async fn doc_import_data_sets(&self, Parameters(p): Parameters<ImportDataSetsParams>) -> Result<CallToolResult, McpError> {
let mut opts = serde_json::Map::new();
if let Some(v) = p.delimiter {
opts.insert("delimiter".into(), json!(v));
}
let opts = Value::Object(opts);
let mut bridge_params = opts.clone();
if let Some(o) = bridge_params.as_object_mut() {
o.insert("path".into(), Value::String(p.path.clone()));
}
let (path, index) = (p.path, p.index);
if let Some(r) = self.headless_op(move |h| h.import_data_sets(index, &path, &opts)).await {
return to_result(r);
}
self.run_command("file.import.variableDataSets".into(), bridge_params, true).await
}
#[tool(description = "Create a new document (defaults: 1920x1080 RGB 8-bit white).")]
async fn doc_new(&self, Parameters(p): Parameters<NewParams>) -> Result<CallToolResult, McpError> {
let mut params = serde_json::Map::new();
@@ -795,9 +617,7 @@ impl PhotocraftMcp {
}
}
#[tool(description = "List engine commands: id, label, menu path, shortcut, parameter doc, enabled now. \
`disabledReason` says why a command is off (null when enabled); `needsDocument` is true when it is off because no \
document is open: create or open one first (doc_new, doc_open).")]
#[tool(description = "List engine commands: id, label, menu path, shortcut, parameter doc, enabled now.")]
async fn command_list(&self, Parameters(p): Parameters<ListParams>) -> Result<CallToolResult, McpError> {
let all = if let Some(r) = self.headless_op(|h| Ok(h.command_list())).await {
r
@@ -846,9 +666,8 @@ impl PhotocraftMcp {
self.jobs_call("jobs.cancel", p.job.map_or_else(|| json!({}), |j| json!({"job": j})), false).await
}
#[tool(description = "Run several engine commands in one call, in order (fewer round trips). Returns {completed, failed, \
results:[{ok, result|error}]}; stops at the first error unless stop_on_error is false. \
Not an atomic transaction: earlier successful steps stay applied (no rollback).")]
#[tool(description = "Run several engine commands in one call (fewer round trips). Returns {completed, failed, \
results:[{ok, result|error}]}; stops at the first error unless stop_on_error is false.")]
async fn command_batch(&self, Parameters(p): Parameters<BatchParams>) -> Result<CallToolResult, McpError> {
if p.steps.len() > MAX_BATCH_STEPS {
return Ok(fail(format!("batch contains {} steps; maximum is {MAX_BATCH_STEPS}", p.steps.len())));
@@ -990,11 +809,6 @@ impl PhotocraftMcp {
Some((_, Some(bits))) => Some(bits),
Some((b, None)) => return Ok(fail(format!("`tgaBits` must be 24 or 32, got {b}"))),
};
let dpx_bits = match p.dpx_bits.map(|b| (b, photocraft_io::DpxBits::from_bits(u64::from(b)))) {
None => None,
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(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
@@ -1004,7 +818,6 @@ impl PhotocraftMcp {
p.format.is_some().then_some("format"),
p.quality.is_some().then_some("quality"),
p.tiff_layers.then_some("tiffLayers"),
dpx_bits.is_some().then_some("dpxBits"),
p.index.is_some().then_some("index"),
]
.into_iter()
@@ -1025,7 +838,6 @@ impl PhotocraftMcp {
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;
if let Some(q) = p.quality {
opts.encode.jpeg_quality = q.clamp(1, 100);
opts.encode.webp_quality = q.clamp(1, 100);
+1 -1
View File
@@ -16,7 +16,7 @@ fn annotated(mut tool: Tool) -> Tool {
tool.name.as_ref(),
"command_list" | "doc_inspect" | "session_list" | "render_preview" | "doc_render_preview" | "ui_inspect" | "ui_screenshot"
);
let additive = matches!(tool.name.as_ref(), "doc_new" | "doc_open" | "doc_place");
let additive = matches!(tool.name.as_ref(), "doc_new" | "doc_open");
tool.title = Some(title.clone());
tool.annotations = Some(ToolAnnotations::from_raw(Some(title), Some(read), Some(!read && !additive), Some(read), Some(false)));
Arc::make_mut(&mut tool.input_schema).insert("additionalProperties".into(), json!(false));
+2 -87
View File
@@ -2,38 +2,6 @@
use super::*;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use crate::security::MAX_REQUEST_BYTES;
/// Read a complete request before allowing any of its bytes to reach rmcp. `lines()`
/// buffers without a limit, while forwarding partial input can make a valid JSON prefix
/// executable at EOF after an oversized suffix is rejected.
async fn bounded_line<R: tokio::io::AsyncRead + Unpin>(source: &mut BufReader<R>, maximum: usize) -> std::io::Result<Option<String>> {
let mut bytes = Vec::new();
loop {
let available = source.fill_buf().await?;
if available.is_empty() {
if bytes.is_empty() {
return Ok(None);
}
return String::from_utf8(bytes).map(Some).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e));
}
let newline = available.iter().position(|&byte| byte == b'\n');
let length = newline.map_or(available.len(), |at| at + 1);
if length > maximum.saturating_sub(bytes.len()) {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("request exceeds {maximum} bytes")));
}
bytes.extend_from_slice(&available[..length]);
source.consume(length);
if newline.is_some() {
bytes.pop();
if bytes.last() == Some(&b'\r') {
bytes.pop();
}
return String::from_utf8(bytes).map(Some).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e));
}
}
}
impl PhotocraftMcp {
/// Serve a JSON-lines stream. A malformed line receives -32700/id:null; the next line
/// is still handled. A single writer serializes parse errors and SDK responses.
@@ -46,8 +14,8 @@ impl PhotocraftMcp {
let (service_out, from_service) = tokio::io::duplex(1 << 20);
let (errors, mut error_rx) = tokio::sync::mpsc::channel::<String>(16);
let reader = tokio::spawn(async move {
let mut lines = BufReader::new(input);
while let Some(line) = bounded_line(&mut lines, MAX_REQUEST_BYTES).await? {
let mut lines = BufReader::new(input).lines();
while let Some(line) = lines.next_line().await? {
if line.trim().is_empty() {
continue;
}
@@ -91,56 +59,3 @@ impl PhotocraftMcp {
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn bounded_line_accepts_exact_limit_and_final_line() {
let mut input = BufReader::new(std::io::Cursor::new(b"1234\nlast!".to_vec()));
assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some("1234"));
assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some("last!"));
assert!(bounded_line(&mut input, 5).await.unwrap().is_none());
let mut oversized_eof = BufReader::new(std::io::Cursor::new(b"longer".to_vec()));
assert_eq!(bounded_line(&mut oversized_eof, 5).await.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
}
#[tokio::test]
async fn oversized_valid_tool_call_never_reaches_dispatch() {
let initialize = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": { "name": "admission-test", "version": "1" }
}
});
let mut input = format!("{initialize}\n{}\n", json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).into_bytes();
let call = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": { "name": "doc_new", "arguments": { "width": 8, "height": 8 } }
})
.to_string()
.into_bytes();
assert!(call.len() < MAX_REQUEST_BYTES);
input.extend_from_slice(&call);
// The valid JSON prefix spans multiple BufReader fills; only its whitespace suffix
// crosses the byte limit. No part of this line may be forwarded to rmcp.
input.extend(vec![b' '; MAX_REQUEST_BYTES - call.len()]);
input.push(b'\n');
let server = PhotocraftMcp::headless();
let observer = server.clone();
let result = tokio::time::timeout(std::time::Duration::from_secs(5), server.serve_io(std::io::Cursor::new(input), tokio::io::sink()))
.await
.expect("oversized MCP input should end the session");
assert!(result.unwrap_err().to_string().contains("request exceeds 1048576 bytes"));
let documents = observer.headless_op(|h| Ok(h.session.documents().len())).await.unwrap().unwrap();
assert_eq!(documents, 0, "the oversized tool call must not run");
}
}
+5 -56
View File
@@ -1,6 +1,6 @@
//! Capability-based filesystem policy for untrusted automation paths.
use std::io::{Read, Write};
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
@@ -51,7 +51,8 @@ impl AuthorizedWorkspace {
if !metadata.is_file() {
return Err(AutomationError::BadRequest(format!("automation read path is not a regular file: `{path}`")));
}
read_to_observed_length(&mut file, metadata.len())
// Bounded reads, and a clear error for a file larger than memory (#375).
photocraft_format::read::read_all(&mut file, metadata.len()).map_err(|e| file_error("read", path, e))
}
/// Load one `.pcraft` document in directory form — the native on-disk layout — below the
@@ -107,34 +108,6 @@ impl AuthorizedWorkspace {
}
}
fn read_to_observed_length(reader: &mut impl Read, observed_length: u64) -> Result<Vec<u8>, AutomationError> {
// Do not reserve from a potentially stale or attacker-controlled metadata length. Read in
// chunks, and reject growth beyond the length observed on the opened file before appending.
let mut bytes = Vec::new();
let mut chunk = [0u8; 64 << 10];
loop {
let remaining = observed_length.saturating_sub(bytes.len() as u64);
let ask = chunk.len().min(usize::try_from(remaining.saturating_add(1)).unwrap_or(chunk.len()));
let n = match reader.read(&mut chunk[..ask]) {
Ok(n) => n,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(AutomationError::Io(format!("automation read failed: {e}"))),
};
if n == 0 {
return Ok(bytes);
}
if n as u64 > remaining {
return Err(AutomationError::BadRequest(format!("automation file grew beyond its observed length of {observed_length} bytes")));
}
let needed = bytes.len().checked_add(n).ok_or_else(|| AutomationError::BadRequest("automation file is too large to read".into()))?;
if needed > bytes.capacity() {
let target = bytes.capacity().saturating_add(bytes.capacity() / 2).max(needed).min(usize::try_from(observed_length).unwrap_or(usize::MAX));
bytes.try_reserve_exact(target - bytes.len()).map_err(|e| AutomationError::Io(format!("automation read allocation failed: {e}")))?;
}
bytes.extend_from_slice(&chunk[..n]);
}
}
fn open_root(path: Option<&Path>, authority: &str) -> Result<Option<RootCapability>, AutomationError> {
let Some(path) = path else { return Ok(None) };
if path.as_os_str().is_empty() {
@@ -252,7 +225,6 @@ pub fn authorize_engine_command(id: &str, params: &Value) -> Result<(), Automati
| "file.fileInfo"
| "file.automate.fitImage"
| "file.automate.conditionalModeChange"
| "file.automate.cropAndStraightenPhotos"
| "file.scripts.flattenAllLayerEffects"
| "file.scripts.flattenAllMasks"
| "file.scripts.deleteAllEmptyLayers"
@@ -363,12 +335,12 @@ fn preference_uses_ambient_filesystem(path: &str) -> bool {
fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
match id {
"actions.load" | "brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => {
"brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => {
// `data` wins over `path` in these commands; any `path` without it reads the filesystem.
params.get("data").is_none() && params.get("path").is_some()
}
// With a `path` the file is written there; without one the bytes come back as `data`.
"actions.save" | "swatches.export" => params.get("path").is_some(),
"swatches.export" => params.get("path").is_some(),
"plugin.reload" => true,
_ => false,
}
@@ -493,23 +465,6 @@ mod tests {
assert_eq!(std::fs::read(inside.join("new-output.txt")).unwrap(), b"created");
}
#[test]
fn large_observed_length_does_not_trigger_a_fixed_limit_or_upfront_reservation() {
let mut shortened = std::io::Cursor::new(b"psb");
assert_eq!(read_to_observed_length(&mut shortened, (1 << 30) + 1).unwrap(), b"psb");
}
#[test]
fn read_growth_is_rejected_at_the_observed_length() {
let mut growing = std::io::repeat(b'x').take(65);
let error = read_to_observed_length(&mut growing, 64).unwrap_err().to_string();
assert!(error.contains("grew beyond its observed length of 64 bytes"), "{error}");
let mut allowed = std::io::repeat(b'x').take(64);
assert_eq!(read_to_observed_length(&mut allowed, 64).unwrap().len(), 64);
let mut empty = std::io::Cursor::new(b"x");
assert!(read_to_observed_length(&mut empty, 0).is_err());
}
#[test]
fn open_pcraft_loads_a_directory_below_the_read_root() {
let (inside, _, workspace) = roots("pcraft-dir");
@@ -696,8 +651,6 @@ mod tests {
assert!(authorize_engine_command(id, &serde_json::json!({})).is_err());
}
assert!(authorize_engine_command("file.new", &serde_json::json!({})).is_ok());
assert!(authorize_engine_command("file.automate.cropAndStraightenPhotos", &serde_json::json!({})).is_ok());
assert!(authorize_desktop_engine_command("file.automate.cropAndStraightenPhotos", &serde_json::json!({})).is_ok());
for id in ["layer.smartObjects.editContents", "layer.smartObjects.convertToLayers", "layer.smartObjects.saveContents"] {
assert!(authorize_desktop_engine_command(id, &serde_json::json!({"layer": 19})).is_ok(), "{id}");
}
@@ -765,8 +718,6 @@ mod tests {
("plugin.install", serde_json::json!({"path": " "})),
("swatches.import", serde_json::json!({"path": "/outside/set.aco"})),
("swatches.export", serde_json::json!({"path": "/outside/set.ase"})),
("actions.load", serde_json::json!({"path": "/outside/set.atn"})),
("actions.save", serde_json::json!({"path": "/outside/set.atn"})),
] {
assert!(authorize_engine_command(id, &params).is_err(), "{id}: {params}");
}
@@ -776,8 +727,6 @@ mod tests {
("plugin.install", serde_json::json!({"data": "QUJD"})),
("swatches.import", serde_json::json!({"data": "QUJD"})),
("swatches.export", serde_json::json!({"format": "ase"})),
("actions.load", serde_json::json!({"data": "QUJD"})),
("actions.save", serde_json::json!({"set": "S"})),
] {
assert!(authorize_engine_command(id, &params).is_ok(), "{id}: {params}");
}
-100
View File
@@ -331,7 +331,6 @@ async fn agent_play_refuses_a_recorded_file_open() {
name: "Open".into(),
steps: vec![("file.open".into(), json!({"path": "/etc/passwd"})), ("layer.new.layer".into(), json!({}))],
disabled: Default::default(),
..Default::default()
});
let (c, server) = connect_headless(headless).await;
let played = run(&c, "actions.play", json!({"action": "Open"})).await;
@@ -344,102 +343,3 @@ async fn agent_play_refuses_a_recorded_file_open() {
shutdown(c, server).await;
std::fs::remove_dir_all(dir).unwrap();
}
/// The next ten acceptance tasks (11–20): strokes, inverse fills, duplication, merges,
/// flattening, adjustments, feathering, expand, renaming/deleting and an export/reopen round
/// trip — all through MCP tools and checked from outside the engine.
#[tokio::test(flavor = "multi_thread")]
async fn agent_completes_ten_more_scripted_tasks() {
let dir = tmp("tasks2");
let (c, server) = connect(&dir).await;
// 11. Stroke a selection: a red square with a black outside stroke.
tool(&c, "doc_new", json!({"width": 80, "height": 80, "background": "white"})).await;
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).await;
run(&c, "edit.fill", json!({"color": "#ff0000"})).await;
run(&c, "edit.stroke", json!({"width": 4, "color": "#000000", "location": "outside"})).await;
let inside = pixel(&c, 40, 40).await;
let edge = pixel(&c, 18, 40).await;
let away = pixel(&c, 5, 5).await;
assert!(inside[0] > 0.99 && inside[1] < 0.01, "task 11: the centre is red {inside:?}");
assert!(edge[0] < 0.1 && edge[1] < 0.1, "task 11: the stroke is black {edge:?}");
assert!(away[0] > 0.99 && away[1] > 0.99, "task 11: outside stays white {away:?}");
run(&c, "select.deselect", json!({})).await;
// 12. Inverse the selection: a blue frame around the red square.
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).await;
run(&c, "select.inverse", json!({})).await;
run(&c, "edit.fill", json!({"color": "#0000ff"})).await;
let centre = pixel(&c, 40, 40).await;
let frame = pixel(&c, 4, 4).await;
assert!(centre[0] > 0.99 && centre[2] < 0.01, "task 12: the centre keeps red {centre:?}");
assert!(frame[2] > 0.99 && frame[0] < 0.01, "task 12: the frame is blue {frame:?}");
run(&c, "select.deselect", json!({})).await;
// 13. Paint a green patch on a new layer and duplicate it (offset, as Photoshop's default).
run(&c, "layer.new.layer", json!({"name": "Patch"})).await;
run(&c, "select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).await;
run(&c, "edit.fill", json!({"color": "#00ff00"})).await;
run(&c, "select.deselect", json!({})).await;
run(&c, "layer.duplicate", json!({})).await;
let doc = inspect(&c).await;
assert_eq!(doc["layers"].as_array().unwrap().len(), 3, "task 13: {doc}");
let patches = doc["layers"].as_array().unwrap().iter().filter(|l| l["name"].as_str().is_some_and(|n| n.starts_with("Patch"))).count();
assert_eq!(patches, 2, "task 13: {doc}");
// 14. Merge the copy down; the two patches become one layer.
run(&c, "layer.mergeDown", json!({})).await;
assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 2, "task 14");
// 15. Flatten the image; the patch survives.
run(&c, "layer.flattenImage", json!({})).await;
let doc = inspect(&c).await;
assert_eq!(doc["layers"].as_array().unwrap().len(), 1, "task 15: {doc}");
let green = pixel(&c, 20, 20).await;
assert!(green[1] > 0.99 && green[0] < 0.01, "task 15: the patch survives {green:?}");
// 16. A Hue/Saturation adjustment layer turns the red square green without touching pixels.
run(&c, "layer.newAdjustmentLayer.hueSaturation", json!({"hue": 120})).await;
assert_eq!(top_layer(&inspect(&c).await)["kind"], "Adjustment", "task 16");
let shifted = pixel(&c, 40, 40).await;
assert!(shifted[1] > 0.5 && shifted[0] < 0.5, "task 16: red turns green {shifted:?}");
run(&c, "layer.delete", json!({})).await;
// 17. Feather the selection and reveal a mask through it: the edge blends.
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).await;
run(&c, "select.modify.feather", json!({"radius": 4})).await;
run(&c, "layer.layerMask.revealSelection", json!({})).await;
let doc = inspect(&c).await;
assert_eq!(top_layer(&doc)["hasMask"], true, "task 17");
let boundary = pixel(&c, 20, 40).await;
assert!(boundary[3] > 0.05 && boundary[3] < 0.95, "task 17: the feathered edge keeps partial alpha {boundary:?}");
run(&c, "select.deselect", json!({})).await;
// 18. Expand a selection and read its bounds back.
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 20, "height": 20})).await;
run(&c, "select.modify.expand", json!({"radius": 5})).await;
let bounds = inspect(&c).await["selectionBounds"].clone();
let b = bounds.as_array().unwrap_or_else(|| panic!("task 18: {bounds}"));
let (x, w) = (b[0].as_i64().unwrap(), b[2].as_i64().unwrap());
assert!((13..=15).contains(&x) && (30..=32).contains(&w), "task 18: expanded outward {bounds}");
run(&c, "select.deselect", json!({})).await;
// 19. Rename a layer and delete it again.
run(&c, "layer.new.layer", json!({"name": "Scratch"})).await;
run(&c, "layer.renameLayer", json!({"name": "Delivery"})).await;
assert_eq!(top_layer(&inspect(&c).await)["name"], "Delivery", "task 19");
run(&c, "layer.delete", json!({})).await;
assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 1, "task 19: deleted");
// 20. Export the final image and reopen the PNG as a flat document.
tool(&c, "doc_export", json!({"path": "final.png"})).await;
tool(&c, "doc_close", json!({})).await;
tool(&c, "doc_open", json!({"path": "final.png"})).await;
let doc = inspect(&c).await;
assert_eq!((doc["width"].as_u64(), doc["height"].as_u64()), (Some(80), Some(80)), "task 20");
assert_eq!(doc["layers"].as_array().unwrap().len(), 1, "task 20: a flat reopen");
tool(&c, "doc_close", json!({})).await;
shutdown(c, server).await;
std::fs::remove_dir_all(dir).unwrap();
}
-184
View File
@@ -1,184 +0,0 @@
//! M11 acceptance, part three: six rounding-out tasks (31–36) over MCP (roadmap M11 DoD).
//!
//! Same contract as the other acceptance files: only MCP tools (`doc_*`, `command_run`), checked
//! through `doc_inspect`, `document.pixel` and exported/saved files.
use photocraft_automation::{AuthorizedWorkspace, PhotocraftMcp};
use rmcp::model::{CallToolRequestParams, CallToolResult, ClientConfig};
use rmcp::service::RunningService;
use rmcp::{ClientHandler, RoleClient, ServiceExt};
use serde_json::{Value, json};
#[derive(Clone, Default)]
struct Client;
impl ClientHandler for Client {
fn get_info(&self) -> ClientConfig {
ClientConfig::default()
}
}
type Conn = RunningService<RoleClient, Client>;
type Server = tokio::task::JoinHandle<()>;
async fn connect(root: &std::path::Path) -> (Conn, Server) {
let (s, c) = tokio::io::duplex(1 << 20);
let workspace = AuthorizedWorkspace::new(Some(root), Some(root)).expect("test workspace");
let server = tokio::spawn(async move {
if let Ok(running) = PhotocraftMcp::headless_with_workspace(workspace).serve(s).await {
let _ = running.waiting().await;
}
});
(Client.serve(c).await.expect("client init"), server)
}
async fn shutdown(c: Conn, server: Server) {
c.cancel().await.unwrap();
server.await.unwrap();
}
fn text(r: &CallToolResult) -> String {
r.content.iter().filter_map(|c| c.as_text()).map(|t| t.text.clone()).collect::<Vec<_>>().join("\n")
}
async fn tool(c: &Conn, name: &str, args: Value) -> Value {
let mut p = CallToolRequestParams::new(name.to_owned());
if let Value::Object(m) = args {
p = p.with_arguments(m);
}
let r = c.call_tool(p).await.expect("call_tool transport");
assert_ne!(r.is_error, Some(true), "{name} failed: {}", text(&r));
serde_json::from_str(&text(&r)).unwrap_or(Value::String(text(&r)))
}
async fn run(c: &Conn, id: &str, params: Value) -> Value {
tool(c, "command_run", json!({"id": id, "params": params})).await
}
async fn inspect(c: &Conn) -> Value {
tool(c, "doc_inspect", json!({})).await
}
async fn pixel(c: &Conn, x: i32, y: i32) -> Vec<f64> {
let v = run(c, "document.pixel", json!({"x": x, "y": y})).await;
let arr = if v.is_array() { v } else { v.get("result").cloned().unwrap_or(v) };
arr.as_array().unwrap_or_else(|| panic!("pixel: {arr}")).iter().map(|x| x.as_f64().unwrap()).collect()
}
fn top_layer(doc: &Value) -> &Value {
&doc["layers"][0]
}
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("pc-agent-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// Six more acceptance tasks (31–36): an undo/redo sweep, a painted barcode, a grayscale
/// conversion with an export check, a native save round trip that preserves type, a layer-style
/// stroke and inverse fills.
#[tokio::test(flavor = "multi_thread")]
async fn agent_completes_six_rounding_out_tasks() {
let dir = tmp("tasks4");
let (c, server) = connect(&dir).await;
// 31. Undo/redo sweep: four fills go away and come back.
tool(&c, "doc_new", json!({"width": 40, "height": 40, "background": "white"})).await;
let h0 = inspect(&c).await["history"].as_array().unwrap().len();
let colors = ["#ff0000", "#00ff00", "#0000ff", "#000000"];
for (i, color) in colors.iter().enumerate() {
let x = 10 * i as i32;
run(&c, "select.rect", json!({"x": x, "y": 0, "width": 10, "height": 10})).await;
run(&c, "edit.fill", json!({"color": color})).await;
}
run(&c, "select.deselect", json!({})).await;
let steps = inspect(&c).await["history"].as_array().unwrap().len() - h0;
assert!(steps >= 4, "at least the four fills are history steps: {steps}");
for _ in 0..steps {
run(&c, "edit.undo", json!({})).await;
}
for i in 0..4 {
let x = 10 * i + 5;
let p = pixel(&c, x, 5).await;
assert!(p[0] > 0.99 && p[1] > 0.99 && p[2] > 0.99, "task 31: fill {i} undone {p:?}");
}
for _ in 0..steps {
run(&c, "edit.redo", json!({})).await;
}
let red = pixel(&c, 5, 5).await;
let black = pixel(&c, 35, 5).await;
assert!(red[0] > 0.99 && red[1] < 0.01, "task 31: the first fill redid {red:?}");
assert!(black[0] < 0.01 && black[1] < 0.01, "task 31: the last fill redid {black:?}");
// 32. Paint a barcode: five black bars on white.
tool(&c, "doc_new", json!({"width": 48, "height": 24, "background": "white"})).await;
for i in 0..5 {
let x = 4 + 8 * i;
run(&c, "select.rect", json!({"x": x, "y": 4, "width": 3, "height": 16})).await;
run(&c, "edit.fill", json!({"color": "#000000"})).await;
}
run(&c, "select.deselect", json!({})).await;
for i in 0..5 {
let bar = pixel(&c, 5 + 8 * i, 12).await;
let gap = pixel(&c, 9 + 8 * i, 12).await;
assert!(bar[0] < 0.01, "task 32: bar {i} is black {bar:?}");
assert!(gap[0] > 0.99, "task 32: gap {i} is white {gap:?}");
}
// 33. Convert to grayscale: the channels flatten and the export keeps them equal.
run(&c, "image.mode.grayscale", json!({})).await;
assert_eq!(inspect(&c).await["mode"], "Grayscale", "task 33");
let g = pixel(&c, 5, 12).await;
assert!((g[0] - g[1]).abs() < 1e-4 && (g[1] - g[2]).abs() < 1e-4, "task 33: the channels agree {g:?}");
tool(&c, "doc_export", json!({"path": "bars.png"})).await;
let img = photocraft_codecs::decode(&std::fs::read(dir.join("bars.png")).unwrap()).unwrap();
let px = img.to_rgba8();
let at = |x: usize, y: usize| px[(y * img.width() as usize + x) * 4];
assert!(at(5, 12) < 8 && at(9, 12) > 247, "task 33: the grayscale export matches the canvas");
tool(&c, "doc_close", json!({})).await;
// 34. A native save round trip preserves a styled type layer.
tool(&c, "doc_new", json!({"width": 160, "height": 80, "background": "white"})).await;
run(&c, "type.create", json!({"x": 16, "y": 48, "text": "Round", "size": 24, "color": "#3366cc"})).await;
tool(&c, "doc_save", json!({"path": "type.pcraft"})).await;
tool(&c, "doc_close", json!({})).await;
tool(&c, "doc_open", json!({"path": "type.pcraft"})).await;
let doc = inspect(&c).await;
assert_eq!(top_layer(&doc)["kind"], "Type", "task 34: {doc}");
assert_eq!(top_layer(&doc)["text"]["text"], "Round", "task 34: {doc}");
tool(&c, "doc_close", json!({})).await;
// 35. A layer-style stroke frames a swatch.
tool(&c, "doc_new", json!({"width": 48, "height": 48, "background": "white"})).await;
run(&c, "layer.new.layer", json!({"name": "Swatch"})).await;
run(&c, "select.rect", json!({"x": 12, "y": 12, "width": 24, "height": 24})).await;
run(&c, "edit.fill", json!({"color": "#ff0000"})).await;
run(&c, "select.deselect", json!({})).await;
run(&c, "layer.layerStyle.stroke", json!({"size": 3, "position": "outside", "color": "#0000ff"})).await;
let doc = inspect(&c).await;
assert_eq!(top_layer(&doc)["effects"]["items"][0]["kind"], "Stroke", "task 35: {doc}");
let border = pixel(&c, 11, 24).await;
let centre = pixel(&c, 24, 24).await;
assert!(border[2] > 0.5 && border[0] < 0.5, "task 35: the stroke is blue {border:?}");
assert!(centre[0] > 0.99 && centre[2] < 0.01, "task 35: the centre stays red {centre:?}");
// 36. Inverse fill: gray around the swatch, then Select All paints everything white.
run(&c, "select.rect", json!({"x": 12, "y": 12, "width": 24, "height": 24})).await;
run(&c, "select.inverse", json!({})).await;
run(&c, "edit.fill", json!({"color": "#808080"})).await;
run(&c, "select.deselect", json!({})).await;
let corner = pixel(&c, 2, 2).await;
let centre = pixel(&c, 24, 24).await;
assert!(corner[0] > 0.4 && corner[0] < 0.6 && (corner[0] - corner[1]).abs() < 0.01, "task 36: the inverse fill is gray {corner:?}");
assert!(centre[0] > 0.99 && centre[2] < 0.01, "task 36: the swatch stays red {centre:?}");
run(&c, "select.all", json!({})).await;
run(&c, "edit.fill", json!({"color": "#ffffff"})).await;
run(&c, "select.deselect", json!({})).await;
let white = pixel(&c, 2, 2).await;
assert!(white[0] > 0.99 && white[1] > 0.99 && white[2] > 0.99, "task 36: everything is white {white:?}");
tool(&c, "doc_close", json!({})).await;
shutdown(c, server).await;
std::fs::remove_dir_all(dir).unwrap();
}
+1 -142
View File
@@ -79,10 +79,6 @@ async fn lists_expected_tools() {
"session_list",
"doc_open",
"doc_new",
"doc_place",
"doc_export_layers",
"doc_export_data_sets",
"doc_import_data_sets",
"doc_save",
"doc_export",
"doc_inspect",
@@ -170,12 +166,6 @@ async fn command_list_filters() {
let enabled = json_of(&call(&client, "command_list", json!({"enabled_only": true})).await);
assert!(enabled.as_array().unwrap().iter().any(|c| c["id"] == "file.new"));
assert!(!enabled.as_array().unwrap().iter().any(|c| c["id"] == "layer.new.layer"), "needs a document");
// The full list says why a document command is off with no document open (#3664).
let layer = all.as_array().unwrap().iter().find(|c| c["id"] == "layer.new.layer").unwrap();
assert_eq!((layer["enabled"].clone(), layer["needsDocument"].clone()), (json!(false), json!(true)), "{layer}");
assert!(layer["disabledReason"].as_str().is_some_and(|r| r.starts_with("no document")), "{layer}");
let new = all.as_array().unwrap().iter().find(|c| c["id"] == "file.new").unwrap();
assert_eq!((new["needsDocument"].clone(), new["disabledReason"].clone()), (json!(false), Value::Null), "{new}");
client.cancel().await.unwrap();
}
@@ -621,107 +611,6 @@ async fn filesystem_policy_rejects_absolute_and_escaping_paths_before_effects()
cleanup(&base);
}
/// #2810: a file from the read root places as a Smart Object layer; `file.placeEmbedded` itself
/// stays refused, so `doc_place` is the only way in and the root is still the scope.
#[tokio::test(flavor = "multi_thread")]
async fn doc_place_is_capability_scoped() {
let base = tmp("place");
let root = base.join("workspace");
let outside = base.join("outside");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let img = photocraft_codecs::Image::from_u8(8, 6, photocraft_codecs::ChannelLayout::Rgb, vec![42; 144]).unwrap();
let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
std::fs::write(root.join("inside.png"), &bytes).unwrap();
std::fs::write(outside.join("outside.png"), bytes).unwrap();
let client = connect(headless_in(&root)).await;
json_of(&call(&client, "doc_new", json!({"width": 40, "height": 30})).await);
let placed = call(&client, "doc_place", json!({"path": "inside.png", "center": [10, 10]})).await;
assert_ne!(placed.is_error, Some(true), "{}", text(&placed));
let layers = json_of(&call(&client, "doc_inspect", json!({})).await)["layers"].as_array().unwrap().len();
assert_eq!(layers, 2, "the placed layer is in the document");
// An absolute path beneath the root is the same file (#2176), so it places too; outside it,
// nothing.
let inside = call(&client, "doc_place", json!({"path": root.join("inside.png").to_string_lossy()})).await;
assert_ne!(inside.is_error, Some(true), "{}", text(&inside));
let placed = json_of(&call(&client, "doc_inspect", json!({})).await)["layers"].as_array().unwrap().len();
assert_eq!(placed, 3, "the absolute path beneath the root places as well");
let refused = call(&client, "doc_place", json!({"path": outside.join("outside.png").to_string_lossy()})).await;
assert_eq!(refused.is_error, Some(true));
assert!(text(&refused).contains("must be inside the automation root"), "{}", text(&refused));
let after = json_of(&call(&client, "doc_inspect", json!({})).await)["layers"].as_array().unwrap().len();
assert_eq!(after, placed, "nothing outside the root was placed");
// The ambient command keeps its refusal: the capability-scoped method is the only way in.
let ambient = call(&client, "command_run", json!({"id": "file.placeEmbedded", "params": {"path": "inside.png"}})).await;
assert_eq!(ambient.is_error, Some(true));
assert!(text(&ambient).contains("ambient filesystem paths"), "{}", text(&ambient));
client.cancel().await.unwrap();
cleanup(&base);
}
/// #3216: the export half of #2810. The write root scopes both exports, the read root the CSV,
/// and the three ambient commands keep their refusal.
#[tokio::test(flavor = "multi_thread")]
async fn doc_exports_and_data_set_import_are_capability_scoped() {
let base = tmp("export");
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let client = connect(headless_in(&root)).await;
json_of(&call(&client, "doc_new", json!({"width": 10, "height": 6, "background": "white", "name": "card"})).await);
let inspected = json_of(&call(&client, "doc_inspect", json!({})).await);
let layer = inspected["layers"][0]["id"].clone();
json_of(
&call(&client, "command_run", json!({"id": "image.variables.define", "params": {"defs": [{"name": "show", "layer": layer, "type": "visibility"}]}}))
.await,
);
json_of(
&call(
&client,
"command_run",
json!({"id": "image.variables.dataSets", "params": {"dataSets": [
{"name": "one", "values": [{"variable": "show", "kind": "visibility", "value": true}]},
{"name": "two", "values": [{"variable": "show", "kind": "visibility", "value": false}]},
]}}),
)
.await,
);
let layers = call(&client, "doc_export_layers", json!({"dir": "layers", "prefix": "card"})).await;
assert_ne!(layers.is_error, Some(true), "{}", text(&layers));
assert_eq!(json_of(&layers)["count"], 1, "{}", text(&layers));
assert_eq!(std::fs::read_dir(root.join("layers")).unwrap().count(), 1);
let sets = call(&client, "doc_export_data_sets", json!({"dir": "sets", "format": "png"})).await;
assert_ne!(sets.is_error, Some(true), "{}", text(&sets));
assert_eq!(json_of(&sets)["count"], 2, "{}", text(&sets));
assert_eq!(std::fs::read_dir(root.join("sets")).unwrap().count(), 2);
// A colliding template is refused before any file of the batch is written.
let clash = call(&client, "doc_export_data_sets", json!({"dir": "clash", "naming": "{document}"})).await;
assert_eq!(clash.is_error, Some(true), "{}", text(&clash));
assert_eq!(std::fs::read_dir(root.join("clash")).unwrap().count(), 0);
// A CSV from the read root.
std::fs::write(root.join("sets.csv"), "Data Set,show\none,true\n").unwrap();
let imported = call(&client, "doc_import_data_sets", json!({"path": "sets.csv"})).await;
assert_ne!(imported.is_error, Some(true), "{}", text(&imported));
assert_eq!(json_of(&imported)["imported"], 1, "{}", text(&imported));
// Outside the roots nothing happens at all.
let escaped = call(&client, "doc_export_layers", json!({"dir": base.join("escaped").to_string_lossy()})).await;
assert_eq!(escaped.is_error, Some(true), "{}", text(&escaped));
assert!(!base.join("escaped").exists());
std::fs::write(base.join("outside.csv"), "Data Set,show\none,true\n").unwrap();
let outside = call(&client, "doc_import_data_sets", json!({"path": base.join("outside.csv").to_string_lossy()})).await;
assert_eq!(outside.is_error, Some(true), "{}", text(&outside));
for command in ["file.export.layersToFiles", "file.export.dataSetsAsFiles", "file.import.variableDataSets"] {
let refused = call(&client, "command_run", json!({"id": command, "params": {}})).await;
assert_eq!(refused.is_error, Some(true), "{command}");
assert!(text(&refused).contains("ambient filesystem paths"), "{}", text(&refused));
}
client.cancel().await.unwrap();
cleanup(&base);
}
/// The template workflow stays in memory, including saving nested contents back to the parent.
#[tokio::test(flavor = "multi_thread")]
async fn embedded_smart_objects_can_be_edited_saved_and_unpacked_without_file_authority() {
@@ -1046,18 +935,6 @@ async fn bridge_reports_unreachable_app() {
client.cancel().await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn command_batch_description_exposes_non_atomic_semantics_when_tools_are_listed() {
let client = connect(PhotocraftMcp::headless()).await;
let tools = client.list_all_tools().await.unwrap();
let tool = tools.iter().find(|tool| tool.name == "command_batch").expect("command_batch tool");
let description = tool.description.as_deref().expect("command_batch description");
for contract in ["in order", "Not an atomic transaction", "first error", "stop_on_error is false", "no rollback"] {
assert!(description.contains(contract), "command_batch description omits `{contract}`: {description}");
}
client.cancel().await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn command_batch_runs_steps_in_order() {
let client = connect(PhotocraftMcp::headless()).await;
@@ -1234,25 +1111,7 @@ async fn convention_resources_are_live_json() {
assert!(read("photocraft://document").await["document"].is_null());
json_of(&call(&client, "doc_new", json!({"width":8,"height":6})).await);
assert_eq!(read("photocraft://document").await["width"], 8);
// Edit › Purge › All is enabled while the process-wide effect, mask and stack caches hold
// anything, and the other tests in this binary fill and purge those caches concurrently, so
// its `enabled` flag and paired `disabledReason` can flip between the two reads. Validate
// each availability pair; every stable field and every other command must match exactly.
let stable = |mut v: Value| {
for c in v.as_array_mut().into_iter().flatten() {
if c["id"] == "edit.purge.all" {
let reason = if c["enabled"].as_bool().unwrap() { Value::Null } else { json!("nothing to purge") };
assert_eq!(c["disabledReason"], reason, "purge availability stays internally consistent");
let command = c.as_object_mut().unwrap();
command.remove("enabled");
command.remove("disabledReason");
}
}
v
};
let commands = stable(read("photocraft://commands").await);
assert!(commands.as_array().is_some_and(|a| a.len() > 100), "{commands}");
assert_eq!(commands, stable(json_of(&call(&client, "command_list", json!({})).await)));
assert_eq!(read("photocraft://commands").await, json_of(&call(&client, "command_list", json!({})).await));
assert!(client.read_resource(rmcp::model::ReadResourceRequestParams::new("photocraft://missing")).await.is_err());
let preview = call(&client, "render_preview", json!({"max_side":4})).await;
assert!(preview.content.iter().any(|c| c.as_image().is_some()));
@@ -1,57 +0,0 @@
//! #3391: `doc.save`, `doc.open`, and type commands emit warnings when text uses a missing font.
use photocraft_automation::Headless;
use serde_json::json;
fn tmp_dir() -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"pc-font-warn-{}-{}",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn missing_font_warnings_emitted_in_headless_and_type_commands() {
let dir = tmp_dir();
let psd_path = dir.join("card.psd");
let png_path = dir.join("card.png");
let mut h = Headless::trusted_local();
h.handle("doc.new", json!({"width": 600, "height": 300, "background": "white"})).unwrap();
// 1. type.create with missing font
let created = h
.handle("engine.execute", json!({"command": "type.create", "params": {"x": 30, "y": 100, "text": "Headline", "font": "NoSuchFamily123", "size": 40}}))
.unwrap();
assert_eq!(created["warnings"], json!(["font 'NoSuchFamily123' is not installed; text may render using a fallback face"]));
// 2. doc.save card.psd
let saved_psd = h.handle("doc.save", json!({"path": psd_path.to_str().unwrap()})).unwrap();
let warnings = saved_psd["warnings"].as_array().expect("warnings array in doc.save");
assert!(
warnings.iter().any(|w| w == "font 'NoSuchFamily123' is not installed; text may render using a fallback face"),
"doc.save PSD warning: {saved_psd}"
);
// 3. doc.save card.png
let saved_png = h.handle("doc.save", json!({"path": png_path.to_str().unwrap()})).unwrap();
let warnings = saved_png["warnings"].as_array().expect("warnings array in doc.save");
assert!(
warnings.iter().any(|w| w == "font 'NoSuchFamily123' is not installed; text may render using a fallback face"),
"doc.save PNG warning: {saved_png}"
);
// 4. doc.close then doc.open
h.handle("doc.close", json!({})).unwrap();
let opened = h.handle("doc.open", json!({"path": psd_path.to_str().unwrap()})).unwrap();
let warnings = opened["warnings"].as_array().expect("warnings array in doc.open");
assert!(
warnings.iter().any(|w| w.as_str().unwrap_or("").contains("is not installed; text may render using a fallback face")),
"doc.open warning: {opened}"
);
let _ = std::fs::remove_dir_all(dir);
}
@@ -1,40 +0,0 @@
//! Save As must store the new filename in the native manifest, not just the in-session tab name.
use photocraft_automation::{Headless, files};
use serde_json::json;
#[test]
fn native_save_as_reopens_with_the_written_filename() {
let dir = std::env::temp_dir().join(format!(
"pc-native-save-name-{}-{}",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let first = dir.join("FirstTest.pcraft");
let second = dir.join("SecondTest.pcraft");
let mut headless = Headless::trusted_local();
headless.handle("doc.new", json!({"width": 8, "height": 8, "name": "Old Title"})).unwrap();
headless.handle("doc.save", json!({"path": first.to_str().unwrap()})).unwrap();
assert_eq!(files::open(&first).unwrap().document.name, "FirstTest.pcraft");
headless.handle("doc.save", json!({"path": second.to_str().unwrap()})).unwrap();
assert_eq!(headless.session.active().unwrap().doc.name, "SecondTest.pcraft");
assert_eq!(files::open(&second).unwrap().document.name, "SecondTest.pcraft");
// Save back in place and retain the correct title after another edit.
headless.handle("engine.execute", json!({"command":"image.adjustments.invert", "params":{}})).unwrap();
headless.handle("doc.save", json!({})).unwrap();
assert_eq!(files::open(&second).unwrap().document.name, "SecondTest.pcraft");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn workspace_encoded_native_bundle_uses_requested_name_without_mutating_source() {
let mut headless = Headless::trusted_local();
headless.handle("doc.new", json!({"width": 8, "height": 8, "name": "Original"})).unwrap();
let doc = &headless.session.active().unwrap().doc;
let (bytes, warnings) = files::save_bytes(doc, "exports/Renamed.pcraft", None, &Default::default()).unwrap();
assert!(warnings.is_empty());
let reopened = files::open_bytes("Renamed.pcraft", &bytes).unwrap().document;
assert_eq!(reopened.name, "Renamed.pcraft");
assert_eq!(doc.name, "Original", "a copy should not retarget the open document");
}
-7
View File
@@ -296,13 +296,6 @@ impl Profile {
self.a2b.iter().all(Option::is_none) && (self.matrix.is_some() && self.trc.is_some() || self.gray_trc.is_some())
}
/// Whether colours can be converted into this profile for every intent. Input profiles with
/// only AToB tables (Apple's Wide Color Sharing Profile on iPhone photos) can't: they
/// describe a source but have no PCS → device path.
pub fn can_be_destination(&self) -> bool {
[Intent::Perceptual, Intent::RelativeColorimetric, Intent::Saturation].into_iter().all(|i| self.pcs_to_device(i).is_ok())
}
fn lut_for(luts: &[Option<Lut>; 3], intent: Intent) -> Option<&Lut> {
let i = match intent {
Intent::Perceptual => 0,
+1 -68
View File
@@ -450,73 +450,6 @@ fn malformed_profiles_are_rejected_without_panicking() {
}
}
// A press's full-K lightness depends on its ink and paper (newsprint can exceed L* 40).
// Keep the relative paper-white check and require finite endpoints with darker, bounded ink.
fn cmyk_endpoints_valid(paper: [f64; 3], black: [f64; 3]) -> bool {
// Allow only float rounding at the physical L* limits, not a different printing condition.
let lightness = -1e-4..=100.0 + 1e-4;
paper.iter().chain(&black).all(|v| v.is_finite())
&& lightness.contains(&paper[0])
&& lightness.contains(&black[0])
&& paper[0] > 90.0
&& black[0] < paper[0]
}
#[test]
fn a_light_black_cmyk_lut_has_the_known_relative_ramp() {
use photocraft_cms::Curve;
use photocraft_cms::pipeline::Stage;
use photocraft_cms::profile::{Lut, LutKind};
let mut p = cmyk().clone();
// Independent linear test model: L* = 100 - 52 K, neutral a*/b*. Exercise A2B1 only;
// the inherited coated profile's inverse is not an inverse of this generated model.
p.a2b = [None, None, None];
p.b2a = [None, None, None];
p.a2b[1] = Some(Lut {
kind: LutKind::Ab,
inputs: 4,
outputs: 3,
stages: vec![
Stage::Curves(vec![Curve::Identity; 4]),
Stage::Clut(Clut::sample(vec![2; 4], 3, |ink, out| {
out.copy_from_slice(&enc([100.0 - 52.0 * f64::from(ink[3]), 0.0, 0.0]));
})),
Stage::Curves(vec![Curve::Identity; 3]),
],
});
let p = Profile::parse(&p.with_encoded_bytes().to_bytes()).unwrap();
let paper = to_lab(&p, &[0.0; 4], Intent::RelativeColorimetric);
let black = to_lab(&p, &[0.0, 0.0, 0.0, 1.0], Intent::RelativeColorimetric);
assert!(cmyk_endpoints_valid(paper, black), "{paper:?} {black:?}");
// Explicit expected values, not the validation predicate: also catch wrong channels,
// intent selection, constant output and incorrect LUT encoding/interpolation.
for (k, expected) in [(0.0, 100.0), (0.25, 87.0), (0.5, 74.0), (0.75, 61.0), (1.0, 48.0)] {
let actual = to_lab(&p, &[0.0, 0.0, 0.0, k], Intent::RelativeColorimetric);
// The writer quantizes the CLUT to 16 bits: at most 0.000763 L* error.
assert!((actual[0] - expected).abs() < 0.001 && actual[1].abs() < 1e-4 && actual[2].abs() < 1e-4, "K={k}: {actual:?}");
}
}
#[test]
fn cmyk_endpoints_reject_invalid_values() {
let paper = [100.0, 0.0, 0.0];
let black = [48.0, 0.0, 0.0];
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
for channel in 0..3 {
let mut p = paper;
let mut k = black;
p[channel] = bad;
k[channel] = bad;
assert!(!cmyk_endpoints_valid(p, black));
assert!(!cmyk_endpoints_valid(paper, k));
}
}
for (p, k) in [(90.0, 48.0), (100.01, 48.0), (100.0, -0.01), (100.0, 100.01), (100.0, 100.0), (99.0, 100.0)] {
assert!(!cmyk_endpoints_valid([p, 0.0, 0.0], [k, 0.0, 0.0]), "L*: paper {p}, black {k}");
}
}
/// Parses every profile the OS ships (macOS ColorSync; read-only, nothing is copied) and
/// converts through each.
#[test]
@@ -567,7 +500,7 @@ fn system_profiles() {
// Real CMYK profile: paper lighter than 100 % K.
let w = to_lab(&p, &[0.0; 4], Intent::RelativeColorimetric);
let k = to_lab(&p, &[0.0, 0.0, 0.0, 1.0], Intent::RelativeColorimetric);
assert!(cmyk_endpoints_valid(w, k), "{}: {w:?} {k:?}", path.display());
assert!(w[0] > 90.0 && k[0] < 40.0, "{}: {w:?} {k:?}", path.display());
// And it round-trips through its own BToA.
if p.b2a[1].is_some() {
let to = Transform::new(lab(), &p, Intent::RelativeColorimetric, false).unwrap();
+1 -3
View File
@@ -22,7 +22,7 @@ heif = ["dep:photocraft-heif"]
thiserror.workspace = true
image = { workspace = true, features = ["gif", "bmp", "tga", "ico", "qoi", "hdr"] }
png = "0.18"
tiff = { version = "0.11", default-features = false, features = ["deflate", "fax", "jpeg", "lzw"] }
tiff = { version = "0.11", default-features = false, features = ["deflate", "lzw"] }
exr = { version = "1.73", default-features = false }
half = "2"
serde_json.workspace = true
@@ -36,8 +36,6 @@ photocraft-heif = { path = "../heif", optional = true }
# Orientation is applied a band of rows per task on native; wasm stays single-threaded.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rayon.workspace = true
# EXR block (de)compression runs on the rayon pool natively; wasm stays single-threaded.
exr = { version = "1.73", default-features = false, features = ["rayon"] }
[dev-dependencies]
proptest.workspace = true
+2 -22
View File
@@ -4,7 +4,7 @@ Codecs for flat raster image formats, written in pure Rust. The crate is standal
workspace crate and defines its own `Image` type. It also builds for `wasm32-unknown-unknown` and
does no I/O: decoders take `&[u8]` and encoders return `Vec<u8>`.
It follows the "Avoiding GIMP's hole" rules in `docs/architecture.md` §1.1:
It follows the "avoid GIMP's hole" rules in `plan/architecture.md` §1.1:
* **Symmetric.** Every format we write, we can also read. The only exceptions are listed in
`ASYMMETRIC_EXCEPTIONS`, each with a reason. The test suite checks this.
@@ -45,7 +45,6 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
| OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` |
| Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` |
| FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in |
| DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in |
| AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` |
| HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` |
@@ -62,9 +61,6 @@ the encode plan, and `fidelity_warnings` reports the conversion when it loses in
`Image::convert` applies the same ICC rule: it keeps the profile only when the colour model stays
the same.
Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded table so
`photocraft-io` can open them in Indexed Color mode.
## Documented asymmetries and limitations
* **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3`
@@ -75,19 +71,6 @@ Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded
kept as text (`HIERARCH` for long names). Tile-compressed (fpack) files are refused with
`CodecError::Unsupported`; Bayer mosaics open undebayered.
* **DPX.** SMPTE 268M frames in either byte order (`SDPX`, `XPDS`): the first image element,
descriptor 6 (luma), 50 (RGB) or 51 (RGBA), unsigned and unencoded, at 8, 10, 12 or 16 bits.
10-bit data may be packed (0) or filled three to a word (1, method A; 2, method B), 12-bit data
packed (0) or one to a 16-bit word (1, method A). 10- and 12-bit code values open as U16
`round(c × 65535 / (2^b − 1))`, so a frame written back at its depth keeps them exactly. No
colour transform is applied: `DecodeWarning::DpxEncoding` names the transfer characteristic and
colorimetric specification, so a log frame is reported as log. More elements than the first
give `DecodeWarning::MoreElements`; the image orientation is applied on import. Writing is
big-endian, one unencoded element, at `EncodeOptions::dpx_bits` (8/10/12/16; by default 8 for
U8 and 16 otherwise), packing 1 for 10 and 12 bits; gray with alpha is written as luma.
Other descriptors, signed or run-length-encoded data and other bit depths return
`CodecError::Unsupported` naming the field. Cineon (`.cin`) is not supported.
* **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused
with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes
and develops them, and `photocraft-io` routes them there.
@@ -118,10 +101,7 @@ Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded
`DecodeWarning::MoreFrames` / `MorePages` in `Image::warnings`, with the total when the file
states it (frames and TIFF directories are counted without decoding them; reduced-resolution
TIFF directories and transparency masks are not pages). Any TIFF page can still be opened
with `decode_tiff_page` (and `photocraft_io::import_tiff_page`). Every frame of a GIF comes
from `decode_gif_frames`: each frame composited onto the logical screen with the disposal
methods applied, as RGBA8 with its delay in milliseconds, within `Limits` and
`MAX_GIF_FRAMES`. The video-layer commands use it (`photocraft_io::import_gif_frames`).
with `decode_tiff_page` (and `photocraft_io::import_tiff_page`).
* **EXIF in TIFF** is stored as a sub-IFD rather than a blob, so it is not preserved yet.
`caps.exif = false` for TIFF, and a warning is raised.
* **Orientation** (EXIF tag 274 in JPEG, PNG `eXIf` and WebP; the decoded page's tag in TIFF and
+1 -8
View File
@@ -16,7 +16,7 @@ cargo-fuzz = true
libfuzzer-sys = "0.4"
photocraft-codecs = { path = "..", features = ["heif"] }
# Called directly too: the codec turns its panics into errors (see decode_heif.rs).
heic-rs = "=0.1.4"
heic-rs = "=0.1.1"
[profile.release]
debug = 1
@@ -62,10 +62,3 @@ path = "fuzz_targets/decode_heif.rs"
test = false
doc = false
bench = false
[[bin]]
name = "decode_gif"
path = "fuzz_targets/decode_gif.rs"
test = false
doc = false
bench = false
@@ -1,13 +0,0 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use photocraft_codecs::{decode_as_with, decode_gif_frames, DecodeOptions, Format, Limits};
fuzz_target!(|data: &[u8]| {
let opts = DecodeOptions {
limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 },
..Default::default()
};
let _ = decode_as_with(Format::Gif, data, &opts);
let _ = decode_gif_frames(data, &opts);
});
-656
View File
@@ -1,656 +0,0 @@
//! DPX (SMPTE 268M Digital Picture Exchange), implemented here from the published layout: the
//! 1664-byte generic header (file and image information, image source), the 384-byte industry
//! header (film and television), then the image data of each element.
//!
//! Reading:
//!
//! * Both byte orders: `SDPX` (big-endian) and `XPDS` (little-endian).
//! * The first image element, descriptor 6 (luma), 50 (RGB) or 51 (RGBA), unsigned, without
//! run-length encoding, at 8, 10, 12 or 16 bits. 10-bit samples are packed into 32-bit words
//! (packing 0), or three to a word with the padding bits at the bottom (1, method A) or the
//! top (2, method B); 12-bit samples are packed (0) or one to a 16-bit word in its top bits
//! (1, method A). Lines start on a 32-bit word boundary.
//! * 8- and 16-bit code values become U8 and U16 samples unchanged; 10- and 12-bit ones become U16
//! as `round(c × 65535 / (2^b − 1))`, so they come back exactly when written at their depth.
//! * No colour transform: the transfer characteristic and colorimetric specification are reported
//! ([`DecodeWarning::DpxEncoding`]), so a log frame is labelled log, not shown as display-referred.
//! * The image orientation is applied on import (the caller turns the image upright).
//! * Header fields a pipeline relies on travel as text metadata under the [`KEYS`] names, and a
//! DPX export writes them back.
//!
//! Writing: one element, big-endian, unencoded, descriptor 6, 50 or 51; 8 and 16 bits with
//! packing 0, 10 and 12 bits with packing 1 (method A). The bit depth follows the image
//! (8 → 8, 16 → 16) unless [`EncodeOptions::dpx_bits`] picks one.
use crate::Format;
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::image::{ChannelLayout, DecodeWarning, Image, SampleType};
use crate::options::{DpxBits, EncodeOptions, Limits};
const F: Format = Format::Dpx;
/// Generic plus industry header: where our image data starts.
const HEADER_LEN: usize = 2048;
const GENERIC_LEN: u32 = 1664;
const INDUSTRY_LEN: u32 = 384;
/// "Undefined" for 32-bit fields (also the bit pattern of an undefined float).
const UNDEF32: u32 = u32::MAX;
const UNDEF8: u8 = u8::MAX;
/// Offset of the first image element and the size of one.
const ELEMENT0: usize = 780;
const ELEMENT_LEN: usize = 72;
const MAX_ELEMENTS: u16 = 8;
// Header field offsets (generic file header, image information, film and TV headers).
const CREATION_TIME: (usize, usize) = (136, 24);
const CREATOR: (usize, usize) = (160, 100);
const PROJECT: (usize, usize) = (260, 200);
const COPYRIGHT: (usize, usize) = (460, 200);
const ORIENTATION: usize = 768;
const FRAME_POSITION: usize = 1712;
const FRAME_RATE: usize = 1724;
const TIME_CODE: usize = 1920;
/// Text metadata keys of the header fields kept through a DPX → document → DPX round trip.
pub(crate) const KEY_CREATOR: &str = "DPX:Creator";
pub(crate) const KEY_PROJECT: &str = "DPX:Project";
pub(crate) const KEY_COPYRIGHT: &str = "DPX:Copyright";
pub(crate) const KEY_CREATION_TIME: &str = "DPX:CreationTime";
pub(crate) const KEY_TRANSFER: &str = "DPX:Transfer";
pub(crate) const KEY_COLORIMETRIC: &str = "DPX:Colorimetric";
pub(crate) const KEY_FRAME_POSITION: &str = "DPX:FramePosition";
pub(crate) const KEY_FRAME_RATE: &str = "DPX:FrameRate";
pub(crate) const KEY_TIME_CODE: &str = "DPX:TimeCode";
pub(crate) const KEYS: [&str; 9] =
[KEY_CREATOR, KEY_PROJECT, KEY_COPYRIGHT, KEY_CREATION_TIME, KEY_TRANSFER, KEY_COLORIMETRIC, KEY_FRAME_POSITION, KEY_FRAME_RATE, KEY_TIME_CODE];
fn err(msg: impl std::fmt::Display) -> CodecError {
CodecError::malformed(F, msg)
}
fn unsupported(msg: impl Into<String>) -> CodecError {
CodecError::unsupported(F, msg)
}
/// The file's byte order.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Order {
Big,
Little,
}
impl Order {
fn bytes<const N: usize>(b: &[u8], at: usize) -> Option<[u8; N]> {
b.get(at..at.checked_add(N)?)?.try_into().ok()
}
fn u16(self, b: &[u8], at: usize) -> Option<u16> {
let s = Self::bytes::<2>(b, at)?;
Some(if self == Order::Big { u16::from_be_bytes(s) } else { u16::from_le_bytes(s) })
}
fn u32(self, b: &[u8], at: usize) -> Option<u32> {
let s = Self::bytes::<4>(b, at)?;
Some(if self == Order::Big { u32::from_be_bytes(s) } else { u32::from_le_bytes(s) })
}
/// A 32-bit field that may be undefined (all ones).
fn defined_u32(self, b: &[u8], at: usize) -> Option<u32> {
self.u32(b, at).filter(|&v| v != UNDEF32)
}
fn f32(self, b: &[u8], at: usize) -> Option<f32> {
self.defined_u32(b, at).map(f32::from_bits).filter(|v| v.is_finite())
}
}
/// The byte order from the magic number, `None` when it is not DPX.
fn order(b: &[u8]) -> Option<Order> {
match b.get(..4)? {
b"SDPX" => Some(Order::Big),
b"XPDS" => Some(Order::Little),
_ => None,
}
}
/// An ASCII header string: up to the first NUL, printable characters only, trimmed. `None` when
/// empty or undefined (all ones).
fn string(b: &[u8], (at, len): (usize, usize)) -> Option<String> {
let raw = b.get(at..at.checked_add(len)?)?;
let end = raw.iter().position(|&c| c == 0).unwrap_or(raw.len());
let s: String = raw.get(..end)?.iter().filter(|c| (32..=126).contains(*c)).map(|&c| c as char).collect();
Some(s.trim().to_string()).filter(|s| !s.is_empty())
}
/// The descriptive fields of image element 0 that decoding needs.
#[derive(Debug, Clone, Copy)]
struct Element {
signed: bool,
descriptor: u8,
transfer: u8,
colorimetric: u8,
bits: u8,
packing: u16,
encoding: u16,
data_offset: usize,
eol_padding: usize,
}
struct Header {
order: Order,
width: u32,
height: u32,
elements: u16,
element: Element,
}
fn parse(b: &[u8]) -> Result<Header, CodecError> {
let o = order(b).ok_or_else(|| err("missing SDPX/XPDS magic number"))?;
let missing = |what: &str| err(format!("the header ends before {what}"));
let image_offset = o.u32(b, 4).ok_or_else(|| missing("the image data offset"))?;
o.u16(b, ORIENTATION).ok_or_else(|| missing("the orientation"))?;
let elements = o.u16(b, ORIENTATION + 2).ok_or_else(|| missing("the element count"))?;
if !(1..=MAX_ELEMENTS).contains(&elements) {
return Err(err(format!("invalid number of image elements {elements}")));
}
let width = o.u32(b, 772).ok_or_else(|| missing("the line length"))?;
let height = o.u32(b, 776).ok_or_else(|| missing("the line count"))?;
let e = ELEMENT0;
let field = |at: usize| o.u32(b, e + at).ok_or_else(|| missing("image element 1"));
let byte = |at: usize| b.get(e + at).copied().ok_or_else(|| missing("image element 1"));
let half = |at: usize| o.u16(b, e + at).ok_or_else(|| missing("image element 1"));
let data_offset = match field(28)? {
0 | UNDEF32 => image_offset,
v => v,
};
let eol_padding = match field(32)? {
UNDEF32 => 0,
v => v,
};
let element = Element {
signed: field(0)? == 1,
descriptor: byte(20)?,
transfer: byte(21)?,
colorimetric: byte(22)?,
bits: byte(23)?,
packing: half(24)?,
encoding: half(26)?,
data_offset: usize::try_from(data_offset).map_err(|_| err("image data offset out of range"))?,
eol_padding: usize::try_from(eol_padding).map_err(|_| err("line padding out of range"))?,
};
Ok(Header { order: o, width, height, elements, element })
}
/// The EXIF orientation (1–8) that turns the stored lines upright: the DPX orientation codes 0–7
/// in SMPTE 268M order (left-to-right top-to-bottom, mirrored, flipped, rotated, transposed…).
/// Anything else, undefined included, is taken as the default (1).
pub(crate) fn orientation(b: &[u8]) -> u16 {
let Some(o) = order(b).and_then(|o| o.u16(b, ORIENTATION)) else { return 1 };
match o {
1 => 2,
2 => 4,
3 => 3,
4 => 5,
5 => 6,
6 => 8,
7 => 7,
_ => 1,
}
}
/// Bytes in one stored line of `samples` samples, padded to a 32-bit word.
fn line_bytes(samples: usize, bits: u8, packing: u16) -> Option<usize> {
let words = match (bits, packing) {
// Three samples to a word, or one to a 16-bit half word.
(10, 1 | 2) => samples.div_ceil(3),
(12, 1 | 2) => samples.div_ceil(2),
_ => samples.checked_mul(usize::from(bits))?.div_ceil(32),
};
words.checked_mul(4)
}
/// Code values of one stored line.
fn unpack_line(line: &[u8], o: Order, bits: u8, packing: u16, out: &mut [u16]) -> Result<(), CodecError> {
let short = || err("truncated image data");
match (bits, packing) {
(8, _) => {
let src = line.get(..out.len()).ok_or_else(short)?;
for (v, &c) in out.iter_mut().zip(src) {
*v = u16::from(c);
}
}
(16, _) => {
for (i, v) in out.iter_mut().enumerate() {
*v = o.u16(line, i * 2).ok_or_else(short)?;
}
}
(10, 1 | 2) => {
let base = if packing == 1 { 22 } else { 20 };
for (i, v) in out.iter_mut().enumerate() {
let word = o.u32(line, i / 3 * 4).ok_or_else(short)?;
*v = ((word >> (base - 10 * (i % 3) as u32)) & 0x3FF) as u16;
}
}
(12, 1 | 2) => {
let shift = if packing == 1 { 4 } else { 0 };
for (i, v) in out.iter_mut().enumerate() {
*v = (o.u16(line, i * 2).ok_or_else(short)? >> shift) & 0xFFF;
}
}
// Packing 0: one bit stream through the line's 32-bit words, high bits first.
_ => {
let mask = (1u64 << bits) - 1;
let (mut acc, mut have, mut word) = (0u64, 0u32, 0usize);
for v in out.iter_mut() {
while have < u32::from(bits) {
acc = (acc << 32) | u64::from(o.u32(line, word * 4).ok_or_else(short)?);
word += 1;
have += 32;
}
have -= u32::from(bits);
*v = ((acc >> have) & mask) as u16;
}
}
}
Ok(())
}
/// A 10- or 12-bit code value as a 16-bit sample: `round(c × 65535 / max)`.
fn widen(c: u16, max: u64) -> u16 {
((u64::from(c) * 2 * 65535 + max) / (2 * max)).min(65535) as u16
}
/// The inverse of [`widen`]: `round(v × max / 65535)`.
fn narrow(v: u16, max: u64) -> u16 {
((u64::from(v) * 2 * max + 65535) / (2 * 65535)) as u16
}
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
let h = parse(bytes)?;
let e = h.element;
let layout = match e.descriptor {
6 => ChannelLayout::Gray,
50 => ChannelLayout::Rgb,
51 => ChannelLayout::Rgba,
d => return Err(unsupported(format!("descriptor {d} is not supported (only 6 luma, 50 RGB and 51 RGBA)"))),
};
if e.signed {
return Err(unsupported("data sign 1 (signed samples) is not supported"));
}
if e.encoding != 0 {
return Err(unsupported(format!("encoding {} (run-length encoded data) is not supported", e.encoding)));
}
let packing_ok = match e.bits {
8 | 16 => e.packing == 0,
10 => e.packing <= 2,
12 => e.packing <= 1,
b => return Err(unsupported(format!("bit size {b} is not supported (only 8, 10, 12 and 16)"))),
};
if !packing_ok {
return Err(unsupported(format!("packing {} is not supported at {} bits", e.packing, e.bits)));
}
let sample = if e.bits == 8 { SampleType::U8 } else { SampleType::U16 };
limits.check(h.width, h.height, layout, sample)?;
let nc = layout.channels();
let per_line = (h.width as usize).checked_mul(nc).ok_or_else(|| CodecError::LimitExceeded("line too long".into()))?;
let height = h.height as usize;
let data = bytes.get(e.data_offset..).ok_or_else(|| err("the image data offset is past the end of the file"))?;
let too_large = || CodecError::LimitExceeded("image too large".into());
let mut line_len = line_bytes(per_line, e.bits, e.packing).ok_or_else(too_large)?;
let mut stride = line_len.checked_add(e.eol_padding).ok_or_else(too_large)?;
// Lines run `stride` apart; the last one needs no padding after it.
let span = |stride: usize, line_len: usize| stride.checked_mul(height - 1)?.checked_add(line_len);
// Some writers pack 8- and 16-bit lines without padding them to a word; take that layout
// when the file is too short for padded lines but holds the unpadded ones.
let tight = per_line * usize::from(e.bits / 8);
if matches!(e.bits, 8 | 16) && e.eol_padding == 0 && line_len != tight && span(stride, line_len).is_none_or(|need| data.len() < need) {
(line_len, stride) = (tight, tight);
}
if span(stride, line_len).ok_or_else(too_large)? > data.len() {
return Err(err("truncated image data"));
}
let mut codes = vec![0u16; per_line.checked_mul(height).ok_or_else(too_large)?];
for (y, row) in codes.chunks_exact_mut(per_line.max(1)).enumerate() {
let start = y * stride;
let line = data.get(start..).ok_or_else(|| err("truncated image data"))?;
unpack_line(line, h.order, e.bits, e.packing, row)?;
}
let mut img = match e.bits {
8 => Image::from_u8(h.width, h.height, layout, codes.iter().map(|&c| c as u8).collect())?,
16 => Image::from_u16(h.width, h.height, layout, &codes)?,
b => {
let max = (1u64 << b) - 1;
let wide: Vec<u16> = codes.iter().map(|&c| widen(c, max)).collect();
Image::from_u16(h.width, h.height, layout, &wide)?
}
};
if h.elements > 1 {
img.warnings.push(DecodeWarning::MoreElements { total: u32::from(h.elements) });
}
if e.transfer != UNDEF8 || e.colorimetric != UNDEF8 {
img.warnings.push(DecodeWarning::DpxEncoding { transfer: e.transfer, colorimetric: e.colorimetric });
}
img.meta.text = header_text(bytes, h.order, e);
Ok(img)
}
/// The kept header fields as text metadata, in [`KEYS`] order; undefined ones are left out.
fn header_text(b: &[u8], o: Order, e: Element) -> Vec<(String, String)> {
let mut text = Vec::new();
let mut put = |k: &str, v: Option<String>| {
if let Some(v) = v {
text.push((k.to_string(), v));
}
};
put(KEY_CREATOR, string(b, CREATOR));
put(KEY_PROJECT, string(b, PROJECT));
put(KEY_COPYRIGHT, string(b, COPYRIGHT));
put(KEY_CREATION_TIME, string(b, CREATION_TIME));
put(KEY_TRANSFER, (e.transfer != UNDEF8).then(|| e.transfer.to_string()));
put(KEY_COLORIMETRIC, (e.colorimetric != UNDEF8).then(|| e.colorimetric.to_string()));
// The industry header is there when the generic header says so and the file holds it.
let industry = o.u32(b, 28).is_some_and(|l| l != 0 && l != UNDEF32) && b.len() >= HEADER_LEN;
if industry {
put(KEY_FRAME_POSITION, o.defined_u32(b, FRAME_POSITION).map(|v| v.to_string()));
put(KEY_FRAME_RATE, o.f32(b, FRAME_RATE).map(|v| v.to_string()));
put(KEY_TIME_CODE, o.defined_u32(b, TIME_CODE).map(format_time_code));
}
text
}
/// A SMPTE time code (BCD `HHMMSSFF`) as `HH:MM:SS:FF`, or `0x…` when it is not valid BCD.
fn format_time_code(v: u32) -> String {
let d: [u32; 8] = std::array::from_fn(|i| (v >> ((7 - i) * 4)) & 0xF);
if d.iter().all(|&d| d <= 9) { format!("{}{}:{}{}:{}{}:{}{}", d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7]) } else { format!("{v:#010x}") }
}
/// The inverse of [`format_time_code`]; `None` for anything else.
fn parse_time_code(s: &str) -> Option<u32> {
if let Some(hex) = s.strip_prefix("0x") {
return u32::from_str_radix(hex, 16).ok();
}
let parts: Vec<&str> = s.split(':').collect();
if parts.len() != 4 || parts.iter().any(|p| p.len() != 2 || !p.bytes().all(|c| c.is_ascii_digit())) {
return None;
}
parts.iter().flat_map(|p| p.bytes()).try_fold(0u32, |acc, c| Some((acc << 4) | u32::from(c - b'0')))
}
/// Bits written for `sample` when the options pick none: 8 for U8, 16 otherwise.
pub(crate) fn bits_for(opt: Option<DpxBits>, sample: SampleType) -> u8 {
match opt {
Some(b) => b.bits(),
None if sample == SampleType::U8 => 8,
None => 16,
}
}
/// The plan's sample type for the chosen bits: U8 for 8 bits, U16 for 10, 12 and 16.
pub(crate) fn plan_sample(opt: Option<DpxBits>, src: SampleType) -> SampleType {
match opt {
Some(DpxBits::Bits8) => SampleType::U8,
Some(_) => SampleType::U16,
None if src == SampleType::U8 => SampleType::U8,
None => SampleType::U16,
}
}
struct Writer {
h: Vec<u8>,
}
impl Writer {
fn u8(&mut self, at: usize, v: u8) {
if let Some(b) = self.h.get_mut(at) {
*b = v;
}
}
fn u16(&mut self, at: usize, v: u16) {
if let Some(s) = self.h.get_mut(at..at + 2) {
s.copy_from_slice(&v.to_be_bytes());
}
}
fn u32(&mut self, at: usize, v: u32) {
if let Some(s) = self.h.get_mut(at..at + 4) {
s.copy_from_slice(&v.to_be_bytes());
}
}
/// Marks a run of numeric fields undefined (all ones).
fn undefined(&mut self, from: usize, to: usize) {
if let Some(s) = self.h.get_mut(from..to) {
s.fill(0xFF);
}
}
/// An ASCII string field, cut to leave room for a terminating NUL.
fn string(&mut self, (at, len): (usize, usize), v: &str) {
let ascii: Vec<u8> = v.chars().map(|c| if (' '..='~').contains(&c) { c as u8 } else { b'?' }).take(len.saturating_sub(1)).collect();
if let Some(s) = self.h.get_mut(at..at + ascii.len()) {
s.copy_from_slice(&ascii);
}
}
}
fn text<'a>(img: &'a Image, key: &str) -> Option<&'a str> {
img.meta.text.iter().find(|(k, _)| k == key).map(|(_, v)| v.trim()).filter(|v| !v.is_empty())
}
/// Appends one line's code values, padded to a 32-bit word.
fn pack_line(out: &mut Vec<u8>, codes: &[u16], bits: u8) {
let start = out.len();
match bits {
8 => out.extend(codes.iter().map(|&c| c as u8)),
10 => {
for chunk in codes.chunks(3) {
let word = chunk.iter().enumerate().fold(0u32, |w, (i, &c)| w | (u32::from(c & 0x3FF) << (22 - 10 * i as u32)));
out.extend_from_slice(&word.to_be_bytes());
}
}
12 => out.extend(codes.iter().flat_map(|&c| ((c & 0xFFF) << 4).to_be_bytes())),
_ => out.extend(codes.iter().flat_map(|&c| c.to_be_bytes())),
}
let written = out.len() - start;
out.resize(start + written.div_ceil(4) * 4, 0);
}
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
let img = src.converted(plan.layout, plan.sample);
let (w, h) = img.dimensions();
let (descriptor, layout) = match img.layout() {
ChannelLayout::Gray => (6u8, ChannelLayout::Gray),
ChannelLayout::Rgb => (50, ChannelLayout::Rgb),
ChannelLayout::Rgba => (51, ChannelLayout::Rgba),
l => return Err(CodecError::encode(F, format!("DPX cannot store {l:?}"))),
};
let bits = bits_for(opts.dpx_bits, img.sample_type());
let codes: Vec<u16> = match (img.sample_type(), bits) {
(SampleType::U8, 8) => img.data().iter().map(|&v| u16::from(v)).collect(),
(SampleType::U16, 16) => img.to_u16_samples().unwrap_or_default(),
(SampleType::U16, 10 | 12) => {
let max = (1u64 << bits) - 1;
img.to_u16_samples().unwrap_or_default().into_iter().map(|v| narrow(v, max)).collect()
}
(s, b) => return Err(CodecError::encode(F, format!("cannot write {s:?} samples at {b} bits"))),
};
let per_line = w as usize * layout.channels();
if codes.len() != per_line * h as usize {
return Err(CodecError::encode(F, "the image has the wrong number of samples"));
}
let data_len = line_bytes(per_line, bits, if matches!(bits, 10 | 12) { 1 } else { 0 })
.and_then(|l| l.checked_mul(h as usize))
.ok_or_else(|| CodecError::encode(F, "image too large"))?;
let total = u32::try_from(HEADER_LEN + data_len).map_err(|_| CodecError::encode(F, "image too large for a DPX file (4 GiB)"))?;
let mut wr = Writer { h: vec![0; HEADER_LEN] };
// Generic file header.
wr.h[..4].copy_from_slice(b"SDPX");
wr.u32(4, HEADER_LEN as u32);
wr.string((8, 8), "V2.0");
wr.u32(16, total);
wr.u32(20, 1);
wr.u32(24, GENERIC_LEN);
wr.u32(28, INDUSTRY_LEN);
wr.u32(32, 0);
let keep = |key| if opts.embed_metadata { text(&img, key) } else { None };
wr.string(CREATION_TIME, keep(KEY_CREATION_TIME).unwrap_or(""));
wr.string(CREATOR, keep(KEY_CREATOR).unwrap_or("PhotoCraft"));
wr.string(PROJECT, keep(KEY_PROJECT).unwrap_or(""));
wr.string(COPYRIGHT, keep(KEY_COPYRIGHT).unwrap_or(""));
wr.u32(660, UNDEF32);
// Image information: default orientation, one element.
wr.u16(ORIENTATION, 0);
wr.u16(ORIENTATION + 2, 1);
wr.u32(772, w);
wr.u32(776, h);
let e = ELEMENT0;
wr.u32(e, 0);
wr.u32(e + 4, 0);
wr.u32(e + 8, UNDEF32);
wr.u32(e + 12, ((1u32 << bits) - 1).min(65535));
wr.u32(e + 16, UNDEF32);
wr.u8(e + 20, descriptor);
// No claim about the encoding unless the document came with one.
let code = |key| keep(key).and_then(|v| v.parse::<u8>().ok()).unwrap_or(UNDEF8);
wr.u8(e + 21, code(KEY_TRANSFER));
wr.u8(e + 22, code(KEY_COLORIMETRIC));
wr.u8(e + 23, bits);
wr.u16(e + 24, if matches!(bits, 10 | 12) { 1 } else { 0 });
wr.u16(e + 26, 0);
wr.u32(e + 28, HEADER_LEN as u32);
wr.u32(e + 32, 0);
wr.u32(e + 36, 0);
// The seven unused elements, the image source header's numbers and the industry header's
// numbers are undefined; their strings stay empty.
wr.undefined(e + ELEMENT_LEN, e + ELEMENT_LEN * usize::from(MAX_ELEMENTS));
wr.undefined(1408, 1432);
wr.undefined(1620, 1644);
wr.undefined(FRAME_POSITION, 1732);
wr.undefined(TIME_CODE, 1972);
if let Some(v) = keep(KEY_FRAME_POSITION).and_then(|v| v.parse::<u32>().ok()) {
wr.u32(FRAME_POSITION, v);
}
if let Some(v) = keep(KEY_FRAME_RATE).and_then(|v| v.parse::<f32>().ok()).filter(|v| v.is_finite()) {
wr.u32(FRAME_RATE, v.to_bits());
}
if let Some(v) = keep(KEY_TIME_CODE).and_then(parse_time_code) {
wr.u32(TIME_CODE, v);
}
let mut out = wr.h;
out.try_reserve_exact(data_len).map_err(|_| CodecError::encode(F, "out of memory"))?;
for row in codes.chunks_exact(per_line.max(1)) {
pack_line(&mut out, row, bits);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// A minimal header for `w`×`h` with element 0 set; image data at 2048.
fn header(o: Order, w: u32, h: u32, descriptor: u8, bits: u8, packing: u16) -> Vec<u8> {
let mut b = vec![0u8; HEADER_LEN];
let put32 = |b: &mut Vec<u8>, at: usize, v: u32| b[at..at + 4].copy_from_slice(&if o == Order::Big { v.to_be_bytes() } else { v.to_le_bytes() });
let put16 = |b: &mut Vec<u8>, at: usize, v: u16| b[at..at + 2].copy_from_slice(&if o == Order::Big { v.to_be_bytes() } else { v.to_le_bytes() });
b[..4].copy_from_slice(if o == Order::Big { b"SDPX" } else { b"XPDS" });
put32(&mut b, 4, HEADER_LEN as u32);
put32(&mut b, 28, INDUSTRY_LEN);
put16(&mut b, ORIENTATION, 0);
put16(&mut b, ORIENTATION + 2, 1);
put32(&mut b, 772, w);
put32(&mut b, 776, h);
b[ELEMENT0 + 20] = descriptor;
b[ELEMENT0 + 21] = UNDEF8;
b[ELEMENT0 + 22] = UNDEF8;
b[ELEMENT0 + 23] = bits;
put16(&mut b, ELEMENT0 + 24, packing);
put32(&mut b, ELEMENT0 + 28, HEADER_LEN as u32);
b[FRAME_POSITION..1732].fill(0xFF);
b[TIME_CODE..1972].fill(0xFF);
b
}
fn words(o: Order, ws: &[u32]) -> Vec<u8> {
ws.iter().flat_map(|w| if o == Order::Big { w.to_be_bytes() } else { w.to_le_bytes() }).collect()
}
#[test]
fn ten_bit_method_a_and_b_unpack_three_to_a_word() {
for o in [Order::Big, Order::Little] {
for (packing, base) in [(1u16, 22u32), (2, 20)] {
let mut b = header(o, 1, 1, 50, 10, packing);
b.extend(words(o, &[(1023 << base) | (512 << (base - 10)) | (1 << (base - 20))]));
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.to_u16_samples().unwrap(), vec![65535, widen(512, 1023), widen(1, 1023)], "{o:?} packing {packing}");
}
}
}
#[test]
fn ten_bit_packing_0_is_one_bit_stream_per_line() {
// Four luma samples: 40 bits over two words, the line padded to the second word.
let samples = [1u64, 2, 1022, 1023];
let stream = samples.iter().fold(0u64, |acc, &s| (acc << 10) | s) << 24;
for o in [Order::Big, Order::Little] {
let mut b = header(o, 4, 2, 6, 10, 0);
let line = words(o, &[(stream >> 32) as u32, stream as u32]);
b.extend(&line);
b.extend(&line);
let img = decode(&b, &Limits::default()).unwrap();
let want: Vec<u16> = samples.iter().map(|&s| widen(s as u16, 1023)).collect();
assert_eq!(img.to_u16_samples().unwrap(), [want.clone(), want].concat(), "{o:?}");
}
}
#[test]
fn twelve_bit_method_a_keeps_the_top_bits() {
let mut b = header(Order::Big, 2, 1, 6, 12, 1);
b.extend_from_slice(&[0xFF, 0xF0, 0x00, 0x10]);
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.to_u16_samples().unwrap(), vec![65535, widen(1, 4095)]);
}
#[test]
fn widen_and_narrow_round_trip_every_code() {
for bits in [10u8, 12] {
let max = (1u64 << bits) - 1;
for c in 0..=max as u16 {
assert_eq!(narrow(widen(c, max), max), c, "{bits}-bit {c}");
}
}
assert_eq!(widen(512, 1023), ((512.0f64 * 65535.0 / 1023.0).round()) as u16);
}
#[test]
fn orientation_codes_map_to_exif() {
let mut b = header(Order::Big, 1, 1, 6, 8, 0);
for (dpx, exif) in [(0u16, 1u16), (1, 2), (2, 4), (3, 3), (4, 5), (5, 6), (6, 8), (7, 7), (0xFFFF, 1)] {
b[ORIENTATION..ORIENTATION + 2].copy_from_slice(&dpx.to_be_bytes());
assert_eq!(orientation(&b), exif, "DPX {dpx}");
}
}
#[test]
fn time_codes_round_trip() {
assert_eq!(format_time_code(0x0102_0304), "01:02:03:04");
assert_eq!(parse_time_code("01:02:03:04"), Some(0x0102_0304));
assert_eq!(parse_time_code(&format_time_code(0xABCD_0000)), Some(0xABCD_0000));
assert_eq!(parse_time_code("1:2:3:4"), None);
}
#[test]
fn eight_bit_lines_without_word_padding_still_open() {
// 1 × 2 RGB: 3 bytes a line, 6 in all; padded lines would need 8.
let mut b = header(Order::Big, 1, 2, 50, 8, 0);
b.extend_from_slice(&[1, 2, 3, 4, 5, 6]);
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.data(), &[1, 2, 3, 4, 5, 6]);
}
}
+7 -26
View File
@@ -35,7 +35,7 @@ fn map_exr(e: exr::error::Error) -> CodecError {
/// Read the headers without the `exr` crate's validation (it rejects deep parts outright),
/// after checking every part's declared size against the limits, before any pixel allocation.
pub(super) fn read_meta(bytes: &[u8], limits: &Limits) -> Result<MetaData, CodecError> {
fn read_meta(bytes: &[u8], limits: &Limits) -> Result<MetaData, CodecError> {
let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(err)?;
// Flat decoding reads every part at once, so the parts' sizes are also capped together.
let mut total: u64 = 0;
@@ -93,31 +93,12 @@ fn pick_part(meta: &MetaData) -> usize {
best
}
/// Selects one part for the `exr` crate's first-valid-layer reader: `create_channels_reader`
/// is asked about the headers in file order and the first `Ok` wins, so every header before
/// `index` (and, harmlessly, those after it) is declined. Only the chosen part's chunks are
/// then decompressed and only its planes allocated; the other parts are skipped unread.
struct OnlyPart<C> {
inner: C,
index: usize,
seen: std::cell::Cell<usize>,
}
impl<'s, C: ReadChannels<'s>> ReadChannels<'s> for OnlyPart<C> {
type Reader = C::Reader;
fn create_channels_reader(&'s self, header: &exr::meta::header::Header) -> exr::error::Result<Self::Reader> {
let position = self.seen.get();
self.seen.set(position.saturating_add(1));
if position == self.index { self.inner.create_channels_reader(header) } else { Err(exr::error::Error::Invalid("part not requested".into())) }
}
}
/// Decode one part's flat layer via the `exr` crate (it handles every flat compression).
/// Other parts are not decompressed, so an AOV file costs one part per request, not the file.
pub(super) fn read_layer(bytes: &[u8], index: usize) -> Result<Layer<AnyChannels<FlatSamples>>, CodecError> {
let only = OnlyPart { inner: read().no_deep_data().largest_resolution_level().all_channels(), index, seen: std::cell::Cell::new(0) };
only.first_valid_layer().all_attributes().from_buffered(Cursor::new(bytes)).map(|image| image.layer_data).map_err(map_exr)
/// Decode the flat layers via the `exr` crate (it handles every flat compression) and
/// return the requested part's layer.
fn read_layer(bytes: &[u8], index: usize) -> Result<Layer<AnyChannels<FlatSamples>>, CodecError> {
let image =
read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_buffered(Cursor::new(bytes)).map_err(map_exr)?;
image.layer_data.into_iter().nth(index).ok_or_else(|| err(format!("part {index} is missing from the file")))
}
fn layer_to_image(layer: &Layer<AnyChannels<FlatSamples>>) -> Result<Image, CodecError> {
+9 -3
View File
@@ -11,6 +11,7 @@ use std::io::Cursor;
use exr::meta::MetaData;
use exr::meta::attribute::AttributeValue;
use exr::prelude::{ReadChannels, ReadLayers, read};
use crate::Format;
use crate::error::CodecError;
@@ -199,8 +200,7 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result<
.iter()
.find(|l| l.name.eq_ignore_ascii_case(layer_name))
.ok_or_else(|| CodecError::unsupported(F, format!("no Cryptomatte layer named {layer_name}")))?;
// Also caps all parts' declared sizes together, like every other flat decode.
let meta = super::exr::read_meta(bytes, limits)?;
let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(|e| CodecError::malformed(F, e))?;
if meta.requirements.has_deep_data {
return Err(CodecError::unsupported(F, "this file has deep parts; Cryptomatte decoding stays with flat files"));
}
@@ -214,7 +214,13 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result<
let per_pixel = (layer.channels.len().max(1) as u64).saturating_mul(8).saturating_add(std::mem::size_of::<Vec<(f32, f32)>>() as u64);
limits.check_bytes(w32, h32, per_pixel)?;
let ex_layer = super::exr::read_layer(bytes, layer.part)?;
let image = read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_buffered(Cursor::new(bytes)).map_err(
|e| match e {
exr::error::Error::NotSupported(m) => CodecError::unsupported(F, format!("not supported: {m}")),
e => CodecError::malformed(F, e),
},
)?;
let ex_layer = image.layer_data.get(layer.part).ok_or_else(|| CodecError::malformed(F, "the Cryptomatte part is missing"))?;
let mut ids: Vec<Vec<f32>> = vec![Vec::new(); layer.channels.len()];
let mut coverages: Vec<Vec<f32>> = vec![Vec::new(); layer.channels.len()];
-79
View File
@@ -1,79 +0,0 @@
//! Every frame of an animated GIF. [`crate::decode`] keeps the first frame only; this walks the
//! whole animation for callers that can hold it (video layers).
//!
//! The `image` crate composites each frame onto the logical screen and applies the disposal
//! methods, so every returned frame is a full, self-contained picture.
use std::io::Cursor;
use image::codecs::gif::GifDecoder;
use image::{AnimationDecoder, ImageDecoder};
use crate::Format;
use crate::error::CodecError;
use crate::image::{ChannelLayout, DecodeWarning, Image};
use crate::options::DecodeOptions;
/// Most frames one GIF may yield. Frames are held in memory, and a tiny canvas could otherwise
/// ask for millions of them while staying inside the byte budget.
pub const MAX_GIF_FRAMES: usize = 100_000;
/// One composited frame of an animation and how long it stays on screen.
#[derive(Debug, Clone, PartialEq)]
pub struct AnimationFrame {
pub image: Image,
/// On-screen time in milliseconds. GIF counts in hundredths of a second, so this is a
/// multiple of 10 (0 when the file gives none).
pub delay_ms: u32,
}
/// Decodes every frame of a GIF, each as a full RGBA8 image of the logical screen size.
///
/// The first frame must decode. If a later frame is damaged the frames before it are returned
/// and the first one carries [`DecodeWarning::Truncated`]. Dimensions, the frame count
/// ([`MAX_GIF_FRAMES`]) and the total decoded bytes are checked against `opts.limits` before
/// each frame is kept.
pub fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Vec<AnimationFrame>, CodecError> {
let limits = &opts.limits;
let map = |e: image::ImageError| match e {
image::ImageError::Limits(l) => CodecError::LimitExceeded(l.to_string()),
image::ImageError::Unsupported(u) => CodecError::unsupported(Format::Gif, u.to_string()),
e => CodecError::malformed(Format::Gif, e),
};
let mut decoder = GifDecoder::new(Cursor::new(bytes)).map_err(map)?;
let mut il = image::Limits::default();
il.max_image_width = Some(limits.max_width);
il.max_image_height = Some(limits.max_height);
il.max_alloc = Some(limits.max_alloc);
decoder.set_limits(il).map_err(map)?;
let (w, h) = decoder.dimensions();
limits.check_bytes(w, h, 4)?;
let frame_bytes = u64::from(w) * u64::from(h) * 4;
let mut out: Vec<AnimationFrame> = Vec::new();
let mut damaged = false;
for frame in decoder.into_frames() {
let frame = match frame {
Ok(f) => f,
Err(e) if out.is_empty() => return Err(map(e)),
Err(_) => {
damaged = true;
break;
}
};
if out.len() >= MAX_GIF_FRAMES {
return Err(CodecError::LimitExceeded(format!("more than {MAX_GIF_FRAMES} frames")));
}
if frame_bytes.saturating_mul(out.len() as u64 + 1) > limits.max_alloc {
return Err(CodecError::LimitExceeded(format!("{} frames of {w}x{h} exceed max_alloc {}", out.len() + 1, limits.max_alloc)));
}
let (numer, denom) = frame.delay().numer_denom_ms();
let delay_ms = numer.checked_div(denom).unwrap_or(0);
let rgba = frame.into_buffer();
out.push(AnimationFrame { image: Image::from_u8(w, h, ChannelLayout::Rgba, rgba.into_raw())?, delay_ms });
}
if damaged && let Some(first) = out.first_mut() {
first.image.warnings.push(DecodeWarning::Truncated { format: Format::Gif });
}
Ok(out)
}
-3
View File
@@ -1,12 +1,9 @@
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 heif;
pub(crate) mod jpeg;
pub(crate) mod pcx;
pub(crate) mod png;
pub(crate) mod pnm;
pub(crate) mod tiff;
-118
View File
@@ -1,118 +0,0 @@
//! ZSoft PCX (Paintbrush) reader: strictly bounded scanline RLE, 16/256-colour
//! palettes and 8-bit three-plane RGB. Read-only: modern saves should use PNG.
use crate::SampleType;
use crate::error::CodecError;
use crate::format::Format;
use crate::image::{ChannelLayout, Image};
use crate::options::Limits;
const HEADER: usize = 128;
fn bad(reason: impl std::fmt::Display) -> CodecError {
CodecError::malformed(Format::Pcx, reason)
}
fn le16(b: &[u8], i: usize) -> u16 {
u16::from_le_bytes([b[i], b[i + 1]])
}
/// PCX lacks a strong magic number. Require plausible complete header fields before
/// identifying it, rather than classifying every file starting with 0x0a as PCX.
pub(crate) fn looks_like_pcx(b: &[u8]) -> bool {
if b.len() < HEADER || b[0] != 0x0a || !matches!(b[1], 0 | 2 | 3 | 5) || b[2] != 1 {
return false;
}
let bits = b[3];
let planes = b[65];
let stride = u32::from(le16(b, 66));
let (x0, y0, x1, y1) = (le16(b, 4), le16(b, 6), le16(b, 8), le16(b, 10));
if x1 < x0 || y1 < y0 || !matches!(bits, 1 | 2 | 4 | 8) || !(1..=4).contains(&planes) {
return false;
}
let width = u32::from(x1 - x0) + 1;
let min_stride = (width * u32::from(bits)).div_ceil(8);
stride >= min_stride && stride > 0 && (bits != 8 || matches!(planes, 1 | 3)) && (bits == 8 || u32::from(bits) * u32::from(planes) <= 4)
}
/// Read one compressed plane's row. RLE packets never cross a scanline.
fn read_row(b: &[u8], pos: &mut usize, out: &mut [u8]) -> Result<(), CodecError> {
let mut n = 0;
while n < out.len() {
let code = *b.get(*pos).ok_or_else(|| bad("truncated pixel RLE"))?;
*pos += 1;
let (count, value) = if code >= 0xc0 {
let count = usize::from(code & 0x3f);
let value = *b.get(*pos).ok_or_else(|| bad("truncated RLE packet"))?;
*pos += 1;
(count, value)
} else {
(1, code)
};
if count == 0 || count > out.len() - n {
return Err(bad("RLE packet crosses a scanline or has zero length"));
}
out[n..n + count].fill(value);
n += count;
}
Ok(())
}
pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result<Image, CodecError> {
if !looks_like_pcx(b) {
return Err(bad("invalid PCX header or unsupported pixel layout"));
}
let (w, h) = (u32::from(le16(b, 8) - le16(b, 4)) + 1, u32::from(le16(b, 10) - le16(b, 6)) + 1);
limits.check(w, h, ChannelLayout::Rgb, SampleType::U8)?;
let (bits, planes, stride) = (usize::from(b[3]), usize::from(b[65]), usize::from(le16(b, 66)));
let row_len = stride.checked_mul(planes).ok_or_else(|| bad("row size overflow"))?;
let total = (w as usize).checked_mul(h as usize).and_then(|n| n.checked_mul(3)).ok_or_else(|| bad("pixel size overflow"))?;
// The last 769 bytes are the optional VGA palette for 8-bit indexed images.
let vga = bits == 8 && planes == 1 && b.len() >= HEADER + 769 && b[b.len() - 769] == 0x0c;
if bits == 8 && planes == 1 && !vga && le16(b, 68) != 2 {
return Err(bad("8-bit indexed PCX has no 256-colour palette"));
}
let image_end = if vga { b.len() - 769 } else { b.len() };
let mut row = vec![0u8; row_len];
let mut pixels = vec![0u8; total];
let mut p = HEADER;
let width = w as usize;
for y in 0..h as usize {
for plane in row.chunks_mut(stride) {
read_row(&b[..image_end], &mut p, plane)?;
}
for x in 0..width {
let rgb = if bits == 8 && planes == 3 {
[row[x], row[stride + x], row[2 * stride + x]]
} else {
let index = if bits == 8 {
usize::from(row[x])
} else {
let bit = x * bits;
let shift = 8 - bits - (bit % 8);
let mask = (1usize << bits) - 1;
(0..planes).fold(0usize, |v, plane| {
let sample = (usize::from(row[plane * stride + bit / 8]) >> shift) & mask;
v | sample << (plane * bits)
})
};
if vga {
let o = image_end + 1 + index * 3;
[b[o], b[o + 1], b[o + 2]]
} else if bits == 8 {
let g = index as u8;
[g, g, g] // palette-info 2: gray scale
} else {
let o = 16 + index * 3;
[b[o], b[o + 1], b[o + 2]]
}
};
pixels[(y * width + x) * 3..(y * width + x + 1) * 3].copy_from_slice(&rgb);
}
}
let mut image = Image::from_u8(w, h, ChannelLayout::Rgb, pixels)?;
let (xdpi, ydpi) = (le16(b, 12), le16(b, 14));
if xdpi > 0 && ydpi > 0 {
image.meta.dpi = Some((f32::from(xdpi), f32::from(ydpi)));
}
Ok(image)
}
+7 -28
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, alloc_zeroed};
use crate::options::{EncodeOptions, Limits, PngCompression};
const F: Format = Format::Png;
pub(crate) const XMP_KEYWORD: &str = "XML:com.adobe.xmp";
@@ -29,10 +29,6 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
let (w, h) = info.size();
// Worst case output: 4 channels x 16 bit.
limits.check_bytes(w, h, 8)?;
// zlib never beats 1032:1; refuse a file too small for the declared image before the
// output buffer exists. Measured on the stored samples (before palette/bit expansion).
let stored = u64::from(w).saturating_mul(u64::from(h)).saturating_mul(info.bits_per_pixel() as u64).div_ceil(8);
Limits::check_plausible(stored, bytes.len(), 1100)?;
}
let mut reader = decoder.read_info().map_err(map_png_err)?;
// APNG: the IDAT image is the first frame, or an extra default image when no fcTL precedes it.
@@ -51,7 +47,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
d => return Err(err(format!("unexpected output bit depth {d:?}"))),
};
let size = reader.output_buffer_size().ok_or_else(|| err("image too large"))?;
let mut buf = alloc_zeroed::<u8>(size)?;
let mut buf = vec![0u8; size];
let out = reader.next_frame(&mut buf).map_err(map_png_err)?;
buf.truncate(out.buffer_size());
// Collect ancillary chunks after IDAT. Tolerate malformed tails once pixels
@@ -525,16 +521,8 @@ fn adler32_combine(a1: u32, a2: u32, len2: usize) -> u32 {
}
/// Writes an 8-bit palette PNG (PNG-8): `indices` row-major, `palette` RGB entries (≤ 256),
/// optional fully transparent entry (`tRNS`) and optional resolution in dpi (`pHYs`, written when
/// both axes are positive and finite). Used for Indexed Color documents.
pub fn encode_indexed(
width: u32,
height: u32,
indices: &[u8],
palette: &[[u8; 3]],
transparent: Option<u8>,
dpi: Option<(f32, f32)>,
) -> Result<Vec<u8>, CodecError> {
/// optional fully transparent entry (`tRNS`). Used for Indexed Color documents.
pub fn encode_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3]], transparent: Option<u8>) -> 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()));
}
@@ -550,15 +538,6 @@ pub fn encode_indexed(
trns[usize::from(t)] = 0;
info.trns = Some(trns.into());
}
if let Some((x, y)) = dpi
&& x.is_finite()
&& y.is_finite()
&& x > 0.0
&& y > 0.0
{
info.pixel_dims =
Some(png::PixelDimensions { xppu: (x / METERS_PER_INCH).round() as u32, yppu: (y / METERS_PER_INCH).round() as u32, unit: png::Unit::Meter });
}
let mut out = Vec::new();
{
let encoder = png::Encoder::with_info(&mut out, info).map_err(|e| CodecError::encode(F, e))?;
@@ -577,14 +556,14 @@ mod indexed_tests {
fn png8_roundtrips_through_the_decoder() {
let pal = [[255, 0, 0], [0, 0, 255], [0, 0, 0]];
let idx = [0u8, 1, 2, 1, 0, 2];
let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), None).unwrap();
let bytes = encode_indexed(3, 2, &idx, &pal, Some(2)).unwrap();
let img = decode(&bytes, &Limits::default()).unwrap();
assert_eq!(img.dimensions(), (3, 2));
let px = img.convert(ChannelLayout::Rgba, SampleType::U8);
assert_eq!(&px.data()[..8], &[255, 0, 0, 255, 0, 0, 255, 255]);
assert_eq!(px.data()[11], 0, "transparent entry");
assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None).is_err());
assert!(encode_indexed(3, 3, &idx, &pal, None, None).is_err());
assert!(encode_indexed(3, 2, &[9; 6], &pal, None).is_err());
assert!(encode_indexed(3, 3, &idx, &pal, None).is_err());
}
}
+29 -65
View File
@@ -79,43 +79,17 @@ fn sample_for_maxval(maxval: u32) -> Result<SampleType, CodecError> {
}
}
/// The decoded raster, built straight in the output representation. It is the only buffer sized
/// by the header, so `Limits::check` on the output budgets all of it: no wider scratch buffer
/// (such as one `u32` per sample) sits beside it (#2938).
struct Samples {
maxval: u32,
sample: SampleType,
bytes: Vec<u8>,
}
impl Samples {
/// Reserve room for `cap` samples (callers bound `cap` by the file length where the header
/// alone could overstate it).
fn with_capacity(maxval: u32, cap: usize) -> Result<Self, CodecError> {
let sample = sample_for_maxval(maxval)?;
let len = cap.checked_mul(sample.bytes()).ok_or_else(|| CodecError::LimitExceeded(format!("{cap} samples do not fit in memory")))?;
let mut bytes = Vec::new();
bytes.try_reserve_exact(len).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {len} bytes for the image")))?;
Ok(Samples { maxval, sample, bytes })
/// Build an image from raw integer values in `[0, maxval]`.
fn from_values(w: u32, h: u32, layout: ChannelLayout, maxval: u32, values: Vec<u32>) -> Result<Image, CodecError> {
let sample = sample_for_maxval(maxval)?;
if values.iter().any(|&v| v > maxval) {
return Err(err("sample exceeds maxval"));
}
/// Append one raw value in `[0, maxval]`, rescaled to the full range of the output sample.
fn push(&mut self, v: u32) -> Result<(), CodecError> {
let m = self.maxval;
if v > m {
return Err(err("sample exceeds maxval"));
}
match (self.sample, m) {
(SampleType::U8, 255) => self.bytes.push(v as u8),
(SampleType::U8, m) => self.bytes.push(((v * 255 + m / 2) / m) as u8),
(_, 65535) => self.bytes.extend_from_slice(&(v as u16).to_ne_bytes()),
(_, m) => self.bytes.extend_from_slice(&(((v as u64 * 65535 + m as u64 / 2) / m as u64) as u16).to_ne_bytes()),
}
Ok(())
}
fn into_image(self, w: u32, h: u32, layout: ChannelLayout) -> Result<Image, CodecError> {
Image::from_raw(w, h, layout, self.sample, self.bytes)
match (sample, maxval) {
(SampleType::U8, 255) => Image::from_u8(w, h, layout, values.into_iter().map(|v| v as u8).collect()),
(SampleType::U16, 65535) => Image::from_u16(w, h, layout, &values.into_iter().map(|v| v as u16).collect::<Vec<_>>()),
(SampleType::U8, m) => Image::from_u8(w, h, layout, values.into_iter().map(|v| ((v * 255 + m / 2) / m) as u8).collect()),
(_, m) => Image::from_u16(w, h, layout, &values.into_iter().map(|v| ((v as u64 * 65535 + m as u64 / 2) / m as u64) as u16).collect::<Vec<_>>()),
}
}
@@ -128,24 +102,18 @@ fn sample_count(w: u32, h: u32, channels: usize) -> Result<usize, CodecError> {
.ok_or_else(|| CodecError::LimitExceeded(format!("{w}x{h}x{channels} samples do not fit in memory")))
}
fn read_binary(data: &[u8], n: usize, maxval: u32) -> Result<Samples, CodecError> {
let mut out = Samples::with_capacity(maxval, n)?;
fn read_binary(data: &[u8], n: usize, maxval: u32) -> Result<Vec<u32>, CodecError> {
if maxval < 256 {
let Some(raw) = data.get(..n) else {
if data.len() < n {
return Err(err("truncated raster"));
};
for &v in raw {
out.push(v as u32)?;
}
Ok(data[..n].iter().map(|&v| v as u32).collect())
} else {
let Some(raw) = n.checked_mul(2).and_then(|len| data.get(..len)) else {
let Some(len) = n.checked_mul(2).filter(|&len| data.len() >= len) else {
return Err(err("truncated raster"));
};
for c in raw.as_chunks::<2>().0 {
out.push(u16::from_be_bytes(*c) as u32)?;
}
Ok(data[..len].as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as u32).collect())
}
Ok(out)
}
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
@@ -195,19 +163,19 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
let layout = if matches!(kind, b'2' | b'5') { ChannelLayout::Gray } else { ChannelLayout::Rgb };
limits.check(w, h, layout, sample_for_maxval(maxval)?)?;
let n = sample_count(w, h, layout.channels())?;
let samples = if matches!(kind, b'5' | b'6') {
let values = if matches!(kind, b'5' | b'6') {
r.single_ws()?;
read_binary(r.rest(), n, maxval)?
} else {
// Each ASCII sample is a digit and a separator (the last may lack one), so the
// file, not the header, bounds the reservation (#1110).
let mut v = Samples::with_capacity(maxval, n.min(r.rest().len() / 2 + 1))?;
let mut v = Vec::with_capacity(n.min(r.rest().len() / 2 + 1));
for _ in 0..n {
v.push(r.uint()?)?;
v.push(r.uint()?);
}
v
};
samples.into_image(w, h, layout)
from_values(w, h, layout, maxval, values)
}
b'7' => decode_pam(&mut r, limits),
b'F' | b'f' => {
@@ -227,22 +195,17 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
return Err(err("truncated raster"));
}
let le = scale < 0.0;
// Write native-endian bytes straight into the output buffer: a separate `Vec<f32>`
// would double the budgeted allocation (#2938).
let row_bytes = row * 4;
let mut out = vec![0u8; row_bytes * h as usize];
for (y, src) in data.chunks_exact(row_bytes).take(h as usize).enumerate() {
let mut out = vec![0f32; row * h as usize];
for y in 0..h as usize {
// PFM rows are stored bottom-to-top.
let dst_start = (h as usize - 1 - y) * row_bytes;
let Some(dst) = out.get_mut(dst_start..dst_start + row_bytes) else {
return Err(err("truncated raster"));
};
for (d, s) in dst.as_chunks_mut::<4>().0.iter_mut().zip(src.as_chunks::<4>().0) {
let v = if le { f32::from_le_bytes(*s) } else { f32::from_be_bytes(*s) };
*d = v.to_ne_bytes();
let dst = (h as usize - 1 - y) * row;
for i in 0..row {
let o = (y * row + i) * 4;
let b4 = [data[o], data[o + 1], data[o + 2], data[o + 3]];
out[dst + i] = if le { f32::from_le_bytes(b4) } else { f32::from_be_bytes(b4) };
}
}
Image::from_raw(w, h, layout, SampleType::F32, out)
Image::from_f32(w, h, layout, &out)
}
_ => Err(err("unknown Netpbm subtype")),
}
@@ -287,7 +250,8 @@ fn decode_pam(r: &mut Reader<'_>, limits: &Limits) -> Result<Image, CodecError>
};
limits.check(w, h, layout, sample_for_maxval(maxval)?)?;
let n = sample_count(w, h, layout.channels())?;
read_binary(r.rest(), n, maxval)?.into_image(w, h, layout)
let values = read_binary(r.rest(), n, maxval)?;
from_values(w, h, layout, maxval, values)
}
pub(crate) fn encode(src: &Image, plan: Plan, _opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
+1 -49
View File
@@ -145,14 +145,6 @@ impl<'a> File<'a> {
Some(if self.le { u64::from_le_bytes(s) } else { u64::from_be_bytes(s) })
}
/// The declared entry count of the directory at `at`, before its table is parsed.
pub fn entry_count(&self, at: u64) -> Option<u64> {
if at < self.header_len() {
return None;
}
if self.big { self.u64_at(at) } else { self.u16_at(at).map(u64::from) }
}
/// The directory at `at`: its whole table, up to and including the next-IFD pointer, must
/// be inside the file and after the header.
pub fn ifd(&self, at: u64) -> Option<Ifd> {
@@ -363,10 +355,6 @@ struct Walk<'a, 'f> {
seen: HashSet<u64>,
pages: Vec<TiffPage>,
complete: bool,
/// Directory entries that may still be parsed. Real directory tables are disjoint, so their
/// total size is bounded by the file; overlapping tables (a hostile file) would otherwise
/// make every one of thousands of directories cost a full 65,535-entry parse.
entry_budget: u64,
}
impl Walk<'_, '_> {
@@ -378,15 +366,6 @@ impl Walk<'_, '_> {
self.complete = false;
return;
}
let Some(n) = self.f.entry_count(at) else {
self.complete = false;
return;
};
if n > self.entry_budget {
self.complete = false;
return;
}
self.entry_budget -= n;
let Some(ifd) = self.f.ifd(at) else {
self.complete = false;
return;
@@ -412,37 +391,10 @@ pub fn tiff_info(bytes: &[u8]) -> Result<TiffInfo, CodecError> {
}
pub(crate) fn info_of(f: &File<'_>) -> Result<TiffInfo, CodecError> {
let mut w = Walk { f, seen: HashSet::new(), pages: Vec::new(), complete: true, entry_budget: (f.b.len() as u64) / 12 + 64 };
let mut w = Walk { f, seen: HashSet::new(), pages: Vec::new(), complete: true };
w.chain(f.first_ifd, None, 0);
if w.pages.is_empty() {
return Err(CodecError::malformed(Format::Tiff, "the first image directory is missing or cut off"));
}
Ok(TiffInfo { big_tiff: f.big, little_endian: f.le, pages: w.pages, complete: w.complete })
}
#[cfg(test)]
mod tests {
use super::*;
/// 10,000 directories 4 bytes apart, each declaring 65,535 overlapping entries: the walk must
/// stop after the file-size entry budget instead of parsing ~6.5e8 entries.
#[test]
fn overlapping_directory_tables_exhaust_the_entry_budget() {
let first = 8usize;
let table = 2 + 65_535 * 12 + 4;
let mut b = vec![0u8; first + table + 4 * 10_000];
b[..8].copy_from_slice(&[b'I', b'I', 42, 0, first as u8, 0, 0, 0]);
for i in 0..10_000usize {
let at = first + 4 * i;
b[at..at + 2].copy_from_slice(&u16::MAX.to_le_bytes());
let next_cell = at + 2 + 65_535 * 12;
let next = (first + 4 * (i + 1)) as u32;
if let Some(cell) = b.get_mut(next_cell..next_cell + 4) {
cell.copy_from_slice(&next.to_le_bytes());
}
}
let info = tiff_info(&b).expect("the first directory is readable");
assert!(!info.complete);
assert!(info.pages.len() < 200, "{} directories parsed", info.pages.len());
}
}
-5
View File
@@ -32,11 +32,6 @@ fn map_err(f: Format, e: image::ImageError) -> CodecError {
}
}
pub(crate) fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecError> {
let decoder = image::codecs::bmp::BmpDecoder::new(Cursor::new(bytes)).map_err(|e| map_err(Format::Bmp, e))?;
Ok(decoder.get_palette().map(|palette| palette.to_vec()))
}
pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
let fmt = image_format(f).ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?;
let mut reader = image::ImageReader::with_format(Cursor::new(bytes), fmt);
+2 -5
View File
@@ -9,7 +9,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, alloc_zeroed};
use crate::options::{EncodeOptions, Limits};
const F: Format = Format::WebP;
@@ -23,11 +23,8 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
let (w, h) = dec.dimensions();
let layout = if dec.has_alpha() { ChannelLayout::Rgba } else { ChannelLayout::Rgb };
limits.check(w, h, layout, SampleType::U8)?;
// VP8L back-references and VP8 skipped macroblocks compress flat areas extremely well, so the
// ratio is generous; it still stops a header-only file from asking for a gigabyte.
Limits::check_plausible(u64::from(w) * u64::from(h) * layout.channels() as u64, bytes.len(), 1 << 16)?;
let size = dec.output_buffer_size().ok_or_else(|| err("image too large"))?;
let mut buf = alloc_zeroed::<u8>(size)?;
let mut buf = vec![0u8; size];
dec.read_image(&mut buf).map_err(err)?;
let icc = dec.icc_profile().ok().flatten();
let exif = dec.exif_metadata().ok().flatten();
+2 -10
View File
@@ -145,19 +145,13 @@ fn pick_layout(src: ChannelLayout, layouts: &[ChannelLayout], alpha: bool) -> Ch
layouts[0]
}
pub(crate) fn plan(image: &Image, format: Format, opts: &EncodeOptions) -> Plan {
pub(crate) fn plan(image: &Image, format: Format, _opts: &EncodeOptions) -> Plan {
let c = caps(format);
let (layout, sample) = (image.layout(), image.sample_type());
match format {
// PNM: integer data can use any layout (PAM); float goes to PFM
// which only has gray and RGB.
Format::Pnm if sample.is_float() => Plan { layout: pick_layout(layout, &[ChannelLayout::Gray, ChannelLayout::Rgb], false), sample: SampleType::F32 },
// DPX has no luma + alpha descriptor: gray with alpha is written as luma, alpha dropped.
// The sample type follows the chosen bits (U8 for 8, U16 for 10, 12 and 16).
Format::Dpx => Plan {
layout: if layout == ChannelLayout::GrayA { ChannelLayout::Gray } else { pick_layout(layout, c.layouts, c.alpha) },
sample: crate::codecs::dpx::plan_sample(opts.dpx_bits, sample),
},
_ => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: pick_sample(sample, c.depths) },
}
}
@@ -243,9 +237,7 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
if m.dpi.is_some() && !c.dpi {
w.push(W::DpiDropped);
}
// DPX keeps its own header fields, not free-form text.
let text_kept = |k: &str| c.text && (format != Format::Dpx || crate::codecs::dpx::KEYS.contains(&k));
if m.text.iter().any(|(k, _)| !text_kept(k)) {
if !m.text.is_empty() && !c.text {
w.push(W::TextDropped);
}
}
+1 -26
View File
@@ -15,8 +15,6 @@ pub enum Format {
Ico,
/// Netpbm family: PBM/PGM/PPM (P1–P6), PAM (P7) and PFM (`PF`/`Pf`).
Pnm,
/// ZSoft Paintbrush: 1/2/4-bit planar palettes, 8-bit indexed and 24-bit RGB.
Pcx,
Qoi,
OpenExr,
/// Radiance RGBE.
@@ -27,8 +25,6 @@ pub enum Format {
Heif,
/// FITS (Flexible Image Transport System), the astronomy format: gray or RGB image data.
Fits,
/// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames.
Dpx,
}
/// What a format can hold **and** what this crate reads/writes for it.
@@ -60,10 +56,6 @@ pub struct FormatCaps {
/// Formats that are enabled but intentionally not symmetric, with the reason.
/// The test-suite asserts that every other enabled format is read+write.
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::Avif,
"AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
@@ -86,7 +78,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C
impl Format {
/// Every format known to the crate (enabled or not).
pub const ALL: [Format; 17] = [
pub const ALL: [Format; 15] = [
Format::Png,
Format::Jpeg,
Format::Tiff,
@@ -96,14 +88,12 @@ impl Format {
Format::Tga,
Format::Ico,
Format::Pnm,
Format::Pcx,
Format::Qoi,
Format::OpenExr,
Format::Hdr,
Format::Avif,
Format::Heif,
Format::Fits,
Format::Dpx,
];
pub fn caps(self) -> FormatCaps {
@@ -127,14 +117,12 @@ impl Format {
Format::Tga => "TGA",
Format::Ico => "ICO",
Format::Pnm => "Netpbm",
Format::Pcx => "PCX",
Format::Qoi => "QOI",
Format::OpenExr => "OpenEXR",
Format::Hdr => "Radiance HDR",
Format::Avif => "AVIF",
Format::Heif => "HEIF",
Format::Fits => "FITS",
Format::Dpx => "DPX",
}
}
@@ -151,14 +139,12 @@ impl Format {
Format::Tga => &["tga", "icb", "vda", "vst"],
Format::Ico => &["ico"],
Format::Pnm => &["pnm", "pbm", "pgm", "ppm", "pam", "pfm"],
Format::Pcx => &["pcx"],
Format::Qoi => &["qoi"],
Format::OpenExr => &["exr"],
Format::Hdr => &["hdr"],
Format::Avif => &["avif"],
Format::Heif => &["heic", "heif", "hif"],
Format::Fits => &["fits", "fit", "fts"],
Format::Dpx => &["dpx"],
}
}
@@ -173,14 +159,12 @@ impl Format {
Format::Tga => "image/x-tga",
Format::Ico => "image/x-icon",
Format::Pnm => "image/x-portable-anymap",
Format::Pcx => "image/x-pcx",
Format::Qoi => "image/qoi",
Format::OpenExr => "image/x-exr",
Format::Hdr => "image/vnd.radiance",
Format::Avif => "image/avif",
Format::Heif => "image/heif",
Format::Fits => "image/fits",
Format::Dpx => "image/x-dpx",
}
}
@@ -223,7 +207,6 @@ pub fn caps(format: Format) -> FormatCaps {
Format::Tga => FormatCaps { layouts: RGB_GRAY, ..base },
Format::Ico => FormatCaps { layouts: &[L::Rgba], ..base },
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::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 },
@@ -232,8 +215,6 @@ pub fn caps(format: Format) -> FormatCaps {
FormatCaps { read: cfg!(feature = "heif"), write: false, depths: &[S::U8, S::U16], icc: true, exif: true, xmp: true, lossy: true, ..base }
}
Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base },
// `text`: the DPX header fields only (see `fidelity_warnings_with`).
Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base },
}
}
@@ -282,15 +263,9 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
if b.len() >= 3 && b[0] == b'P' && matches!(b[1], b'1'..=b'7' | b'F' | b'f') && b[2].is_ascii_whitespace() {
return Some(Format::Pnm);
}
if b.starts_with(b"SDPX") || b.starts_with(b"XPDS") {
return Some(Format::Dpx);
}
if b.starts_with(b"SIMPLE =") {
return Some(Format::Fits);
}
if crate::codecs::pcx::looks_like_pcx(b) {
return Some(Format::Pcx);
}
if looks_like_tga(b) {
return Some(Format::Tga);
}
-144
View File
@@ -156,32 +156,6 @@ pub enum DecodeWarning {
/// Deep image data (per-pixel sample lists) was composited into the flat image; the samples
/// themselves (Z and other deep channels) are not part of the flat result.
DeepFlattened { format: Format, max_samples_per_pixel: u32 },
/// Only the first image element of a DPX file was decoded; `total` counts them all.
MoreElements { total: u32 },
/// A DPX element's transfer characteristic and colorimetric specification (SMPTE 268M codes,
/// 255 when undefined). The code values are imported as stored, without a colour transform.
DpxEncoding { transfer: u8, colorimetric: u8 },
}
/// The SMPTE 268M name of a transfer characteristic or colorimetric code.
fn dpx_code_name(code: u8) -> &'static str {
match code {
0 => "user-defined",
1 => "printing density",
2 => "linear",
3 => "logarithmic",
4 => "unspecified video",
5 => "SMPTE 274M",
6 => "ITU-R BT.709",
7 => "ITU-R BT.601 625-line",
8 => "ITU-R BT.601 525-line",
9 => "NTSC composite video",
10 => "PAL composite video",
11 => "Z linear",
12 => "Z homogeneous",
255 => "undefined",
_ => "unknown",
}
}
impl fmt::Display for DecodeWarning {
@@ -201,13 +175,6 @@ impl fmt::Display for DecodeWarning {
"{format} deep data was composited into a flat image (up to {max_samples_per_pixel} samples per pixel); per-pixel depth is not kept",
format = format.name()
),
DecodeWarning::MoreElements { total } => write!(f, "only the first of {total} image elements was imported"),
DecodeWarning::DpxEncoding { transfer, colorimetric } => write!(
f,
"DPX transfer characteristic: {} ({transfer}), colorimetric: {} ({colorimetric}); the code values were imported as stored, without a colour transform",
dpx_code_name(*transfer),
dpx_code_name(*colorimetric)
),
}
}
}
@@ -545,8 +512,6 @@ impl Image {
out.extend_from_slice(&one);
}
out
} else if let Some(fast) = self.convert_int_direct(layout, sample) {
fast
} else if layout == self.layout {
quantize(&self.to_normalized(), sample)
} else {
@@ -566,55 +531,6 @@ impl Image {
}
}
/// Integer fast path of [`Image::convert`] for U8/U16 sources and targets whose result is
/// a pure channel copy: same colour model (alpha added as opaque, or dropped) or gray to
/// RGB. Bit-identical to the generic f32 route (see the equivalence tests) but without
/// the two intermediate f32 buffers. Returns `None` for everything else.
fn convert_int_direct(&self, layout: ChannelLayout, sample: SampleType) -> Option<Vec<u8>> {
let int = |s: SampleType| matches!(s, SampleType::U8 | SampleType::U16);
if !int(self.sample) || !int(sample) {
return None;
}
let gray_to_rgb = self.layout.is_gray() && layout.is_rgb();
if !gray_to_rgb && !layout.same_model(self.layout) {
return None;
}
let one: &[u8] = match sample {
SampleType::U8 => &[u8::MAX],
_ => &u16::MAX.to_ne_bytes(),
};
let (fc, tc) = (self.layout.channels(), layout.channels());
let (sb, db) = (self.sample.bytes(), sample.bytes());
let (src_color, dst_color) = (self.layout.color_channels(), layout.color_channels());
let n = self.data.len() / (fc * sb);
let mut out = Vec::with_capacity(n.checked_mul(tc)?.checked_mul(db)?);
macro_rules! run {
($conv:expr) => {{
let conv = $conv;
for px in self.data.chunks_exact(fc * sb) {
for j in 0..dst_color {
let c = if src_color == 1 { 0 } else { j };
conv(&px[c * sb..(c + 1) * sb], &mut out);
}
if layout.has_alpha() {
if self.layout.has_alpha() {
conv(&px[src_color * sb..(src_color + 1) * sb], &mut out);
} else {
out.extend_from_slice(one);
}
}
}
}};
}
match (self.sample, sample) {
(SampleType::U8, SampleType::U8) => run!(|s: &[u8], o: &mut Vec<u8>| o.push(s[0])),
(SampleType::U16, SampleType::U16) => run!(|s: &[u8], o: &mut Vec<u8>| o.extend_from_slice(s)),
(SampleType::U8, SampleType::U16) => run!(|s: &[u8], o: &mut Vec<u8>| o.extend_from_slice(&(u16::from(s[0]) * 257).to_ne_bytes())),
_ => run!(|s: &[u8], o: &mut Vec<u8>| o.push(((u32::from(u16::from_ne_bytes([s[0], s[1]])) + 128) / 257) as u8)),
}
Some(out)
}
/// [`Image::convert`], borrowing `self` when it already has that layout and sample type
/// (encoders use it so a full-size image isn't copied for nothing).
pub fn converted(&self, layout: ChannelLayout, sample: SampleType) -> std::borrow::Cow<'_, Image> {
@@ -745,66 +661,6 @@ mod tests {
use super::*;
/// The generic f32 route `convert` used before the integer fast paths (the reference).
fn convert_via_f32(img: &Image, layout: ChannelLayout, sample: SampleType) -> Vec<u8> {
quantize(&convert_layout(&img.to_normalized(), img.layout, layout), sample)
}
#[test]
fn int_fast_path_matches_f32_route_for_every_value() {
// Every U8 and every U16 value through every depth pair (alpha goes through the same per-sample conversion; covered by the layout test).
let all8: Vec<u8> = (0..=255).collect();
let all16: Vec<u16> = (0..=65535).collect();
for (src, to) in
[(SampleType::U8, SampleType::U8), (SampleType::U8, SampleType::U16), (SampleType::U16, SampleType::U8), (SampleType::U16, SampleType::U16)]
{
let img = if src == SampleType::U8 {
Image::from_u8(256, 1, ChannelLayout::Gray, all8.clone()).unwrap()
} else {
Image::from_u16(65536, 1, ChannelLayout::Gray, &all16).unwrap()
};
for layout in [ChannelLayout::Gray, ChannelLayout::Rgb] {
assert_eq!(img.convert(layout, to).data(), &convert_via_f32(&img, layout, to)[..], "{src:?}->{to:?} {layout:?}");
}
}
}
#[test]
fn int_fast_path_matches_f32_route_for_every_layout() {
let mut seed = 0x9e3779b97f4a7c15u64;
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
for from_s in [SampleType::U8, SampleType::U16] {
for from_l in ChannelLayout::ALL {
let n = 64 * from_l.channels();
let mut vals = Vec::new();
for i in 0..n {
// Edge values (0, 1, max-1, max, mid) mixed with random ones.
let r = next();
let v = match i % 7 {
0 => 0.0,
1 => 1.0,
2 => 1.0 / 255.0,
3 => 254.0 / 255.0,
_ => (r % 65536) as f32 / 65535.0,
};
vals.push(v);
}
let img = Image::from_normalized(64, 1, from_l, from_s, &vals).unwrap();
for to_l in ChannelLayout::ALL {
for to_s in SampleType::ALL {
let got = img.convert(to_l, to_s);
assert_eq!(got.data(), &convert_via_f32(&img, to_l, to_s)[..], "{from_l:?}/{from_s:?} -> {to_l:?}/{to_s:?}");
}
}
}
}
}
#[test]
fn u8_u16_roundtrip_exact() {
let v: Vec<u8> = (0..=255).collect();
+2 -22
View File
@@ -22,11 +22,9 @@ mod image;
mod options;
pub mod orientation;
pub mod resolution;
pub mod source_settings;
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::encode_indexed as encode_png_indexed;
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};
@@ -37,13 +35,12 @@ pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect,
pub use crate::image::{
ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType,
};
pub use crate::options::{DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
pub use crate::options::{DecodeOptions, 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};
pub use half::f16;
use crate::codecs::{dpx, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp};
use crate::codecs::{exr, fits, heif, jpeg, png, pnm, tiff, via_image, webp};
/// Detect the format and decode with default [`Limits`].
pub fn decode(bytes: &[u8]) -> Result<Image, CodecError> {
@@ -74,9 +71,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
Format::Tiff => tiff::decode(bytes, l),
Format::WebP => webp::decode(bytes, l),
Format::Pnm => pnm::decode(bytes, l),
Format::Pcx => pcx::decode(bytes, l),
Format::Fits => fits::decode(bytes, l),
Format::Dpx => dpx::decode(bytes, l),
Format::OpenExr => exr::decode(bytes, l),
Format::Heif => heif::decode(bytes, l, opts.keep_orientation),
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l),
@@ -94,17 +89,11 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
let o = match format {
Format::Tiff => tiff::orientation(bytes, None),
Format::Heif => 1,
Format::Dpx => dpx::orientation(bytes),
_ => img.meta.exif.as_deref().map_or(1, exif_orientation),
};
finish_decode(img, o, opts)
}
/// 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)
}
/// Lists the pages (image directories) of a TIFF or BigTIFF file without decoding pixels: the
/// main IFD chain and its SubIFDs, each with its size, kind (page, reduced-resolution copy or
/// mask) and storage. [`TiffInfo::default_page`] is the page [`decode`] opens.
@@ -119,13 +108,6 @@ pub fn decode_tiff_page(bytes: &[u8], page: usize, opts: &DecodeOptions) -> Resu
finish_decode(img, tiff::orientation(bytes, Some(page)), opts)
}
/// Decodes every frame of a GIF (a still GIF yields one), each a full RGBA8 picture of the
/// logical screen with its delay. [`decode`] keeps the first frame only. See
/// [`MAX_GIF_FRAMES`] and `opts.limits` for what is refused.
pub fn decode_gif_frames(bytes: &[u8], opts: &DecodeOptions) -> Result<Vec<AnimationFrame>, CodecError> {
crate::codecs::gif_frames::decode(bytes, opts)
}
/// The Orientation (1–8) of the page [`decode`] opens from a TIFF or BigTIFF file; 1 when it is
/// absent, malformed or out of range.
pub fn tiff_orientation(bytes: &[u8]) -> u16 {
@@ -224,9 +206,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
Format::Tiff => tiff::encode(image, plan, opts),
Format::WebP => webp::encode(image, plan, opts),
Format::Pnm => pnm::encode(image, plan, opts),
Format::Pcx => Err(CodecError::unsupported(format, "PCX is import-only; save as PNG instead")),
Format::Fits => fits::encode(image, plan, opts),
Format::Dpx => dpx::encode(image, plan, opts),
Format::OpenExr => exr::encode(image, plan, opts),
Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")),
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts),
+2 -62
View File
@@ -21,10 +21,8 @@ pub struct Limits {
impl Default for Limits {
fn default() -> Self {
// 2^30 pixels (e.g. 32768²) and, on 64-bit targets, 8 GiB: enough for the large
// documents Photoshop users open (a 20000² RGBA 16-bit image is 3.2 GB). On 32-bit
// targets (wasm32 has a 4 GiB address space shared with the document, history and
// the decoder's own buffers) 1 GiB is the most one buffer can safely ask for.
let max_alloc = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 1 << 30 };
// documents Photoshop users open (a 20000² RGBA 16-bit image is 3.2 GB).
let max_alloc = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 };
Limits { max_width: 1 << 18, max_height: 1 << 18, max_pixels: 1 << 30, max_alloc }
}
}
@@ -62,19 +60,6 @@ impl Limits {
Ok(())
}
/// Decompression-bomb guard for formats that compress: refuse a file too small to hold
/// `declared_bytes` of (stored, pre-expansion) image data, given that the compression
/// never beats `max_ratio`:1. Called before the output buffer is allocated, so a
/// header-only hostile file cannot make the decoder reserve gigabytes. A fixed 1 MiB of
/// slack keeps tiny images and headers with large metadata unaffected.
pub(crate) fn check_plausible(declared_bytes: u64, file_len: usize, max_ratio: u64) -> Result<(), CodecError> {
let capacity = (file_len as u64).saturating_mul(max_ratio).saturating_add(1 << 20);
if declared_bytes > capacity {
return Err(CodecError::LimitExceeded(format!("a {file_len} byte file cannot hold {declared_bytes} bytes of image data")));
}
Ok(())
}
pub(crate) fn alloc_usize(&self) -> usize {
usize::try_from(self.max_alloc).unwrap_or(usize::MAX)
}
@@ -121,38 +106,6 @@ pub enum ExrCompression {
Piz,
}
/// Bits per sample of a DPX file.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DpxBits {
Bits8,
Bits10,
Bits12,
Bits16,
}
impl DpxBits {
/// `8`, `10`, `12` or `16` as [`DpxBits`]; any other number is `None`.
pub fn from_bits(bits: u64) -> Option<Self> {
match bits {
8 => Some(DpxBits::Bits8),
10 => Some(DpxBits::Bits10),
12 => Some(DpxBits::Bits12),
16 => Some(DpxBits::Bits16),
_ => None,
}
}
/// The bits per sample.
pub fn bits(self) -> u8 {
match self {
DpxBits::Bits8 => 8,
DpxBits::Bits10 => 10,
DpxBits::Bits12 => 12,
DpxBits::Bits16 => 16,
}
}
}
/// Options for [`crate::encode`].
#[derive(Debug, Clone)]
pub struct EncodeOptions {
@@ -173,8 +126,6 @@ pub struct EncodeOptions {
/// only when it could pass the 4 GiB a classic TIFF can address.
pub tiff_bigtiff: bool,
pub exr_compression: ExrCompression,
/// DPX bits per sample. `None` follows the image: 8 for 8-bit, 16 otherwise.
pub dpx_bits: Option<DpxBits>,
/// Embed the ICC profile when the format supports it.
pub embed_icc: bool,
/// Embed EXIF/XMP/DPI/text when the format supports it.
@@ -193,19 +144,8 @@ impl Default for EncodeOptions {
tiff_compression: TiffCompression::Deflate,
tiff_bigtiff: false,
exr_compression: ExrCompression::Zip16,
dpx_bits: None,
embed_icc: true,
embed_metadata: true,
}
}
}
/// A zero-filled buffer of `len` elements, allocated fallibly: an allocation failure becomes
/// [`CodecError::LimitExceeded`] instead of aborting the process (which `catch_unwind` cannot
/// stop).
pub(crate) fn alloc_zeroed<T: Copy + Default>(len: usize) -> Result<Vec<T>, CodecError> {
let mut v = Vec::new();
v.try_reserve_exact(len).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {len} elements for the image")))?;
v.resize(len, T::default());
Ok(v)
}
-355
View File
@@ -1,355 +0,0 @@
//! The encoder settings a lossy file was written with, read back from its own data, so a save
//! can write it again the way it came (File › Save keeps a JPEG's quality and a WebP's lossy or
//! lossless encoding).
//!
//! * **JPEG**: the quality is estimated from the quantisation tables (DQT). Most encoders scale
//! the example tables of ITU-T T.81 Annex K (K.1 luminance, K.2 chrominance) by the
//! Independent JPEG Group's quality curve, so the quality whose scaled tables match the file's
//! best is the one it was saved at (exact for such tables; the closest fit for others).
//! * **WebP**: a `VP8L` image is lossless; a `VP8 ` image is lossy, and its base quantiser index
//! (RFC 6386 §9.6) maps back to a quality on the scale our encoder uses.
//!
//! Everything here reads hostile bytes: every index is checked and a malformed file gives `None`.
use crate::codecs::vp8;
/// ITU-T T.81 Annex K.1, the example luminance quantisation table, in natural (row) order.
const ANNEX_K_LUMA: [u16; 64] = [
16, 11, 10, 16, 24, 40, 51, 61, //
12, 12, 14, 19, 26, 58, 60, 55, //
14, 13, 16, 24, 40, 57, 69, 56, //
14, 17, 22, 29, 51, 87, 80, 62, //
18, 22, 37, 56, 68, 109, 103, 77, //
24, 35, 55, 64, 81, 104, 113, 92, //
49, 64, 78, 87, 103, 121, 120, 101, //
72, 92, 95, 98, 112, 100, 103, 99,
];
/// ITU-T T.81 Annex K.2, the example chrominance quantisation table, in natural order.
const ANNEX_K_CHROMA: [u16; 64] = [
17, 18, 24, 47, 99, 99, 99, 99, //
18, 21, 26, 66, 99, 99, 99, 99, //
24, 26, 56, 99, 99, 99, 99, 99, //
47, 66, 99, 99, 99, 99, 99, 99, //
99, 99, 99, 99, 99, 99, 99, 99, //
99, 99, 99, 99, 99, 99, 99, 99, //
99, 99, 99, 99, 99, 99, 99, 99, //
99, 99, 99, 99, 99, 99, 99, 99,
];
/// The natural-order index of the k-th coefficient in zig-zag order (T.81 Figure A.6): DQT
/// stores its 64 values in this order.
const ZIGZAG: [usize; 64] = [
0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29,
22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63,
];
/// One quantisation table from a DQT segment, in natural order, with its precision.
#[derive(Debug, Clone, Copy)]
struct Table {
values: [u16; 64],
/// 16-bit entries (Pq = 1) rather than 8-bit.
wide: bool,
}
/// The quality (1–100, the Independent JPEG Group's scale that Save As and Export As use) a
/// JPEG was most likely saved at, from its quantisation tables; `None` when it has no usable
/// luminance table (or isn't a JPEG).
pub fn jpeg_quality(bytes: &[u8]) -> Option<u8> {
let tables = jpeg_tables(bytes);
let [luma, chroma, ..] = tables;
let luma = luma?;
let mut best: Option<(u64, u8)> = None;
for q in 1..=100u8 {
let mut err = table_error(&luma, &ANNEX_K_LUMA, q);
if let Some(c) = chroma {
err = err.saturating_add(table_error(&c, &ANNEX_K_CHROMA, q));
}
if best.is_none_or(|(e, _)| err < e) {
best = Some((err, q));
}
}
best.map(|(_, q)| q)
}
/// The Independent JPEG Group's quality scaling of `base` (libjpeg's `jpeg_quality_scaling` and
/// `jpeg_add_quant_table`, as published with its source): a percentage scale of 5000 / q below
/// quality 50 and 200 − 2q from it, rounded, and kept within 1..=255 (8-bit) or 1..=32767.
fn scaled(base: u16, q: u8, wide: bool) -> u32 {
let q = u32::from(q.clamp(1, 100));
let scale = if q < 50 { 5000 / q } else { 200 - 2 * q };
let v = (u32::from(base) * scale + 50) / 100;
v.clamp(1, if wide { 32767 } else { 255 })
}
/// How far `t` is from `base` scaled to quality `q`: the summed absolute difference.
fn table_error(t: &Table, base: &[u16; 64], q: u8) -> u64 {
t.values.iter().zip(base).map(|(&v, &b)| u64::from(u32::from(v).abs_diff(scaled(b, q, t.wide)))).sum()
}
/// The quantisation tables 0..=3 defined before the first scan (a later definition of the same
/// slot replaces an earlier one).
fn jpeg_tables(b: &[u8]) -> [Option<Table>; 4] {
let mut tables = [None; 4];
if b.get(..2) != Some(&[0xFF, 0xD8]) {
return tables;
}
let mut i = 2usize;
while let (Some(&0xFF), Some(&marker)) = (b.get(i), b.get(i.saturating_add(1))) {
if marker == 0xFF {
i = i.saturating_add(1);
continue;
}
if marker == 0xD8 || (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
i = i.saturating_add(2);
continue;
}
if marker == 0xDA || marker == 0xD9 {
break;
}
let Some(len) = b.get(i + 2..i + 4).map(|l| usize::from(u16::from_be_bytes([l[0], l[1]]))) else { break };
let Some(seg) = len.checked_sub(2).and_then(|n| b.get(i + 4..i + 4 + n)) else { break };
if marker == 0xDB {
read_dqt(seg, &mut tables);
}
i = i.saturating_add(2).saturating_add(len);
}
tables
}
/// The tables of one DQT segment; a truncated or malformed table ends the segment.
fn read_dqt(mut seg: &[u8], tables: &mut [Option<Table>; 4]) {
while let Some((&pq_tq, rest)) = seg.split_first() {
let wide = pq_tq >> 4 == 1;
if pq_tq >> 4 > 1 {
return;
}
let slot = usize::from(pq_tq & 0x0F);
let n = if wide { 128 } else { 64 };
let Some(data) = rest.get(..n) else { return };
let mut values = [0u16; 64];
for (k, &natural) in ZIGZAG.iter().enumerate() {
let v = if wide { data.get(2 * k..2 * k + 2).map_or(0, |p| u16::from_be_bytes([p[0], p[1]])) } else { data.get(k).map_or(0, |&p| u16::from(p)) };
if let Some(dst) = values.get_mut(natural) {
*dst = v;
}
}
if let Some(dst) = tables.get_mut(slot) {
*dst = Some(Table { values, wide });
}
seg = rest.get(n..).unwrap_or(&[]);
}
}
/// How a WebP file's image is compressed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WebpEncoding {
/// VP8L.
Lossless,
/// VP8, with the quality (0–100, our encoder's scale) its quantiser maps back to, when the
/// frame header could be read.
Lossy { quality: Option<u8> },
}
/// The compression of a WebP file's (first) image, or `None` when it isn't a WebP or holds no
/// image chunk.
pub fn webp_encoding(bytes: &[u8]) -> Option<WebpEncoding> {
if bytes.get(..4) != Some(b"RIFF") || bytes.get(8..12) != Some(b"WEBP") {
return None;
}
find_image_chunk(bytes.get(12..)?, 0)
}
/// The first `VP8 ` or `VP8L` chunk among `chunks`, looking inside animation frames.
fn find_image_chunk(mut chunks: &[u8], depth: u8) -> Option<WebpEncoding> {
while let Some(head) = chunks.get(..8) {
let fourcc = head.get(..4)?;
let size = usize::try_from(u32::from_le_bytes([head[4], head[5], head[6], head[7]])).ok()?;
let end = 8usize.checked_add(size)?;
// A truncated last chunk still holds its header.
let payload = chunks.get(8..end.min(chunks.len()))?;
match fourcc {
b"VP8L" => return Some(WebpEncoding::Lossless),
b"VP8 " => return Some(WebpEncoding::Lossy { quality: vp8_quality(payload) }),
// An animation frame: a 16-byte header, then the frame's own chunks.
b"ANMF" if depth == 0 => {
if let Some(found) = payload.get(16..).and_then(|c| find_image_chunk(c, 1)) {
return Some(found);
}
}
_ => {}
}
chunks = chunks.get(end.checked_add(size % 2)?..).unwrap_or(&[]);
}
None
}
/// The quality a VP8 key frame's base quantiser index maps back to on our encoder's scale (the
/// quality whose index is nearest; the middle one when several share it).
fn vp8_quality(frame: &[u8]) -> Option<u8> {
let qi = vp8_base_quantizer(frame)?;
let distance = |q: u8| vp8::quantizer_index(q).abs_diff(qi);
let best = (0..=100u8).map(distance).min()?;
let matches: Vec<u8> = (0..=100u8).filter(|&q| distance(q) == best).collect();
let mid = matches.get(matches.len() / 2)?;
Some(*mid)
}
/// The base quantiser index of a VP8 key frame: `y_ac_qi` from its frame header (RFC 6386
/// §9.2–9.6, §19.2), averaged over the segments when segmentation sets absolute quantisers or
/// adjusted by their mean delta otherwise.
fn vp8_base_quantizer(frame: &[u8]) -> Option<u8> {
let tag = frame.get(..3)?;
// Bit 0 clear: a key frame, which carries the start code and dimensions.
if tag[0] & 1 != 0 || frame.get(3..6)? != [0x9D, 0x01, 0x2A] {
return None;
}
let mut d = BoolDecoder::new(frame.get(10..)?);
d.literal(1); // color_space
d.literal(1); // clamping_type
let mut segments: Option<(bool, [i32; 4])> = None;
if d.flag() {
let update_map = d.flag();
if d.flag() {
let absolute = d.flag();
let mut q = [0i32; 4];
for v in &mut q {
*v = if d.flag() { d.signed(7) } else { 0 };
}
for _ in 0..4 {
if d.flag() {
d.signed(6);
}
}
segments = Some((absolute, q));
}
if update_map {
for _ in 0..3 {
if d.flag() {
d.literal(8);
}
}
}
}
d.literal(1); // filter_type
d.literal(6); // loop_filter_level
d.literal(3); // sharpness_level
if d.flag() && d.flag() {
// loop_filter_adj_enable, mode_ref_lf_delta_update: 4 reference and 4 mode deltas.
for _ in 0..8 {
if d.flag() {
d.signed(6);
}
}
}
d.literal(2); // log2_nbr_of_dct_partitions
if d.exhausted() {
return None;
}
let base = d.literal(7) as i32;
let qi = match segments {
Some((true, q)) => q.iter().sum::<i32>() / 4,
Some((false, q)) => base + q.iter().sum::<i32>() / 4,
None => base,
};
u8::try_from(qi.clamp(0, 127)).ok()
}
/// RFC 6386 §7's boolean decoder, reading zeros past the end of its data.
struct BoolDecoder<'a> {
data: &'a [u8],
pos: usize,
value: u32,
range: u32,
bit_count: u32,
/// Bytes read past the end of the data.
overrun: usize,
}
impl<'a> BoolDecoder<'a> {
fn new(data: &'a [u8]) -> Self {
let mut d = BoolDecoder { data, pos: 0, value: 0, range: 255, bit_count: 0, overrun: 0 };
d.value = (d.next_byte() << 8) | d.next_byte();
d
}
fn next_byte(&mut self) -> u32 {
match self.data.get(self.pos) {
Some(&b) => {
self.pos += 1;
u32::from(b)
}
None => {
self.overrun = self.overrun.saturating_add(1);
0
}
}
}
/// The header was read from beyond the end of the data (a couple of bytes of look-ahead
/// are normal).
fn exhausted(&self) -> bool {
self.overrun > 2
}
fn bool(&mut self, prob: u32) -> bool {
let split = 1 + (((self.range - 1) * prob) >> 8);
let big_split = split << 8;
let bit = if self.value >= big_split {
self.range -= split;
self.value -= big_split;
true
} else {
self.range = split;
false
};
while self.range < 128 {
self.value <<= 1;
self.range <<= 1;
self.bit_count += 1;
if self.bit_count == 8 {
self.bit_count = 0;
self.value |= self.next_byte();
}
}
bit
}
fn flag(&mut self) -> bool {
self.bool(128)
}
fn literal(&mut self, bits: u32) -> u32 {
(0..bits).fold(0, |v, _| (v << 1) | u32::from(self.flag()))
}
/// A magnitude of `bits` bits followed by a sign bit.
fn signed(&mut self, bits: u32) -> i32 {
let v = self.literal(bits) as i32;
if self.flag() { -v } else { v }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scaling_matches_the_published_curve() {
// Quality 50 is the Annex K table itself; 100 is all ones; 75 halves it.
assert_eq!(scaled(16, 50, false), 16);
assert_eq!(scaled(121, 100, false), 1);
assert_eq!(scaled(16, 75, false), 8);
assert_eq!(scaled(121, 1, false), 255);
assert_eq!(scaled(121, 1, true), 6050);
}
#[test]
fn zigzag_is_a_permutation() {
let mut seen = [false; 64];
for &n in &ZIGZAG {
seen[n] = true;
}
assert!(seen.iter().all(|&s| s));
}
}
-1
View File
@@ -59,7 +59,6 @@ fn default_build_formats_all_symmetric() {
Format::OpenExr,
Format::Hdr,
Format::Fits,
Format::Dpx,
];
for f in expected {
assert!(caps(f).read && caps(f).write, "{f:?}");
-386
View File
@@ -1,386 +0,0 @@
//! DPX (SMPTE 268M) read and write, against fixtures packed here independently of the codec:
//! every descriptor, bit depth and packing it reads, both byte orders, the header fields kept
//! through a round trip, the bit-depth choice on export and the files it refuses (#1049).
use photocraft_codecs::*;
const HEADER: usize = 2048;
const ELEMENT0: usize = 780;
/// A DPX file built field by field.
#[derive(Clone)]
struct Fixture {
big: bool,
width: u32,
height: u32,
descriptor: u8,
bits: u8,
packing: u16,
encoding: u16,
signed: bool,
transfer: u8,
colorimetric: u8,
orientation: u16,
elements: u16,
strings: Vec<((usize, usize), &'static str)>,
frame_position: u32,
frame_rate: f32,
time_code: u32,
/// Code values, interleaved, top line first.
codes: Vec<u16>,
}
impl Fixture {
fn new(descriptor: u8, bits: u8, packing: u16, width: u32, height: u32) -> Self {
let channels = channels(descriptor);
let max = (1u32 << bits) - 1;
// Spread over the whole range, ends included.
let n = (width * height) as usize * channels;
let codes = (0..n)
.map(|i| {
if i == 0 {
0
} else if i == 1 {
max as u16
} else {
((i as u64 * 2654435761) % (u64::from(max) + 1)) as u16
}
})
.collect();
Fixture {
big: true,
width,
height,
descriptor,
bits,
packing,
encoding: 0,
signed: false,
transfer: 255,
colorimetric: 255,
orientation: 0,
elements: 1,
strings: Vec::new(),
frame_position: u32::MAX,
frame_rate: f32::from_bits(u32::MAX),
time_code: u32::MAX,
codes,
}
}
fn u16(&self, v: u16) -> [u8; 2] {
if self.big { v.to_be_bytes() } else { v.to_le_bytes() }
}
fn u32(&self, v: u32) -> [u8; 4] {
if self.big { v.to_be_bytes() } else { v.to_le_bytes() }
}
fn line(&self, codes: &[u16]) -> Vec<u8> {
let mut out = Vec::new();
match (self.bits, self.packing) {
(8, _) => out.extend(codes.iter().map(|&c| c as u8)),
(16, _) => out.extend(codes.iter().flat_map(|&c| self.u16(c))),
(10, 1 | 2) => {
let base = if self.packing == 1 { 22 } else { 20 };
for chunk in codes.chunks(3) {
let w = chunk.iter().enumerate().fold(0u32, |w, (i, &c)| w | (u32::from(c) << (base - 10 * i as u32)));
out.extend(self.u32(w));
}
}
(12, 1) => out.extend(codes.iter().flat_map(|&c| self.u16(c << 4))),
_ => {
// One bit stream, high bits first, cut into 32-bit words.
let mut bits: Vec<bool> = codes.iter().flat_map(|&c| (0..self.bits).rev().map(move |b| (c >> b) & 1 == 1)).collect();
bits.resize(bits.len().div_ceil(32) * 32, false);
for word in bits.chunks(32) {
out.extend(self.u32(word.iter().fold(0u32, |w, &b| (w << 1) | u32::from(b))));
}
}
}
out.resize(out.len().div_ceil(4) * 4, 0);
out
}
fn bytes(&self) -> Vec<u8> {
let mut b = vec![0u8; HEADER];
let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v);
put(0, if self.big { b"SDPX" } else { b"XPDS" });
put(4, &self.u32(HEADER as u32));
put(8, b"V2.0");
put(24, &self.u32(1664));
put(28, &self.u32(384));
put(768, &self.u16(self.orientation));
put(770, &self.u16(self.elements));
put(772, &self.u32(self.width));
put(776, &self.u32(self.height));
for el in 0..usize::from(self.elements.min(8)) {
let e = ELEMENT0 + el * 72;
put(e, &self.u32(u32::from(self.signed)));
put(e + 20, &[self.descriptor, self.transfer, self.colorimetric, self.bits]);
put(e + 24, &self.u16(self.packing));
put(e + 26, &self.u16(self.encoding));
put(e + 28, &self.u32(HEADER as u32));
}
for &((at, len), s) in &self.strings {
assert!(s.len() < len);
put(at, s.as_bytes());
}
put(1712, &self.u32(self.frame_position));
put(1724, &self.u32(self.frame_rate.to_bits()));
put(1920, &self.u32(self.time_code));
let per_line = self.width as usize * channels(self.descriptor);
for line in self.codes.chunks(per_line) {
b.extend(self.line(line));
}
b
}
}
fn channels(descriptor: u8) -> usize {
match descriptor {
6 => 1,
50 => 3,
_ => 4,
}
}
fn layout(descriptor: u8) -> ChannelLayout {
match descriptor {
6 => ChannelLayout::Gray,
50 => ChannelLayout::Rgb,
_ => ChannelLayout::Rgba,
}
}
/// The 16-bit sample a code value opens as: `round(c × 65535 / (2^b − 1))`.
fn expected(c: u16, bits: u8) -> u16 {
(f64::from(c) * 65535.0 / f64::from((1u32 << bits) - 1)).round() as u16
}
/// Reads the code values of a big-endian file we wrote (packing 0 at 8/16 bits, 1 at 10/12).
fn written_codes(b: &[u8]) -> (u8, u16, Vec<u16>) {
let be16 = |at: usize| u16::from_be_bytes([b[at], b[at + 1]]);
let be32 = |at: usize| u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]);
let (w, h) = (be32(772) as usize, be32(776) as usize);
let nc = channels(b[ELEMENT0 + 20]);
let (bits, packing) = (b[ELEMENT0 + 23], be16(ELEMENT0 + 24));
let off = be32(ELEMENT0 + 28) as usize;
let per_line = w * nc;
let stride = match bits {
10 => per_line.div_ceil(3) * 4,
_ => (per_line * if bits == 8 { 1 } else { 2 }).div_ceil(4) * 4,
};
let mut codes = Vec::new();
for y in 0..h {
let line = &b[off + y * stride..];
for i in 0..per_line {
codes.push(match bits {
8 => u16::from(line[i]),
10 => ((be32_at(line, i / 3 * 4) >> (22 - 10 * (i % 3) as u32)) & 0x3FF) as u16,
12 => u16::from_be_bytes([line[i * 2], line[i * 2 + 1]]) >> 4,
_ => u16::from_be_bytes([line[i * 2], line[i * 2 + 1]]),
});
}
}
(bits, packing, codes)
}
fn be32_at(b: &[u8], at: usize) -> u32 {
u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]])
}
fn opts(bits: Option<DpxBits>) -> EncodeOptions {
EncodeOptions { dpx_bits: bits, ..EncodeOptions::default() }
}
const COMBOS: [(u8, u16); 7] = [(8, 0), (10, 0), (10, 1), (10, 2), (12, 0), (12, 1), (16, 0)];
#[test]
fn every_descriptor_depth_and_packing_opens_with_its_model_and_values() {
for descriptor in [6, 50, 51] {
for (bits, packing) in COMBOS {
// Widths that leave partly filled words at the end of a line.
let fx = Fixture::new(descriptor, bits, packing, 5, 3);
let img = decode(&fx.bytes()).unwrap_or_else(|e| panic!("{descriptor} {bits}/{packing}: {e}"));
let what = format!("descriptor {descriptor}, {bits} bits, packing {packing}");
assert_eq!(detect(&fx.bytes()), Some(Format::Dpx));
assert_eq!((img.width(), img.height()), (5, 3), "{what}");
assert_eq!(img.layout(), layout(descriptor), "{what}");
assert_eq!(img.layout().has_alpha(), descriptor == 51, "{what}");
match bits {
8 => assert_eq!(img.as_u8().unwrap(), fx.codes.iter().map(|&c| c as u8).collect::<Vec<_>>(), "{what}"),
16 => assert_eq!(img.to_u16_samples().unwrap(), fx.codes, "{what}"),
_ => assert_eq!(img.to_u16_samples().unwrap(), fx.codes.iter().map(|&c| expected(c, bits)).collect::<Vec<_>>(), "{what}"),
}
}
}
}
#[test]
fn big_and_little_endian_files_open_identically() {
for (bits, packing) in COMBOS {
let be = Fixture::new(50, bits, packing, 4, 2);
let le = Fixture { big: false, ..be.clone() };
assert_eq!(&le.bytes()[..4], b"XPDS");
assert_eq!(decode(&be.bytes()).unwrap(), decode(&le.bytes()).unwrap(), "{bits} bits, packing {packing}");
}
}
#[test]
fn ten_bit_code_value_opens_as_the_rounded_16_bit_value() {
let mut fx = Fixture::new(6, 10, 1, 1, 1);
fx.codes = vec![512];
assert_eq!(decode(&fx.bytes()).unwrap().to_u16_samples().unwrap(), vec![(512.0f64 * 65535.0 / 1023.0).round() as u16]);
}
#[test]
fn ten_and_twelve_bit_frames_written_back_at_their_depth_keep_their_code_values() {
for (bits, choice) in [(10, DpxBits::Bits10), (12, DpxBits::Bits12)] {
for packing in [0, 1] {
let fx = Fixture::new(50, bits, packing, 7, 3);
let out = encode(&decode(&fx.bytes()).unwrap(), Format::Dpx, &opts(Some(choice))).unwrap();
let (wbits, wpacking, codes) = written_codes(&out);
assert_eq!((wbits, wpacking), (bits, 1));
assert_eq!(codes, fx.codes, "{bits} bits, packing {packing}");
}
}
}
#[test]
fn sixteen_bit_frames_round_trip_losslessly_and_files_start_with_sdpx() {
let fx = Fixture { big: false, ..Fixture::new(51, 16, 0, 6, 4) };
let img = decode(&fx.bytes()).unwrap();
let out = encode(&img, Format::Dpx, &EncodeOptions::default()).unwrap();
assert_eq!(&out[..4], b"SDPX");
let (bits, packing, codes) = written_codes(&out);
assert_eq!((bits, packing, codes), (16, 0, fx.codes));
assert_eq!(decode(&out).unwrap().to_u16_samples(), img.to_u16_samples());
}
#[test]
fn log_frames_report_their_encoding_and_keep_the_plain_mapping() {
let mut fx = Fixture::new(50, 10, 1, 3, 2);
(fx.transfer, fx.colorimetric) = (3, 1);
let img = decode(&fx.bytes()).unwrap();
assert_eq!(img.warnings, vec![DecodeWarning::DpxEncoding { transfer: 3, colorimetric: 1 }]);
let note = img.warnings[0].to_string();
assert!(note.contains("logarithmic (3)") && note.contains("printing density (1)"), "{note}");
assert_eq!(img.to_u16_samples().unwrap(), fx.codes.iter().map(|&c| expected(c, 10)).collect::<Vec<_>>());
}
#[test]
fn only_the_first_of_several_image_elements_is_imported() {
let mut fx = Fixture::new(6, 8, 0, 2, 2);
fx.elements = 2;
let img = decode(&fx.bytes()).unwrap();
assert_eq!(img.warnings.iter().map(ToString::to_string).collect::<Vec<_>>(), ["only the first of 2 image elements was imported"]);
}
#[test]
fn listed_header_fields_survive_a_round_trip() {
let mut fx = Fixture::new(50, 10, 1, 2, 2);
fx.strings = vec![((160, 100), "Resolve 20.1"), ((260, 200), "Reel 7"), ((460, 200), "(c) 2026 Studio"), ((136, 24), "2026:10:12:09:30:00UTC")];
(fx.transfer, fx.colorimetric, fx.frame_position, fx.frame_rate, fx.time_code) = (3, 1, 86400, 23.976, 0x0102_0304);
let img = decode(&fx.bytes()).unwrap();
for (k, v) in [
("DPX:Creator", "Resolve 20.1"),
("DPX:Project", "Reel 7"),
("DPX:Copyright", "(c) 2026 Studio"),
("DPX:CreationTime", "2026:10:12:09:30:00UTC"),
("DPX:Transfer", "3"),
("DPX:Colorimetric", "1"),
("DPX:FramePosition", "86400"),
("DPX:FrameRate", "23.976"),
("DPX:TimeCode", "01:02:03:04"),
] {
assert!(img.meta.text.contains(&(k.into(), v.into())), "{k} = {v} in {:?}", img.meta.text);
}
let out = encode(&img, Format::Dpx, &opts(Some(DpxBits::Bits10))).unwrap();
let src = fx.bytes();
// Field by field: the strings, the element's encoding codes, film and TV fields.
for (at, len) in [(160, 100), (260, 200), (460, 200), (136, 24), (ELEMENT0 + 21, 2), (1712, 4), (1724, 4), (1920, 4)] {
assert_eq!(out[at..at + len], src[at..at + len], "field at {at}");
}
assert_eq!(decode(&out).unwrap().meta.text, img.meta.text);
}
#[test]
fn chosen_bit_depth_sets_the_header_and_packing() {
let img = Image::from_u16(5, 2, ChannelLayout::Rgb, &(0..30u16).map(|i| i * 2000).collect::<Vec<_>>()).unwrap();
for (choice, bits, packing) in [(DpxBits::Bits8, 8, 0), (DpxBits::Bits10, 10, 1), (DpxBits::Bits12, 12, 1), (DpxBits::Bits16, 16, 0)] {
let out = encode(&img, Format::Dpx, &opts(Some(choice))).unwrap();
assert_eq!((out[ELEMENT0 + 23], u16::from_be_bytes([out[ELEMENT0 + 24], out[ELEMENT0 + 25]])), (bits, packing), "{choice:?}");
let back = decode(&out).unwrap();
assert_eq!((back.width(), back.height(), back.layout()), (5, 2, ChannelLayout::Rgb));
}
// Without a choice the depth follows the image.
let gray8 = Image::from_u8(3, 1, ChannelLayout::Gray, vec![0, 128, 255]).unwrap();
assert_eq!(encode(&gray8, Format::Dpx, &EncodeOptions::default()).unwrap()[ELEMENT0 + 23], 8);
assert_eq!(encode(&img, Format::Dpx, &EncodeOptions::default()).unwrap()[ELEMENT0 + 23], 16);
let float = img.convert(ChannelLayout::Rgb, SampleType::F32);
assert_eq!(encode(&float, Format::Dpx, &EncodeOptions::default()).unwrap()[ELEMENT0 + 23], 16);
assert!(fidelity_warnings(&float, Format::Dpx).contains(&FidelityWarning::DepthReduced { from: SampleType::F32, to: SampleType::U16 }));
}
#[test]
fn gray_with_alpha_is_written_as_luma_with_a_warning() {
let img = Image::from_u8(2, 1, ChannelLayout::GrayA, vec![10, 128, 200, 255]).unwrap();
let out = encode(&img, Format::Dpx, &EncodeOptions::default()).unwrap();
assert_eq!(out[ELEMENT0 + 20], 6);
assert_eq!(decode(&out).unwrap().as_u8().unwrap(), &[10, 200]);
assert!(fidelity_warnings(&img, Format::Dpx).contains(&FidelityWarning::AlphaDiscarded));
}
#[test]
fn only_free_form_text_is_reported_as_dropped() {
let mut img = Image::from_u8(1, 1, ChannelLayout::Rgb, vec![1, 2, 3]).unwrap();
img.meta.text = vec![("DPX:Project".into(), "Reel 7".into())];
assert!(!fidelity_warnings(&img, Format::Dpx).contains(&FidelityWarning::TextDropped));
img.meta.text.push(("Comment".into(), "from a PNG".into()));
assert!(fidelity_warnings(&img, Format::Dpx).contains(&FidelityWarning::TextDropped));
}
#[test]
fn stored_orientation_is_turned_upright() {
// Orientation 4: the stored lines are the image's columns, left to right.
let mut fx = Fixture::new(6, 8, 0, 3, 1);
fx.codes = vec![1, 2, 3];
fx.orientation = 4;
let img = decode(&fx.bytes()).unwrap();
assert_eq!((img.width(), img.height(), img.as_u8().unwrap()), (1, 3, &[1u8, 2, 3][..]));
}
#[test]
fn unsupported_and_damaged_files_error_instead_of_panicking() {
let unsupported = |bytes: Vec<u8>, what: &str| {
assert!(matches!(decode(&bytes), Err(CodecError::Unsupported { format: Format::Dpx, .. })), "{what}");
};
unsupported(Fixture { descriptor: 100, ..Fixture::new(50, 8, 0, 2, 2) }.bytes(), "ABGR descriptor");
unsupported(Fixture { encoding: 1, ..Fixture::new(50, 10, 1, 2, 2) }.bytes(), "run-length encoding");
unsupported(Fixture { signed: true, ..Fixture::new(50, 16, 0, 2, 2) }.bytes(), "signed samples");
let mut float = Fixture::new(50, 16, 0, 2, 2).bytes();
float[ELEMENT0 + 23] = 32;
unsupported(float, "32-bit float");
let mut method_b = Fixture::new(50, 12, 1, 2, 2).bytes();
method_b[ELEMENT0 + 24..ELEMENT0 + 26].copy_from_slice(&2u16.to_be_bytes());
unsupported(method_b, "12-bit method B");
let good = Fixture::new(51, 10, 1, 9, 9).bytes();
let mut no_elements = good.clone();
no_elements[770..772].copy_from_slice(&0u16.to_be_bytes());
let mut past_end = good.clone();
past_end[ELEMENT0 + 28..ELEMENT0 + 32].copy_from_slice(&u32::MAX.wrapping_sub(1).to_be_bytes());
let mut huge = good.clone();
huge[772..780].copy_from_slice(&[0xFF; 8]);
for (what, bytes) in [
("header cut short", good[..900].to_vec()),
("truncated element", good[..good.len() - 1].to_vec()),
("no image elements", no_elements),
("data offset past the end", past_end),
("absurd size", huge),
("magic alone", b"SDPX".to_vec()),
] {
assert!(decode_as(Format::Dpx, &bytes).is_err(), "{what}");
}
}
-29
View File
@@ -197,32 +197,3 @@ fn parts_are_capped_together() {
let e = exr_info(&aov_file(), &limits).unwrap_err().to_string();
assert!(e.contains("across 3 parts"), "{e}");
}
/// Two ZIP1 parts of 4x4 RGBA; every chunk of part 0 is then overwritten with garbage, so
/// inflating it fails.
fn file_with_corrupt_first_part() -> Vec<u8> {
let px = |k: f32| -> Vec<(&'static str, Vec<f32>)> { ["R", "G", "B", "A"].iter().map(|c| (*c, vec![k; 16])).collect() };
let mut bytes = write_image(vec![layer_f32("first", Compression::ZIP1, &px(0.25)), layer_f32("second", Compression::ZIP1, &px(0.75))]);
let mut cursor = std::io::Cursor::new(&bytes[..]);
exr::meta::MetaData::read_from_buffered(&mut cursor, false).unwrap();
let table = cursor.position() as usize;
// Part 0 owns the first four of the eight chunks (one scan line per ZIP1 block).
for i in 0..4 {
let at = table + i * 8;
let offset = u64::from_le_bytes(bytes[at..at + 8].try_into().unwrap()) as usize;
let size = u32::from_le_bytes(bytes[offset + 8..offset + 12].try_into().unwrap()) as usize;
bytes[offset + 12..offset + 12 + size].fill(0xA5);
}
bytes
}
#[test]
fn decoding_one_part_does_not_decode_the_others() {
let bytes = file_with_corrupt_first_part();
// Part 0 is unreadable ...
assert!(decode_exr_part(&bytes, 0, &DecodeOptions::default()).is_err());
// ... but asking for part 1 must not touch it.
let img = decode_exr_part(&bytes, 1, &DecodeOptions::default()).expect("part 1 decodes on its own");
assert_eq!((img.width(), img.height()), (4, 4));
assert!(img.to_normalized().iter().all(|v| near(*v, 0.75)), "wrong pixels");
}

Some files were not shown because too many files have changed in this diff Show More