Compare commits
294 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ec29e6a6f | |||
| 59a92886b8 | |||
| ae55cf7094 | |||
| cf6473dcd9 | |||
| 122d60fb10 | |||
| 60879d8776 | |||
| d41f9bc63d | |||
| ac9e15a079 | |||
| eb7321b0e4 | |||
| 46740ad4bc | |||
| f48a72cbe3 | |||
| 3ba0e9419c | |||
| 7366a0b429 | |||
| 992d945da7 | |||
| a1fbfd9a6c | |||
| 9f98df582a | |||
| 6c0dcf428b | |||
| 61c52c08db | |||
| d303bf9b83 | |||
| ae72084a9c | |||
| 9d37e1518b | |||
| 5c4ad593de | |||
| fd4ba52f79 | |||
| 67c90e307f | |||
| 90c42f5555 | |||
| 3f0a633056 | |||
| e2a35b04fd | |||
| 13f5c104f6 | |||
| 7015f2916d | |||
| bbfaefd5f0 | |||
| 4ff2dc33ca | |||
| cd36d1ea0d | |||
| 9862dfbb09 | |||
| 40427f25dc | |||
| 95dbb01ad9 | |||
| 23abc4302b | |||
| 049a92c288 | |||
| 3204231d7d | |||
| dd992c7e57 | |||
| 7f8b5816ca | |||
| 63fd5ec7b6 | |||
| d32d3f41bb | |||
| 50b97605fb | |||
| ddf6696dce | |||
| e753a71851 | |||
| 5dac5c6d61 | |||
| b3222ee8e7 | |||
| 91b08488c8 | |||
| 7b0317ff41 | |||
| a301e31864 | |||
| d43db3e153 | |||
| 7f9c4394c1 | |||
| 184c226a76 | |||
| 2db6b7d8f9 | |||
| 3d6050b931 | |||
| 9e623e7a45 | |||
| b3309e27d7 | |||
| 67e4b8a499 | |||
| 9163afbdcf | |||
| f4fa53140c | |||
| dc35ce7630 | |||
| a8b52f1dbf | |||
| 019a391f40 | |||
| 27fecdab44 | |||
| 0b935da882 | |||
| 423421a879 | |||
| 5fe4d09478 | |||
| 73a56d7603 | |||
| fd21e73411 | |||
| 5a0e2e2658 | |||
| bb17dd555c | |||
| a58b6f3d9e | |||
| 8072636e0f | |||
| 094c4f30e4 | |||
| 7958dc63fb | |||
| 4010630c98 | |||
| 76fa06dbd5 | |||
| 73ed08ddbe | |||
| 8d49ecf50d | |||
| aa4d4859b4 | |||
| 3f72158f0a | |||
| 81f9745033 | |||
| cbcdeb2549 | |||
| bbe77354e0 | |||
| 6c7328b709 | |||
| de22bca760 | |||
| 286ff73533 | |||
| 05c4cd685e | |||
| 3e428e7302 | |||
| 9ff6c57e03 | |||
| 3cc0d48410 | |||
| 04d2874e38 | |||
| 8af15cf72a | |||
| 7890b277a0 | |||
| dcc9f86aa5 | |||
| fc02c91ffb | |||
| b0554b8178 | |||
| 25c0ec78d5 | |||
| 6231325aea | |||
| bacc886a6d | |||
| 6037ade5ed | |||
| 2aa1f8d2c6 | |||
| c5da1a0d5f | |||
| 836fd52089 | |||
| e35671ffa9 | |||
| e4e3f00f48 | |||
| 81bff2aaa4 | |||
| b071929ecb | |||
| 56c5fd5797 | |||
| 224cebbd8a | |||
| edc1b1e774 | |||
| 53d814f643 | |||
| 3dd5fc1cdc | |||
| 6751a46003 | |||
| 89312de597 | |||
| 0c2ca488ae | |||
| e54adc250c | |||
| 38236f90c2 | |||
| e156a9ed85 | |||
| 476f983876 | |||
| 7893a6e04d | |||
| 21f1012ce2 | |||
| b564f2ca9c | |||
| 6547ca0b84 | |||
| 4705fc5980 | |||
| 0dbc288667 | |||
| e4298f554b | |||
| 304720b1a0 | |||
| 590bec7998 | |||
| 5ed758763d | |||
| 2cb396d154 | |||
| 64a02107ba | |||
| 16341720c0 | |||
| 7c77143b97 | |||
| 04396dd85f | |||
| 27f56eb762 | |||
| ce16e100b0 | |||
| be877c27d0 | |||
| 6ca7e60cb0 | |||
| 73520f8681 | |||
| db509dc4d9 | |||
| ad3417dc05 | |||
| 0d6e658e73 | |||
| 6f1c4481bf | |||
| 5bc66094f8 | |||
| 24bd7533fc | |||
| 83f4452bf2 | |||
| 19e51cf0ad | |||
| c0fa2269d2 | |||
| 32552a61a1 | |||
| f86517a029 | |||
| 62346f7fb6 | |||
| 3d0c819d46 | |||
| dd708f4ce4 | |||
| e79d6571ba | |||
| a1a7b17ee0 | |||
| 79cfa2b65a | |||
| bade115202 | |||
| 6d03075ed7 | |||
| 5bdb12ec51 | |||
| ae0e5eb731 | |||
| ad5fb53f68 | |||
| a2cdc6c2d7 | |||
| 9f49cae093 | |||
| 6d21a69968 | |||
| 4736a4bcbe | |||
| 8b21621671 | |||
| dbf9d0d998 | |||
| 62f4b9d453 | |||
| 986f977a01 | |||
| 986f68f969 | |||
| 403e321034 | |||
| 4cf8a5fd05 | |||
| 97c6769d6b | |||
| 78c611007b | |||
| dac995ee6c | |||
| 8c6e4a3abc | |||
| 84aa8df05a | |||
| b623db1ed7 | |||
| 55b3301384 | |||
| 6ca04e5afa | |||
| c7c6031254 | |||
| d2b5b144aa | |||
| c5a9e7f9b2 | |||
| 857a4f7d8a | |||
| da3da0942c | |||
| dbca08cd20 | |||
| 649b1d5d92 | |||
| 35b5b8ceb0 | |||
| 20cb95bc27 | |||
| 6a852f8852 | |||
| cb1c06fc57 | |||
| 83fcbe7d9b | |||
| f14817a5d2 | |||
| 61b6276c78 | |||
| b4a949225d | |||
| e8c7656150 | |||
| 82c58fabab | |||
| 5e0a6d404d | |||
| 962e0a2f98 | |||
| 973e7776ea | |||
| 2bf49f10a7 | |||
| 80d646fc17 | |||
| ee05bc833b | |||
| 6ecad20132 | |||
| b14037083d | |||
| 7e5d2ce6da | |||
| e3d118e0f3 | |||
| bea81c2f3d | |||
| 0467f715c6 | |||
| 8c557fcb7d | |||
| 3794ad471f | |||
| 67ca74fe18 | |||
| d380309338 | |||
| 9237a8bdde | |||
| 39aa849b47 | |||
| c20b81b251 | |||
| 4030096482 | |||
| 80b8deab0f | |||
| 17855ab3d1 | |||
| afac33f8d7 | |||
| 5d8e5c138b | |||
| 9829d7ea37 | |||
| 82d2b7e0f1 | |||
| f0322f74e8 | |||
| 51c671c5a0 | |||
| a538567a0d | |||
| 6651724db8 | |||
| 8f102b0222 | |||
| 23417a60b6 | |||
| fb36a3fdc4 | |||
| 60ef9d836f | |||
| 980cf05be4 | |||
| 538c5020b2 | |||
| 3a06871d69 | |||
| 055e4fee1b | |||
| 48c1721470 | |||
| 27222cf4b7 | |||
| ce48721c6b | |||
| cf98b91f7b | |||
| 0e2c34af3e | |||
| 9db0b30910 | |||
| 638b5cc4e6 | |||
| 7fd53f0e3f | |||
| 9a9d23d258 | |||
| 6f6d2ea467 | |||
| f65f7cc9eb | |||
| 6e9ba6511d | |||
| ee9c9ef159 | |||
| 59820ecafb | |||
| 04e7e7d7f8 | |||
| aba9b2b4ef | |||
| 1051265f75 | |||
| b0e555c60f | |||
| 4bb6ffc46f | |||
| cc902c681b | |||
| b8ea8206cc | |||
| 2af2cec6ea | |||
| 223e9499c3 | |||
| 6de37ce7f0 | |||
| c6407cac07 | |||
| bb46f846d9 | |||
| 698c0f554d | |||
| 6d08ae6400 | |||
| ccee4894bd | |||
| c450e755ae | |||
| 872d3df2c5 | |||
| 5a907b110e | |||
| 599aea0086 | |||
| f1059f1885 | |||
| 14733b5223 | |||
| 4ab61338ca | |||
| 886372812d | |||
| 42ee0d9675 | |||
| c18ee8c88b | |||
| 92baf43103 | |||
| aa1febaffb | |||
| d706acdde9 | |||
| 661ba806d1 | |||
| 8584e5dbf8 | |||
| 4e334b479b | |||
| f0d3156ad6 | |||
| 3455234537 | |||
| da3f5ffd54 | |||
| 0142b24fcb | |||
| 2b2bb45870 | |||
| 4fe6dc6764 | |||
| b26256faee | |||
| d7e2f171f3 | |||
| d523405627 | |||
| cc772a03c1 | |||
| 8f7cf009bb | |||
| 5e5a0f6d2d | |||
| 7c2e8c3d95 |
@@ -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
|
||||
@@ -257,6 +257,14 @@ 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 }
|
||||
|
||||
@@ -21,3 +21,10 @@ 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
|
||||
|
||||
@@ -35,3 +35,9 @@ 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 }}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "tools/craft-skills"]
|
||||
path = tools/craft-skills
|
||||
url = https://git.marcospaulo.dev.br/storytold/craft-skills.git
|
||||
@@ -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.
|
||||
|
||||
+5
-2
@@ -19,6 +19,9 @@ 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) |
|
||||
@@ -44,9 +47,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)
|
||||
@@ -70,7 +74,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
+1268
-129
File diff suppressed because it is too large
Load Diff
+40
-11
@@ -4,13 +4,13 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.0"
|
||||
version = "0.8.3"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
craft-ui = { git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "7fcee66b8eef8eac3c7644fd024e1f1b051f5a3e", features = ["serde"] }
|
||||
# internal
|
||||
photocraft-geom = { path = "crates/geom" }
|
||||
photocraft-color = { path = "crates/color" }
|
||||
@@ -38,6 +38,7 @@ photocraft-testkit = { path = "crates/testkit" }
|
||||
photocraft-tablet = { path = "crates/tablet" }
|
||||
|
||||
# external
|
||||
craft-print = { git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "7fcee66b8eef8eac3c7644fd024e1f1b051f5a3e" }
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = "1"
|
||||
@@ -50,7 +51,9 @@ zune-core = "0.5"
|
||||
zune-jpeg = "0.5"
|
||||
proptest = "1"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui = "0.36"
|
||||
# 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_kittest = "0.36"
|
||||
rfd = "0.17"
|
||||
log = "0.4"
|
||||
@@ -81,13 +84,14 @@ 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 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.
|
||||
# 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.
|
||||
[profile.wasm-release]
|
||||
inherits = "release"
|
||||
opt-level = "s"
|
||||
opt-level = "z"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
strip = "debuginfo"
|
||||
@@ -95,11 +99,35 @@ strip = "debuginfo"
|
||||
[profile.wasm-release.package."*"]
|
||||
opt-level = "s"
|
||||
|
||||
# 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]
|
||||
# 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]
|
||||
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]
|
||||
@@ -112,3 +140,4 @@ opt-level = 3
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-paint]
|
||||
opt-level = 3
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 — 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
|
||||
|
||||
[](https://www.star-history.com/?repos=storytold%2Fphotocraft&type=date&legend=top-left)
|
||||
|
||||
@@ -15,19 +15,21 @@ pub const USAGE: &str = "\
|
||||
photocraft-cli: headless Photocraft
|
||||
|
||||
USAGE:
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
|
||||
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
|
||||
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
|
||||
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
|
||||
--tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as
|
||||
its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24.
|
||||
--dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16.
|
||||
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>]
|
||||
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
|
||||
Open a file, run engine commands in order, save the result. Each --params
|
||||
applies to the preceding --cmd. Prints each command's JSON result.
|
||||
photocraft-cli batch --actions <actions.json> [--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,
|
||||
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
|
||||
Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a
|
||||
set: its action --action names plays, as below. JSON steps are [id, params] pairs,
|
||||
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
|
||||
(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
|
||||
@@ -45,7 +47,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/save/inspect/render/select/close, session.list, batch, methods
|
||||
doc.open/new/place/save/exportLayers/exportDataSets/importDataSets/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
|
||||
@@ -67,17 +69,17 @@ struct Subcommand {
|
||||
}
|
||||
|
||||
const SUBCOMMANDS: &[Subcommand] = &[
|
||||
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits"], bare: &["--tiff-layers"], run: convert },
|
||||
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert },
|
||||
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
|
||||
Subcommand {
|
||||
name: "run",
|
||||
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits"],
|
||||
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
|
||||
bare: &["--tiff-layers"],
|
||||
run: run_cmds,
|
||||
},
|
||||
Subcommand {
|
||||
name: "batch",
|
||||
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits"],
|
||||
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
|
||||
bare: &["--in-place", "--tiff-layers"],
|
||||
run: batch,
|
||||
},
|
||||
@@ -213,6 +215,10 @@ 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)
|
||||
}
|
||||
|
||||
@@ -240,7 +246,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, &missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&o.document)));
|
||||
warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document));
|
||||
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?;
|
||||
warn_all(err, &ws);
|
||||
Ok(())
|
||||
@@ -292,11 +298,15 @@ 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}"))?;
|
||||
@@ -308,9 +318,11 @@ 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()
|
||||
@@ -338,7 +350,9 @@ 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())?;
|
||||
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
|
||||
// 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();
|
||||
warn_all(err, &ws);
|
||||
}
|
||||
Ok(())
|
||||
@@ -384,6 +398,38 @@ 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 {
|
||||
@@ -402,8 +448,10 @@ 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 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 (actions, set, warnings) = load_actions_file(actions_path, a.get("--action"))?;
|
||||
for w in &warnings {
|
||||
let _ = writeln!(err, "warning: {w}");
|
||||
}
|
||||
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).
|
||||
|
||||
@@ -189,9 +189,11 @@ 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();
|
||||
assert_eq!(v["name"], "Run");
|
||||
// Saving a native document gives the reopened bundle its output filename.
|
||||
assert_eq!(v["name"], "r.pcraft");
|
||||
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.
|
||||
@@ -201,6 +203,50 @@ 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();
|
||||
@@ -409,6 +455,40 @@ 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]
|
||||
@@ -638,3 +718,33 @@ 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());
|
||||
}
|
||||
|
||||
@@ -125,14 +125,19 @@ 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 must match.
|
||||
// Everything but the file's own path and saved document name 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 (ia, ib) = (info(&a), info(&b));
|
||||
let (mut ia, mut 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));
|
||||
|
||||
@@ -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 = ["default_fonts", "wgpu", "accesskit"] }
|
||||
eframe = { workspace = true, features = ["wgpu", "accesskit"] }
|
||||
rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
|
||||
@@ -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-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-wasm-opt-params="--converge" 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; }
|
||||
|
||||
@@ -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", "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", "dpx", "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,27 +305,22 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
let error = self.presets.error();
|
||||
if error.is_some() || self.presets.unsaved() {
|
||||
if let Some(error) = self.presets.error() {
|
||||
egui::Panel::top("browser_preset_storage").show(ui, |ui| {
|
||||
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.")
|
||||
});
|
||||
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 {
|
||||
ui.label(i18n::t("Saving brush presets… Keep this tab open until saving finishes."));
|
||||
}
|
||||
i18n::t("Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them.")
|
||||
});
|
||||
});
|
||||
}
|
||||
self.app.ui(ui, frame);
|
||||
@@ -352,6 +347,7 @@ 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 {
|
||||
@@ -382,6 +378,8 @@ 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());
|
||||
|
||||
@@ -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 = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence"] }
|
||||
eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -64,6 +64,7 @@ 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"
|
||||
|
||||
@@ -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,
|
||||
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, repeat_auth_reply,
|
||||
};
|
||||
use photocraft_ui_egui::ControlRequest;
|
||||
use photocraft_ui_egui::control::{REPLY_DEADLINE, timeout_error};
|
||||
@@ -79,6 +79,12 @@ 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);
|
||||
@@ -202,4 +208,44 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,11 +90,21 @@ 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(at_least_min_size)),
|
||||
window_builder: Some(Box::new(restored_window)),
|
||||
..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
|
||||
@@ -230,6 +240,8 @@ 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
|
||||
@@ -297,6 +309,9 @@ 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);
|
||||
@@ -379,7 +394,14 @@ 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 {
|
||||
app.services.read_displays = Some(std::sync::Arc::new(monitor_profile::detect_async));
|
||||
#[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));
|
||||
}
|
||||
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),
|
||||
@@ -612,6 +634,22 @@ 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
|
||||
@@ -632,4 +670,13 @@ 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,7 +6,12 @@
|
||||
//! 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. Other platforms report no displays (sRGB, or the profile chosen in Color Settings).
|
||||
//! 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).
|
||||
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
@@ -16,15 +21,33 @@ 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>> {
|
||||
if !cfg!(target_os = "macos") {
|
||||
return None;
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Some(spawn_reader(detect))
|
||||
}
|
||||
#[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());
|
||||
});
|
||||
Some(rx)
|
||||
rx
|
||||
}
|
||||
|
||||
/// One line per screen, primary (menu-bar) screen first: id, frame (x, y, width, height in
|
||||
@@ -110,9 +133,225 @@ fn run_helper(cmd: &mut std::process::Command, timeout: std::time::Duration) ->
|
||||
Ok(String::from_utf8_lossy(&out).into_owned())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn detect() -> Detection {
|
||||
Err("this platform doesn't report display profiles".into())
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The helper's reply ([`SCRIPT`]) → displays, frames flipped to a top-left origin with y down
|
||||
@@ -285,6 +524,7 @@ 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() {
|
||||
|
||||
@@ -48,6 +48,66 @@ 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);
|
||||
@@ -55,10 +115,7 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
|
||||
use x11rb::{
|
||||
connection::Connection,
|
||||
image::{Image, PixelLayout},
|
||||
protocol::{
|
||||
randr::ConnectionExt as _,
|
||||
xproto::{AtomEnum, ConnectionExt, VisualClass},
|
||||
},
|
||||
protocol::{randr::ConnectionExt as _, xproto::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")?;
|
||||
@@ -71,13 +128,16 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
|
||||
{
|
||||
first.primary = true;
|
||||
}
|
||||
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() };
|
||||
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()
|
||||
};
|
||||
if regions.len() > 16 {
|
||||
return Err("Too many screens for the color picker".into());
|
||||
}
|
||||
let mut total = 0usize;
|
||||
for &(x, y, mw, mh, _) in ®ions {
|
||||
for &(x, y, mw, mh, _, _) in ®ions {
|
||||
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());
|
||||
}
|
||||
@@ -92,20 +152,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 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 output_profile_atom = icc_profile_atom(&conn, "_ICC_PROFILE");
|
||||
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) in regions {
|
||||
for (x, y, mw, mh, primary, outputs) 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 {
|
||||
@@ -120,7 +180,7 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
|
||||
width: usize::from(mw),
|
||||
height: usize::from(mh),
|
||||
rgba,
|
||||
profile: if primary { primary_profile.clone() } else { None },
|
||||
profile,
|
||||
});
|
||||
}
|
||||
Ok(Capture::Images(images))
|
||||
@@ -359,6 +419,18 @@ 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::{
|
||||
|
||||
@@ -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", "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",
|
||||
"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",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
|
||||
@@ -43,6 +43,7 @@ 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)),
|
||||
]
|
||||
}
|
||||
@@ -61,6 +62,7 @@ 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
|
||||
@@ -115,6 +117,13 @@ 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);
|
||||
@@ -191,6 +200,11 @@ 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 {
|
||||
@@ -204,25 +218,36 @@ 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.
|
||||
/// 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).
|
||||
fn save_through(
|
||||
doc: &Document,
|
||||
path: &str,
|
||||
settings: &photocraft_ui_egui::ExportSettings,
|
||||
ctl: &photocraft_ui_egui::jobs_ui::SaveCtl,
|
||||
write: impl Fn(&str, &[u8]) -> Result<(), String>,
|
||||
write: impl Fn(&str, &[u8], &dyn Fn(f32)) -> Result<(), String>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
ctl.progress(0.0, "Encoding");
|
||||
let opts = export_options(settings);
|
||||
let r = crate::crash_guard::guard("Save", || photocraft_io::export(doc, path, &opts).map_err(|e| e.to_string()))?;
|
||||
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()))?;
|
||||
ctl.commit()?;
|
||||
ctl.progress(0.0, "Writing");
|
||||
write(path, &r.bytes)?;
|
||||
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, "");
|
||||
Ok(r.warnings)
|
||||
}
|
||||
|
||||
@@ -232,7 +257,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| write_atomic(Path::new(p), b))
|
||||
save_through(doc, path, settings, ctl, |p, b, progress| write_atomic_progress(Path::new(p), b, progress))
|
||||
}
|
||||
|
||||
type SharedRecovery = Rc<RefCell<Option<RecoveryStore>>>;
|
||||
@@ -341,7 +366,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,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)
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -7,10 +7,15 @@ 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 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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -49,8 +54,9 @@ 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 explicit JSON-depth policy, aggregate document/session-memory accounting, compositor
|
||||
scratch-space accounting, or command-duration/cancellation budget;
|
||||
- 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;
|
||||
- one thread per accepted TCP connection;
|
||||
- no structured security audit event stream;
|
||||
- desktop screenshot capture/encoding and document import/export have no automation-specific
|
||||
|
||||
@@ -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; 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; 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.
|
||||
|
||||
## Review checklist
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
# 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
|
||||
@@ -104,8 +108,28 @@ 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
|
||||
|
||||
@@ -59,6 +59,22 @@ 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;
|
||||
|
||||
@@ -992,8 +992,9 @@ 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));
|
||||
for c in 0..4 {
|
||||
assert!((m[c] - (o[c] + f[c]) / 2.0).abs() < 1e-5, "({x},{y}) channel {c}");
|
||||
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}");
|
||||
}
|
||||
}
|
||||
let cancel = || true;
|
||||
|
||||
@@ -169,7 +169,7 @@ fn apply_inner(
|
||||
return None;
|
||||
}
|
||||
if let Some(selection) = selection {
|
||||
crate::mix_selection(&mut data, output, selection, &src);
|
||||
crate::mix_selection(&mut data, output, selection, &src, fmt.alpha);
|
||||
}
|
||||
if ctl.cancelled() {
|
||||
return None;
|
||||
|
||||
@@ -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);
|
||||
crate::mix_selection(&mut expected_data, rect, mask, &src, alpha);
|
||||
}
|
||||
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);
|
||||
crate::mix_selection(&mut data, area, mask, &src, true);
|
||||
}
|
||||
expected.write_region(area, &data);
|
||||
let expected = Image::read(&expected, check);
|
||||
|
||||
@@ -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);
|
||||
crate::mix_selection(&mut data, area, selection, &src, format.alpha);
|
||||
}
|
||||
let mut result = surface.clone();
|
||||
result.write_region(area, &data);
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
//! 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; 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.
|
||||
//! 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`).
|
||||
//! 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,
|
||||
@@ -54,6 +55,38 @@ 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)]
|
||||
@@ -136,6 +169,9 @@ 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 {
|
||||
@@ -191,6 +227,7 @@ impl Default for CameraRaw {
|
||||
vignette_style: "highlightPriority".into(),
|
||||
seed: 0,
|
||||
pixel_scale: 1.0,
|
||||
encoding_gamma: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -491,7 +528,11 @@ impl CameraRaw {
|
||||
self.temperature == 0.0 && self.tint == 0.0 && self.exposure == 0.0
|
||||
}
|
||||
fn tone_neutral(&self) -> bool {
|
||||
[self.contrast, self.highlights, self.shadows, self.whites, self.blacks, self.texture, self.clarity].iter().all(|v| *v == 0.0)
|
||||
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)
|
||||
}
|
||||
fn hsl_neutral(&self) -> bool {
|
||||
self.hsl_hue.iter().chain(&self.hsl_sat).chain(&self.hsl_lum).all(|v| *v == 0.0)
|
||||
@@ -561,16 +602,13 @@ 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 on luminance.
|
||||
if !p.tone_neutral() {
|
||||
// 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) {
|
||||
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;
|
||||
@@ -580,12 +618,6 @@ 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;
|
||||
@@ -593,12 +625,6 @@ 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;
|
||||
@@ -611,6 +637,13 @@ 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 {
|
||||
@@ -732,6 +765,36 @@ 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);
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
//! 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:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,12 @@
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use crate::inpaint::{Adapt, CompleteParams, Px, complete_masked};
|
||||
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::poisson::membrane_fill_with;
|
||||
|
||||
/// Options for [`fill`].
|
||||
@@ -143,7 +148,8 @@ 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.
|
||||
fn variant(src: Src, deg: f32, scale: f32, mirror: bool) -> Variant {
|
||||
/// `Err` when cancelled.
|
||||
fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Result<Variant, Cancelled> {
|
||||
let Src { w, h, ch, .. } = src;
|
||||
let (s, c) = (-deg.to_radians()).sin_cos();
|
||||
// Output size: the rotated, scaled bounding box.
|
||||
@@ -153,8 +159,12 @@ fn variant(src: Src, deg: f32, scale: f32, mirror: bool) -> Variant {
|
||||
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] };
|
||||
let mut px = vec![0.0f32; ch];
|
||||
for y in 0..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];
|
||||
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;
|
||||
@@ -163,13 +173,14 @@ fn variant(src: Src, deg: f32, scale: f32, mirror: bool) -> Variant {
|
||||
sx = (w as f32 - 1.0) - sx;
|
||||
}
|
||||
if sample(src, sx, sy, &mut px) {
|
||||
let i = y * ow + x;
|
||||
out.ok[i] = true;
|
||||
out.img[i * ch..(i + 1) * ch].copy_from_slice(&px);
|
||||
orow[x] = true;
|
||||
irow[x * ch..(x + 1) * ch].copy_from_slice(&px);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
par::chunks2_mut(&mut out.img, ow * ch, &mut out.ok, ow, true, row);
|
||||
ctl.check()?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Fill `hole` in `img`, copying only from pixels where `source` is true (and not in the hole).
|
||||
@@ -191,81 +202,136 @@ 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.to_vec());
|
||||
return Ok(img.into_owned());
|
||||
}
|
||||
let params = CompleteParams { seed: opts.seed, adapt: opts.color_adaptation, ..Default::default() };
|
||||
let gap = 2 * params.patch_radius + 2;
|
||||
let ok: Vec<bool> = hole.iter().zip(source).map(|(h, s)| !h && *s).collect();
|
||||
let src = Src { w, h, ch, img, ok: &ok };
|
||||
let ok: Vec<bool> = par::map(w * h, true, |i| !hole[i] && source[i]);
|
||||
let src = Src { w, h, ch, img: &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));
|
||||
extra.push(variant(src, 0.0, 1.0, true, ctl)?);
|
||||
}
|
||||
for ° in &opts.rotations {
|
||||
extra.push(variant(src, deg, 1.0, false));
|
||||
extra.push(variant(src, deg, 1.0, false, ctl)?);
|
||||
}
|
||||
if opts.scale {
|
||||
extra.push(variant(src, 0.0, 0.8, false));
|
||||
extra.push(variant(src, 0.0, 1.25, false));
|
||||
extra.push(variant(src, 0.0, 0.8, false, ctl)?);
|
||||
extra.push(variant(src, 0.0, 1.25, false, ctl)?);
|
||||
}
|
||||
// Mosaic: identity at the left, variants in a column to its right, gaps that are no image.
|
||||
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 role = |s: usize| {
|
||||
if hole[s] {
|
||||
Px::Hole
|
||||
} else if ok[s] {
|
||||
Px::Source
|
||||
} else {
|
||||
Px::Known
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
};
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
oy += v.h + gap;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
oy += v.h + gap;
|
||||
}
|
||||
(mw, mh, Some(mimg), roles)
|
||||
};
|
||||
drop((extra, ok));
|
||||
ctl.check()?;
|
||||
// The completion is nearly all the work.
|
||||
let cancel = || ctl.cancelled();
|
||||
let progress = |f: f32| ctl.progress(f * 0.99);
|
||||
let filled = complete_masked(mw, mh, ch, &mimg, &roles, ¶ms, &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]);
|
||||
}
|
||||
}
|
||||
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, ¶ms, &inner)?.map_err(Cow::Owned),
|
||||
Cow::Borrowed(b) => complete_masked(w, h, ch, b, &roles, ¶ms, &inner)?.ok_or(Cow::Borrowed(b)),
|
||||
};
|
||||
match filled {
|
||||
Ok(f) => f,
|
||||
Err(img) => membrane_fill_with(w, h, ch, &img, hole, ctl)?,
|
||||
}
|
||||
}
|
||||
None => out = membrane_fill_with(w, h, ch, img, hole, ctl)?,
|
||||
}
|
||||
Some(mimg) => match complete_masked(mw, mh, ch, &mimg, &roles, ¶ms, &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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
None => membrane_fill_with(w, h, ch, &img, hole, ctl)?,
|
||||
},
|
||||
};
|
||||
ctl.check()?;
|
||||
ctl.progress(1.0);
|
||||
Ok(out)
|
||||
@@ -305,36 +371,50 @@ 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> {
|
||||
use crate::segment::{FREE, HARD_FG, RgbImage, grid_cut_with};
|
||||
// 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};
|
||||
assert_eq!(img.len(), w * h * ch);
|
||||
assert_eq!(hole.len(), w * h);
|
||||
let everything: Vec<bool> = hole.iter().map(|h| !h).collect();
|
||||
let everything = || -> Vec<bool> { par::map(hole.len(), true, |i| !hole[i]) };
|
||||
if w == 0 || h == 0 || ch == 0 || !hole.iter().any(|h| *h) {
|
||||
return everything;
|
||||
return Ok(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);
|
||||
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;
|
||||
// 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 pl in &mut planes {
|
||||
pl.iter_mut().zip(&cnt).for_each(|(v, n)| *v /= n.max(1.0));
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
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);
|
||||
// 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();
|
||||
@@ -354,8 +434,9 @@ pub fn auto_sampling(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool])
|
||||
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 everything;
|
||||
return Ok(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];
|
||||
@@ -410,20 +491,21 @@ pub fn auto_sampling(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool])
|
||||
// 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).
|
||||
let area = grid_cut_with(&rgb, &cost_fg, &cost_bg, &fixed, |d2| 8.0 * (-d2 / (2.0 * 0.05 * 0.05)).exp() + 0.05);
|
||||
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 mut out = vec![false; w * h];
|
||||
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];
|
||||
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];
|
||||
}
|
||||
}
|
||||
});
|
||||
// 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;
|
||||
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());
|
||||
}
|
||||
out
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The Patch Tool's and the Content-Aware Move Tool's Color option (0–10) as the colour adaptation
|
||||
@@ -497,6 +579,36 @@ 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);
|
||||
@@ -671,9 +783,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);
|
||||
let v = variant(src, 90.0, 1.0, false, &Interrupt::NONE).unwrap();
|
||||
assert_eq!((v.w, v.h), (6, 10));
|
||||
let v = variant(src, 0.0, 0.8, true);
|
||||
let v = variant(src, 0.0, 0.8, true, &Interrupt::NONE).unwrap();
|
||||
assert_eq!((v.w, v.h), (8, 5));
|
||||
assert!(v.ok.iter().filter(|o| **o).count() > 20);
|
||||
}
|
||||
|
||||
+1423
-400
File diff suppressed because it is too large
Load Diff
+30
-4
@@ -49,6 +49,7 @@ mod other;
|
||||
mod other2;
|
||||
pub mod paint;
|
||||
pub mod panorama;
|
||||
mod par;
|
||||
pub mod perspective;
|
||||
mod photo_util;
|
||||
mod pixelate;
|
||||
@@ -1094,7 +1095,7 @@ fn apply_tiled_with_impl(
|
||||
return (*t, Vec::new());
|
||||
};
|
||||
if let Some(sel) = selection {
|
||||
mix_selection(&mut data, *t, sel, src);
|
||||
mix_selection(&mut data, *t, sel, src, fmt.alpha);
|
||||
}
|
||||
tick();
|
||||
(*t, data)
|
||||
@@ -1178,7 +1179,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));
|
||||
mix_selection(data, *t, sel, &Image::read(surface, *t), fmt.alpha);
|
||||
}
|
||||
s.write_region(*t, data);
|
||||
}
|
||||
@@ -1201,8 +1202,9 @@ 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`).
|
||||
fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image) {
|
||||
/// 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) {
|
||||
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);
|
||||
@@ -1210,6 +1212,27 @@ fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image) {
|
||||
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;
|
||||
@@ -1228,6 +1251,9 @@ 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)]
|
||||
|
||||
+266
-58
@@ -116,7 +116,8 @@ pub struct LiquifyStroke {
|
||||
/// Brush diameter (px).
|
||||
#[serde(default = "d_size")]
|
||||
pub size: f64,
|
||||
/// Edge feathering, 0 (hard) ..100 (soft).
|
||||
/// 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`]).
|
||||
#[serde(default = "d_density")]
|
||||
pub density: f64,
|
||||
/// Overall strength, 0..100.
|
||||
@@ -153,7 +154,7 @@ impl LiquifyStroke {
|
||||
}
|
||||
|
||||
/// The displacement field plus the freeze mask, over `bounds` (document pixels).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LiquifyField {
|
||||
pub bounds: Rect,
|
||||
/// Document pixels per field node.
|
||||
@@ -164,6 +165,38 @@ 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.
|
||||
@@ -176,7 +209,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] }
|
||||
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None }
|
||||
}
|
||||
|
||||
/// Document position of node (i, j) (node 0 sits on the first pixel centre).
|
||||
@@ -219,6 +252,7 @@ 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 => {
|
||||
@@ -305,77 +339,120 @@ 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 {
|
||||
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 }
|
||||
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 }
|
||||
}
|
||||
|
||||
/// Continues a stroke from `a` to `b`: dabs every fifth of the brush radius (a stationary
|
||||
/// tool held still dabs once at `b`).
|
||||
/// 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`.
|
||||
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 len = (b[0] - a[0]).hypot(b[1] - a[1]);
|
||||
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);
|
||||
if len == 0.0 {
|
||||
return if s.tool.is_stationary() { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb) } else { dirty };
|
||||
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;
|
||||
}
|
||||
let r = (s.size / 2.0).max(0.5);
|
||||
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 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 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 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)> {
|
||||
/// 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)> {
|
||||
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 (n, o) = (steps as f64, if stationary { 0.0 } else { 1.0 });
|
||||
let (mut k0, mut k1) = (1.0f64, n);
|
||||
let (mut u0, mut u1) = (0.0f64, 1.0f64);
|
||||
for ax in 0..2 {
|
||||
let d = b[ax] - a[ax];
|
||||
let d = dir[ax];
|
||||
if d == 0.0 {
|
||||
if a[ax].is_nan() || a[ax] < lo[ax] || a[ax] > hi[ax] {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
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));
|
||||
}
|
||||
// NaN: a segment whose length overflowed.
|
||||
if k0.is_nan() || k1.is_nan() || k0 > k1 {
|
||||
if u0.is_nan() || u1.is_nan() || u0 > u1 {
|
||||
return None;
|
||||
}
|
||||
Some((k0 as usize, k1 as usize))
|
||||
Some((u0 * len, u1 * len))
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// 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 {
|
||||
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;
|
||||
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure * weight;
|
||||
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 {
|
||||
@@ -420,8 +497,7 @@ impl LiquifyField {
|
||||
if t2 >= 1.0 {
|
||||
continue;
|
||||
}
|
||||
let soft = (1.0 - t2) * (1.0 - t2);
|
||||
let fall = (1.0 - density) + density * soft;
|
||||
let fall = falloff(t2, density);
|
||||
let idx = j * self.w + i;
|
||||
let k_raw = strength * fall;
|
||||
if s.tool == LiquifyTool::Freeze || s.tool == LiquifyTool::Thaw {
|
||||
@@ -526,6 +602,23 @@ 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];
|
||||
@@ -904,21 +997,9 @@ mod tests {
|
||||
use super::*;
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
|
||||
/// The segment walk before #937: every step, whether or not its dab can reach the field.
|
||||
/// The segment walk before #937: every dab, whether or not it can reach the field.
|
||||
fn full_walk(f: &mut LiquifyField, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
|
||||
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
|
||||
f.walk_segment(s, a, b, false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -928,7 +1009,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]); 9] = [
|
||||
let segments: [([f64; 3], [f64; 3]); 10] = [
|
||||
([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
|
||||
@@ -938,6 +1019,7 @@ 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] {
|
||||
@@ -956,6 +1038,81 @@ 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
|
||||
@@ -1066,6 +1223,57 @@ 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] {
|
||||
@@ -1144,7 +1352,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 = 0.0;
|
||||
fr.density = 100.0;
|
||||
fr.points = vec![vec![40.0, 32.0]];
|
||||
let mut g = LiquifyField::new(bounds(), 2.0);
|
||||
g.apply_stroke(&fr);
|
||||
|
||||
@@ -35,7 +35,8 @@ pub struct RefineParams {
|
||||
pub feather: f32,
|
||||
/// 0–100.
|
||||
pub contrast: f32,
|
||||
/// −100–100 (% of `max(radius, 4 px)`).
|
||||
/// −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.
|
||||
pub shift_edge: f32,
|
||||
}
|
||||
|
||||
@@ -59,8 +60,18 @@ 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 {
|
||||
(self.shift_edge.clamp(-100.0, 100.0) / 100.0 * self.radius.max(4.0)).round().clamp(-64.0, 64.0) as 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 }
|
||||
}
|
||||
fn uses_image(&self) -> bool {
|
||||
self.radius > 0.0
|
||||
@@ -370,13 +381,38 @@ where
|
||||
*v = ((*v - 0.5) * gain + 0.5).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
let d = p.shift_px();
|
||||
if d != 0 {
|
||||
a = morphology(&a, w, h, d.unsigned_abs() as usize, d > 0);
|
||||
let amount = p.shift_amount();
|
||||
if amount.abs() >= 1.0e-4 {
|
||||
a = shift_mask(&a, w, h, amount, morphology);
|
||||
}
|
||||
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.
|
||||
@@ -720,6 +756,16 @@ 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! 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
|
||||
}
|
||||
@@ -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);
|
||||
crate::mix_selection(&mut expected_data, bounds, &selection, &image, ctx.alpha);
|
||||
let mut expected = surface.clone();
|
||||
expected.write_region(bounds, &expected_data);
|
||||
for tile in [1, 7, 17, 256] {
|
||||
|
||||
+645
-105
@@ -1,10 +1,12 @@
|
||||
//! 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, 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.
|
||||
//! * **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.
|
||||
//! * **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
|
||||
@@ -18,6 +20,8 @@
|
||||
//! * **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};
|
||||
@@ -125,120 +129,619 @@ 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 long = f64::from(bounds.width().max(bounds.height()));
|
||||
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 spacing = (long / density.cells()).max(2.0);
|
||||
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();
|
||||
}
|
||||
}
|
||||
} 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));
|
||||
let field = Field::new(src, bounds, e);
|
||||
let loops = field.outline();
|
||||
let empty = PuppetMesh { spacing, ..Default::default() };
|
||||
if loops.is_empty() {
|
||||
return empty;
|
||||
}
|
||||
// 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;
|
||||
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;
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
// 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));
|
||||
continue;
|
||||
}
|
||||
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]);
|
||||
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;
|
||||
}
|
||||
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]);
|
||||
_ => 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
mesh
|
||||
order.reverse();
|
||||
order
|
||||
}
|
||||
|
||||
/// A compressed sparse row matrix (symmetric, used by CG).
|
||||
@@ -330,9 +833,9 @@ impl Csr {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
struct BandChol {
|
||||
n: usize,
|
||||
p: usize,
|
||||
@@ -833,6 +1336,43 @@ 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] {
|
||||
|
||||
@@ -23,6 +23,12 @@ 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)]
|
||||
@@ -110,24 +116,35 @@ 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.maxflow_bk();
|
||||
return self.bk(cancel);
|
||||
}
|
||||
source |= capacity > EPS;
|
||||
sink |= capacity < -EPS;
|
||||
}
|
||||
if source && sink && self.r_cap.iter().all(|v| v.is_finite() && *v >= 0.0) {
|
||||
return self.maxflow_push_relabel();
|
||||
return self.push_relabel(cancel);
|
||||
}
|
||||
}
|
||||
self.maxflow_bk()
|
||||
self.bk(cancel)
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -145,7 +162,12 @@ 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 {
|
||||
@@ -197,7 +219,7 @@ impl Graph {
|
||||
self.adopt(o as usize, time, &mut queue, &mut orphans);
|
||||
}
|
||||
}
|
||||
self.flow
|
||||
Some(self.flow)
|
||||
}
|
||||
|
||||
fn augment(&mut self, m: usize, orphans: &mut VecDeque<u32>) {
|
||||
|
||||
@@ -5,14 +5,20 @@
|
||||
//! 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::{EPS, Graph, NONE, TERMINAL};
|
||||
use super::{CANCEL_EVERY, 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.maxflow_bk();
|
||||
return self.bk(cancel);
|
||||
};
|
||||
let mut back = vec![0.0f32; n];
|
||||
let mut excess = vec![0.0f64; n];
|
||||
@@ -34,7 +40,12 @@ 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 {
|
||||
@@ -127,7 +138,7 @@ impl Graph {
|
||||
a = self.next[ai];
|
||||
}
|
||||
}
|
||||
self.flow
|
||||
Some(self.flow)
|
||||
}
|
||||
|
||||
fn global_relabel(&mut self, source_height: u32, back: &[f32]) {
|
||||
|
||||
@@ -242,14 +242,38 @@ 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 Vec::new() };
|
||||
let Some(n) = img.w.checked_mul(img.h).filter(|&n| n < u32::MAX as usize - 2) else { return Some(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 Vec::new();
|
||||
return Some(Vec::new());
|
||||
}
|
||||
let mut free = Vec::new();
|
||||
let mut idx = vec![u32::MAX; n];
|
||||
@@ -262,7 +286,7 @@ fn grid_cut_weighted<const OPTIMIZED: bool>(img: &RgbImage, cost_fg: &[f32], cos
|
||||
}
|
||||
let mut out: Vec<bool> = fixed.iter().map(|f| *f == HARD_FG).collect();
|
||||
if count == 0 {
|
||||
return out;
|
||||
return Some(out);
|
||||
}
|
||||
let mut g = Graph::with_capacity(count as usize, count as usize * 4);
|
||||
for i in 0..n {
|
||||
@@ -307,7 +331,7 @@ fn grid_cut_weighted<const OPTIMIZED: bool>(img: &RgbImage, cost_fg: &[f32], cos
|
||||
}
|
||||
}
|
||||
if OPTIMIZED {
|
||||
g.maxflow();
|
||||
g.maxflow_with(cancel)?;
|
||||
} else {
|
||||
g.maxflow_bk();
|
||||
}
|
||||
@@ -316,7 +340,7 @@ fn grid_cut_weighted<const OPTIMIZED: bool>(img: &RgbImage, cost_fg: &[f32], cos
|
||||
out[i] = g.in_source(idx[i] as usize);
|
||||
}
|
||||
}
|
||||
out
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn add_fixed_link(g: &mut Graph, node: usize, fixed: u8, wt: f32) {
|
||||
|
||||
@@ -35,6 +35,35 @@ 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();
|
||||
|
||||
+167
-29
@@ -766,47 +766,84 @@ 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).
|
||||
/// 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).
|
||||
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 {
|
||||
if points.len() < 3 || w == 0 {
|
||||
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));
|
||||
}
|
||||
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
|
||||
}
|
||||
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 {
|
||||
})
|
||||
.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);
|
||||
}
|
||||
active.retain(|e| e.bottom > y);
|
||||
if active.is_empty() {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for v in &mut m {
|
||||
*v = v.min(1.0);
|
||||
}
|
||||
for v in rows {
|
||||
*v = v.min(1.0);
|
||||
}
|
||||
});
|
||||
m
|
||||
}
|
||||
|
||||
@@ -1161,6 +1198,107 @@ 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);
|
||||
|
||||
@@ -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);
|
||||
crate::mix_selection(&mut expected, area, &selection, &src, alpha);
|
||||
let mut reference = surface.clone();
|
||||
reference.write_region(area, &expected);
|
||||
for tile in [17, 256] {
|
||||
|
||||
@@ -167,17 +167,7 @@ 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,
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
crate::mix_selection(&mut raw, area, &selection, &src, fmt.alpha);
|
||||
let mut want = s.clone();
|
||||
want.write_region(area, &raw);
|
||||
for tile in [1, 7, 17, 256] {
|
||||
|
||||
@@ -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);
|
||||
crate::mix_selection(&mut data, t, &selection, &src, alpha);
|
||||
want.write_region(t, &data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
crate::mix_selection(&mut data, t, &selection, &src, alpha);
|
||||
want.write_region(t, &data);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-127
@@ -4,7 +4,8 @@
|
||||
//! 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.
|
||||
//! derived from its manual and observation, not from its implementation; Shadows/Highlights is
|
||||
//! measured on Photoshop (see [`shadows_highlights`]).
|
||||
|
||||
use photocraft_color::convert::{lab_to_srgb, srgb_to_lab};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -17,18 +18,6 @@ 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 {
|
||||
@@ -64,95 +53,8 @@ impl Default for ShadowsHighlights {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
mod shadows_highlights;
|
||||
pub use shadows_highlights::shadows_highlights;
|
||||
|
||||
/// 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.
|
||||
@@ -378,31 +280,6 @@ 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]];
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
//! 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]);
|
||||
}
|
||||
}
|
||||
@@ -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,7 +368,8 @@ 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 {
|
||||
outp[o + c] = (acc[c] / al).clamp(0.0, 1.0) as f32;
|
||||
// Integer storage clips on write; float colours retain their signed/HDR range.
|
||||
outp[o + c] = (acc[c] / al) as f32;
|
||||
}
|
||||
outp[o + a] = al as f32;
|
||||
}
|
||||
@@ -482,6 +483,44 @@ 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();
|
||||
|
||||
@@ -114,6 +114,73 @@ 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 {
|
||||
@@ -363,6 +430,25 @@ 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];
|
||||
|
||||
+98
-19
@@ -6,7 +6,9 @@
|
||||
//! 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 folds over itself the
|
||||
//! 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
|
||||
//! later (lower-right) cell wins. Work is split per destination tile (parallel on native).
|
||||
|
||||
use photocraft_color::PixelFormat;
|
||||
@@ -25,6 +27,9 @@ 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
|
||||
@@ -172,14 +177,37 @@ 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;
|
||||
// Source position per destination pixel (NaN = not covered).
|
||||
// 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.
|
||||
let mut uv = vec![[f64::NAN; 2]; w * h];
|
||||
for tri in tris {
|
||||
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() {
|
||||
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;
|
||||
@@ -197,16 +225,18 @@ 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.
|
||||
// Source footprint of the covered pixels, including the spread of their subsamples.
|
||||
let (mut fx0, mut fy0, mut fx1, mut fy1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
|
||||
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]);
|
||||
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);
|
||||
}
|
||||
if fx0 > fx1 {
|
||||
return None;
|
||||
@@ -233,11 +263,9 @@ 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];
|
||||
for (o, q) in uv.iter().enumerate() {
|
||||
if q[0].is_nan() {
|
||||
continue;
|
||||
}
|
||||
let (u, v) = (q[0] - 0.5, q[1] - 0.5);
|
||||
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]| {
|
||||
acc[..n].fill(0.0);
|
||||
match interp {
|
||||
Interp::Nearest => {
|
||||
@@ -276,6 +304,31 @@ 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.
|
||||
@@ -394,17 +447,43 @@ 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 %: the centre column keeps its pixels, the ends drop.
|
||||
// Arc 50 % (Photoshop): the bottom corners keep their pixels, the top rises and spreads.
|
||||
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 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 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 (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:?}");
|
||||
assert!(arc.content_bounds().y1 > r.y1 + 2, "ends drop below the original box");
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -41,6 +41,17 @@ 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.
|
||||
@@ -56,9 +67,10 @@ 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(doc, path, &previews(doc))?;
|
||||
w.save_path(&named, path, &previews(&named))?;
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let r = photocraft_io::export(doc, &ext, opts)?;
|
||||
@@ -75,7 +87,8 @@ 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 {
|
||||
return Ok((photocraft_format::save_to_bytes(doc, &previews(doc))?, Vec::new()));
|
||||
let named = named_for_output(doc, name);
|
||||
return Ok((photocraft_format::save_to_bytes(&named, &previews(&named))?, Vec::new()));
|
||||
}
|
||||
let result = photocraft_io::export(doc, &ext, opts)?;
|
||||
Ok((result.bytes, result.warnings))
|
||||
|
||||
@@ -9,7 +9,7 @@ use photocraft_format::PcraftWriter;
|
||||
use photocraft_io::ExportOptions;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::workspace::{authorize_engine_command, authorize_engine_step};
|
||||
use crate::workspace::{ExportSequence, authorize_engine_command, authorize_engine_step};
|
||||
use crate::{AuthorizedWorkspace, AutomationError, files};
|
||||
|
||||
fn untrusted(filesystem: Filesystem) -> Headless {
|
||||
@@ -96,16 +96,125 @@ 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": o.warnings,
|
||||
"warnings": 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> {
|
||||
@@ -133,7 +242,7 @@ impl Headless {
|
||||
}
|
||||
};
|
||||
let target_text = target.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?;
|
||||
let warnings = match &self.filesystem {
|
||||
let mut 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();
|
||||
@@ -145,6 +254,7 @@ 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
|
||||
@@ -210,21 +320,7 @@ impl Headless {
|
||||
}
|
||||
|
||||
pub fn command_list(&self) -> Value {
|
||||
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(),
|
||||
)
|
||||
Value::Array(command_specs().iter().map(|c| photocraft_engine::commands::list_entry(&self.session, c, self.session.disabled_reason(c.id))).collect())
|
||||
}
|
||||
|
||||
pub fn command_run(&mut self, id: &str, params: Value) -> Result<Value, AutomationError> {
|
||||
@@ -282,3 +378,20 @@ 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,12 @@
|
||||
//! - `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.save {path?, format?, quality?, index?}`, `doc.inspect {index?}`,
|
||||
//! `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.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?}`
|
||||
@@ -25,7 +30,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,
|
||||
read_bounded_line, repeat_auth_reply,
|
||||
};
|
||||
use crate::{AutomationError, Headless};
|
||||
|
||||
@@ -37,8 +42,12 @@ 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",
|
||||
@@ -95,11 +104,19 @@ 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;
|
||||
@@ -108,6 +125,21 @@ 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") {
|
||||
@@ -257,7 +289,7 @@ fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token:
|
||||
LineRead::Line => {}
|
||||
}
|
||||
let reply = if authenticated {
|
||||
respond(h, &line)
|
||||
repeat_auth_reply(&line).unwrap_or_else(|| respond(h, &line))
|
||||
} else {
|
||||
let (reply, ok) = authentication_reply(&line, token);
|
||||
authenticated = ok;
|
||||
@@ -572,6 +604,120 @@ 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();
|
||||
@@ -635,6 +781,36 @@ 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 _};
|
||||
|
||||
@@ -118,6 +118,18 @@ 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();
|
||||
@@ -294,6 +306,17 @@ 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()));
|
||||
|
||||
@@ -52,6 +52,79 @@ 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 {
|
||||
@@ -94,6 +167,9 @@ 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>,
|
||||
}
|
||||
@@ -233,7 +309,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),
|
||||
@@ -246,7 +322,8 @@ 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`. Other fields are an error.
|
||||
/// `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.
|
||||
pub fields: Value,
|
||||
}
|
||||
|
||||
@@ -519,6 +596,107 @@ 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();
|
||||
@@ -617,7 +795,9 @@ impl PhotocraftMcp {
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(description = "List engine commands: id, label, menu path, shortcut, parameter doc, enabled now.")]
|
||||
#[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).")]
|
||||
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
|
||||
@@ -666,8 +846,9 @@ 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 (fewer round trips). Returns {completed, failed, \
|
||||
results:[{ok, result|error}]}; stops at the first error unless stop_on_error is false.")]
|
||||
#[tool(description = "Run several engine commands in one call, in order (fewer round trips). Returns {completed, failed, \
|
||||
results:[{ok, result|error}]}; stops at the first error unless stop_on_error is false. \
|
||||
Not an atomic transaction: earlier successful steps stay applied (no rollback).")]
|
||||
async fn command_batch(&self, Parameters(p): Parameters<BatchParams>) -> Result<CallToolResult, McpError> {
|
||||
if p.steps.len() > MAX_BATCH_STEPS {
|
||||
return Ok(fail(format!("batch contains {} steps; maximum is {MAX_BATCH_STEPS}", p.steps.len())));
|
||||
@@ -809,6 +990,11 @@ 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
|
||||
@@ -818,6 +1004,7 @@ 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()
|
||||
@@ -838,6 +1025,7 @@ 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);
|
||||
|
||||
@@ -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");
|
||||
let additive = matches!(tool.name.as_ref(), "doc_new" | "doc_open" | "doc_place");
|
||||
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,6 +2,38 @@
|
||||
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.
|
||||
@@ -14,8 +46,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).lines();
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
let mut lines = BufReader::new(input);
|
||||
while let Some(line) = bounded_line(&mut lines, MAX_REQUEST_BYTES).await? {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -59,3 +91,56 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Capability-based filesystem policy for untrusted automation paths.
|
||||
|
||||
use std::io::Write;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -51,8 +51,7 @@ impl AuthorizedWorkspace {
|
||||
if !metadata.is_file() {
|
||||
return Err(AutomationError::BadRequest(format!("automation read path is not a regular file: `{path}`")));
|
||||
}
|
||||
// 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))
|
||||
read_to_observed_length(&mut file, metadata.len())
|
||||
}
|
||||
|
||||
/// Load one `.pcraft` document in directory form — the native on-disk layout — below the
|
||||
@@ -108,6 +107,34 @@ 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() {
|
||||
@@ -225,6 +252,7 @@ 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"
|
||||
@@ -335,12 +363,12 @@ fn preference_uses_ambient_filesystem(path: &str) -> bool {
|
||||
|
||||
fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
|
||||
match id {
|
||||
"brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => {
|
||||
"actions.load" | "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`.
|
||||
"swatches.export" => params.get("path").is_some(),
|
||||
"actions.save" | "swatches.export" => params.get("path").is_some(),
|
||||
"plugin.reload" => true,
|
||||
_ => false,
|
||||
}
|
||||
@@ -465,6 +493,23 @@ 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");
|
||||
@@ -651,6 +696,8 @@ 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}");
|
||||
}
|
||||
@@ -718,6 +765,8 @@ 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, ¶ms).is_err(), "{id}: {params}");
|
||||
}
|
||||
@@ -727,6 +776,8 @@ 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, ¶ms).is_ok(), "{id}: {params}");
|
||||
}
|
||||
|
||||
@@ -331,6 +331,7 @@ 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;
|
||||
@@ -343,3 +344,102 @@ 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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! 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();
|
||||
}
|
||||
@@ -79,6 +79,10 @@ 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",
|
||||
@@ -166,6 +170,12 @@ 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();
|
||||
}
|
||||
|
||||
@@ -611,6 +621,107 @@ 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() {
|
||||
@@ -935,6 +1046,18 @@ 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;
|
||||
@@ -1111,7 +1234,25 @@ 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);
|
||||
assert_eq!(read("photocraft://commands").await, json_of(&call(&client, "command_list", json!({})).await));
|
||||
// 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!(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()));
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! #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);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! 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");
|
||||
}
|
||||
@@ -296,6 +296,13 @@ 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,
|
||||
|
||||
+68
-1
@@ -450,6 +450,73 @@ 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]
|
||||
@@ -500,7 +567,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!(w[0] > 90.0 && k[0] < 40.0, "{}: {w:?} {k:?}", path.display());
|
||||
assert!(cmyk_endpoints_valid(w, k), "{}: {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();
|
||||
|
||||
@@ -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", "lzw"] }
|
||||
tiff = { version = "0.11", default-features = false, features = ["deflate", "fax", "jpeg", "lzw"] }
|
||||
exr = { version = "1.73", default-features = false }
|
||||
half = "2"
|
||||
serde_json.workspace = true
|
||||
@@ -36,6 +36,8 @@ 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
|
||||
|
||||
+22
-2
@@ -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 "avoid GIMP's hole" rules in `plan/architecture.md` §1.1:
|
||||
It follows the "Avoiding GIMP's hole" rules in `docs/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,6 +45,7 @@ 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` |
|
||||
|
||||
@@ -61,6 +62,9 @@ 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`
|
||||
@@ -71,6 +75,19 @@ the same.
|
||||
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.
|
||||
@@ -101,7 +118,10 @@ the same.
|
||||
`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`).
|
||||
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`).
|
||||
* **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
|
||||
|
||||
@@ -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.1"
|
||||
heic-rs = "=0.1.4"
|
||||
|
||||
[profile.release]
|
||||
debug = 1
|
||||
@@ -62,3 +62,10 @@ 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
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#![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);
|
||||
});
|
||||
@@ -0,0 +1,656 @@
|
||||
//! 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]);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
fn read_meta(bytes: &[u8], limits: &Limits) -> Result<MetaData, CodecError> {
|
||||
pub(super) 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,12 +93,31 @@ fn pick_part(meta: &MetaData) -> usize {
|
||||
best
|
||||
}
|
||||
|
||||
/// 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")))
|
||||
/// 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)
|
||||
}
|
||||
|
||||
fn layer_to_image(layer: &Layer<AnyChannels<FlatSamples>>) -> Result<Image, CodecError> {
|
||||
|
||||
@@ -11,7 +11,6 @@ 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;
|
||||
@@ -200,7 +199,8 @@ 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}")))?;
|
||||
let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(|e| CodecError::malformed(F, e))?;
|
||||
// Also caps all parts' declared sizes together, like every other flat decode.
|
||||
let meta = super::exr::read_meta(bytes, limits)?;
|
||||
if meta.requirements.has_deep_data {
|
||||
return Err(CodecError::unsupported(F, "this file has deep parts; Cryptomatte decoding stays with flat files"));
|
||||
}
|
||||
@@ -214,13 +214,7 @@ 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 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 ex_layer = super::exr::read_layer(bytes, layer.part)?;
|
||||
|
||||
let mut ids: Vec<Vec<f32>> = vec![Vec::new(); layer.channels.len()];
|
||||
let mut coverages: Vec<Vec<f32>> = vec![Vec::new(); layer.channels.len()];
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! 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)
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! 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,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};
|
||||
use crate::options::{EncodeOptions, Limits, PngCompression, alloc_zeroed};
|
||||
|
||||
const F: Format = Format::Png;
|
||||
pub(crate) const XMP_KEYWORD: &str = "XML:com.adobe.xmp";
|
||||
@@ -29,6 +29,10 @@ 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.
|
||||
@@ -47,7 +51,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 = vec![0u8; size];
|
||||
let mut buf = alloc_zeroed::<u8>(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
|
||||
@@ -521,8 +525,16 @@ 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`). 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> {
|
||||
/// 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> {
|
||||
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()));
|
||||
}
|
||||
@@ -538,6 +550,15 @@ pub fn encode_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3
|
||||
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))?;
|
||||
@@ -556,14 +577,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)).unwrap();
|
||||
let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), None).unwrap();
|
||||
let img = decode(&bytes, &Limits::default()).unwrap();
|
||||
assert_eq!(img.dimensions(), (3, 2));
|
||||
let px = img.convert(ChannelLayout::Rgba, SampleType::U8);
|
||||
assert_eq!(&px.data()[..8], &[255, 0, 0, 255, 0, 0, 255, 255]);
|
||||
assert_eq!(px.data()[11], 0, "transparent entry");
|
||||
assert!(encode_indexed(3, 2, &[9; 6], &pal, None).is_err());
|
||||
assert!(encode_indexed(3, 3, &idx, &pal, None).is_err());
|
||||
assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None).is_err());
|
||||
assert!(encode_indexed(3, 3, &idx, &pal, None, None).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,17 +79,43 @@ fn sample_for_maxval(maxval: u32) -> Result<SampleType, CodecError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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"));
|
||||
/// 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 })
|
||||
}
|
||||
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<_>>()),
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,18 +128,24 @@ 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<Vec<u32>, CodecError> {
|
||||
fn read_binary(data: &[u8], n: usize, maxval: u32) -> Result<Samples, CodecError> {
|
||||
let mut out = Samples::with_capacity(maxval, n)?;
|
||||
if maxval < 256 {
|
||||
if data.len() < n {
|
||||
return Err(err("truncated raster"));
|
||||
}
|
||||
Ok(data[..n].iter().map(|&v| v as u32).collect())
|
||||
} else {
|
||||
let Some(len) = n.checked_mul(2).filter(|&len| data.len() >= len) else {
|
||||
let Some(raw) = data.get(..n) else {
|
||||
return Err(err("truncated raster"));
|
||||
};
|
||||
Ok(data[..len].as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as u32).collect())
|
||||
for &v in raw {
|
||||
out.push(v as u32)?;
|
||||
}
|
||||
} else {
|
||||
let Some(raw) = n.checked_mul(2).and_then(|len| data.get(..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(out)
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
@@ -163,19 +195,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 values = if matches!(kind, b'5' | b'6') {
|
||||
let samples = 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 = Vec::with_capacity(n.min(r.rest().len() / 2 + 1));
|
||||
let mut v = Samples::with_capacity(maxval, n.min(r.rest().len() / 2 + 1))?;
|
||||
for _ in 0..n {
|
||||
v.push(r.uint()?);
|
||||
v.push(r.uint()?)?;
|
||||
}
|
||||
v
|
||||
};
|
||||
from_values(w, h, layout, maxval, values)
|
||||
samples.into_image(w, h, layout)
|
||||
}
|
||||
b'7' => decode_pam(&mut r, limits),
|
||||
b'F' | b'f' => {
|
||||
@@ -195,17 +227,22 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
return Err(err("truncated raster"));
|
||||
}
|
||||
let le = scale < 0.0;
|
||||
let mut out = vec![0f32; row * h as usize];
|
||||
for y in 0..h as usize {
|
||||
// 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() {
|
||||
// PFM rows are stored bottom-to-top.
|
||||
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) };
|
||||
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();
|
||||
}
|
||||
}
|
||||
Image::from_f32(w, h, layout, &out)
|
||||
Image::from_raw(w, h, layout, SampleType::F32, out)
|
||||
}
|
||||
_ => Err(err("unknown Netpbm subtype")),
|
||||
}
|
||||
@@ -250,8 +287,7 @@ 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())?;
|
||||
let values = read_binary(r.rest(), n, maxval)?;
|
||||
from_values(w, h, layout, maxval, values)
|
||||
read_binary(r.rest(), n, maxval)?.into_image(w, h, layout)
|
||||
}
|
||||
|
||||
pub(crate) fn encode(src: &Image, plan: Plan, _opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
|
||||
@@ -145,6 +145,14 @@ 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> {
|
||||
@@ -355,6 +363,10 @@ 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<'_, '_> {
|
||||
@@ -366,6 +378,15 @@ 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;
|
||||
@@ -391,10 +412,37 @@ 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 };
|
||||
let mut w = Walk { f, seen: HashSet::new(), pages: Vec::new(), complete: true, entry_budget: (f.b.len() as u64) / 12 + 64 };
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ 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);
|
||||
|
||||
@@ -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};
|
||||
use crate::options::{EncodeOptions, Limits, alloc_zeroed};
|
||||
|
||||
const F: Format = Format::WebP;
|
||||
|
||||
@@ -23,8 +23,11 @@ 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 = vec![0u8; size];
|
||||
let mut buf = alloc_zeroed::<u8>(size)?;
|
||||
dec.read_image(&mut buf).map_err(err)?;
|
||||
let icc = dec.icc_profile().ok().flatten();
|
||||
let exif = dec.exif_metadata().ok().flatten();
|
||||
|
||||
@@ -145,13 +145,19 @@ 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) },
|
||||
}
|
||||
}
|
||||
@@ -237,7 +243,9 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
|
||||
if m.dpi.is_some() && !c.dpi {
|
||||
w.push(W::DpiDropped);
|
||||
}
|
||||
if !m.text.is_empty() && !c.text {
|
||||
// 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)) {
|
||||
w.push(W::TextDropped);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ 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.
|
||||
@@ -25,6 +27,8 @@ 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.
|
||||
@@ -56,6 +60,10 @@ 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 \
|
||||
@@ -78,7 +86,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; 15] = [
|
||||
pub const ALL: [Format; 17] = [
|
||||
Format::Png,
|
||||
Format::Jpeg,
|
||||
Format::Tiff,
|
||||
@@ -88,12 +96,14 @@ 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 {
|
||||
@@ -117,12 +127,14 @@ 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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,12 +151,14 @@ 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"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,12 +173,14 @@ 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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +223,7 @@ 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 },
|
||||
@@ -215,6 +232,8 @@ 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 },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,9 +282,15 @@ 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);
|
||||
}
|
||||
|
||||
@@ -156,6 +156,32 @@ 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 {
|
||||
@@ -175,6 +201,13 @@ 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)
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -512,6 +545,8 @@ 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 {
|
||||
@@ -531,6 +566,55 @@ 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> {
|
||||
@@ -661,6 +745,66 @@ 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();
|
||||
|
||||
@@ -22,9 +22,11 @@ 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};
|
||||
@@ -35,12 +37,13 @@ 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, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
|
||||
pub use crate::options::{DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
|
||||
pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp};
|
||||
pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution};
|
||||
pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding};
|
||||
pub use half::f16;
|
||||
|
||||
use crate::codecs::{exr, fits, heif, jpeg, png, pnm, tiff, via_image, webp};
|
||||
use crate::codecs::{dpx, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp};
|
||||
|
||||
/// Detect the format and decode with default [`Limits`].
|
||||
pub fn decode(bytes: &[u8]) -> Result<Image, CodecError> {
|
||||
@@ -71,7 +74,9 @@ 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),
|
||||
@@ -89,11 +94,17 @@ 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.
|
||||
@@ -108,6 +119,13 @@ 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 {
|
||||
@@ -206,7 +224,9 @@ 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),
|
||||
|
||||
@@ -21,8 +21,10 @@ 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).
|
||||
let max_alloc = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 };
|
||||
// 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 };
|
||||
Limits { max_width: 1 << 18, max_height: 1 << 18, max_pixels: 1 << 30, max_alloc }
|
||||
}
|
||||
}
|
||||
@@ -60,6 +62,19 @@ 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)
|
||||
}
|
||||
@@ -106,6 +121,38 @@ 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 {
|
||||
@@ -126,6 +173,8 @@ 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.
|
||||
@@ -144,8 +193,19 @@ 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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ 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:?}");
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
//! 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}");
|
||||
}
|
||||
}
|
||||
@@ -197,3 +197,32 @@ 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
Reference in New Issue
Block a user